Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 17:54:30 +09:00
9 changed files with 686 additions and 19 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 "",
@@ -109,16 +132,121 @@ def _library_rows(
return rows
def _downstream_name(result: dict[str, Any]) -> str:
"""뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로.
⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로**
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). ⏳ 부채 — 걷는 시점은 PLAN 10장.
"""
if result["name"] == "" and result.get("spec"):
return str(result["spec"])
return str(result["name"])
def replace_with_templates(
quantities: list[Any],
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 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
⚠ 사용자가 고친 식은 **그 구조물이 속한 장 이름**으로 찾음 — 구조물도와 같은 값이 되게.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
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
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
for quantity, item in zip(quantities, inputs):
template = load_template(quantity.type_id)
if template is None or not quantity.components:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
)
sheet = {
"type_id": quantity.type_id,
"height_m": quantity.height_m,
"options": options,
"face": face,
"face_reason": face_reason,
}
structure = {
"height_m": quantity.height_m,
"length_m": quantity.length_m,
"options": options,
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
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):
if solved is None:
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
template = load_template(quantity.type_id) 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]:
if result.get("skipped"):
continue
source = by_seq.get(result["seq"]) or {}
if result.get("error"):
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 ""),
basis,
source="user" if user else engine_source.get(name, ""),
spec="" if name != result["name"] else str(result.get("spec") or ""),
)
)
quantity.components = components
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None:
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `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 ""))
@@ -133,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
@@ -468,6 +468,8 @@ class StructureQuantity:
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
library_item: str = ""
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
@@ -1507,8 +1509,17 @@ def build_table(
section_modes: dict[float, str] | None = None,
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 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
"""화면·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()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
expanded_inputs: list[dict[str, Any]] = []
@@ -1524,6 +1535,17 @@ def build_table(
if rubble is not None:
quantity.components.append(rubble)
quantities.append(quantity)
if use_templates:
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates
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]] = {}
@@ -1559,6 +1581,8 @@ def build_table(
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
"ground_type": item.ground_type,
"ground_type_basis": item.ground_type_basis,
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
"library_item": item.library_item,
"notes": item.notes,
"components": [
{
+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
+8 -1
View File
@@ -90,6 +90,8 @@ export interface UnitQuantityStructure {
start_m?: number | null;
end_m?: number | null;
notes: string[];
/** 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. */
library_item?: string;
components: {
name: string;
unit: string;
@@ -506,7 +508,12 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
tr.append(textCell(component.basis, "b08-grid__note"));
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개";
// 양식으로 선 구조물은 「양식」 — 양식 있음/없음이 조용히 섞이지 않게(PLAN 3장 ④-2).
const kind = structure.library_item
? "양식"
: component.basis_kind === "observed"
? "실무 관측"
: "치수 전개";
tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}` : kind));
markRow(tr, UNIT_QUANTITY_KEYS);
body.append(tr);
+233 -6
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}자리`
@@ -91,6 +103,10 @@ export interface StructureSheet extends StandardSheetSpec {
}[];
notes: string[];
unpriced_rows: string[];
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
library_item?: { type_id: string; name: string };
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
}
export interface StructureSheetsResponse {
@@ -112,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%; }
@@ -159,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,
@@ -201,15 +394,21 @@ 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
? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}`
: "";
head.append(
el("span", "", `${sheet.title}${sheet.member_count}개소${total}`),
el(
"span",
"",
`${sheet.title}${sheet.member_count}개소${total} · ` +
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
(sheet.library_item ? `양식 「${sheet.library_item.name}` : "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
@@ -218,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(
@@ -273,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 ?? [];
@@ -333,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);
});
@@ -0,0 +1,88 @@
"""`build_table` 이 양식 있는 종류를 양식으로 갈음 — 값은 전개와 같고 흐름은 그대로.
2026-09-13, PLAN 3 -2. 겨누는
찰쌓기 성분이 양식 풀이 값으로 · 구조물에 `library_item` 붙음(양식 있음/없음)
·이름·규격· 곳이 전개와 같음 자재총괄·토공집계 갈래가 바뀜(브레인 챙길 )
양식 없는 종류(메쌓기) 전개 그대로 · `library_item`
배합 성분이 자재총괄로 새지 않음(명세 6·15)
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
build_table as build_material_table,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS, build_table # noqa: E402
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _structures() -> list[dict]:
return [
{
"structure_id": "wet",
"type_id": "masonry_wet",
"start_m": 80.0,
"end_m": 90.0,
"options": {
"height_m": 2.5,
"length_m": 10,
"foundation": "기초유",
"stone_kind": "야면석·호박돌",
},
},
{
"structure_id": "dry",
"type_id": "masonry_dry",
"start_m": 140.0,
"end_m": 150.0,
"options": {"height_m": 2, "length_m": 10, "back_len_cm": 35},
},
]
def test_양식_갈음은_값을_안_움직인다() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_table(_structures(), names, None, None, 0.2, use_templates=False)
after = build_table(_structures(), names, None, None, 0.2)
wet_before, dry_before = before["structures"]
wet_after, dry_after = after["structures"]
assert wet_after["library_item"] == "돌쌓기(찰)"
assert dry_after["library_item"] == "" # 양식 없음 — 전개 그대로
assert dry_after["components"] == dry_before["components"]
assert len(wet_after["components"]) == len(wet_before["components"])
for got, want in zip(wet_after["components"], wet_before["components"]):
assert (got["name"], got["unit"], got["destination"], got["spec"]) == (
want["name"],
want["unit"],
want["destination"],
want["spec"],
)
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
# 돌 줄은 뒤 단계가 찾는 종류 이름으로(1장 코드 잇기 끝나기 전) · 관측 출처는 그대로.
stone = next(c for c in wet_after["components"] if c["name"] == "야면석·호박돌")
assert stone["source"] == "uljin_library"
def test_자재총괄_갈래와_배합_성분() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_material_table(
build_table(_structures(), names, None, None, 0.2, use_templates=False)
)
after = build_material_table(build_table(_structures(), names, None, None, 0.2))
rows = lambda table: {(r["name"], r["unit"]): r["total_amount"] for r in table["rows"]} # noqa: E731
assert rows(after).keys() == rows(before).keys()
for key, value in rows(before).items():
assert rows(after)[key] == pytest.approx(value, rel=1e-9), key
assert not any(name in MIX_COMPONENTS for name, _unit in rows(after))
@@ -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(
@@ -82,7 +82,10 @@ def test_양식_값이_전개와_같다(case: dict) -> None:
length = case.pop("length")
structure = _structure(height, length, **case)
engine = build_table([structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble)
# ⚠ 전개만 봄 — `build_table` 은 이제 양식으로 갈음하므로(④-2) 끄지 않으면 자기와 대조가 됨.
engine = build_table(
[structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble, use_templates=False
)
components = engine["structures"][0]["components"]
template = load_template("masonry_wet")