feat(b08): 구조물도 줄마다 반올림 칸 — 엑셀 함수 이름을 함께, 원단위도 m당 반올림 × 연장
- 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기 - 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림 - build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림 - 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌 - 시험 2개 추가 · 전체 1533 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -114,9 +114,10 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
|
|||||||
type_id = template.get("type_id")
|
type_id = template.get("type_id")
|
||||||
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
||||||
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
||||||
# 고친 식이 곧 이 항목의 식 — 「사용자 식」 표시와 되돌릴 자리는 떼어 냄.
|
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||||
|
dropped = {"default_formula", "default_rounding"}
|
||||||
rows = [
|
rows = [
|
||||||
{**{k: v for k, v in row.items() if k != "default_formula"}, "source": "library"}
|
{**{k: v for k, v in row.items() if k not in dropped}, "source": "library"}
|
||||||
for row in overridden_rows(template, overrides)
|
for row in overridden_rows(template, overrides)
|
||||||
]
|
]
|
||||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||||
|
|||||||
@@ -79,16 +79,52 @@ def template_vars(
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
#: 반올림 갈래 — 엑셀 대응(명세 13장 대응표). 사유·근거 문구에 엑셀 이름으로 보임.
|
||||||
|
ROUNDING_WORDS = {
|
||||||
|
"none": "안 함",
|
||||||
|
"floor": "INT",
|
||||||
|
"trunc": "ROUNDDOWN",
|
||||||
|
"round": "ROUND",
|
||||||
|
"ceil_away": "ROUNDUP",
|
||||||
|
"ceil": "위로(엑셀 없음)",
|
||||||
|
"round_half_even": "짝수 반올림(엑셀 없음)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rounding_key(rounding: dict[str, Any] | None) -> tuple[str, int]:
|
||||||
|
"""같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나."""
|
||||||
|
mode = str((rounding or {}).get("mode") or "none")
|
||||||
|
return (mode, 0) if mode == "none" else (mode, int((rounding or {}).get("digits") or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_of(row: dict[str, Any], edit: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""저장된 고침 한 줄에서 **양식과 다른 칸만** — 식·반올림. 같으면 뺌(「고친 적 없음」)."""
|
||||||
|
changes: dict[str, Any] = {}
|
||||||
|
formula = str(edit.get("formula") or "").strip()
|
||||||
|
if formula and formula != row.get("formula"):
|
||||||
|
changes["formula"] = formula
|
||||||
|
rounding = edit.get("rounding")
|
||||||
|
if rounding and rounding_key(rounding) != rounding_key(row.get("rounding")):
|
||||||
|
mode, digits = rounding_key(rounding)
|
||||||
|
changes["rounding"] = {"mode": mode, "digits": digits}
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
def overridden_rows(
|
def overridden_rows(
|
||||||
template: dict[str, Any], overrides: dict[str, Any] | None
|
template: dict[str, Any], overrides: dict[str, Any] | None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""양식 줄에 **그 장에서 사용자가 고친 식**을 얹음 — 고친 줄은 출처 `user`, 원래 식은 따로."""
|
"""양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `user`, 원래 값은 따로."""
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for row in template.get("rows") or []:
|
for row in template.get("rows") or []:
|
||||||
edit = (overrides or {}).get(str(row["seq"])) or {}
|
changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {})
|
||||||
formula = str(edit.get("formula") or "").strip()
|
if changes:
|
||||||
if formula and formula != row.get("formula"):
|
row = {
|
||||||
row = {**row, "formula": formula, "source": "user", "default_formula": row["formula"]}
|
**row,
|
||||||
|
**changes,
|
||||||
|
"source": "user",
|
||||||
|
"default_formula": row.get("formula"),
|
||||||
|
"default_rounding": row.get("rounding"),
|
||||||
|
}
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -133,6 +169,7 @@ def _library_rows(
|
|||||||
"source": source.get("source") or "library",
|
"source": source.get("source") or "library",
|
||||||
"destination": source.get("destination") or "",
|
"destination": source.get("destination") or "",
|
||||||
"rounding": source.get("rounding"),
|
"rounding": source.get("rounding"),
|
||||||
|
"default_rounding": source.get("default_rounding", source.get("rounding")),
|
||||||
"skipped": bool(result.get("skipped")),
|
"skipped": bool(result.get("skipped")),
|
||||||
"reason": result.get("reason") or "",
|
"reason": result.get("reason") or "",
|
||||||
"error": result.get("error") or "",
|
"error": result.get("error") or "",
|
||||||
@@ -152,6 +189,25 @@ def _downstream_name(result: dict[str, Any]) -> str:
|
|||||||
return str(result["name"])
|
return str(result["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user_basis(row: dict[str, Any]) -> str:
|
||||||
|
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
|
||||||
|
parts = []
|
||||||
|
if row.get("formula") != row.get("default_formula"):
|
||||||
|
parts.append(f"사용자 식 = {row.get('formula')} (양식 식 {row.get('default_formula')})")
|
||||||
|
if rounding_key(row.get("rounding")) != rounding_key(row.get("default_rounding")):
|
||||||
|
parts.append(
|
||||||
|
f"사용자 반올림 = {_rounding_words(row.get('rounding'))}"
|
||||||
|
f" (양식 {_rounding_words(row.get('default_rounding'))})"
|
||||||
|
)
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _rounding_words(rounding: dict[str, Any] | None) -> str:
|
||||||
|
mode, digits = rounding_key(rounding)
|
||||||
|
word = ROUNDING_WORDS.get(mode, mode)
|
||||||
|
return word if mode == "none" else f"{word} {digits}자리"
|
||||||
|
|
||||||
|
|
||||||
def replace_with_templates(
|
def replace_with_templates(
|
||||||
quantities: list[Any],
|
quantities: list[Any],
|
||||||
inputs: list[dict[str, Any]],
|
inputs: list[dict[str, Any]],
|
||||||
@@ -166,6 +222,8 @@ def replace_with_templates(
|
|||||||
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
|
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
|
||||||
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
|
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
|
||||||
⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게.
|
⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게.
|
||||||
|
⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그
|
||||||
|
반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림.
|
||||||
"""
|
"""
|
||||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
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 slope_of
|
||||||
@@ -178,6 +236,9 @@ def replace_with_templates(
|
|||||||
template = template_of(quantity.type_id, templates)
|
template = template_of(quantity.type_id, templates)
|
||||||
if template is None or not quantity.components:
|
if template is None or not quantity.components:
|
||||||
continue
|
continue
|
||||||
|
# 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`).
|
||||||
|
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
|
||||||
|
continue
|
||||||
options = item.get("options") or {}
|
options = item.get("options") or {}
|
||||||
face, face_reason = structure_face_role(
|
face, face_reason = structure_face_role(
|
||||||
_section_mode_at(item, section_modes), options.get("side")
|
_section_mode_at(item, section_modes), options.get("side")
|
||||||
@@ -189,11 +250,7 @@ def replace_with_templates(
|
|||||||
"face": face,
|
"face": face,
|
||||||
"face_reason": face_reason,
|
"face_reason": face_reason,
|
||||||
}
|
}
|
||||||
structure = {
|
structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options}
|
||||||
"height_m": quantity.height_m,
|
|
||||||
"length_m": quantity.length_m,
|
|
||||||
"options": options,
|
|
||||||
}
|
|
||||||
values = template_vars(template, structure, slope_of(sheet)[0], settings)
|
values = template_vars(template, structure, slope_of(sheet)[0], settings)
|
||||||
overrides = (formula_overrides or {}).get(quantity.type_id)
|
overrides = (formula_overrides or {}).get(quantity.type_id)
|
||||||
targets.append((quantity, template_sheet(template, values, overrides)))
|
targets.append((quantity, template_sheet(template, values, overrides)))
|
||||||
@@ -225,7 +282,7 @@ def replace_with_templates(
|
|||||||
name = _downstream_name(result)
|
name = _downstream_name(result)
|
||||||
user = source.get("source") == "user"
|
user = source.get("source") == "user"
|
||||||
basis = (
|
basis = (
|
||||||
f"사용자 식 = {source.get('formula')} (양식 식 {source.get('default_formula')})"
|
_user_basis(source)
|
||||||
if user
|
if user
|
||||||
else engine_basis.get(name) or str(source.get("formula_text") or "")
|
else engine_basis.get(name) or str(source.get("formula_text") or "")
|
||||||
)
|
)
|
||||||
@@ -233,7 +290,7 @@ def replace_with_templates(
|
|||||||
Component(
|
Component(
|
||||||
name,
|
name,
|
||||||
str(source.get("unit") or ""),
|
str(source.get("unit") or ""),
|
||||||
float(result["amount"]),
|
float(result["amount"]) * quantity.length_m,
|
||||||
str(source.get("destination") or ""),
|
str(source.get("destination") or ""),
|
||||||
basis,
|
basis,
|
||||||
source="user" if user else engine_source.get(name, ""),
|
source="user" if user else engine_source.get(name, ""),
|
||||||
@@ -302,6 +359,17 @@ def apply_templates(
|
|||||||
"imported_from": template.get("imported_from"),
|
"imported_from": template.get("imported_from"),
|
||||||
}
|
}
|
||||||
sheet["formula_sheet"] = body
|
sheet["formula_sheet"] = body
|
||||||
|
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
||||||
|
sheet["var_candidates"] = [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": spec.get("label") or name,
|
||||||
|
"value": body["vars"].get(name),
|
||||||
|
"candidates": spec["candidates"],
|
||||||
|
}
|
||||||
|
for name, spec in (template.get("vars") or {}).items()
|
||||||
|
if spec.get("candidates")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def save_sheet_overrides(
|
def save_sheet_overrides(
|
||||||
@@ -310,10 +378,11 @@ def save_sheet_overrides(
|
|||||||
template: dict[str, Any],
|
template: dict[str, Any],
|
||||||
edits: list[dict[str, Any]],
|
edits: list[dict[str, Any]],
|
||||||
) -> tuple[dict[str, Any], int]:
|
) -> tuple[dict[str, Any], int]:
|
||||||
"""양식 하나(`key` = type_id)의 고친 식을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
|
"""양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
|
||||||
|
|
||||||
⚠ 빈 식·양식 식과 같은 식은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
|
한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로.
|
||||||
양식이 바뀌어도 그 장만 옛 식으로 남음.
|
⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
|
||||||
|
양식이 바뀌어도 그 장만 옛 값으로 남음.
|
||||||
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
|
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
|
||||||
"""
|
"""
|
||||||
known = {row["seq"]: row for row in template.get("rows") or []}
|
known = {row["seq"]: row for row in template.get("rows") or []}
|
||||||
@@ -324,15 +393,15 @@ def save_sheet_overrides(
|
|||||||
seq = int(edit["seq"])
|
seq = int(edit["seq"])
|
||||||
if seq not in known:
|
if seq not in known:
|
||||||
raise ValueError(f"양식에 없는 줄 차례: {seq}")
|
raise ValueError(f"양식에 없는 줄 차례: {seq}")
|
||||||
formula = str(edit.get("formula") or "").strip()
|
entry = _edit_of(known[seq], edit)
|
||||||
before = (sheet.get(str(seq)) or {}).get("formula")
|
before = sheet.get(str(seq))
|
||||||
if not formula or formula == known[seq].get("formula"):
|
if not entry:
|
||||||
if sheet.pop(str(seq), None) is not None:
|
if sheet.pop(str(seq), None) is not None:
|
||||||
changed += 1
|
changed += 1
|
||||||
continue
|
continue
|
||||||
if before != formula:
|
if before != entry:
|
||||||
changed += 1
|
changed += 1
|
||||||
sheet[str(seq)] = {"formula": formula}
|
sheet[str(seq)] = entry
|
||||||
if sheet:
|
if sheet:
|
||||||
merged[key] = sheet
|
merged[key] = sheet
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ class StandardSheetSpecRequest(BaseModel):
|
|||||||
blinding_concrete: str | None = None
|
blinding_concrete: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoundingEdit(BaseModel):
|
||||||
|
"""줄 반올림 — 갈래는 명세 13장 일곱 가지만. 자리수가 음수면 10의 자리 쪽."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
mode: Literal["none", "floor", "trunc", "round", "ceil_away", "ceil", "round_half_even"]
|
||||||
|
digits: int = Field(default=0, ge=-6, le=10)
|
||||||
|
|
||||||
|
|
||||||
class FormulaEdit(BaseModel):
|
class FormulaEdit(BaseModel):
|
||||||
"""고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**."""
|
"""고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**."""
|
||||||
|
|
||||||
@@ -124,6 +133,8 @@ class FormulaEdit(BaseModel):
|
|||||||
|
|
||||||
seq: int = Field(ge=1)
|
seq: int = Field(ge=1)
|
||||||
formula: str | None = Field(default=None, max_length=2000)
|
formula: str | None = Field(default=None, max_length=2000)
|
||||||
|
#: 빠지면(null) 양식 반올림 — 식과 같이 **그 줄의 뜻한 상태 전부**를 보냄.
|
||||||
|
rounding: RoundingEdit | None = None
|
||||||
|
|
||||||
|
|
||||||
class StructureSheetFormulaRequest(BaseModel):
|
class StructureSheetFormulaRequest(BaseModel):
|
||||||
|
|||||||
@@ -13,7 +13,17 @@
|
|||||||
|
|
||||||
import { API_BASE_URL } from "@config/config_frontend";
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
|
import type { FormulaSheet } from "./B08_Quantity_Formula";
|
||||||
|
import {
|
||||||
|
amountText,
|
||||||
|
DESTINATION_LABELS,
|
||||||
|
el,
|
||||||
|
formulaTable,
|
||||||
|
noteText,
|
||||||
|
num,
|
||||||
|
type FormulaEditRow,
|
||||||
|
type Rounding,
|
||||||
|
} from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||||
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
||||||
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
||||||
import {
|
import {
|
||||||
@@ -39,55 +49,15 @@ export interface StructureSheetRow {
|
|||||||
/** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */
|
/** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */
|
||||||
default_formula?: string;
|
default_formula?: string;
|
||||||
destination?: string;
|
destination?: string;
|
||||||
rounding?: { mode: string; digits: number } | null;
|
rounding?: Rounding | null;
|
||||||
|
/** 양식 원래 반올림 — 고친 줄이면 `rounding` 과 다름. */
|
||||||
|
default_rounding?: Rounding | null;
|
||||||
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
|
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
|
||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESTINATION_LABELS: Record<string, string> = {
|
|
||||||
earthwork: "토공집계",
|
|
||||||
material: "자재총괄",
|
|
||||||
unit_price: "일위대가",
|
|
||||||
reference: "보여주기",
|
|
||||||
haul_deduction: "운반 공제",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROUNDING_LABELS: Record<string, string> = {
|
|
||||||
floor: "내림(INT)",
|
|
||||||
trunc: "버림",
|
|
||||||
round: "반올림",
|
|
||||||
ceil_away: "올림",
|
|
||||||
ceil: "위로",
|
|
||||||
round_half_even: "짝수 반올림",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
|
|
||||||
function amountText(row: StructureSheetRow): string {
|
|
||||||
if (row.skipped) return "안 섬";
|
|
||||||
return num(row.unit_amount, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
|
|
||||||
function noteText(row: StructureSheetRow): string {
|
|
||||||
if (row.skipped) return row.reason ?? "";
|
|
||||||
if (row.error) return `⚠ ${row.error}`;
|
|
||||||
// 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③).
|
|
||||||
const origin =
|
|
||||||
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}자리`
|
|
||||||
: origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StructureSheet extends StandardSheetSpec {
|
export interface StructureSheet extends StandardSheetSpec {
|
||||||
height_m: number;
|
height_m: number;
|
||||||
unit_label: string;
|
unit_label: string;
|
||||||
@@ -114,6 +84,13 @@ export interface StructureSheet extends StandardSheetSpec {
|
|||||||
};
|
};
|
||||||
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
||||||
formula_sheet?: FormulaSheet;
|
formula_sheet?: FormulaSheet;
|
||||||
|
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
|
||||||
|
var_candidates?: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value: number | string | null;
|
||||||
|
candidates: { value: number | string; source?: string }[];
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructureSheetsResponse {
|
export interface StructureSheetsResponse {
|
||||||
@@ -142,6 +119,9 @@ const CSS = `
|
|||||||
border: 1px solid var(--color-border); }
|
border: 1px solid var(--color-border); }
|
||||||
.b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); }
|
.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__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; }
|
||||||
|
.b08-sheet__formula select { font-size: 11px; max-width: 11rem; }
|
||||||
|
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
|
||||||
|
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
|
||||||
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
|
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.b08-sheet { flex-direction: column; }
|
.b08-sheet { flex-direction: column; }
|
||||||
@@ -190,11 +170,11 @@ async function putStructureSheetSpec(
|
|||||||
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
|
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
|
/** 고친 식·반올림 저장 — 값은 안 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
|
||||||
async function putStructureSheetFormulas(
|
async function putStructureSheetFormulas(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sheetKey: string,
|
sheetKey: string,
|
||||||
rows: { seq: number; formula: string | null }[],
|
rows: FormulaEditRow[],
|
||||||
): Promise<{ changed: number; errors: string[] }> {
|
): Promise<{ changed: number; errors: string[] }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`,
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`,
|
||||||
@@ -214,170 +194,6 @@ async function putStructureSheetFormulas(
|
|||||||
return { changed: payload.changed ?? 0, errors: payload.errors ?? [] };
|
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,
|
|
||||||
text = "",
|
|
||||||
): HTMLElementTagNameMap[K] {
|
|
||||||
const node = document.createElement(tag);
|
|
||||||
node.className = className;
|
|
||||||
node.textContent = text;
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function num(value: number | null | undefined, digits: number): string {
|
|
||||||
if (value === null || value === undefined || Number.isNaN(value)) return "-";
|
|
||||||
return value.toLocaleString("ko-KR", {
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function warn(title: string, items: string[]): HTMLElement | null {
|
function warn(title: string, items: string[]): HTMLElement | null {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
|
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
|
||||||
@@ -452,6 +268,18 @@ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLEleme
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const item of sheet.var_candidates ?? []) {
|
||||||
|
const others = item.candidates
|
||||||
|
.map((c) => `${c.value}${c.source ? `(${c.source})` : ""}`)
|
||||||
|
.join(" · ");
|
||||||
|
main.append(
|
||||||
|
el(
|
||||||
|
"p",
|
||||||
|
"b08-grid__caption",
|
||||||
|
`대안 후보 — ${item.label}: 지금 ${item.value} · 후보 ${others}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const notice of [
|
for (const notice of [
|
||||||
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
|
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
|
||||||
warn(
|
warn(
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B08_Quantity_UI_StructureSheet_Formula.ts
|
||||||
|
* 구조물도 양식 장의 **식·반올림 칸 표** (PLAN 3장 ⑤ · 반올림 칸) — 700줄 제한으로 본 파일에서 뗌.
|
||||||
|
*
|
||||||
|
* ⚠ 칸을 고치면 **이 화면이 같은 풀이기(TS)로 즉시** 다시 풂(왕복 없음) · 값은 [식 저장] 때 서버가 다시 냄.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
evaluateSheet,
|
||||||
|
type FormulaRounding,
|
||||||
|
type FormulaSheet,
|
||||||
|
type RoundingMode,
|
||||||
|
} from "./B08_Quantity_Formula";
|
||||||
|
import type { StructureSheet, StructureSheetRow } from "./B08_Quantity_UI_StructureSheet";
|
||||||
|
|
||||||
|
export const DESTINATION_LABELS: Record<string, string> = {
|
||||||
|
earthwork: "토공집계",
|
||||||
|
material: "자재총괄",
|
||||||
|
unit_price: "일위대가",
|
||||||
|
reference: "보여주기",
|
||||||
|
haul_deduction: "운반 공제",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반올림 갈래 — **엑셀 이름을 함께 보임**(명세 13장 대응표). ⚠ INT↔ROUNDDOWN · 위로↔ROUNDUP 은
|
||||||
|
* 양수에서 같고 **음수에서만 갈려** 시험을 빠져나감 — 이름만 보고 고르면 틀림.
|
||||||
|
*/
|
||||||
|
export const ROUNDING_LABELS: Record<string, string> = {
|
||||||
|
none: "안 함",
|
||||||
|
floor: "내림 — 엑셀 INT",
|
||||||
|
trunc: "버림 — 엑셀 ROUNDDOWN",
|
||||||
|
round: "사사오입 — 엑셀 ROUND",
|
||||||
|
ceil_away: "올림 — 엑셀 ROUNDUP",
|
||||||
|
ceil: "위로(+쪽) — 엑셀 없음",
|
||||||
|
round_half_even: "짝수 반올림 — 엑셀 없음",
|
||||||
|
};
|
||||||
|
export const ROUNDING_HINT =
|
||||||
|
"음수에서 갈림: -2.5 → INT -3 · ROUNDDOWN -2 · ROUNDUP -3 · 위로 -2 (양수는 INT=ROUNDDOWN)";
|
||||||
|
|
||||||
|
export type Rounding = { mode: string; digits: number };
|
||||||
|
|
||||||
|
/** 같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나(서버 `rounding_key` 와 같은 뜻). */
|
||||||
|
export function roundingKey(rounding: Rounding | null | undefined): string {
|
||||||
|
const mode = rounding?.mode ?? "none";
|
||||||
|
return mode === "none" ? "none" : `${mode}:${Math.trunc(rounding?.digits ?? 0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
|
||||||
|
export function amountText(row: StructureSheetRow): string {
|
||||||
|
if (row.skipped) return "안 섬";
|
||||||
|
return num(row.unit_amount, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
|
||||||
|
export function noteText(row: StructureSheetRow): string {
|
||||||
|
if (row.skipped) return row.reason ?? "";
|
||||||
|
if (row.error) return `⚠ ${row.error}`;
|
||||||
|
// 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③).
|
||||||
|
const origin =
|
||||||
|
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}자리`
|
||||||
|
: origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormulaEditRow = { seq: number; formula: string | null; rounding: Rounding | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 양식 장의 원단위 수량표 — 식·반올림 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂.
|
||||||
|
* `onSave` 는 바뀐 줄의 **뜻한 상태 전부**(식·반올림)를 받음 — 양식과 같은 칸은 null. 값은 서버가 다시 냄.
|
||||||
|
*/
|
||||||
|
export function formulaTable(
|
||||||
|
sheet: StructureSheet,
|
||||||
|
onSave: (rows: FormulaEditRow[]) => 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 ?? ""}|${roundingKey(row.rounding)}`]),
|
||||||
|
);
|
||||||
|
const defaults = new Map(
|
||||||
|
sheet.rows.map((row) => [
|
||||||
|
row.no,
|
||||||
|
{ formula: row.default_formula ?? "", rounding: row.default_rounding ?? null },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const cells = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
amount: HTMLElement;
|
||||||
|
note: HTMLElement;
|
||||||
|
input: HTMLInputElement;
|
||||||
|
mode: HTMLSelectElement;
|
||||||
|
digits: HTMLInputElement;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
// 칸에서 읽은 한 줄 — 빈 식은 양식 식(되돌림).
|
||||||
|
const current = (seq: number): { formula: string; rounding: FormulaRounding } => {
|
||||||
|
const cell = cells.get(seq)!;
|
||||||
|
return {
|
||||||
|
formula: cell.input.value.trim() || defaults.get(seq)?.formula || "",
|
||||||
|
rounding: {
|
||||||
|
mode: cell.mode.value as RoundingMode,
|
||||||
|
digits: Math.trunc(Number(cell.digits.value) || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const stateOf = (seq: number): string => {
|
||||||
|
const now = current(seq);
|
||||||
|
return `${now.formula}|${roundingKey(now.rounding)}`;
|
||||||
|
};
|
||||||
|
const isUser = (seq: number): boolean => {
|
||||||
|
const now = current(seq);
|
||||||
|
const base = defaults.get(seq);
|
||||||
|
return (
|
||||||
|
now.formula !== base?.formula || roundingKey(now.rounding) !== roundingKey(base?.rounding)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (cells.has(row.seq) ? { ...row, ...current(row.seq) } : 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 local: StructureSheetRow = {
|
||||||
|
...row,
|
||||||
|
unit_amount: result.amount === null ? null : Number(result.amount),
|
||||||
|
skipped: result.skipped,
|
||||||
|
reason: result.reason ?? "",
|
||||||
|
error: result.error ?? "",
|
||||||
|
rounding: current(result.seq).rounding,
|
||||||
|
source: isUser(result.seq) ? "user" : "library",
|
||||||
|
};
|
||||||
|
cell.amount.textContent = amountText(local);
|
||||||
|
cell.note.textContent = noteText(local);
|
||||||
|
const changed = stateOf(result.seq) !== saved.get(result.seq);
|
||||||
|
cell.input.classList.toggle("is-changed", changed);
|
||||||
|
cell.mode.classList.toggle("is-changed", changed);
|
||||||
|
cell.digits.disabled = cell.mode.value === "none";
|
||||||
|
}
|
||||||
|
const dirty = [...cells.keys()].some((seq) => stateOf(seq) !== 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", recompute);
|
||||||
|
// 반올림 — 고르개에 엑셀 이름을 함께 적음(명세 13장 대응표, 음수에서만 갈리는 함정).
|
||||||
|
const base = row.default_rounding ?? null;
|
||||||
|
const baseText = `${ROUNDING_LABELS[base?.mode ?? "none"]}${base && base.mode !== "none" ? ` ${base.digits}자리` : ""}`;
|
||||||
|
const mode = document.createElement("select");
|
||||||
|
mode.title = `${ROUNDING_HINT} · 양식: ${baseText}`;
|
||||||
|
for (const [value, label] of Object.entries(ROUNDING_LABELS)) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = label;
|
||||||
|
mode.append(option);
|
||||||
|
}
|
||||||
|
mode.value = row.rounding?.mode ?? "none";
|
||||||
|
mode.addEventListener("change", recompute);
|
||||||
|
const digits = document.createElement("input");
|
||||||
|
digits.type = "number";
|
||||||
|
digits.min = "-6";
|
||||||
|
digits.max = "10";
|
||||||
|
digits.step = "1";
|
||||||
|
digits.value = String(row.rounding?.digits ?? 0);
|
||||||
|
digits.title = "자리수 — 2 는 소수 둘째 자리, -1 은 10의 자리";
|
||||||
|
digits.className = "b08-sheet__digits";
|
||||||
|
digits.addEventListener("input", recompute);
|
||||||
|
const revert = el("button", "", "양식대로");
|
||||||
|
revert.type = "button";
|
||||||
|
revert.title = "이 줄의 식·반올림을 양식 원래 값으로 되돌림";
|
||||||
|
revert.addEventListener("click", () => {
|
||||||
|
input.value = defaults.get(row.no)?.formula ?? "";
|
||||||
|
mode.value = base?.mode ?? "none";
|
||||||
|
digits.value = String(base?.digits ?? 0);
|
||||||
|
recompute();
|
||||||
|
});
|
||||||
|
line.append(input, mode, digits, 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, mode, digits });
|
||||||
|
tbody.append(tr);
|
||||||
|
}
|
||||||
|
grid.append(thead, tbody);
|
||||||
|
scroller.append(grid);
|
||||||
|
|
||||||
|
saveButton.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
saveButton.disabled = true;
|
||||||
|
status.textContent = "저장 중…";
|
||||||
|
const rows = [...cells.keys()]
|
||||||
|
.filter((seq) => stateOf(seq) !== saved.get(seq))
|
||||||
|
.map((seq) => {
|
||||||
|
const now = current(seq);
|
||||||
|
const base = defaults.get(seq);
|
||||||
|
// 양식과 같은 칸은 null — 서버가 「고친 적 없음」으로 지움.
|
||||||
|
return {
|
||||||
|
seq,
|
||||||
|
formula: now.formula !== base?.formula ? now.formula : null,
|
||||||
|
rounding:
|
||||||
|
roundingKey(now.rounding) !== roundingKey(base?.rounding) ? now.rounding : 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", () => {
|
||||||
|
for (const row of sheet.rows) {
|
||||||
|
const cell = cells.get(row.no);
|
||||||
|
if (!cell) continue;
|
||||||
|
cell.input.value = row.formula ?? "";
|
||||||
|
cell.mode.value = row.rounding?.mode ?? "none";
|
||||||
|
cell.digits.value = String(row.rounding?.digits ?? 0);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
className: string,
|
||||||
|
text = "",
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
node.className = className;
|
||||||
|
node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num(value: number | null | undefined, digits: number): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return "-";
|
||||||
|
return value.toLocaleString("ko-KR", {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -167,6 +167,76 @@ def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다
|
|||||||
assert stored["quantity"]["structure_formula_overrides"] == {}
|
assert stored["quantity"]["structure_formula_overrides"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_줄마다_반올림을_고치면_m당에서_반올림하고_뒷줄이_그_값을_본다(
|
||||||
|
client: TestClient, project: Path
|
||||||
|
) -> None:
|
||||||
|
"""PLAN 3장 반올림 칸 — 실무 구조물도처럼 m당 값을 반올림 · 원단위는 m당 × 연장."""
|
||||||
|
sheet = _sheet(client)
|
||||||
|
rows = {row["name"]: row for row in sheet["rows"]}
|
||||||
|
area = rows["돌쌓기"]["unit_amount"] # 2.5 × √1.09 ≈ 2.61
|
||||||
|
saved = client.put(
|
||||||
|
f"{SHEETS}/formulas",
|
||||||
|
json={
|
||||||
|
"sheet_key": sheet["key"],
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"seq": rows["돌쌓기"]["no"],
|
||||||
|
"formula": None,
|
||||||
|
"rounding": {"mode": "floor", "digits": 0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert saved.status_code == 200 and saved.json()["changed"] == 1, saved.text
|
||||||
|
after = {row["name"]: row for row in _sheet(client)["rows"]}
|
||||||
|
assert after["돌쌓기"]["unit_amount"] == pytest.approx(2.0) and area > 2.5
|
||||||
|
assert after["돌쌓기"]["source"] == "user"
|
||||||
|
assert after["돌쌓기"]["formula"] == rows["돌쌓기"]["formula"] # 식은 그대로
|
||||||
|
assert after["돌쌓기"]["default_rounding"] == {"mode": "none", "digits": 0}
|
||||||
|
# 뒷줄(모르터 = A × 0.009)이 반올림한 A 를 봄.
|
||||||
|
assert after["모르터"]["unit_amount"] == pytest.approx(2.0 * 0.009)
|
||||||
|
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||||||
|
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
|
||||||
|
str(rows["돌쌓기"]["no"]): {"rounding": {"mode": "floor", "digits": 0}}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 원단위(build_table) = m당 반올림 값 × 연장 10m — 합계를 반올림하지 않음.
|
||||||
|
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, structure_formulas=stored["quantity"]["structure_formula_overrides"]
|
||||||
|
)
|
||||||
|
parts = {c["name"]: c for c in table["structures"][0]["components"]}
|
||||||
|
assert parts["돌쌓기"]["amount"] == pytest.approx(20.0)
|
||||||
|
assert parts["모르터"]["amount"] == pytest.approx(0.18)
|
||||||
|
assert parts["돌쌓기"]["basis"] == "사용자 반올림 = INT 0자리 (양식 안 함)"
|
||||||
|
|
||||||
|
# 되돌리기 — 반올림 null 이면 양식대로, 저장 칸에서도 지워짐.
|
||||||
|
client.put(
|
||||||
|
f"{SHEETS}/formulas",
|
||||||
|
json={"sheet_key": sheet["key"], "rows": [{"seq": rows["돌쌓기"]["no"], "rounding": None}]},
|
||||||
|
)
|
||||||
|
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
|
||||||
|
assert stored["quantity"]["structure_formula_overrides"] == {}
|
||||||
|
# 모르는 갈래는 받지 않음.
|
||||||
|
bad = client.put(
|
||||||
|
f"{SHEETS}/formulas",
|
||||||
|
json={
|
||||||
|
"sheet_key": sheet["key"],
|
||||||
|
"rows": [{"seq": 1, "rounding": {"mode": "bankers", "digits": 0}}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert bad.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_대안_후보를_싣는다(client: TestClient) -> None:
|
||||||
|
candidates = {item["name"]: item for item in _sheet(client)["var_candidates"]}
|
||||||
|
assert candidates["HOLE_AREA"]["value"] == 2
|
||||||
|
assert candidates["HOLE_AREA"]["candidates"][0]["value"] == 2.5
|
||||||
|
|
||||||
|
|
||||||
def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None:
|
def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None:
|
||||||
"""고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음."""
|
"""고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음."""
|
||||||
sheet = _sheet(client)
|
sheet = _sheet(client)
|
||||||
|
|||||||
Reference in New Issue
Block a user