From cf0ccfac36710aa491d0ddf6495f43d7554564e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 18:47:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EB=B0=98=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=EC=B9=B8=20=E2=80=94=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98,=20=EC=9B=90=EB=8B=A8=EC=9C=84=EB=8F=84=20m=EB=8B=B9?= =?UTF-8?q?=20=EB=B0=98=EC=98=AC=EB=A6=BC=20=C3=97=20=EC=97=B0=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기 - 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림 - build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림 - 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌 - 시험 2개 추가 · 전체 1533 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureLibrary.py | 5 +- .../B08_Quantity_Engine_StructureTemplate.py | 109 +++++-- .../B08_Quantity_Router_StructureSheet.py | 11 + .../B08_Quantity_UI_StructureSheet.ts | 248 +++------------ .../B08_Quantity_UI_StructureSheet_Formula.ts | 300 ++++++++++++++++++ .../tester/test_b08_structure_sheet_router.py | 70 ++++ 6 files changed, 511 insertions(+), 232 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_StructureSheet_Formula.ts diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index 1c54a93c..cc229dec 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -114,9 +114,10 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A type_id = template.get("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)}" - # 고친 식이 곧 이 항목의 식 — 「사용자 식」 표시와 되돌릴 자리는 떼어 냄. + # 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄. + dropped = {"default_formula", "default_rounding"} 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) ] item = {k: v for k, v in template.items() if k != "imported_from"} diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 44ed635e..a01a7fa6 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -79,16 +79,52 @@ def template_vars( 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( template: dict[str, Any], overrides: dict[str, Any] | None ) -> list[dict[str, Any]]: - """양식 줄에 **그 장에서 사용자가 고친 식**을 얹음 — 고친 줄은 출처 `user`, 원래 식은 따로.""" + """양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `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"]} + changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {}) + if changes: + row = { + **row, + **changes, + "source": "user", + "default_formula": row.get("formula"), + "default_rounding": row.get("rounding"), + } rows.append(row) return rows @@ -133,6 +169,7 @@ def _library_rows( "source": source.get("source") or "library", "destination": source.get("destination") or "", "rounding": source.get("rounding"), + "default_rounding": source.get("default_rounding", source.get("rounding")), "skipped": bool(result.get("skipped")), "reason": result.get("reason") or "", "error": result.get("error") or "", @@ -152,6 +189,25 @@ def _downstream_name(result: dict[str, Any]) -> str: 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( quantities: list[Any], inputs: list[dict[str, Any]], @@ -166,6 +222,8 @@ def replace_with_templates( ⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게. ⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠. ⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게. + ⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그 + 반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림. """ from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets 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) if template is None or not quantity.components: continue + # 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`). + if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS: + continue options = item.get("options") or {} face, face_reason = structure_face_role( _section_mode_at(item, section_modes), options.get("side") @@ -189,11 +250,7 @@ def replace_with_templates( "face": face, "face_reason": face_reason, } - structure = { - "height_m": quantity.height_m, - "length_m": quantity.length_m, - "options": options, - } + structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options} values = template_vars(template, structure, slope_of(sheet)[0], settings) overrides = (formula_overrides or {}).get(quantity.type_id) targets.append((quantity, template_sheet(template, values, overrides))) @@ -225,7 +282,7 @@ def replace_with_templates( name = _downstream_name(result) user = source.get("source") == "user" basis = ( - f"사용자 식 = {source.get('formula')} (양식 식 {source.get('default_formula')})" + _user_basis(source) if user else engine_basis.get(name) or str(source.get("formula_text") or "") ) @@ -233,7 +290,7 @@ def replace_with_templates( Component( name, str(source.get("unit") or ""), - float(result["amount"]), + float(result["amount"]) * quantity.length_m, str(source.get("destination") or ""), basis, source="user" if user else engine_source.get(name, ""), @@ -302,6 +359,17 @@ def apply_templates( "imported_from": template.get("imported_from"), } 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( @@ -310,10 +378,11 @@ def save_sheet_overrides( template: dict[str, Any], edits: list[dict[str, Any]], ) -> tuple[dict[str, Any], int]: - """양식 하나(`key` = type_id)의 고친 식을 갈아 끼운 **새 전체 값**과 바뀐 줄 수. + """양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수. - ⚠ 빈 식·양식 식과 같은 식은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면 - 양식이 바뀌어도 그 장만 옛 식으로 남음. + 한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로. + ⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면 + 양식이 바뀌어도 그 장만 옛 값으로 남음. ⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게. """ known = {row["seq"]: row for row in template.get("rows") or []} @@ -324,15 +393,15 @@ def save_sheet_overrides( 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"): + entry = _edit_of(known[seq], edit) + before = sheet.get(str(seq)) + if not entry: if sheet.pop(str(seq), None) is not None: changed += 1 continue - if before != formula: + if before != entry: changed += 1 - sheet[str(seq)] = {"formula": formula} + sheet[str(seq)] = entry if sheet: merged[key] = sheet else: diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index df2eb073..d70ea1c8 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -117,6 +117,15 @@ class StandardSheetSpecRequest(BaseModel): 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): """고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**.""" @@ -124,6 +133,8 @@ class FormulaEdit(BaseModel): seq: int = Field(ge=1) formula: str | None = Field(default=None, max_length=2000) + #: 빠지면(null) 양식 반올림 — 식과 같이 **그 줄의 뜻한 상태 전부**를 보냄. + rounding: RoundingEdit | None = None class StructureSheetFormulaRequest(BaseModel): diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index 2ca5dd3d..a7222fb3 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -13,7 +13,17 @@ 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 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 { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; import { @@ -39,55 +49,15 @@ export interface StructureSheetRow { /** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */ default_formula?: string; destination?: string; - rounding?: { mode: string; digits: number } | null; + rounding?: Rounding | null; + /** 양식 원래 반올림 — 고친 줄이면 `rounding` 과 다름. */ + default_rounding?: Rounding | null; /** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */ skipped?: boolean; reason?: string; error?: string; } -const DESTINATION_LABELS: Record = { - earthwork: "토공집계", - material: "자재총괄", - unit_price: "일위대가", - reference: "보여주기", - haul_deduction: "운반 공제", -}; - -const ROUNDING_LABELS: Record = { - 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 { height_m: number; unit_label: string; @@ -114,6 +84,13 @@ export interface StructureSheet extends StandardSheetSpec { }; /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ formula_sheet?: FormulaSheet; + /** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */ + var_candidates?: { + name: string; + label: string; + value: number | string | null; + candidates: { value: number | string; source?: string }[]; + }[]; } export interface StructureSheetsResponse { @@ -142,6 +119,9 @@ const CSS = ` 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__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; } @media (max-width: 900px) { .b08-sheet { flex-direction: column; } @@ -190,11 +170,11 @@ async function putStructureSheetSpec( return { changed: payload.changed ?? 0, notes: payload.notes ?? [] }; } -/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */ +/** 고친 식·반올림 저장 — 값은 안 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */ async function putStructureSheetFormulas( projectId: string, sheetKey: string, - rows: { seq: number; formula: string | null }[], + rows: FormulaEditRow[], ): Promise<{ changed: number; errors: string[] }> { const response = await fetch( `${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 ?? [] }; } -/** - * 양식 장의 원단위 수량표 — 식 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂. - * `onSave` 는 고친 식만 받음 — 값은 서버가 다시 냄. 되돌린 줄은 빈 식(= 양식 식)으로 감. - */ -function formulaTable( - sheet: StructureSheet, - onSave: (rows: { seq: number; formula: string | null }[]) => Promise, - 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(); - 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( - 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 { if (!items.length) return null; 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 [ warn("단위당을 못 낸 줄", sheet.unpriced_rows), warn( diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Formula.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Formula.ts new file mode 100644 index 00000000..2bd55cfd --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Formula.ts @@ -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 = { + earthwork: "토공집계", + material: "자재총괄", + unit_price: "일위대가", + reference: "보여주기", + haul_deduction: "운반 공제", +}; + +/** + * 반올림 갈래 — **엑셀 이름을 함께 보임**(명세 13장 대응표). ⚠ INT↔ROUNDDOWN · 위로↔ROUNDUP 은 + * 양수에서 같고 **음수에서만 갈려** 시험을 빠져나감 — 이름만 보고 고르면 틀림. + */ +export const ROUNDING_LABELS: Record = { + 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, + 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( + 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, + }); +} diff --git a/resources/tester/test_b08_structure_sheet_router.py b/resources/tester/test_b08_structure_sheet_router.py index a82ab102..d902c4c1 100644 --- a/resources/tester/test_b08_structure_sheet_router.py +++ b/resources/tester/test_b08_structure_sheet_router.py @@ -167,6 +167,76 @@ def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다 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: """고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음.""" sheet = _sheet(client)