Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user