From c7d917864c47bb09da192c0812d4557293b221a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:18:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=95=94=20=EC=8B=9C=EA=B3=B5?= =?UTF-8?q?=EB=B2=95=20=EC=B9=B8=20=C2=B7=20=EA=B4=80=EA=B8=89=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=ED=91=9C=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=81=AC=EA=B8=B0=20=EC=9A=94=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 암 시공법(ripping/blasting)을 갈래 비율 칸 아래에 붙임. 갈래 이름만으로는 품셈 공종(암절취 FP-09-04 / 발파암 FP-09-05)을 못 고르던 자리. 기본은 「안 정함」이고, 안 정하면 인계에서 사유와 함께 드러남. 비율 미입력 상태의 「암」 한 줄에도 칸을 냄. - 관급/사급은 표 안에서 줄마다 고름. 관급 줄에만 설치주체가 열리고 사급으로 되돌리면 잠기며 비워짐. 만진 줄은 표시가 남음. - 산출 요약(최소·중앙·최대)을 원단위·자재총괄·인계에 붙임. 단위별로 갈라 냄 — 값이 있기만 하면 시험이 못 잡는 자릿수 어긋남을 사람이 훑게 하는 장치. 화면에서 걸려 고친 것 둘 - 관급을 골라도 설치주체 칸이 잠긴 채 남던 것. 고른 즉시 열고 닫게 함. - 한 번 고른 시공법을 되돌릴 길이 없던 것. 설정 저장이 병합이라 빈 값을 보내도 옛 값이 남았음. `save_section(replace_keys=…)` 로 되돌릴 수 있어야 하는 칸만 통째로 갈아 끼움. 나머지는 그대로 병합. 앞 커밋에서 온 타입 오류 2건도 고침 (`slopeColumnCount` 미사용, `variant: "outlined"` 는 없는 값). 앞선 확인에서 npx 가 엉뚱한 패키지를 실행해 「오류 없음」으로 잘못 봤음 — tsc 는 config/node_modules 것으로 부를 것. 검증 — 새 시험 5건 + 인계 28건 통과, 전체 509 passed. tsc 오류 0. 화면 실조작으로 시공법 저장·되돌리기, 관급/설치주체 저장·되돌리기 확인 후 검증으로 바꾼 값은 원래대로 복원. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 57 ++++++++- .../B08_Quantity_Engine_MaterialSummary.py | 7 ++ .../B08_Quantity_Engine_UnitQuantity.py | 3 + B08_Quantity/B08_Quantity_Router_Earthwork.py | 26 +++- B08_Quantity/B08_Quantity_Router_Material.py | 7 +- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 8 +- .../B08_Quantity_UI_EarthworkGrid_Style.ts | 19 +++ B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 118 +++++++++++++++++- B08_Quantity/B08_Quantity_UI_Page.ts | 94 ++++++++++++-- common_util/common_util_project_settings.py | 40 +++++- common_util/common_util_quantity_spread.py | 47 +++++++ ui_template/ui_template_locale_b2.ts | 4 + 12 files changed, 402 insertions(+), 28 deletions(-) create mode 100644 common_util/common_util_quantity_spread.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 9c42b7d5..f7cbef49 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -34,6 +34,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from common_util.common_util_quantity_spread import spread_by_unit + DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" DATASET_PREFIX = "work_item_mapping_" @@ -43,6 +45,11 @@ ORIGIN_STRUCTURE = "structure" ORIGIN_SLOPE = "slope" ORIGIN_HAUL = "haul" +#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 +#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). +METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} +NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" + #: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) @@ -68,7 +75,7 @@ class WorkItemMapping: structure: list[dict[str, Any]] = field(default_factory=list) pending_user: dict[str, Any] = field(default_factory=dict) - def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" exact = [ row @@ -121,8 +128,25 @@ def _spec_detail(structure: dict[str, Any]) -> str: return "·".join(parts) +def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: + """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. + + 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. + 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. + """ + if ground is None or ground == "토사": + return ground, "" + method = methods.get(ground) + mapped = METHOD_TO_GROUND.get(method or "") + if mapped: + return mapped, "" + return None, NOTE_METHOD_MISSING + + def _earthwork_rows( - summary_table: dict[str, Any], mapping: WorkItemMapping + summary_table: dict[str, Any], + mapping: WorkItemMapping, + methods: dict[str, str | None], ) -> tuple[list[dict[str, Any]], list[str]]: """토공집계표 줄을 내역 줄로 옮긴다. @@ -138,10 +162,12 @@ def _earthwork_rows( ground = row.get("item") or None origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK is_subtotal = group in SUBTOTAL_GROUPS - entry = mapping.for_earthwork(group, ground) + lookup_ground, method_note = _mapping_ground(ground, methods) + entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None code = (entry or {}).get("work_item_code") if code is None and not is_subtotal: - unmatched.append(f"{group}({ground})" if ground else group) + label = f"{group}({ground})" if ground else group + unmatched.append(f"{label} — {method_note}" if method_note else label) rows.append( { "work_item_code": code, @@ -157,6 +183,7 @@ def _earthwork_rows( "spec_detail": "", # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. "in_bill": bool(row.get("in_bill", True)) and not is_subtotal, + "excavation_method": methods.get(ground) if ground else None, "in_bill_reason": "집계 합계 줄 — 검산용" if is_subtotal else str(row.get("note") or ""), @@ -268,14 +295,16 @@ def build_handoff( mapping: WorkItemMapping | None = None, ground_class_set: str | None = None, ground_classes: list[str] | None = None, + ground_methods: dict[str, str | None] | None = None, ) -> dict[str, Any]: """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" table = mapping or load_mapping() work_items: list[dict[str, Any]] = [] unmatched: list[str] = [] + methods = {key: value for key, value in (ground_methods or {}).items() if value} if summary_table: - rows, misses = _earthwork_rows(summary_table, table) + rows, misses = _earthwork_rows(summary_table, table, methods) work_items.extend(rows) unmatched.extend(misses) if haul_table: @@ -294,11 +323,29 @@ def build_handoff( # 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다. "ground_class_set": ground_class_set, "ground_classes": list(ground_classes or []), + "ground_methods": dict(methods), + # 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다. + "missing_method_classes": sorted( + { + str(row.get("ground_class")) + for row in work_items + if row.get("ground_class") + and row.get("ground_class") != "토사" + and row.get("work_item_code") is None + and row.get("origin") == ORIGIN_EARTHWORK + } + ), # 자재 쪽에만 할증이 있다 — 작업 공종에는 없다. "surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")), "unmatched_work_items": sorted(set(unmatched)), "mapping_pending_user": table.pending_user, "mapping_edition": table.effective_date, + "quantity_spread": spread_by_unit( + [row for row in work_items if row["in_bill"]], value_key="quantity" + ), + "material_spread": spread_by_unit( + [{"unit": row["unit"], "q": row["total_amount"]} for row in materials], value_key="q" + ), "bill_row_count": sum(1 for row in work_items if row["in_bill"]), "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), } diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index 90df4a85..81d09270 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -46,6 +46,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from common_util.common_util_quantity_spread import spread_by_unit + # ── 데이터 자리 ────────────────────────────────────────────────────── DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge" DATASET_PREFIX = "material_surcharge_" @@ -303,4 +305,9 @@ def build_table( "double_count_warnings": verify_single_surcharge(unit_quantity_table), "skipped_by_destination": skipped, "row_count": len(ordered), + # 값의 크기가 말이 되나 — 자릿수 어긋남은 사람이 훑어야 보인다(단위별로 가른다). + "amount_spread": spread_by_unit( + [{"unit": row.unit, "amount": row.total_amount} for row in ordered], + value_key="amount", + ), } diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 1325f3d9..0d7601d5 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -34,6 +34,8 @@ import math from dataclasses import dataclass, field from typing import Any, Iterable +from common_util.common_util_quantity_spread import spread_by_unit + # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). @@ -347,4 +349,5 @@ def build_table( "surcharge_applied": False, "mix_components_found": violations, "structure_count": len(quantities), + "amount_spread": spread_by_unit(totals.values(), value_key="amount"), } diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index e9b9b314..02c6d373 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -35,6 +35,7 @@ from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes from common_util.common_util_project_settings import ( + ROCK_METHODS, application_ratio, quantity_settings, rock_classes, @@ -130,7 +131,12 @@ class QuantitySettingsBody(BaseModel): rock_class_set: str | None = None rock_classes: list[str] | None = None rock_ratios_pct: dict[str, float] | None = None + # 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다. + rock_methods: dict[str, str] | None = None application_ratios_pct: dict[str, float] | None = None + # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. + # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). + material_supply: dict[str, Any] | None = None @router.put("/{project_id}/quantity/settings") @@ -150,8 +156,20 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, ) values = {key: value for key, value in body.model_dump().items() if value is not None} + if "rock_methods" in values: + # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 + # 여기서 버리면 그 갈래는 미지정으로 돌아간다. + values["rock_methods"] = { + name: method + for name, method in values["rock_methods"].items() + if method in ROCK_METHODS + } try: - saved = await asyncio.to_thread(save_section, root, "quantity", values) + # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 + # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). + saved = await asyncio.to_thread( + _save_quantity, root, values, ("rock_methods", "material_supply") + ) except Exception: logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) return JSONResponse( @@ -161,6 +179,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}}) +def _save_quantity( + root: str, values: dict[str, Any], replace_keys: tuple[str, ...] +) -> dict[str, Any]: + return save_section(root, "quantity", values, replace_keys=replace_keys) + + @router.get("/{project_id}/quantity/earthwork-table") async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse: """경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다.""" diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index c61790cb..53c2b928 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -28,7 +28,11 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table -from common_util.common_util_project_settings import quantity_settings, rock_classes +from common_util.common_util_project_settings import ( + quantity_settings, + rock_classes, + rock_method, +) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection @@ -143,6 +147,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table=material_table, ground_class_set=settings.get("rock_class_set"), ground_classes=rock_classes(settings), + ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)}, ) handoff["summary"] = summarize(handoff) handoff["skipped_structures"] = skipped diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 87b97a84..ade6b38f 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -59,6 +59,10 @@ export interface QuantitySettings { rock_classes?: string[]; rock_ratios_pct?: Record; application_ratios_pct?: Record; + /** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */ + rock_methods?: Record; + /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ + material_supply?: Record; } export interface EarthworkTable { @@ -188,10 +192,6 @@ const PAIR_LABELS = ["단면적", "입 적"]; const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); -/** 사면 열 개수 — 계열마다 (거리, 면적) 두 칸. */ -const slopeColumnCount = (): number => - SLOPE_GROUPS.reduce((n, group) => n + group.faces.length * 2, 0); - /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ function stationLabel(chainage: number, interval = 20): string { const no = Math.floor(chainage / interval); diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index 9e06c426..c3d9dcbb 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -13,6 +13,25 @@ const STYLE_ID = "b08-earthwork-grid-style"; const CSS = ` .b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; } +/* 표 안에서 고르는 칸 — 관급/사급처럼 **줄마다 갈리는 값**을 여기서 정한다. */ +.b08-grid__select { + width: 100%; + min-width: 5.5rem; + padding: 0.15rem 0.25rem; + font: inherit; + color: var(--color-text); + background: var(--color-surface-raised); + border: 1px solid var(--color-border, rgba(128, 128, 128, 0.4)); + border-radius: 3px; +} +.b08-grid__select:disabled { + opacity: 0.45; /* 사급 줄의 설치 주체 — 뜻이 없으므로 흐리게 둔다 */ +} +/* 만진 줄은 표시가 남는다 — 무엇을 바꿨는지 보여야 한다. */ +.b08-grid__table td.is-changed { + box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf); +} + .b08-grid__caption { margin: 0; font-size: 12px; diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index bb301fd8..d4ce6b13 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -33,6 +33,7 @@ export interface MaterialTable { missing_rate_materials: string[]; missing_supply_materials: string[]; missing_install_by_materials: string[]; + amount_spread: Record; double_count_warnings: string[]; skipped_by_destination: Record; row_count: number; @@ -110,8 +111,78 @@ function warning(title: string, items: string[]): HTMLElement | null { return element; } +/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */ +const SUPPLY_OPTIONS = [ + { value: "unknown", label: "미분류" }, + { value: "contractor_supplied", label: "사급" }, + { value: "owner_supplied", label: "관급" }, +]; +const INSTALL_BY_OPTIONS = [ + { value: "", label: "미지정" }, + { value: "contractor", label: "도급자설치" }, + { value: "owner", label: "관 직접설치" }, +]; + +export interface SupplyChoice { + supply: string; + install_by: string | null; +} + +export interface MaterialGridOptions { + /** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */ + choices: Record; + onChange: () => void; +} + +/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */ +function choiceCell( + value: string, + options: { value: string; label: string }[], + disabled: boolean, + onChange: (value: string) => void, +): HTMLTableCellElement { + const td = document.createElement("td"); + const select = document.createElement("select"); + select.className = "b08-grid__select"; + for (const option of options) { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.append(element); + } + select.value = value; + select.disabled = disabled; + select.addEventListener("change", () => { + onChange(select.value); + td.classList.add("is-changed"); + }); + td.append(select); + return td; +} + +/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */ +function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null { + const units = Object.keys(spread || {}); + if (!units.length) return null; + const element = document.createElement("p"); + element.className = "b08-grid__caption"; + element.textContent = + title + + " " + + units + .map((unit) => { + const s = spread[unit]; + return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`; + }) + .join(" / "); + return element; +} + /** 자재총괄표 — 할증이 붙는 유일한 자리. */ -export function renderMaterialGrid(table: MaterialTable): HTMLElement { +export function renderMaterialGrid( + table: MaterialTable, + options?: MaterialGridOptions, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -121,6 +192,9 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement { caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`; wrap.append(caption); + const spread = spreadLine(table.amount_spread, "물량 크기:"); + if (spread) wrap.append(spread); + for (const notice of [ warning("⚠ 중복 할증 위험", table.double_count_warnings), warning("할증률 미확보", table.missing_rate_materials), @@ -153,8 +227,46 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement { // 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다. tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0))); tr.append(textCell(num(row.total_amount, 2))); - tr.append(textCell(row.supply_label)); - tr.append(textCell(row.install_by_label)); + if (options) { + // 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정). + const chosen = options.choices[row.name] ?? { + supply: row.supply, + install_by: row.install_by, + }; + const installCell = choiceCell( + chosen.install_by ?? "", + INSTALL_BY_OPTIONS, + chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다 + (value) => { + const current = options.choices[row.name] ?? chosen; + options.choices[row.name] = { supply: current.supply, install_by: value || null }; + options.onChange(); + }, + ); + tr.append( + choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => { + const current = options.choices[row.name] ?? chosen; + const next = { + // 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다. + supply: value, + install_by: value === "owner_supplied" ? (current.install_by ?? null) : null, + }; + options.choices[row.name] = next; + // ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도 + // 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리). + const select = installCell.querySelector("select") as HTMLSelectElement | null; + if (select) { + select.disabled = value !== "owner_supplied"; + select.value = next.install_by ?? ""; + } + options.onChange(); + }), + ); + tr.append(installCell); + } else { + tr.append(textCell(row.supply_label)); + tr.append(textCell(row.install_by_label)); + } tr.append(textCell(row.note, "b08-grid__note")); body.append(tr); } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index e9e7b040..06ee458f 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -74,6 +74,10 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr rock_class_set: draft.rock_class_set ?? null, rock_ratios_pct: draft.rock_ratios_pct, application_ratios_pct: draft.application_ratios_pct, + // ⚠ 「안 정함」으로 되돌린 갈래까지 **통째로** 보낸다. 정한 것만 보내면 서버가 + // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. + rock_methods: draft.rock_methods, + material_supply: draft.material_supply, }), }, ); @@ -124,11 +128,47 @@ function numberField(label: string, value: number, onInput: (value: number) => v return row; } +/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */ +function selectField( + label: string, + value: string, + options: { value: string; label: string }[], + onChange: (value: string) => void, +): HTMLElement { + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const select = document.createElement("select"); + select.className = "b08-quantity__input"; + for (const option of options) { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.append(element); + } + select.value = value; + // 자동저장은 만들지 않는다 — 고른 값은 캐시에만 남는다(CLAUDE.md 5장). + select.addEventListener("change", () => onChange(select.value)); + row.append(name, select); + return row; +} + +/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */ +export interface SupplyChoice { + supply: string; + install_by: string | null; +} + /** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */ interface DraftSettings { rock_class_set?: string; rock_ratios_pct: Record; application_ratios_pct: Record; + // 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다. + rock_methods: Record; + // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. + material_supply: Record; dirty: boolean; } @@ -154,16 +194,42 @@ function buildQuantitySidePanel( } // ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ── - const classes = table?.summary?.rock_classes ?? []; + const classes = [...(table?.summary?.rock_classes ?? [])]; + // ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도 + // 시공법을 정할 수 있어야 공종이 선다 — 그때만 칸을 하나 더 낸다. + const hasRockFallback = (table?.summary?.rows ?? []).some((row) => row.item === "암"); + if (hasRockFallback && !classes.includes("암")) classes.push("암"); if (classes.length) { panel.append(field(L("B08_Quantity_Side_RockRatios"), "")); for (const name of classes) { - panel.append( - numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => { - draft.rock_ratios_pct[name] = value; - draft.dirty = true; - }), - ); + // 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다. + if (name !== "암") { + panel.append( + numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => { + draft.rock_ratios_pct[name] = value; + draft.dirty = true; + }), + ); + } + // ⚠ 암 갈래는 **시공법까지 정해야** 공종이 갈린다 — 품셈이 긁어내기(암절취)와 + // 터뜨리기(발파암)를 다른 공종으로 두기 때문이다. 「토사」에는 안 붙인다. + if (name !== "토사") { + panel.append( + selectField( + ` ${name} ${L("B08_Quantity_Side_Method_Label")}`, + draft.rock_methods[name] ?? "", + [ + { value: "", label: L("B08_Quantity_Method_Unset") }, + { value: "ripping", label: L("B08_Quantity_Method_Ripping") }, + { value: "blasting", label: L("B08_Quantity_Method_Blasting") }, + ], + (value) => { + draft.rock_methods[name] = value; + draft.dirty = true; + }, + ), + ); + } } } @@ -183,7 +249,7 @@ function buildQuantitySidePanel( const saveButton = createButton({ label: L("B08_Quantity_Btn_Save"), - variant: "outlined", + variant: "ghost", onClick: () => { if (!projectId) { showToast(L("B08_Quantity_Save_Failed"), "error"); @@ -240,6 +306,7 @@ function buildQuantityBody( table: EarthworkTable | null, failed: boolean, material: MaterialResponse | null, + draft: DraftSettings, ): HTMLElement { const body = document.createElement("div"); body.className = "b08-quantity__body"; @@ -288,7 +355,12 @@ function buildQuantityBody( label: L("B08_Quantity_Tab_Material"), build: () => material - ? renderMaterialGrid(material.material) + ? renderMaterialGrid(material.material, { + choices: draft.material_supply, + onChange: () => { + draft.dirty = true; + }, + }) : message(L("B08_Quantity_Material_Failed")), }, ]; @@ -344,6 +416,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise { rock_class_set: stored.rock_class_set, rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, + rock_methods: { ...((stored.rock_methods ?? {}) as Record) }, + material_supply: { ...((stored.material_supply ?? {}) as Record) }, dirty: false, }; const reload = (): void => { @@ -372,7 +446,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 5, leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), - mainContent: buildQuantityBody(table, failed, material), + mainContent: buildQuantityBody(table, failed, material, draft), stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index c9099467..9fb57302 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -33,7 +33,7 @@ from __future__ import annotations import json from pathlib import Path -from typing import Any +from typing import Any, Iterable from common_util.common_util_json import atomic_write_json @@ -57,6 +57,17 @@ ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = { } DEFAULT_ROCK_CLASS_SET = "geochang5" +# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다. +ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취 +ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암 +ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING) + + +def rock_method(settings: dict[str, Any], rock_class: str) -> str | None: + """갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**""" + value = (settings.get("rock_methods") or {}).get(rock_class) + return value if value in ROCK_METHODS else None + def default_settings() -> dict[str, Any]: """빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다.""" @@ -68,10 +79,17 @@ def default_settings() -> dict[str, Any]: # 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 — # 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다. "rock_ratios_pct": {}, + # 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`. + # ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고, + # 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05). + # 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서 + # 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리). + "rock_methods": {}, "conversion_factors_override": None, "haul_limits_m_override": None, "application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS}, - # 자재총괄의 관급/사급 구분 — `{자재명: "public"|"private"}`. + # 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}` + # 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`. # ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로 # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. "material_supply": {}, @@ -124,16 +142,30 @@ def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]: SECTIONS = ("quantity", "estimation") -def save_section(project_root: str | Path, section: str, values: dict[str, Any]) -> dict[str, Any]: +def save_section( + project_root: str | Path, + section: str, + values: dict[str, Any], + *, + replace_keys: Iterable[str] = (), +) -> dict[str, Any]: """한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**. 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는 함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다. + + ⚠ `replace_keys` — **지울 수 있어야 하는 칸**은 병합이 아니라 통째로 갈아 끼운다. + 「고른 값을 안 정함으로 되돌리기」가 병합으로는 안 되기 때문이다(2026-09-07 화면에서 + 걸린 자리 — 시공법을 한 번 고르면 되돌릴 길이 없었다). """ if section not in SECTIONS: raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})") settings = load_settings(project_root) - settings[section] = _merge(settings.get(section) or {}, values) + merged = _merge(settings.get(section) or {}, values) + for key in replace_keys: + if key in values: + merged[key] = values[key] + settings[section] = merged settings["schema_version"] = SCHEMA_VERSION atomic_write_json(settings_path(project_root), settings) return settings diff --git a/common_util/common_util_quantity_spread.py b/common_util/common_util_quantity_spread.py new file mode 100644 index 00000000..13be2b74 --- /dev/null +++ b/common_util/common_util_quantity_spread.py @@ -0,0 +1,47 @@ +"""산출 요약 — 값의 **크기가 말이 되나**를 한눈에 보이는 자리 (2026-09-07 조율 창 권고). + +왜 있나 + 서브 창이 씨앗뿜어붙이기를 **합계 68.8원**으로 세워 두고도 몰랐던 일이 있었다. + 값이 **있기는 하니** 어떤 시험도 안 잡는다. 자릿수가 어긋난 것은 사람이 훑어야 보이고, + 훑으려면 **최솟값·중앙값·최댓값이 표 옆에 떠 있어야** 한다. + +⚠ 이것은 검사가 아니라 **눈에 띄게 하는 장치**다 + 기준을 정해 놓고 걸러 내지 않는다 — 임도 물량은 ㎥·㎡·m·ton·개가 섞여 있어 「얼마 이하면 + 이상하다」를 한 벌로 못 정한다. **단위별로 나눠** 내고 판단은 사람에게 맡긴다. +""" + +from __future__ import annotations + +from statistics import median +from typing import Any, Iterable + + +def spread(values: Iterable[float]) -> dict[str, float] | None: + """최솟값·중앙값·최댓값. 값이 없으면 `None` — 0 으로 만들지 않는다.""" + numbers = [float(v) for v in values if isinstance(v, (int, float))] + if not numbers: + return None + return { + "min": min(numbers), + "median": float(median(numbers)), + "max": max(numbers), + "count": len(numbers), + } + + +def spread_by_unit( + rows: Iterable[dict[str, Any]], *, value_key: str +) -> dict[str, dict[str, float]]: + """단위별로 갈라 낸다. ㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다.""" + buckets: dict[str, list[float]] = {} + for row in rows: + value = row.get(value_key) + if not isinstance(value, (int, float)): + continue + buckets.setdefault(str(row.get("unit") or "?"), []).append(float(value)) + result: dict[str, dict[str, float]] = {} + for unit, numbers in buckets.items(): + found = spread(numbers) + if found: + result[unit] = found + return result diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 27a1ff93..a0180334 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -623,6 +623,10 @@ export const ui_locales_b2 = { B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], + B08_Quantity_Side_Method_Label: ["시공법", "Method"], + B08_Quantity_Method_Unset: ["안 정함", "Not set"], + B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], + B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], B08_Quantity_Material_Failed: [ "자재총괄을 불러오지 못했습니다.", "Failed to load the material summary.",