From e90a5703ee265816f7d99aa71184063cd4d91d22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:50:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=A4=80=EB=B9=84=EA=B3=B5=C2=B7?= =?UTF-8?q?=EC=82=AC=EB=B0=A9=EA=B3=B5=20=ED=83=AD=20+=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B5=9C=EC=A2=85=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⑪ 준비공·사방공 — 자리를 만들되 없는 값을 지어내지 않음. - ⚠ 벌목은 값을 안 냄. 토공집계의 「지장목제거」로 이미 서 있어 또 세우면 같은 나무를 두 번 벰. 참고 면적만 보이고 「다른 표에서 이미 섬」으로 가리킴. - 못 서는 줄에 사유를 적음 — 표토제거(두께·구간 미정) · 제근(입목 본수 없음) · 규준틀(개소 기준 미정). 공종코드는 미리 적어 둠. - 사방공은 레지스트리의 실제 type_id 로 봄. 없으면 「해당 없음」 — 0 을 적지 않음. 이름을 지어내면 영영 안 걸리므로 레지스트리와 대조하는 시험을 둠. ⑫ 최종 점검 — 탭 6장 전수를 실화면에서 돌려 값으로 서는 것 확인 (토적표 65측점 · 토공집계 10줄 · 운반거리 안내 · 준비공 5줄 · 구조물 원단위 10줄 · 자재총괄 4줄). 전체 566 passed, tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 138 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 19 +++ B08_Quantity/B08_Quantity_UI_Page.ts | 16 +- B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 71 +++++++++ ui_template/ui_template_locale_b2.ts | 1 + 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Preparation.py diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py new file mode 100644 index 00000000..7a3bf0ae --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -0,0 +1,138 @@ +"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3). + +8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은 +값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과 +「원래 없는 것」이 구별되지 않는다. + +⚠⚠ 지장목제거와 겹치지 않는다 (이중계상) + 벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률). + 여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고 + 「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다. + +⚠ 값이 없는 줄의 사유를 적는다 + · 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**. + · 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다. + · 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다. + +⚠ 사방공은 이 노선에 실물이 없으면 「해당 없음」이다 + 있는 것처럼 0 을 적지 않는다. 구조물 목록에 사방 시설이 서면 그때 값이 선다. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +#: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이). +#: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다. +EROSION_CONTROL_TYPES = frozenset( + { + "erosion_check", # 골막이 + "bed_sill", # 바닥막이 + "check_dam_small", # 소형사방댐(복합형) + "revetment", # 기슭막이 + "soil_guard", # 흙막이 + } +) + +STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" +STATUS_PENDING = "값을 낼 근거가 없음" +STATUS_NOT_APPLICABLE = "해당 없음" +STATUS_READY = "값 있음" + + +def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[str, Any]]: + """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" + slope = slope_totals or {} + tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( + slope.get("tree_removal_cut", 0.0) + ) + return [ + { + "group": "준비공", + "item": "벌목·지장목제거", + "unit": "㎡", + "amount": None, + "status": STATUS_COUNTED_ELSEWHERE, + # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. + "reference_amount": tree_area, + "reason": "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상", + "work_item_code": None, + }, + { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": "면적은 사면적에서 나오나 **두께·대상 구간**이 설계로 안 정해져 있음 (품셈 9-15)", + "work_item_code": "FP-09-15", + }, + { + "group": "준비공", + "item": "제근·뿌리다듬기", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", + "work_item_code": "FP-09-21", + }, + { + "group": "준비공", + "item": "규준틀", + "unit": "개소", + "amount": None, + "status": STATUS_PENDING, + "reason": "개소 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있음 (품셈 11-2)", + "work_item_code": "FP-11-02", + }, + ] + + +def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]: + """사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다.""" + found = sorted( + { + str(item.get("type_id")) + for item in structures + if str(item.get("type_id")) in EROSION_CONTROL_TYPES + } + ) + if not found: + return [ + { + "group": "사방공", + "item": "사방 시설", + "unit": "", + "amount": None, + "status": STATUS_NOT_APPLICABLE, + "reason": "이 노선에 사방 시설이 배치돼 있지 않음 — 있는 것처럼 0 을 적지 않음", + "work_item_code": None, + } + ] + return [ + { + "group": "사방공", + "item": type_id, + "unit": "개소", + "amount": None, + "status": STATUS_PENDING, + "reason": "구조물 원단위가 아직 없음 — 전개식·관측값 모두 미확보", + "work_item_code": None, + } + for type_id in found + ] + + +def build_table( + slope_totals: dict[str, float] | None = None, + structures: Iterable[dict[str, Any]] = (), +) -> dict[str, Any]: + """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" + rows = preparation_rows(slope_totals) + erosion_rows(structures) + return { + "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], + "rows": rows, + "ready_count": sum(1 for row in rows if row["status"] == STATUS_READY), + "pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING), + "row_count": len(rows), + } diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 02c6d373..f52240cf 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -31,6 +31,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table +from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table 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 @@ -94,12 +95,30 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: }, ) ) + # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). + table["preparation"] = build_preparation_table( + slope.get("totals") or {}, await _route_structures(project_id) + ) table["settings"] = settings table["project_root_known"] = project_root is not None table["route_id"] = route_id return JSONResponse(content=table) +async def _route_structures(project_id: UUID) -> list[dict[str, Any]]: + """배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록.""" + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + from B05_Profile.B05_Profile_Structures_Repository import load_structures + + _revision, items = load_structures(root) + return [item.model_dump() for item in items] + except Exception: + logger.warning("B08 준비공 — 구조물 목록을 못 읽음: project_id=%s", project_id) + return [] + + async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]: """프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다.""" try: diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 06ee458f..65dbbb1c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -19,7 +19,12 @@ import { } from "../A00_Common/b_workflow_nav"; import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; -import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid"; +import { + renderHaulGrid, + renderPreparationGrid, + renderSummaryGrid, + type PreparationTable, +} from "./B08_Quantity_UI_SummaryGrid"; import { renderMaterialGrid, renderUnitQuantityGrid, @@ -346,6 +351,15 @@ function buildQuantityBody( ? renderHaulGrid(table.haul, Boolean(table.haul_available)) : message(L("B08_Quantity_Haul_Missing")), }, + { + label: L("B08_Quantity_Tab_Preparation"), + build: () => { + const preparation = (table as unknown as { preparation?: PreparationTable }).preparation; + return preparation + ? renderPreparationGrid(preparation) + : message(L("B08_Quantity_Grid_Empty")); + }, + }, { label: L("B08_Quantity_Tab_UnitQuantity"), build: () => diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index 317784e5..b4c03e32 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -188,3 +188,74 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen wrap.append(scroller); return wrap; } + +export interface PreparationRow { + group: string; + item: string; + unit: string; + amount: number | null; + status: string; + reason: string; + reference_amount?: number; + work_item_code: string | null; +} + +export interface PreparationTable { + columns: string[]; + rows: PreparationRow[]; + pending_count: number; + row_count: number; +} + +/** 준비공·사방공 — **못 서는 줄도 보인다.** + * + * 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 + * 상태와 사유를 달아 그대로 세운다. + */ +export function renderPreparationGrid(table: PreparationTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`; + wrap.append(caption); + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of table.columns) { + const th = document.createElement("th"); + th.textContent = label; + headRow.append(th); + } + head.append(headRow); + + const body = document.createElement("tbody"); + let lastGroup = ""; + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station")); + lastGroup = row.group; + tr.append(textCell(row.item)); + tr.append(textCell(row.unit, "b08-grid__unit")); + // 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다. + tr.append(textCell(row.amount === null ? "-" : num(row.amount, 2))); + tr.append(textCell(row.status)); + const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note"); + if (row.reference_amount) { + note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index a0180334..73dae8c7 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -623,6 +623,7 @@ 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_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"], B08_Quantity_Side_Method_Label: ["시공법", "Method"], B08_Quantity_Method_Unset: ["안 정함", "Not set"], B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],