feat(b08): 구조물도 식 고치기 — 화면 즉시 계산 · 저장은 서버가 다시 풂

- 양식 장의 식 칸을 화면에서 고치면 같은 풀이기(TS)로 왕복 없이 즉시 다시 풂
- [식 저장]은 식만 보내고 서버가 Node 로 다시 푼 값을 돌려줌 — 브라우저 값을 받아 적지 않음
- 고친 식은 프로젝트 장 단위(산출 조건 structure_formula_overrides) · 양식 식과 같거나 비면 지움
- 원단위·자재총괄·인계·유토 입력(build_table)도 같은 고친 식을 봄 · 고친 줄 출처 user(「사용자 식」)
- 틀린 식도 막지 않고 저장하되 줄마다 오류를 돌려줌 · 양식에 없는 줄 차례는 400
- 개인 라이브러리 저장 단추는 4장(라이브러리 저장소) 몫으로 남김

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 17:52:20 +09:00
co-authored by Claude Opus 5
parent 6f7ed9d09c
commit eb12eec35a
6 changed files with 477 additions and 25 deletions
@@ -5,6 +5,8 @@
⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이
수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」).
⚠ 사용자가 고친 식(PLAN 3장 ⑤)은 **프로젝트 장 단위** — 산출 조건 `quantity` 구획의
`structure_formula_overrides` = `{장 이름: {차례: {"formula": 식}}}`. 고친 줄은 출처 `user`.
"""
from __future__ import annotations
@@ -16,6 +18,9 @@ from typing import Any
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
OVERRIDES_KEY = "structure_formula_overrides"
def load_template(type_id: str) -> dict[str, Any] | None:
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
@@ -65,10 +70,26 @@ def template_vars(
return values
def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로, 제원만 끼움."""
def overridden_rows(
template: dict[str, Any], overrides: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""양식 줄에 **그 장에서 사용자가 고친 식**을 얹음 — 고친 줄은 출처 `user`, 원래 식은 따로."""
rows: list[dict[str, Any]] = []
for row in template.get("rows") or []:
edit = (overrides or {}).get(str(row["seq"])) or {}
formula = str(edit.get("formula") or "").strip()
if formula and formula != row.get("formula"):
row = {**row, "formula": formula, "source": "user", "default_formula": row["formula"]}
rows.append(row)
return rows
def template_sheet(
template: dict[str, Any], values: dict[str, Any], overrides: dict[str, Any] | None = None
) -> dict[str, Any]:
"""식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로(고친 식은 얹음), 제원만 끼움."""
return {
"rows": template.get("rows") or [],
"rows": overridden_rows(template, overrides),
"vars": values,
"tables": template.get("tables") or {},
}
@@ -79,10 +100,10 @@ _PER_LENGTH_UNITS = frozenset({"m"})
def _library_rows(
template: dict[str, Any], solved: list[dict[str, Any]], billing: float
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
) -> list[dict[str, Any]]:
"""풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬까지 실음(명세 13장)."""
by_seq = {row["seq"]: row for row in template.get("rows") or []}
"""풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬·출처까지 실음(명세 13장)."""
by_seq = {row["seq"]: row for row in body.get("rows") or []}
rows: list[dict[str, Any]] = []
for result in solved:
source = by_seq.get(result["seq"]) or {}
@@ -94,6 +115,8 @@ def _library_rows(
"spec": result.get("spec") or "",
"basis": source.get("formula_text") or "",
"formula": source.get("formula") or "",
# 되돌릴 자리 — 고친 줄만 원래 양식 식이 따로 옴.
"default_formula": source.get("default_formula") or source.get("formula") or "",
"unit_amount": unit_amount,
"amount": None if unit_amount is None else unit_amount * billing,
"unit": source.get("unit") or "",
@@ -113,7 +136,7 @@ def _downstream_name(result: dict[str, Any]) -> str:
"""뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로.
⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로**
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). 그 일감이 끝나면 이 갈음을 걷을 것.
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). ⏳ 부채 — 걷는 시점은 PLAN 10장.
"""
if result["name"] == "" and result.get("spec"):
return str(result["spec"])
@@ -125,15 +148,17 @@ def replace_with_templates(
inputs: list[dict[str, Any]],
section_modes: dict[float, str] | None,
rubble_base_thickness_m: float | None,
formula_overrides: dict[str, Any] | None = None,
) -> None:
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄.
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게(브레인 챙길 것 ②).
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
⚠ 사용자가 고친 식은 **그 구조물이 속한 장 이름**으로 찾음 — 구조물도와 같은 값이 되게.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_key, slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
@@ -160,23 +185,24 @@ def replace_with_templates(
"options": options,
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
targets.append((quantity, template_sheet(template, values)))
overrides = (formula_overrides or {}).get(sheet_key(sheet))
targets.append((quantity, template_sheet(template, values, overrides)))
if not targets:
return
solved = evaluate_sheets([body for _quantity, body in targets])
for index, (quantity, _body) in enumerate(targets):
for index, (quantity, body) in enumerate(targets):
if solved is None:
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
template = load_template(quantity.type_id) or {}
by_seq = {row["seq"]: row for row in template.get("rows") or []}
by_seq = {row["seq"]: row for row in body["rows"]}
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
engine_source = {c.name: c.source for c in quantity.components}
# 근거 문구도 전개 것을 씀 — 「강도 210 — 안 정해 기본값」·「판정 1:0.3 성토」처럼
# **그 구조물에서 왜 그 값인지**가 들어 있어 양식의 붙박이 설명보다 많이 말함(값은 같음).
# ⚠ ⑤ 에서 사용자가 식을 고친 줄은 전개 문구가 틀리게 되므로 그때 양식 설명으로 갈 것.
# ⚠ 사용자가 식을 고친 줄은 전개 문구가 틀리므로 「사용자 식」 문구로 감.
engine_basis = {c.name: c.basis for c in quantity.components}
components = []
for result in solved[index]:
@@ -187,14 +213,20 @@ def replace_with_templates(
quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}")
continue
name = _downstream_name(result)
user = source.get("source") == "user"
basis = (
f"사용자 식 = {source.get('formula')} (양식 식 {source.get('default_formula')})"
if user
else engine_basis.get(name) or str(source.get("formula_text") or "")
)
components.append(
Component(
name,
str(source.get("unit") or ""),
float(result["amount"]),
str(source.get("destination") or ""),
engine_basis.get(name) or str(source.get("formula_text") or ""),
source=engine_source.get(name, ""),
basis,
source="user" if user else engine_source.get(name, ""),
spec="" if name != result["name"] else str(result.get("spec") or ""),
)
)
@@ -209,10 +241,12 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장).
⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음.
⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례.
⚠ 화면이 조작 중 **왕복 없이** 다시 풀 수 있게 풀이 장 한 벌(`formula_sheet`)을 함께 실음.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
all_overrides = (settings or {}).get(OVERRIDES_KEY) or {}
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
for sheet in payload.get("sheets") or []:
template = load_template(str(sheet.get("type_id") or ""))
@@ -227,22 +261,60 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
"options": sheet.get("options"),
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
targets.append((sheet, template_sheet(template, values)))
body = template_sheet(template, values, all_overrides.get(sheet.get("key")))
targets.append((sheet, body))
if not targets:
return
solved = evaluate_sheets([body for _sheet, body in targets])
for index, (sheet, _body) in enumerate(targets):
for index, (sheet, body) in enumerate(targets):
template = load_template(str(sheet.get("type_id") or "")) or {}
if solved is None:
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
sheet["rows"] = _library_rows(template, solved[index], billing)
sheet["rows"] = _library_rows(body, solved[index], billing)
sheet["unpriced_rows"] = [
row["name"]
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")}
sheet["formula_sheet"] = body
def save_sheet_overrides(
current: dict[str, Any] | None,
key: str,
template: dict[str, Any],
edits: list[dict[str, Any]],
) -> tuple[dict[str, Any], int]:
"""장 하나의 고친 식을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
⚠ 빈 식·양식 식과 같은 식은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
양식이 바뀌어도 그 장만 옛 식으로 남음.
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
"""
known = {row["seq"]: row for row in template.get("rows") or []}
merged = {name: dict(rows) for name, rows in (current or {}).items()}
sheet = merged.get(key, {})
changed = 0
for edit in edits:
seq = int(edit["seq"])
if seq not in known:
raise ValueError(f"양식에 없는 줄 차례: {seq}")
formula = str(edit.get("formula") or "").strip()
before = (sheet.get(str(seq)) or {}).get("formula")
if not formula or formula == known[seq].get("formula"):
if sheet.pop(str(seq), None) is not None:
changed += 1
continue
if before != formula:
changed += 1
sheet[str(seq)] = {"formula": formula}
if sheet:
merged[key] = sheet
else:
merged.pop(key, None)
return merged, changed
@@ -1510,12 +1510,15 @@ def build_table(
ground_types: dict[float, str] | None = None,
rubble_base_thickness_m: float | None = None,
use_templates: bool = True,
structure_formulas: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
⭐ 2026-09-13(PLAN 3장 ④-2) — **양식이 있는 종류는 양식 풀이 값으로 성분을 갈음**
(`B08_Quantity_Engine_StructureTemplate.replace_with_templates`). 원단위·자재총괄·인계가
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
⭐ `structure_formulas` — 사용자가 장마다 고친 식(산출 조건 `structure_formula_overrides`,
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
"""
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
@@ -1536,7 +1539,13 @@ def build_table(
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates
replace_with_templates(quantities, expanded_inputs, section_modes, rubble_base_thickness_m)
replace_with_templates(
quantities,
expanded_inputs,
section_modes,
rubble_base_thickness_m,
structure_formulas,
)
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
+6 -1
View File
@@ -155,6 +155,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
# 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤).
structure_formulas=settings.get("structure_formula_overrides"),
)
material_table = build_material_table(
unit_table,
@@ -200,12 +202,14 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
quantity_settings(project_root).get("rubble_base_thickness_m"),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
)
except Exception:
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
@@ -247,6 +251,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
)
material_table = build_material_table(
unit_table,
@@ -65,6 +65,7 @@ def project_structure_sheets(
section_modes,
ground_types,
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
)
payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
@@ -112,6 +113,82 @@ class StandardSheetSpecRequest(BaseModel):
blinding_concrete: str | None = None
class FormulaEdit(BaseModel):
"""고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**."""
model_config = ConfigDict(extra="forbid")
seq: int = Field(ge=1)
formula: str | None = Field(default=None, max_length=2000)
class StructureSheetFormulaRequest(BaseModel):
"""구조물도 장 하나에서 고친 식들(PLAN 3장 ⑤ · 프로젝트 장 단위)."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
rows: list[FormulaEdit] = Field(max_length=200)
@router.put("/{project_id}/quantity/structure-sheets/formulas")
async def put_structure_sheet_formulas(
project_id: UUID, payload: StructureSheetFormulaRequest
) -> JSONResponse:
"""장 하나의 고친 식을 **산출 조건에 저장**하고 **서버가 같은 풀이기로 다시 푼 값**을 돌려줌.
⚠ 브라우저가 푼 값을 받아 적지 않음 — 식만 받고 값은 Node 로 다시 냄(판정 Ⓐ · 데이터 3층).
⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장).
⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임.
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
OVERRIDES_KEY,
load_template,
save_sheet_overrides,
)
from common_util.common_util_project_settings import quantity_settings, save_section
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
template = load_template(str((picked or {}).get("type_id") or "")) if picked else None
if picked is None or template is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
)
try:
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
merged, changed = save_sheet_overrides(
current, payload.sheet_key, template, [row.model_dump() for row in payload.rows]
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
# ⚠ 통째로 갈아 끼움 — 병합이면 되돌린(지운) 식이 남음(`save_section` replace_keys).
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{OVERRIDES_KEY: merged},
replace_keys=[OVERRIDES_KEY],
)
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
fresh = next((s for s in sheets if s.get("key") == payload.sheet_key), None) or {}
rows = fresh.get("rows") or []
return JSONResponse(
content={
"status": "success",
"changed": changed,
"rows": rows,
# 막지 않고 알림 — 어느 줄이 왜 안 섰는지.
"errors": [f"{row['name']}: {row['error']}" for row in rows if row.get("error")],
}
)
@router.put("/{project_id}/quantity/structure-sheets/spec")
async def put_structure_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest
+224 -5
View File
@@ -7,10 +7,13 @@
* ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음.
* ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만.
* ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로.
* ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음).
* [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 프로젝트 장 단위.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
@@ -32,6 +35,8 @@ export interface StructureSheetRow {
source: string;
/** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */
formula?: string;
/** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */
default_formula?: string;
destination?: string;
rounding?: { mode: string; digits: number } | null;
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
@@ -67,8 +72,15 @@ function amountText(row: StructureSheetRow): string {
function noteText(row: StructureSheetRow): string {
if (row.skipped) return row.reason ?? "";
if (row.error) return `${row.error}`;
// 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③).
const origin =
row.source === "library" ? "양식" : row.basis_kind === "observed" ? "실무 관측" : "치수 전개";
row.source === "user"
? "사용자 식"
: row.source === "library"
? "양식"
: row.basis_kind === "observed"
? "실무 관측"
: "치수 전개";
const mode = row.rounding?.mode;
return mode && mode !== "none"
? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리`
@@ -93,6 +105,8 @@ export interface StructureSheet extends StandardSheetSpec {
unpriced_rows: string[];
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
library_item?: { type_id: string; name: string };
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
}
export interface StructureSheetsResponse {
@@ -114,6 +128,14 @@ const CSS = `
/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */
.b08-sheet__rows td:nth-child(3) { white-space: pre-line; min-width: 16rem; }
.b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; }
/* 식 칸 — 고칠 수 있는 칸은 입력으로, 고친 칸은 표시가 남음. */
.b08-sheet__formula { display: flex; gap: 4px; margin-top: 2px; }
.b08-sheet__formula input { flex: 1 1 auto; min-width: 12rem; font: 12px var(--font-mono, monospace);
padding: 1px 4px; color: var(--color-text); background: var(--color-surface);
border: 1px solid var(--color-border); }
.b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); }
.b08-sheet__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; }
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; }
@@ -161,6 +183,175 @@ async function putStructureSheetSpec(
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
}
/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
async function putStructureSheetFormulas(
projectId: string,
sheetKey: string,
rows: { seq: number; formula: string | null }[],
): Promise<{ changed: number; errors: string[] }> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, rows }),
},
);
const payload = (await response.json().catch(() => ({}))) as {
changed?: number;
errors?: string[];
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return { changed: payload.changed ?? 0, errors: payload.errors ?? [] };
}
/**
* 양식 장의 원단위 수량표 — 식 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂.
* `onSave` 는 고친 식만 받음 — 값은 서버가 다시 냄. 되돌린 줄은 빈 식(= 양식 식)으로 감.
*/
function formulaTable(
sheet: StructureSheet,
onSave: (rows: { seq: number; formula: string | null }[]) => Promise<string[]>,
onDirty: (dirty: boolean) => void,
): HTMLElement {
const body = sheet.formula_sheet as FormulaSheet;
const saved = new Map(sheet.rows.map((row) => [row.no, row.formula ?? ""]));
const defaults = new Map(sheet.rows.map((row) => [row.no, row.default_formula ?? ""]));
const edits = new Map<number, string>();
const cells = new Map<
number,
{ amount: HTMLElement; note: HTMLElement; input: HTMLInputElement }
>();
const wrap = el("div", "b08-grid");
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table b08-grid__table--summary b08-sheet__rows");
const headRow = document.createElement("tr");
for (const label of ["공종", "규격", "산출 근거 · 식", "수량", "단위", "갈 곳", "비고"]) {
headRow.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(headRow);
const tbody = document.createElement("tbody");
const status = el("span", "b08-grid__caption");
const saveButton = el("button", "b08-spec__save", "식 저장");
saveButton.type = "button";
const discardButton = el("button", "b08-quantity__tab", "고친 것 버리기");
discardButton.type = "button";
// 고친 식으로 장을 다시 풀어 **모든 줄**을 고침 — 앞 줄을 고치면 뒷줄도 따라 바뀜.
const recompute = (): void => {
const results = evaluateSheet({
...body,
rows: body.rows.map((row) =>
edits.has(row.seq) ? { ...row, formula: edits.get(row.seq), source: "user" } : row,
),
});
for (const result of results) {
const cell = cells.get(result.seq);
const row = sheet.rows.find((item) => item.no === result.seq);
if (!cell || !row) continue;
const formula = cell.input.value.trim();
const local: StructureSheetRow = {
...row,
unit_amount: result.amount === null ? null : Number(result.amount),
skipped: result.skipped,
reason: result.reason ?? "",
error: result.error ?? "",
source: formula && formula !== defaults.get(result.seq) ? "user" : "library",
};
cell.amount.textContent = amountText(local);
cell.note.textContent = noteText(local);
cell.input.classList.toggle("is-changed", formula !== saved.get(result.seq));
}
const dirty = [...cells.entries()].some(
([seq, cell]) => cell.input.value.trim() !== saved.get(seq),
);
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈)"
: "";
onDirty(dirty);
};
for (const row of sheet.rows) {
const tr = document.createElement("tr");
const basis = el("td", "", row.basis.replace(/\*\*/g, ""));
const line = el("div", "b08-sheet__formula");
const input = document.createElement("input");
input.type = "text";
input.value = row.formula ?? "";
input.title = `양식 식: ${row.default_formula ?? ""}`;
input.addEventListener("input", () => {
const value = input.value.trim();
if (value === saved.get(row.no)) edits.delete(row.no);
else edits.set(row.no, value || (defaults.get(row.no) ?? ""));
recompute();
});
const revert = el("button", "", "양식 식으로");
revert.type = "button";
revert.title = "이 줄을 양식 원래 식으로 되돌림";
revert.addEventListener("click", () => {
input.value = defaults.get(row.no) ?? "";
input.dispatchEvent(new Event("input"));
});
line.append(input, revert);
basis.append(line);
const amount = el("td", "", amountText(row));
const note = el("td", "", noteText(row));
tr.append(
el("td", "", row.name),
el("td", "", row.spec),
basis,
amount,
el("td", "", row.unit),
el("td", "", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? ""),
note,
);
cells.set(row.no, { amount, note, input });
tbody.append(tr);
}
grid.append(thead, tbody);
scroller.append(grid);
saveButton.addEventListener("click", () => {
void (async () => {
saveButton.disabled = true;
status.textContent = "저장 중…";
const rows = [...cells.entries()]
.filter(([seq, cell]) => cell.input.value.trim() !== saved.get(seq))
.map(([seq, cell]) => {
const value = cell.input.value.trim();
// 양식 식과 같거나 비면 「고친 적 없음」으로 되돌림.
return { seq, formula: value && value !== defaults.get(seq) ? value : null };
});
try {
const errors = await onSave(rows);
status.textContent = errors.length ? `⚠ 저장했으나 안 서는 줄: ${errors.join(" · ")}` : "";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "식을 저장하지 못함";
saveButton.disabled = false;
}
})();
});
discardButton.addEventListener("click", () => {
edits.clear();
for (const [seq, cell] of cells) cell.input.value = saved.get(seq) ?? "";
recompute();
});
const actions = el("div", "b08-sheet__actions");
actions.append(saveButton, discardButton, status);
saveButton.disabled = true;
discardButton.disabled = true;
wrap.append(scroller, actions);
return wrap;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
@@ -203,8 +394,8 @@ function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
return scroller;
}
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표 · 막힌 사유 · 개소 목록. */
function sheetBody(sheet: StructureSheet): HTMLElement {
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */
function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement {
const main = el("div", "b08-sheet__main");
const head = el("p", "b08-sheet__head");
const total = sheet.billing_total
@@ -226,6 +417,8 @@ function sheetBody(sheet: StructureSheet): HTMLElement {
main.append(
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
);
} else if (editor) {
main.append(editor);
} else {
main.append(
table(
@@ -281,6 +474,13 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return wrap;
}
// 저장 안 한 식이 조용히 사라지지 않게 — 나갈 때 묻기(자동저장은 안 만듦, CLAUDE.md 5장).
let dirty = false;
window.addEventListener("beforeunload", (event) => {
if (!dirty || !wrap.isConnected) return;
event.preventDefault();
event.returnValue = "저장 안 한 식이 있음";
});
const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => {
const sheets = response.sheets ?? [];
@@ -341,12 +541,31 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
initialNotes,
),
);
pane.replaceChildren(sheetBody(sheet), aside);
const editor = sheet.formula_sheet
? formulaTable(
sheet,
async (rows) => {
const result = await putStructureSheetFormulas(projectId, sheet.key, rows);
// 서버가 다시 푼 값으로 **다시 받아** 그림 — 화면 계산을 정본으로 적지 않음.
await load(sheet.members[0]?.structure_id ?? null);
return result.errors;
},
(next) => {
dirty = next;
},
)
: null;
pane.replaceChildren(sheetBody(sheet, editor), aside);
};
sheets.forEach((sheet, index) => {
const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`);
button.type = "button";
button.addEventListener("click", () => show(index));
button.addEventListener("click", () => {
// 다른 장으로 가면 고친 식이 사라짐 — 조용히 버리지 않고 물음.
if (dirty && !window.confirm("저장 안 한 식이 있음 — 버리고 다른 장으로 갈까요?")) return;
dirty = false;
show(index);
});
buttons.push(button);
tabs.append(button);
});
@@ -112,6 +112,76 @@ def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None:
assert rows[name]["reason"], rows[name]
def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다(
client: TestClient, project: Path
) -> None:
"""PLAN 3장 ⑤ — 프로젝트 장 단위 저장 · 값은 서버가 다시 냄 · 고친 줄은 출처 user."""
sheet = _sheet(client)
rows = {row["name"]: row for row in sheet["rows"]}
원래 = rows["모르터"]
saved = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": "A*0.018"}]},
)
assert saved.status_code == 200, saved.text
assert saved.json()["changed"] == 1 and saved.json()["errors"] == []
after = {row["name"]: row for row in _sheet(client)["rows"]}
assert after["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"] * 2)
assert after["모르터"]["source"] == "user"
assert after["모르터"]["default_formula"] == 원래["formula"]
assert after["돌쌓기"]["source"] == "library" # 안 고친 줄은 그대로 양식
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"][sheet["key"]] == {
str(원래["no"]): {"formula": "A*0.018"}
}
# 원단위·자재총괄(build_table)도 같은 고친 식을 봄 — 구조물도와 안 갈림.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
structures, names, _ = _collect_structures(str(project))
table = build_table(
structures,
names,
None,
None,
None,
structure_formulas=stored["quantity"]["structure_formula_overrides"],
)
mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터")
assert mortar["amount"] == pytest.approx(after["모르터"]["unit_amount"] * 10.0)
assert mortar["source"] == "user" and "사용자 식" in mortar["basis"]
# 되돌리기 — 빈 식이면 양식 식으로, 저장 칸에서도 지워짐.
reverted = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": None}]},
)
assert reverted.status_code == 200 and reverted.json()["changed"] == 1
back = {row["name"]: row for row in _sheet(client)["rows"]}
assert back["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"])
assert back["모르터"]["source"] == "library"
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
def test_틀린_식도_막지_않고_저장하되_오류를_돌려준다(client: TestClient) -> None:
sheet = _sheet(client)
seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터")
saved = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*없는칸"}]},
)
assert saved.status_code == 200, saved.text
assert any("모르터" in error and "모르는 이름" in error for error in saved.json()["errors"])
bad = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 999, "formula": "1"}]},
)
assert bad.status_code == 400
def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None:
기본 = _unit_amount(_sheet(client), "기초잡석")
(project / "project_settings.json").write_text(