From ba726399af5d46de903b13b785bb400e6324d5b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:05:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B08):=20=EC=9C=84=20=ED=83=AD=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EA=B3=B5=EC=9A=A9=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0(md)=20=C2=B7=20=EB=B3=B4=EC=A1=B0=20?= =?UTF-8?q?=ED=83=AD=20=EB=8B=A8=EC=B6=94=EB=8A=94=20sm=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20(PLAN=2069-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_UI_Page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d7b03fa65..e783faee5 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,7 +2,8 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * 탭 = 구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표 — 차례대로 만듦. + * 탭 = 구조물 집계표 · 구조물도 · 토공 · 수리집수면적표 — 차례대로 만듦. + * 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). * 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭. * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * ========================================================================== */ @@ -69,7 +70,6 @@ export function renderB08Quantity(root: HTMLElement): void { const button = createButton({ label: L(label), variant: id === tab ? "filled" : "ghost", - size: "sm", onClick: () => id !== tab && select(id), }); button.setAttribute("role", "tab"); From e857819d2ee1a60f27209c2ca72f3d739f8248eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:05:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=20=ED=83=AD?= =?UTF-8?q?=20=E2=80=94=20=EB=B3=B4=EC=A1=B0=20=ED=83=AD=20=EB=84=B7(?= =?UTF-8?q?=ED=86=A0=EA=B3=B5=EC=A7=91=EA=B3=84=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=ED=86=A0=EC=A0=81=ED=91=9C=20=C2=B7=20=ED=8F=89=EA=B7=A0?= =?UTF-8?q?=EC=9A=B4=EB=B0=98=EA=B1=B0=EB=A6=AC=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EB=AC=B4=EB=8C=80=EC=9A=B4=EB=B0=98?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC)=20=C2=B7=20=ED=86=A0=EC=A0=81=ED=91=9C=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=EC=B9=B8=20(PLAN=2069-2=20=C2=B7=2069-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토적표 칸 = 사용자 견본 32칸 · 보정량계 = 절토 보정량만 · 면 계열(면고르기 · 지장목제거 · 제근 · 노면정리) 거리 = B07 횡단 수량 함수 · 면적 = 앞뒤 평균 × 측점 거리 - 토공집계표 · 운반 시트 = 거창 실무 워크북 같은 이름 시트 칸 · 식 · Q · L = 저장된 유토곡선 배분(서버 · 읽기만) - 시험 test_69_b08_earthwork_tabs.py · 보정량계 시험 정함대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_Engine_Earthwork.py | 57 +++- .../B08_Quantity_Engine_Earthwork_Summary.py | 111 +++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 22 +- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 282 ++++++++++++------ .../B08_Quantity_UI_Earthwork_Sheets.ts | 133 +++++++++ B08_Quantity/B08_Quantity_UI_Page.css | 11 +- .../tester/test_69_b08_earthwork_tabs.py | 200 +++++++++++++ resources/tester/test_b08_earthwork_table.py | 11 +- ui_template/ui_template_locale_b2.ts | 2 +- 9 files changed, 722 insertions(+), 107 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py create mode 100644 B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts create mode 100644 resources/tester/test_69_b08_earthwork_tabs.py diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork.py b/B08_Quantity/B08_Quantity_Engine_Earthwork.py index aa654e5a3..704537f89 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork.py @@ -10,8 +10,15 @@ 측점 · 거리 · 절토[토사·암 각 (단면적·입적·보정량)] · 측구터파기[토사·암 각 3칸] · 보정량계 · 성토[단면적·입적] · 유용토 · 차인토량 · 누가토량 + · 면고르기[성토면·절토면] · 지장목제거[성토·절토] · 제근 · 노면정리 각 (거리·면적) - 사면 계열(층따기·면고르기·법면보호공·지장목제거)은 사면길이가 아직 없어 일감 3에서 붙인다. +보정량계 = 절토 보정량만 (PLAN 69 정함 · 사용자 견본 120+18 행 36.2 + 55.2 = 91.4) + 측구터파기 보정량은 칸에 두되 보정량계에 넣지 않는다. + +면 계열 (PLAN 69-3) — 측점 「거리」 = B07 횡단도 수량과 같은 값 + 면고르기 · 지장목제거 = `B07_DesignDetail_Engine_Cross_Quantity.derived_cells` 그대로 · + 노면정리 = 그 측점 노면 폭(설계 `roadbed_width_m`) · 제근 = B07 에 값이 없어 빈 칸. + 면적 = (앞 거리 + 현 거리) ÷ 2 × 측점 거리 — 첫 측점은 거리만. 층따기는 토공집계표용. 평균단면적법 (신규 문서 5장 「다. 공사수량의 산출」) 체적 = (앞 측점 단면적 + 현 측점 단면적) ÷ 2 × 두 측점 사이 거리. @@ -39,6 +46,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # 절토 암을 어느 환산계수로 볼지 — 측점의 `cut_rock_kind` 를 그대로 쓴다. @@ -57,6 +65,17 @@ _FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적 ROCK_KINDS = ("ripping_rock", "blasting_rock") +#: 면 계열 칸 — 화면 차례 · `benching`(층따기)은 토공집계표에만 씀 +FACE_KEYS = ( + "grading_fill", + "grading_cut", + "tree_removal_fill", + "tree_removal_cut", + "grubbing", + "road_surface", + "benching", +) + #: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. Factors = dict[str, dict[str, float]] @@ -82,6 +101,8 @@ class StationArea: ditch_rock_area_m2: float | None = None ditch_split_basis: str | None = None cut_rock_kind: str | None = None + # 면 계열 그 측점 거리(m) — `None` = B07 에 값 없음 + faces: dict[str, float | None] = field(default_factory=dict) @classmethod def from_design(cls, chainage_m: float, design: dict[str, Any]) -> "StationArea": @@ -93,8 +114,12 @@ class StationArea: value = design.get(key) return float(value) if isinstance(value, (int, float)) else None + cells = derived_cells(float(chainage_m), design) + faces = {key: cells.get(key) for key in FACE_KEYS} + faces["road_surface"] = optional("roadbed_width_m") return cls( chainage_m=float(chainage_m), + faces=faces, cut_soil_area_m2=num("cut_soil_area_m2"), cut_rock_area_m2=num("cut_rock_area_m2"), fill_area_m2=num("fill_area_m2"), @@ -137,6 +162,8 @@ class EarthworkRow: # 절토 암 칸이 어느 갈래(환산계수)로 셌나 — 화면이 리핑암 · 발파암 열로 가름 cut_rock_kind: str = _DEFAULT_ROCK_KIND notes: list[str] = field(default_factory=list) + # 면 계열 — `{키: {"length_m": 거리, "area_m2": 면적}}` · 값 없으면 None + faces: dict[str, dict[str, float | None]] = field(default_factory=dict) def _split_ditch(area: StationArea) -> tuple[float, float, str]: @@ -224,12 +251,9 @@ def build_rows( row.ditch_soil_adjusted_m3 = row.ditch_soil_volume_m3 * soil_factor row.ditch_rock_adjusted_m3 = row.ditch_rock_volume_m3 * rock_factor - row.adjusted_total_m3 = ( - row.cut_soil_adjusted_m3 - + row.cut_rock_adjusted_m3 - + row.ditch_soil_adjusted_m3 - + row.ditch_rock_adjusted_m3 - ) + row.faces = _face_cells(previous, station) + # 보정량계 = 절토 보정량만(PLAN 69 정함) — 측구터파기는 칸에만 + row.adjusted_total_m3 = row.cut_soil_adjusted_m3 + row.cut_rock_adjusted_m3 # 유용토 = 그 측점에서 절취분과 성토분이 서로 만나는 몫. row.diverted_m3 = min(row.adjusted_total_m3, row.fill_volume_m3) row.balance_m3 = row.adjusted_total_m3 - row.fill_volume_m3 @@ -242,6 +266,22 @@ def build_rows( return rows +def _face_cells( + previous: StationArea | None, station: StationArea +) -> dict[str, dict[str, float | None]]: + """면 계열 거리 · 면적 — 면적 = 앞뒤 평균 거리 × 측점 거리 · 첫 측점은 거리만.""" + out: dict[str, dict[str, float | None]] = {} + for key in FACE_KEYS: + length = station.faces.get(key) + before = previous.faces.get(key) if previous else None + area = None + if previous is not None and (length is not None or before is not None): + distance = station.chainage_m - previous.chainage_m + area = ((before or 0.0) + (length or 0.0)) / 2.0 * distance + out[key] = {"length_m": length, "area_m2": area} + return out + + def totals(rows: list[EarthworkRow]) -> dict[str, float]: """합계 행. 단면적은 합이 뜻이 없어 싣지 않는다(실무 토적표도 비워 둔다).""" keys = ( @@ -264,6 +304,9 @@ def totals(rows: list[EarthworkRow]) -> dict[str, float]: mine = [row for row in rows if row.cut_rock_kind == kind] out[f"cut_{kind}_volume_m3"] = sum(row.cut_rock_volume_m3 for row in mine) out[f"cut_{kind}_adjusted_m3"] = sum(row.cut_rock_adjusted_m3 for row in mine) + out[f"ditch_{kind}_volume_m3"] = sum(row.ditch_rock_volume_m3 for row in mine) + for key in FACE_KEYS: + out[f"{key}_area_m2"] = sum(row.faces.get(key, {}).get("area_m2") or 0.0 for row in rows) return out diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py new file mode 100644 index 000000000..2acad151a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -0,0 +1,111 @@ +"""토공 보조 탭 셋 — 토공집계표 · 평균운반거리 · 종방향 무대운반거리 (PLAN 69-2). + +근거 = 거창 실무 워크북(`00.25년_산불_거창(…)내역+수량.xlsx`)의 같은 이름 시트 칸 · 식 그대로. + + 평균운반거리 덤프트럭 · 불도우저 각 토량(Q) · 거리(L) · Q×L · 합계 · + 평균 = ROUND(ΣQ×L ÷ ΣQ, 2) + 종방향 무대운반거리 무대운반 토량(Q) · 거리(L) · Q×L · 합계 · 평균(같은 식) + 토공집계표 토적표 합계 줄 + 위 두 시트 ΣQ 를 시트 식대로 묶음 + +Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토적도가 그리는 것과 같은 정본) · +장비 띠(`blocks[].bands[]`)와 떨어진 구간 운반(`transfers[]`)의 `equipment` 로 가름 +(`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). + +암 갈래 — 리핑암 = 「연암」 줄 · 발파암 = 「경암」 줄. 풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 +값이 없어 비움(None). +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +#: 운반 시트 열 — 장비 키 +HAUL_EQUIPMENT = ("dump_truck", "dozer", "free_haul") + + +def excel_round(value: float, digits: int = 0) -> float: + """엑셀 ROUND — 반은 0 에서 먼 쪽(파이썬 round 는 짝수 쪽).""" + step = Decimal(1).scaleb(-digits) + return float(Decimal(str(value)).quantize(step, rounding=ROUND_HALF_UP)) + + +def haul_lists(haul_plan: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + """장비별 `{rows: [{q, l, ql}], q, ql, average_m}` — 시트 한 줄 = 띠 · 운반 하나.""" + items: dict[str, list[dict[str, float]]] = {key: [] for key in HAUL_EQUIPMENT} + plan = haul_plan if isinstance(haul_plan, dict) else {} + bands = [band for block in plan.get("blocks") or [] for band in block.get("bands") or []] + for item in [*bands, *(plan.get("transfers") or [])]: + rows = items.get(str(item.get("equipment"))) + q, dist = item.get("volume_m3"), item.get("haul_distance_m") + if rows is None or not isinstance(q, (int, float)) or not isinstance(dist, (int, float)): + continue + rows.append({"q": float(q), "l": float(dist), "ql": float(q) * float(dist)}) + out: dict[str, dict[str, Any]] = {} + for key, rows in items.items(): + q = sum(row["q"] for row in rows) + ql = sum(row["ql"] for row in rows) + out[key] = { + "rows": rows, + "q": q, + "ql": ql, + "average_m": excel_round(ql / q, 2) if q > 0 else None, + } + return out + + +def _row(group: str, kind: str, spec: str, unit: str, value: float | None, note: str = ""): + return {"group": group, "kind": kind, "spec": spec, "unit": unit, "value": value, "note": note} + + +def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석).""" + t = totals + r0 = excel_round + free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] + diverted = t["diverted_m3"] + tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2) + m3 = "㎥" + return [ + _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 + _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(t["cut_ripping_rock_volume_m3"])), + _row("흙깍기", "보통암", "굴삭기 + 브레카", m3, None), + _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(t["cut_blasting_rock_volume_m3"])), + _row("측구 터파기", "토사", "기계", m3, r0(t["ditch_soil_volume_m3"])), # 토적표!K137 + _row("측구 터파기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(t["ditch_ripping_rock_volume_m3"])), + _row("측구 터파기", "보통암", "굴삭기 + 브레카", m3, None), + _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(t["ditch_blasting_rock_volume_m3"])), + _row("보정량계", "", "", m3, r0(t["adjusted_total_m3"])), # 토적표!P137 + _row("성토", "", "", m3, r0(t["fill_volume_m3"])), # 토적표!R137 + _row("유용토 소계", "", "", m3, diverted + free_q), # G18+G19 + _row("횡방향유용토", "", "백호우", m3, diverted, "유용토"), # 토적표!S137 + _row("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25 + _row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42 + _row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ + _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137 + _row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137 + _row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"), + _row("표토제거", "", "", "㎡", None), + _row( + "초류종자살포", + "", + "씨드스프레이", + "㎡", + r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]), + "성토면50%반영+절토면", + ), + _row("법면보호공", "", "", "㎡", None), + # 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8 + _row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"), + _row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"), + _row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"), + _row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None), + _row("구조물터파기", "경암", "발파 + 굴삭기", m3, None), + _row("토사다짐", "", "", m3, None, "구조물 수량"), + _row("되메우기", "", "", m3, None, "구조물 수량"), + _row("잔토", "", "", m3, None, "현장내 성토(유용토)"), + _row("운반성토", "", "BH+DT+BH", m3, None), + ] diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 970a74732..a7abaa3e1 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -1,6 +1,9 @@ -"""B08 3탭 토적표 길 (PLAN 61-3) — 읽기만, 아무 파일 · DB 에도 쓰지 않음. +"""B08 토공 탭 길 (PLAN 61-3 · 69-2) — 읽기만, 아무 파일 · DB 에도 쓰지 않음. - GET /api/projects/{id}/quantity/earthwork 측점별 횡단 설계 단면적 → 평균단면적법 토적표 + GET /api/projects/{id}/quantity/earthwork + 토적표 + 토공집계표 + 평균운반거리 · 종방향 무대운반거리 + +운반 Q · L = 종단 레코드에 저장된 유토곡선 배분(`mass_haul.haul_plan`) — B07 토적도와 같은 정본. 단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) · 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`. @@ -17,9 +20,11 @@ from fastapi import APIRouter, HTTPException from B06_Section.B06_Section_Repository import ( get_confirmed_route_context, get_cross_section_designs, + get_longitudinal_section, get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import haul_lists, summary_rows from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_project_settings import mixed_conversion_factors, quantity_settings from config.config_db import get_db_pool @@ -38,6 +43,17 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]: raise HTTPException(status_code=404, detail="노선이 없습니다 — 노선 · 횡단 설계부터.") route_id = int(context["route_id"]) rows = await get_cross_section_designs(connection, route_id) + section = await get_longitudinal_section(connection, project_id, route_id) factors = await asyncio.to_thread(lambda: mixed_conversion_factors(quantity_settings(root))) stations = [StationArea.from_design(row["chainage_m"], row["design"]) for row in rows] - return {**build_table(stations, factors), "route_id": route_id, "confirmed": confirmed} + table = build_table(stations, factors) + mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} + haul = haul_lists(mass_haul.get("haul_plan")) + return { + **table, + "summary": summary_rows(table["totals"], haul), + "haul": haul, + "haul_saved": bool(mass_haul.get("haul_plan")), + "route_id": route_id, + "confirmed": confirmed, + } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 73175de72..a6f826259 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -1,15 +1,25 @@ /* ============================================================================= * B08_Quantity_UI_Earthwork.ts - * 3탭 토적표(PLAN 61-3) — 줄 = 측점 · 열 = 옛 토적 엔진 출력 칸 그대로 · 맨 아래 합계 줄. + * 토공 탭(PLAN 61-3 · 69-2 · 69-3) — 보조 탭 넷 = 토공집계표 · 토적표 · 평균운반거리 · 종방향 무대운반거리. * - * GET …/quantity/earthwork (서버 단독 · 읽기만) — 평균단면적법 · 보정량 = 입적 × 다짐 환산계수 - * 절토 암은 그 측점이 센 갈래(`cut_rock_kind`)대로 리핑암 · 발파암 열에 놓음. - * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀. + * GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림. + * 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 · + * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 + * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」. + * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, createCard } from "@ui/ui_template_elements"; import { stationLabel } from "../common_util/common_util_station"; import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + renderEarthworkSummary, + renderFreeHaul, + renderHaulDistance, +} from "./B08_Quantity_UI_Earthwork_Sheets"; + +type Face = { length_m: number | null; area_m2: number | null }; interface EarthworkRow { chainage_m: number; @@ -34,31 +44,70 @@ interface EarthworkRow { balance_m3: number; cumulative_m3: number; notes: string[]; + faces: Record; } -interface EarthworkTable { +export interface HaulRow { + q: number; + l: number; + ql: number; +} + +export interface HaulList { + rows: HaulRow[]; + q: number; + ql: number; + average_m: number | null; +} + +export interface SummaryRow { + group: string; + kind: string; + spec: string; + unit: string; + value: number | null; + note: string; +} + +export interface EarthworkTable { rows: EarthworkRow[]; totals: Record; conversion_factors: Record; station_count: number; route_id: number; confirmed: boolean; + summary: SummaryRow[]; + haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; + haul_saved: boolean; } -const ROCKS = ["ripping_rock", "blasting_rock"] as const; -const GROUPS: Array<[string, number]> = [ - ["절토 토사", 3], - ["절토 리핑암", 3], - ["절토 발파암", 3], - ["측구터파기 토사", 3], - ["측구터파기 암", 3], -]; +const ROCK_KIND_LABEL: Record = { ripping_rock: "리핑암", blasting_rock: "발파암" }; const TRIPLE = ["단면적", "입적", "보정량"]; +const FACE_GROUPS: Array<[string, Array<[string, string]>]> = [ + [ + "면고르기", + [ + ["grading_fill", "성토면"], + ["grading_cut", "절토면"], + ], + ], + [ + "지장목제거", + [ + ["tree_removal_fill", "성토"], + ["tree_removal_cut", "절토"], + ], + ], + ["제근", [["grubbing", ""]]], + ["노면정리", [["road_surface", ""]]], +]; +const FACE_KEYS = FACE_GROUPS.flatMap(([, faces]) => faces.map(([key]) => key)); +const DASH = "-"; const num = (value: number | undefined | null): string => value == null || !Number.isFinite(value) ? "" : value.toFixed(2); -function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { +export function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { const node = document.createElement(tag); node.textContent = text; if (span?.row) node.rowSpan = span.row; @@ -66,41 +115,63 @@ function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: numbe return node; } -function note(text: string): HTMLElement { +export function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; p.textContent = text; return p; } +/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */ +export function tableCard( + info: HTMLElement[], + className: string, + parts: HTMLElement[], +): HTMLElement { + const el = document.createElement("table"); + el.className = `ui-general-block__table b08-summary__table ${className}`; + el.append(...parts); + const wrap = document.createElement("div"); + wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; + wrap.append(el); + return createCard({ body: [...info, wrap] }); +} + +/** 머리 석 줄 — 견본 차례 · 32칸 */ function head(): HTMLTableSectionElement { + const station = cell("th", "측점", { row: 3 }); + station.classList.add("b08-summary__station"); const top = document.createElement("tr"); - top.append(cell("th", "측점", { row: 2 }), cell("th", "거리", { row: 2 })); - for (const [label, span] of GROUPS) top.append(cell("th", label, { col: span })); top.append( - cell("th", "보정량계", { row: 2 }), - cell("th", "성토", { col: 2 }), - cell("th", "유용토", { row: 2 }), - cell("th", "차인토량", { row: 2 }), - cell("th", "누가토량", { row: 2 }), + station, + cell("th", "거리", { row: 3 }), + cell("th", "절토", { col: 6 }), + cell("th", "측구터파기", { col: 6 }), + cell("th", "보정량계", { row: 3 }), + cell("th", "성토", { col: 2, row: 2 }), + cell("th", "유용토", { row: 3 }), + cell("th", "차인토량", { row: 3 }), + cell("th", "누가토량", { row: 3 }), + ...FACE_GROUPS.map(([label, faces]) => + cell("th", label, { col: faces.length * 2, row: faces.length > 1 ? 1 : 2 }), + ), ); - top.firstElementChild?.classList.add("b08-summary__station"); + const mid = document.createElement("tr"); + for (let i = 0; i < 2; i += 1) + mid.append(cell("th", "토사", { col: 3 }), cell("th", "암석", { col: 3 })); + for (const [, faces] of FACE_GROUPS) { + if (faces.length > 1) mid.append(...faces.map(([, label]) => cell("th", label, { col: 2 }))); + } const sub = document.createElement("tr"); - for (const _ of GROUPS) sub.append(...TRIPLE.map((t) => cell("th", t))); + for (let i = 0; i < 4; i += 1) sub.append(...TRIPLE.map((t) => cell("th", t))); sub.append(cell("th", "단면적"), cell("th", "입적")); + for (const _ of FACE_KEYS) sub.append(cell("th", "거리"), cell("th", "면적")); const thead = document.createElement("thead"); - thead.append(top, sub); + thead.append(top, mid, sub); return thead; } -/** 절토 암 세 칸 — 그 측점 갈래 열에만 · 다른 갈래 열은 빈 칸 */ -function rockCells(row: EarthworkRow, kind: string): string[] { - return row.cut_rock_kind === kind - ? [num(row.cut_rock_area_m2), num(row.cut_rock_volume_m3), num(row.cut_rock_adjusted_m3)] - : ["", "", ""]; -} - -function bodyRow(row: EarthworkRow, interval: number): HTMLTableRowElement { +function bodyRow(row: EarthworkRow, first: boolean, interval: number): HTMLTableRowElement { const tr = document.createElement("tr"); tr.dataset.b08Earthwork = String(row.chainage_m); const station = cell( @@ -109,55 +180,55 @@ function bodyRow(row: EarthworkRow, interval: number): HTMLTableRowElement { ); station.className = "b08-summary__station"; if (row.notes.length) station.title = row.notes.join("\n"); + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(견본 「-」) + const v = (value: number): string => (first ? DASH : num(value)); + const triple = (area: number, volume: number, adjusted: number) => [ + num(area), + v(volume), + v(adjusted), + ]; const values = [ num(row.distance_m), - num(row.cut_soil_area_m2), - num(row.cut_soil_volume_m3), - num(row.cut_soil_adjusted_m3), - ...ROCKS.flatMap((kind) => rockCells(row, kind)), - num(row.ditch_soil_area_m2), - num(row.ditch_soil_volume_m3), - num(row.ditch_soil_adjusted_m3), - num(row.ditch_rock_area_m2), - num(row.ditch_rock_volume_m3), - num(row.ditch_rock_adjusted_m3), - num(row.adjusted_total_m3), + ...triple(row.cut_soil_area_m2, row.cut_soil_volume_m3, row.cut_soil_adjusted_m3), + ...triple(row.cut_rock_area_m2, row.cut_rock_volume_m3, row.cut_rock_adjusted_m3), + ...triple(row.ditch_soil_area_m2, row.ditch_soil_volume_m3, row.ditch_soil_adjusted_m3), + ...triple(row.ditch_rock_area_m2, row.ditch_rock_volume_m3, row.ditch_rock_adjusted_m3), + v(row.adjusted_total_m3), num(row.fill_area_m2), - num(row.fill_volume_m3), - num(row.diverted_m3), - num(row.balance_m3), - num(row.cumulative_m3), + v(row.fill_volume_m3), + v(row.diverted_m3), + v(row.balance_m3), + v(row.cumulative_m3), + ...FACE_KEYS.flatMap((key) => { + const face = row.faces?.[key]; + return [num(face?.length_m), first ? DASH : num(face?.area_m2)]; + }), ]; - tr.append(station, ...values.map((v) => cell("td", v))); + const cells = values.map((text) => cell("td", text)); + // 암석 칸 — 어느 갈래(환산계수)로 셌는지 마우스를 올려 봄 + if (row.cut_rock_area_m2 > 0) cells[4].title = ROCK_KIND_LABEL[row.cut_rock_kind] ?? ""; + tr.append(station, ...cells); return tr; } -/** 합계 줄 — 단면적은 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ +/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ function totalRow(table: EarthworkTable): HTMLTableRowElement { const t = table.totals; const last = table.rows[table.rows.length - 1]; + const triple = (key: string) => ["", num(t[`${key}_volume_m3`]), num(t[`${key}_adjusted_m3`])]; const values = [ num(t.distance_m), - "", - num(t.cut_soil_volume_m3), - num(t.cut_soil_adjusted_m3), - ...ROCKS.flatMap((kind) => [ - "", - num(t[`cut_${kind}_volume_m3`]), - num(t[`cut_${kind}_adjusted_m3`]), - ]), - "", - num(t.ditch_soil_volume_m3), - num(t.ditch_soil_adjusted_m3), - "", - num(t.ditch_rock_volume_m3), - num(t.ditch_rock_adjusted_m3), + ...triple("cut_soil"), + ...triple("cut_rock"), + ...triple("ditch_soil"), + ...triple("ditch_rock"), num(t.adjusted_total_m3), "", num(t.fill_volume_m3), num(t.diverted_m3), num(t.balance_m3), num(last?.cumulative_m3), + ...FACE_KEYS.flatMap((key) => ["", num(t[`${key}_area_m2`])]), ]; const tr = document.createElement("tr"); tr.className = "b08-earthwork__total"; @@ -168,8 +239,38 @@ function totalRow(table: EarthworkTable): HTMLTableRowElement { return tr; } +function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { + if (!table.rows.length) { + host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); + return; + } + const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); + const info = note( + `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + + `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + + `단위 m · ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, + ); + const tbody = document.createElement("tbody"); + tbody.append(...table.rows.map((row, i) => bodyRow(row, i === 0, interval))); + const tfoot = document.createElement("tfoot"); + tfoot.append(totalRow(table)); + const card = tableCard([info], "b08-earthwork__table", [head(), tbody, tfoot]); + card.querySelector("table")!.dataset.b08Earthwork = "table"; + host.replaceChildren(card); +} + +const SHEETS = [ + { id: "summary", label: "토공집계표" }, + { id: "sheet", label: "토적표" }, + { id: "haul", label: "평균운반거리" }, + { id: "free", label: "종방향 무대운반거리" }, +] as const; + +type SheetId = (typeof SHEETS)[number]["id"]; + export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { - host.replaceChildren(note("토적표를 만드는 중…")); + host.replaceChildren(note("토공 수량을 만드는 중…")); let table: EarthworkTable; let interval = 20; try { @@ -186,29 +287,34 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro table = body; interval = gap; } catch (error) { - host.replaceChildren(note(`토적표를 불러오지 못했습니다 — ${String(error)}`)); + host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } - if (!table.rows.length) { - host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); - return; - } - const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); - const info = note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + - `발파암 ${c("blasting_rock")} · 단위 ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, - ); - const tbody = document.createElement("tbody"); - tbody.append(...table.rows.map((row) => bodyRow(row, interval))); - const tfoot = document.createElement("tfoot"); - tfoot.append(totalRow(table)); - const el = document.createElement("table"); - el.className = "ui-general-block__table b08-summary__table b08-earthwork__table"; - el.dataset.b08Earthwork = "table"; - el.append(head(), tbody, tfoot); - const wrap = document.createElement("div"); - wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; - wrap.append(el); - host.replaceChildren(info, wrap); + + const tabs = document.createElement("div"); + tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; + tabs.setAttribute("role", "tablist"); + const view = document.createElement("div"); + const select = (picked: SheetId): void => { + tabs.replaceChildren( + ...SHEETS.map(({ id, label }) => { + const button = createButton({ + label, + variant: id === picked ? "filled" : "ghost", + size: "sm", + onClick: () => id !== picked && select(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === picked)); + button.dataset.b08EarthworkSheet = id; + return button; + }), + ); + if (picked === "sheet") renderSheet(view, table, interval); + else if (picked === "summary") renderEarthworkSummary(view, table); + else if (picked === "haul") renderHaulDistance(view, table); + else renderFreeHaul(view, table); + }; + host.replaceChildren(tabs, view); + select("summary"); } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts new file mode 100644 index 000000000..2a04e3521 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -0,0 +1,133 @@ +/* ============================================================================= + * B08_Quantity_UI_Earthwork_Sheets.ts + * 토공 보조 탭 셋(PLAN 69-2) — 토공집계표 · 평균운반거리 · 종방향 무대운반거리. + * + * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). + * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. + * ========================================================================== */ + +import { + cell, + note, + tableCard, + type EarthworkTable, + type HaulList, +} from "./B08_Quantity_UI_Earthwork"; + +const fmt = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) + ? "—" + : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + +function row(cells: HTMLElement[], className = ""): HTMLTableRowElement { + const tr = document.createElement("tr"); + if (className) tr.className = className; + tr.append(...cells); + return tr; +} + +function station(text: string, span?: { row?: number; col?: number }): HTMLElement { + const th = cell("th", text, span); + th.className = "b08-summary__station"; + return th; +} + +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 · 같은 구분은 한 칸으로 묶음 */ +export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { + const thead = document.createElement("thead"); + thead.append( + row([station("구분"), ...["공종", "규격", "단위", "계", "비고"].map((t) => cell("th", t))]), + ); + const tbody = document.createElement("tbody"); + const rows = table.summary; + rows.forEach((item, i) => { + const cells: HTMLElement[] = []; + if (rows[i - 1]?.group !== item.group) { + let span = 1; + while (rows[i + span]?.group === item.group) span += 1; + cells.push(station(item.group, { row: span })); + } + const value = cell("td", fmt(item.value)); + value.className = "b08-earthwork__num"; + cells.push(cell("td", item.kind), cell("td", item.spec), cell("td", item.unit), value); + cells.push(cell("td", item.note)); + tbody.append(row(cells)); + }); + const info = note( + "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + + "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(— = 아직 값 없음)", + ); + host.replaceChildren(tableCard([info], "b08-earthwork__sheet", [thead, tbody])); +} + +function haulNote(table: EarthworkTable): HTMLElement[] { + return table.haul_saved + ? [] + : [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; +} + +/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ +function haulSheet( + host: HTMLElement, + table: EarthworkTable, + groups: Array<[string, HaulList]>, + info: string, +): void { + const thead = document.createElement("thead"); + thead.append( + row([station("번호", { row: 2 }), ...groups.map(([label]) => cell("th", label, { col: 3 }))]), + row(groups.flatMap(() => ["토량(Q)", "거리(L)", "Q×L"].map((t) => cell("th", t)))), + ); + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const tbody = document.createElement("tbody"); + for (let i = 0; i < count; i += 1) { + const cells = groups.flatMap(([, list]) => { + const item = list.rows[i]; + return item ? [fmt(item.q), fmt(item.l), fmt(item.ql)] : ["", "", ""]; + }); + tbody.append(row([station(String(i + 1)), ...cells.map((t) => cell("td", t))])); + } + const tfoot = document.createElement("tfoot"); + tfoot.append( + row( + [ + station("합계"), + ...groups.flatMap(([, list]) => [fmt(list.q), "", fmt(list.ql)].map((t) => cell("td", t))), + ], + "b08-earthwork__total", + ), + ); + const averages = groups.map(([label, list], i) => + note( + `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, + ), + ); + const card = tableCard([note(info), ...haulNote(table)], "b08-earthwork__haul", [ + thead, + tbody, + tfoot, + ]); + card.append(...averages); + host.replaceChildren(card); +} + +export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { + haulSheet( + host, + table, + [ + ["덤프트럭", table.haul.dump_truck], + ["불도우저", table.haul.dozer], + ], + "유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + ); +} + +export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { + haulSheet( + host, + table, + [["종방향무대", table.haul.free_haul]], + "유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 6a4a47611..d9991c177 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -68,11 +68,18 @@ color: var(--color-danger); } -/* 3탭 토적표 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ -.b08-earthwork__table td { +/* 토공 탭 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ +.b08-earthwork__table td, +.b08-earthwork__haul td, +.b08-earthwork__sheet .b08-earthwork__num { text-align: right; } +/* 토적표 머리 셋째 줄 — 둘째 줄 아래에 붙음 */ +.b08-summary__table thead tr + tr + tr th { + top: calc(2 * (1lh + 2 * var(--spacing-4, 4px) + 1px)); +} + .b08-earthwork__total th, .b08-earthwork__total td { position: sticky; diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py new file mode 100644 index 000000000..d86e88575 --- /dev/null +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -0,0 +1,200 @@ +"""B08 토공 탭 (PLAN 69-2 · 69-3) — 토적표 견본 칸 · 면 계열 · 운반 시트 · 토공집계표 식. + +정답지 ① 사용자 견본 두 행(120 · 120+18) ② 거창 실무 워크북 토적표 131행(70) · +③ 같은 워크북 토공집계표 · 평균운반거리 · 종방향무대운반거리 시트 식. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Earthwork import ( # noqa: E402 + FACE_KEYS, + StationArea, + build_rows, + build_table, +) +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( # noqa: E402 + excel_round, + haul_lists, + summary_rows, +) + +# 견본 보정량 36.2 / 41.1 · 55.2 / 48.0 에서 거꾸로 — 토사 0.88 · 연암 1.15 +SAMPLE_FACTORS = { + "soil": {"compacted": 0.88}, + "ripping_rock": {"compacted": 1.15}, + "blasting_rock": {"compacted": 1.3}, +} +SAMPLE_FACES = ( + # grading_fill · grading_cut · tree_fill · tree_cut · grubbing · road + (4.4, 1.3, 4.7, 4.6, 1.3, 4.0), + (6.9, 0.4, 10.1, 4.6, 4.8, 5.7), +) + + +def _faces(values: tuple[float, ...]) -> dict[str, float | None]: + return dict(zip(FACE_KEYS, (*values, None))) + + +def 견본_두_측점() -> list[StationArea]: + return [ + StationArea( + chainage_m=120.0, + cut_soil_area_m2=3.28, + cut_rock_area_m2=1.61, + ditch_soil_area_m2=0.0, + ditch_rock_area_m2=0.18, + fill_area_m2=1.9, + cut_rock_kind="ripping_rock", + faces=_faces(SAMPLE_FACES[0]), + ), + StationArea( + chainage_m=138.0, + cut_soil_area_m2=1.29, + cut_rock_area_m2=3.72, + ditch_soil_area_m2=0.0, + ditch_rock_area_m2=0.0, + fill_area_m2=19.13, + cut_rock_kind="ripping_rock", + faces=_faces(SAMPLE_FACES[1]), + ), + ] + + +def test_견본_120_18_행() -> None: + first, row = build_rows(견본_두_측점(), SAMPLE_FACTORS) + assert row.distance_m == pytest.approx(18) + assert round(row.cut_soil_volume_m3, 1) == 41.1 + assert round(row.cut_soil_adjusted_m3, 1) == 36.2 + assert round(row.cut_rock_volume_m3, 1) == 48.0 + assert round(row.cut_rock_adjusted_m3, 1) == 55.2 + assert round(row.ditch_rock_volume_m3, 1) == 1.6 + assert round(row.ditch_rock_adjusted_m3, 1) == 1.9 + assert round(row.adjusted_total_m3, 1) == 91.4 # 절토 보정량만 + assert round(row.fill_volume_m3, 1) == 189.3 + assert round(row.diverted_m3, 1) == 91.4 + assert round(row.balance_m3, 1) == -97.9 + assert round(row.cumulative_m3, 1) == -97.9 + areas = [round(row.faces[key]["area_m2"], 1) for key in FACE_KEYS[:6]] + assert areas == [101.7, 15.3, 133.2, 82.8, 54.9, 87.3] + + +def test_첫_측점은_면_거리만() -> None: + first = build_rows(견본_두_측점(), SAMPLE_FACTORS)[0] + assert [first.faces[key]["length_m"] for key in FACE_KEYS[:6]] == list(SAMPLE_FACES[0]) + assert all(first.faces[key]["area_m2"] is None for key in FACE_KEYS) + + +def test_거창_토적표_131행() -> None: + """워크북 130(+10) → 131(70) — 절토 · 측구 · 성토 · 지장목 성토 면적 같은 입력 다시 셈.""" + stations = [ + StationArea( + chainage_m=0.0, + cut_soil_area_m2=6.72, + ditch_soil_area_m2=0.18, + ditch_rock_area_m2=0.0, + fill_area_m2=0.19, + faces=_faces((0.1, 4.7, 2.4, 6.3, 0, 0)), + ), + StationArea( + chainage_m=10.0, + cut_soil_area_m2=3.15, + ditch_soil_area_m2=0.18, + ditch_rock_area_m2=0.0, + fill_area_m2=15.02, + faces=_faces((8.0, 3.8, 11.3, 4.5, 0, 0)), + ), + ] + row = build_rows(stations)[1] + assert row.cut_soil_volume_m3 == pytest.approx(49.35) # E131 + assert row.cut_soil_adjusted_m3 == pytest.approx(44.415) # F131 44.41 + assert row.ditch_soil_volume_m3 == pytest.approx(1.8) # K131 + assert row.fill_volume_m3 == pytest.approx(76.05) # R131 + assert row.faces["tree_removal_fill"]["area_m2"] == pytest.approx(68.5) # AG131 + # 워크북 면적은 거리 소수 둘째 자리로 셈 — 표기 거리로는 ±0.5 안 + assert row.faces["grading_fill"]["area_m2"] == pytest.approx(40.7, abs=0.5) # Y131 + assert row.faces["tree_removal_cut"]["area_m2"] == pytest.approx(54.4, abs=0.5) # AI131 + + +def test_면_거리는_B07_횡단도_수량과_같음() -> None: + design = { + "design_line": [ + {"offset_m": o, "elevation_m": z} + for o, z in ((-8, 102), (-2, 100), (2, 100), (6, 102.5)) + ], + "road_edges": {"left": {"offset_m": 2}, "right": {"offset_m": -2}}, + "cut_slope_ratio": 0.8, + "fill_slope_ratio": 1.5, + "roadbed_width_m": 4.0, + } + area = StationArea.from_design(120.0, design) + cells = derived_cells(120.0, design) + for key in ("grading_fill", "grading_cut", "tree_removal_fill", "tree_removal_cut"): + assert area.faces[key] == cells.get(key) + assert area.faces["road_surface"] == 4.0 + assert area.faces["grubbing"] is None # B07 에 값 없음 + + +def test_엑셀_ROUND() -> None: + assert excel_round(2.5) == 3 + assert excel_round(-97.5) == -98 + assert excel_round(1.005, 2) == 1.01 + + +HAUL_PLAN = { + "blocks": [ + { + "bands": [ + {"equipment": "free_haul", "volume_m3": 50, "haul_distance_m": 10}, + {"equipment": "dozer", "volume_m3": 20, "haul_distance_m": 40}, + {"equipment": "dump_truck", "volume_m3": 68, "haul_distance_m": 35}, + ] + }, + {"bands": [{"equipment": "free_haul", "volume_m3": 32, "haul_distance_m": 13}]}, + ], + "transfers": [{"equipment": "dump_truck", "volume_m3": 2, "haul_distance_m": 61}], +} + + +def test_운반_시트() -> None: + haul = haul_lists(HAUL_PLAN) + free = haul["free_haul"] + assert [(r["q"], r["l"]) for r in free["rows"]] == [(50, 10), (32, 13)] + assert free["q"] == 82 and free["ql"] == 50 * 10 + 32 * 13 + assert free["average_m"] == excel_round((500 + 416) / 82, 2) + dump = haul["dump_truck"] + assert dump["q"] == 70 and dump["ql"] == 68 * 35 + 2 * 61 # 덤프 띠 + 떨어진 구간 운반 + assert haul["dozer"]["average_m"] == 40 + assert haul_lists(None)["dump_truck"] == {"rows": [], "q": 0, "ql": 0, "average_m": None} + + +def test_토공집계표_식() -> None: + table = build_table(견본_두_측점(), SAMPLE_FACTORS) + t = table["totals"] + rows = summary_rows(t, haul_lists(HAUL_PLAN)) + value = {(r["group"], r["kind"], r["spec"]): r["value"] for r in rows} + assert value[("흙깍기", "토사", "기계(굴삭기)")] == excel_round(t["cut_soil_volume_m3"]) + assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == 48 + assert value[("흙깍기", "경암", "발파 + 굴삭기")] == 0 + assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == 2 + assert value[("보정량계", "", "")] == 91 + assert value[("성토", "", "")] == 189 + assert value[("종방향유용토", "", "백호우")] == 82 + assert value[("토사운반", "", "BH+DT+BH")] == 70 + assert value[("토사운반", "", "19 TON B/D")] == 20 + assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82) + assert value[("잔토처리", "", "백호우")] == -98 + assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8) + assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3) + tree = excel_round(133.2 * 0.5 + 82.8, 2) + assert value[("지장목제거", "", "")] == excel_round(tree * 0.8) + assert value[("표토제거", "", "")] is None + assert len(rows) == 33 # 시트 C5:C37 diff --git a/resources/tester/test_b08_earthwork_table.py b/resources/tester/test_b08_earthwork_table.py index 236954b7e..2d14dd0bc 100644 --- a/resources/tester/test_b08_earthwork_table.py +++ b/resources/tester/test_b08_earthwork_table.py @@ -70,15 +70,14 @@ def test_측구_단면_0_18이_체적으로() -> None: assert rows[2].ditch_soil_adjusted_m3 == pytest.approx(1.62) -def test_보정량계는_네_갈래_합() -> None: +def test_보정량계는_절토_보정량만() -> None: + """PLAN 69 정함 — 측구터파기 보정량은 칸에만(거창 BOM 15열 17.91 = 16.29 + 1.62 와 다름).""" row = build_rows(거창_앞_네_측점())[2] assert row.adjusted_total_m3 == pytest.approx( - row.cut_soil_adjusted_m3 - + row.cut_rock_adjusted_m3 - + row.ditch_soil_adjusted_m3 - + row.ditch_rock_adjusted_m3 + row.cut_soil_adjusted_m3 + row.cut_rock_adjusted_m3 ) - assert row.adjusted_total_m3 == pytest.approx(16.29 + 1.62) # BOM 15열 17.91 + assert row.adjusted_total_m3 == pytest.approx(16.29) + assert row.ditch_soil_adjusted_m3 == pytest.approx(1.62) def test_누가토량은_차인토량의_누계() -> None: diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index aebdfa3cb..367a774db 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -595,7 +595,7 @@ export const ui_locales_b2 = { "수량 단계 확정에 실패했습니다.", "Failed to confirm the quantity stage.", ], - B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], + B08_Quantity_Tab_Earthwork: ["토공", "Earthwork"], B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], B08_Quantity_Grid_Empty: [ "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", From 04740584c30011e6c7c37fdfb0a7083b35c23842 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:16:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(B08):=20=ED=86=A0=EC=A0=81=ED=91=9C=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EC=A4=84=20=E2=80=94=20=EA=B0=92=EC=9D=B4?= =?UTF-8?q?=20=ED=95=98=EB=82=98=EB=8F=84=20=EC=97=86=EB=8A=94=20=EB=A9=B4?= =?UTF-8?q?=20=EC=B9=B8(=EC=A0=9C=EA=B7=BC)=EC=9D=80=200=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EB=B9=88=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index a6f826259..2a82c1051 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -228,7 +228,11 @@ function totalRow(table: EarthworkTable): HTMLTableRowElement { num(t.diverted_m3), num(t.balance_m3), num(last?.cumulative_m3), - ...FACE_KEYS.flatMap((key) => ["", num(t[`${key}_area_m2`])]), + // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 + ...FACE_KEYS.flatMap((key) => [ + "", + table.rows.some((row) => row.faces?.[key]?.area_m2 != null) ? num(t[`${key}_area_m2`]) : "", + ]), ]; const tr = document.createElement("tr"); tr.className = "b08-earthwork__total"; From 8b1fd2f6eddd07b36439c4756295d0205b29dcc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:19:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=ED=83=AD=20=EB=84=B7=EC=9D=84=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EB=A7=8E=EC=9D=80=20=EC=9E=90=EB=A3=8C=20=ED=91=9C=EB=A1=9C?= =?UTF-8?q?=20(PLAN=2069-2=20=C2=B7=2069-3=20=C2=B7=2070-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토적표 32칸 · 측점 고정 · 묶음 머리 석 줄 · 합계 줄 / 토공집계표 · 운반 시트도 같은 표 · 카드 안 - 옛 토적표 전용 CSS 걷어냄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 267 ++++++++---------- .../B08_Quantity_UI_Earthwork_Sheets.ts | 122 ++++---- B08_Quantity/B08_Quantity_UI_Page.css | 30 -- 3 files changed, 165 insertions(+), 254 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 2a82c1051..8bb5a5f22 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -5,12 +5,13 @@ * GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림. * 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 · * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 - * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」. - * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`. + * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—). + * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { stationLabel } from "../common_util/common_util_station"; import { stationInterval } from "./B08_Quantity_UI_Summary"; import { @@ -81,39 +82,51 @@ export interface EarthworkTable { haul_saved: boolean; } -const ROCK_KIND_LABEL: Record = { ripping_rock: "리핑암", blasting_rock: "발파암" }; -const TRIPLE = ["단면적", "입적", "보정량"]; -const FACE_GROUPS: Array<[string, Array<[string, string]>]> = [ - [ - "면고르기", - [ - ["grading_fill", "성토면"], - ["grading_cut", "절토면"], - ], - ], - [ - "지장목제거", - [ - ["tree_removal_fill", "성토"], - ["tree_removal_cut", "절토"], - ], - ], - ["제근", [["grubbing", ""]]], - ["노면정리", [["road_surface", ""]]], +/** 칸 묶음 — [머리 묶음, 키 앞머리] · 단면적 · 입적 · 보정량 셋 */ +const TRIPLES: Array<[string[], string]> = [ + [["절토", "토사"], "cut_soil"], + [["절토", "암석"], "cut_rock"], + [["측구터파기", "토사"], "ditch_soil"], + [["측구터파기", "암석"], "ditch_rock"], +]; +/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ +const FACES: Array<[string[], string]> = [ + [["면고르기", "성토면"], "grading_fill"], + [["면고르기", "절토면"], "grading_cut"], + [["지장목제거", "성토"], "tree_removal_fill"], + [["지장목제거", "절토"], "tree_removal_cut"], + [["제근"], "grubbing"], + [["노면정리"], "road_surface"], ]; -const FACE_KEYS = FACE_GROUPS.flatMap(([, faces]) => faces.map(([key]) => key)); -const DASH = "-"; -const num = (value: number | undefined | null): string => - value == null || !Number.isFinite(value) ? "" : value.toFixed(2); +const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals: 2, +}); -export function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { - const node = document.createElement(tag); - node.textContent = text; - if (span?.row) node.rowSpan = span.row; - if (span?.col) node.colSpan = span.col; - return node; -} +/** 견본 차례 32칸 */ +const COLUMNS: DataColumn[] = [ + { key: "station", label: "측점", width: 72 }, + n2("distance", "거리"), + ...TRIPLES.flatMap(([group, key]) => [ + n2(`${key}_area`, "단면적", group), + n2(`${key}_volume`, "입적", group), + n2(`${key}_adjusted`, "보정량", group), + ]), + n2("adjusted_total", "보정량계"), + n2("fill_area", "단면적", ["성토"]), + n2("fill_volume", "입적", ["성토"]), + n2("diverted", "유용토"), + n2("balance", "차인토량"), + n2("cumulative", "누가토량"), + ...FACES.flatMap(([group, key]) => [ + n2(`${key}_length`, "거리", group), + n2(`${key}_area`, "면적", group), + ]), +]; export function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -122,125 +135,63 @@ export function note(text: string): HTMLElement { return p; } -/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */ +/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */ export function tableCard( info: HTMLElement[], - className: string, - parts: HTMLElement[], + table: HTMLElement, + after: HTMLElement[] = [], ): HTMLElement { - const el = document.createElement("table"); - el.className = `ui-general-block__table b08-summary__table ${className}`; - el.append(...parts); - const wrap = document.createElement("div"); - wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; - wrap.append(el); - return createCard({ body: [...info, wrap] }); + return createCard({ body: [...info, table, ...after] }); } -/** 머리 석 줄 — 견본 차례 · 32칸 */ -function head(): HTMLTableSectionElement { - const station = cell("th", "측점", { row: 3 }); - station.classList.add("b08-summary__station"); - const top = document.createElement("tr"); - top.append( - station, - cell("th", "거리", { row: 3 }), - cell("th", "절토", { col: 6 }), - cell("th", "측구터파기", { col: 6 }), - cell("th", "보정량계", { row: 3 }), - cell("th", "성토", { col: 2, row: 2 }), - cell("th", "유용토", { row: 3 }), - cell("th", "차인토량", { row: 3 }), - cell("th", "누가토량", { row: 3 }), - ...FACE_GROUPS.map(([label, faces]) => - cell("th", label, { col: faces.length * 2, row: faces.length > 1 ? 1 : 2 }), - ), - ); - const mid = document.createElement("tr"); - for (let i = 0; i < 2; i += 1) - mid.append(cell("th", "토사", { col: 3 }), cell("th", "암석", { col: 3 })); - for (const [, faces] of FACE_GROUPS) { - if (faces.length > 1) mid.append(...faces.map(([, label]) => cell("th", label, { col: 2 }))); +function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) + const v = (value: number): number | null => (first ? null : value); + const out: DataRow = { + station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), + distance: row.distance_m, + adjusted_total: v(row.adjusted_total_m3), + fill_area: row.fill_area_m2, + fill_volume: v(row.fill_volume_m3), + diverted: v(row.diverted_m3), + balance: v(row.balance_m3), + cumulative: v(row.cumulative_m3), + }; + const r = row as unknown as Record; + for (const [, key] of TRIPLES) { + out[`${key}_area`] = r[`${key}_area_m2`]; + out[`${key}_volume`] = v(r[`${key}_volume_m3`]); + out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); } - const sub = document.createElement("tr"); - for (let i = 0; i < 4; i += 1) sub.append(...TRIPLE.map((t) => cell("th", t))); - sub.append(cell("th", "단면적"), cell("th", "입적")); - for (const _ of FACE_KEYS) sub.append(cell("th", "거리"), cell("th", "면적")); - const thead = document.createElement("thead"); - thead.append(top, mid, sub); - return thead; -} - -function bodyRow(row: EarthworkRow, first: boolean, interval: number): HTMLTableRowElement { - const tr = document.createElement("tr"); - tr.dataset.b08Earthwork = String(row.chainage_m); - const station = cell( - "th", - stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), - ); - station.className = "b08-summary__station"; - if (row.notes.length) station.title = row.notes.join("\n"); - // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(견본 「-」) - const v = (value: number): string => (first ? DASH : num(value)); - const triple = (area: number, volume: number, adjusted: number) => [ - num(area), - v(volume), - v(adjusted), - ]; - const values = [ - num(row.distance_m), - ...triple(row.cut_soil_area_m2, row.cut_soil_volume_m3, row.cut_soil_adjusted_m3), - ...triple(row.cut_rock_area_m2, row.cut_rock_volume_m3, row.cut_rock_adjusted_m3), - ...triple(row.ditch_soil_area_m2, row.ditch_soil_volume_m3, row.ditch_soil_adjusted_m3), - ...triple(row.ditch_rock_area_m2, row.ditch_rock_volume_m3, row.ditch_rock_adjusted_m3), - v(row.adjusted_total_m3), - num(row.fill_area_m2), - v(row.fill_volume_m3), - v(row.diverted_m3), - v(row.balance_m3), - v(row.cumulative_m3), - ...FACE_KEYS.flatMap((key) => { - const face = row.faces?.[key]; - return [num(face?.length_m), first ? DASH : num(face?.area_m2)]; - }), - ]; - const cells = values.map((text) => cell("td", text)); - // 암석 칸 — 어느 갈래(환산계수)로 셌는지 마우스를 올려 봄 - if (row.cut_rock_area_m2 > 0) cells[4].title = ROCK_KIND_LABEL[row.cut_rock_kind] ?? ""; - tr.append(station, ...cells); - return tr; + for (const [, key] of FACES) { + out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; + out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); + } + return out; } /** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ -function totalRow(table: EarthworkTable): HTMLTableRowElement { +function totalRow(table: EarthworkTable): DataRow { const t = table.totals; - const last = table.rows[table.rows.length - 1]; - const triple = (key: string) => ["", num(t[`${key}_volume_m3`]), num(t[`${key}_adjusted_m3`])]; - const values = [ - num(t.distance_m), - ...triple("cut_soil"), - ...triple("cut_rock"), - ...triple("ditch_soil"), - ...triple("ditch_rock"), - num(t.adjusted_total_m3), - "", - num(t.fill_volume_m3), - num(t.diverted_m3), - num(t.balance_m3), - num(last?.cumulative_m3), - // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 - ...FACE_KEYS.flatMap((key) => [ - "", - table.rows.some((row) => row.faces?.[key]?.area_m2 != null) ? num(t[`${key}_area_m2`]) : "", - ]), - ]; - const tr = document.createElement("tr"); - tr.className = "b08-earthwork__total"; - tr.dataset.b08EarthworkTotal = ""; - const label = cell("th", "합계"); - label.className = "b08-summary__station"; - tr.append(label, ...values.map((v) => cell("td", v))); - return tr; + const out: DataRow = { + station: "합계", + distance: t.distance_m, + adjusted_total: t.adjusted_total_m3, + fill_volume: t.fill_volume_m3, + diverted: t.diverted_m3, + balance: t.balance_m3, + cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, + }; + for (const [, key] of TRIPLES) { + out[`${key}_volume`] = t[`${key}_volume_m3`]; + out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; + } + // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 + for (const [, key] of FACES) { + const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); + out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; + } + return out; } function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { @@ -248,20 +199,26 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } - const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); - const info = note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + - `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + - `단위 m · ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, - ); - const tbody = document.createElement("tbody"); - tbody.append(...table.rows.map((row, i) => bodyRow(row, i === 0, interval))); - const tfoot = document.createElement("tfoot"); - tfoot.append(totalRow(table)); - const card = tableCard([info], "b08-earthwork__table", [head(), tbody, tfoot]); - card.querySelector("table")!.dataset.b08Earthwork = "table"; - host.replaceChildren(card); + const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? ""; + const flagged = table.rows.filter((row) => row.notes.length); + const info = [ + note( + `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + + `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + + `단위 m · ㎡ · ㎥`, + ), + ...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), + ]; + const grid = createDataTable({ + columns: COLUMNS, + rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), + frozen: 1, + total: totalRow(table), + caption: "토적표", + }); + grid.root.dataset.b08Earthwork = "table"; + host.replaceChildren(tableCard(info, grid.root)); } const SHEETS = [ diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 2a04e3521..884c95f86 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -4,60 +4,45 @@ * * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. + * 표 = 공용 많은 자료 표(`ui_template_data_table`). * ========================================================================== */ -import { - cell, - note, - tableCard, - type EarthworkTable, - type HaulList, -} from "./B08_Quantity_UI_Earthwork"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; const fmt = (value: number | null | undefined): string => value == null || !Number.isFinite(value) ? "—" : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); -function row(cells: HTMLElement[], className = ""): HTMLTableRowElement { - const tr = document.createElement("tr"); - if (className) tr.className = className; - tr.append(...cells); - return tr; -} +const SUMMARY_COLUMNS: DataColumn[] = [ + { key: "group", label: "구분", width: 120 }, + { key: "kind", label: "공종", width: 72 }, + { key: "spec", label: "규격", width: 128 }, + { key: "unit", label: "단위", width: 56 }, + { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, + { key: "note", label: "비고", width: 200 }, +]; -function station(text: string, span?: { row?: number; col?: number }): HTMLElement { - const th = cell("th", text, span); - th.className = "b08-summary__station"; - return th; -} - -/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 · 같은 구분은 한 칸으로 묶음 */ +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { - const thead = document.createElement("thead"); - thead.append( - row([station("구분"), ...["공종", "규격", "단위", "계", "비고"].map((t) => cell("th", t))]), - ); - const tbody = document.createElement("tbody"); - const rows = table.summary; - rows.forEach((item, i) => { - const cells: HTMLElement[] = []; - if (rows[i - 1]?.group !== item.group) { - let span = 1; - while (rows[i + span]?.group === item.group) span += 1; - cells.push(station(item.group, { row: span })); - } - const value = cell("td", fmt(item.value)); - value.className = "b08-earthwork__num"; - cells.push(cell("td", item.kind), cell("td", item.spec), cell("td", item.unit), value); - cells.push(cell("td", item.note)); - tbody.append(row(cells)); - }); const info = note( "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(— = 아직 값 없음)", ); - host.replaceChildren(tableCard([info], "b08-earthwork__sheet", [thead, tbody])); + const grid = createDataTable({ + columns: SUMMARY_COLUMNS, + // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「—」(값 없음)로 그림 + rows: table.summary.map((row) => ({ + ...row, + kind: row.kind || " ", + spec: row.spec || " ", + note: row.note || " ", + })), + frozen: 1, + caption: "토공집계표", + }); + host.replaceChildren(tableCard([info], grid.root)); } function haulNote(table: EarthworkTable): HTMLElement[] { @@ -73,42 +58,41 @@ function haulSheet( groups: Array<[string, HaulList]>, info: string, ): void { - const thead = document.createElement("thead"); - thead.append( - row([station("번호", { row: 2 }), ...groups.map(([label]) => cell("th", label, { col: 3 }))]), - row(groups.flatMap(() => ["토량(Q)", "거리(L)", "Q×L"].map((t) => cell("th", t)))), - ); - const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); - const tbody = document.createElement("tbody"); - for (let i = 0; i < count; i += 1) { - const cells = groups.flatMap(([, list]) => { - const item = list.rows[i]; - return item ? [fmt(item.q), fmt(item.l), fmt(item.ql)] : ["", "", ""]; - }); - tbody.append(row([station(String(i + 1)), ...cells.map((t) => cell("td", t))])); - } - const tfoot = document.createElement("tfoot"); - tfoot.append( - row( + const columns: DataColumn[] = [ + { key: "no", label: "번호", width: 56 }, + ...groups.flatMap(([label], g) => [ - station("합계"), - ...groups.flatMap(([, list]) => [fmt(list.q), "", fmt(list.ql)].map((t) => cell("td", t))), - ], - "b08-earthwork__total", + ["q", "토량(Q)"], + ["l", "거리(L)"], + ["ql", "Q×L"], + ].map(([key, text]) => ({ + key: `${key}${g}`, + label: text, + group: [label], + kind: "number" as const, + decimals: 2, + width: 104, + })), ), - ); + ]; + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const rows: DataRow[] = Array.from({ length: count }, (_, i) => { + const out: DataRow = { no: String(i + 1) }; + groups.forEach(([, list], g) => { + const item = list.rows[i]; + if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); + }); + return out; + }); + const total: DataRow = { no: "합계" }; + groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); const averages = groups.map(([label, list], i) => note( `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, ), ); - const card = tableCard([note(info), ...haulNote(table)], "b08-earthwork__haul", [ - thead, - tbody, - tfoot, - ]); - card.append(...averages); - host.replaceChildren(card); + const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info }); + host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index d9991c177..6fd399917 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -67,33 +67,3 @@ .b08-summary__name.is-missing { color: var(--color-danger); } - -/* 토공 탭 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ -.b08-earthwork__table td, -.b08-earthwork__haul td, -.b08-earthwork__sheet .b08-earthwork__num { - text-align: right; -} - -/* 토적표 머리 셋째 줄 — 둘째 줄 아래에 붙음 */ -.b08-summary__table thead tr + tr + tr th { - top: calc(2 * (1lh + 2 * var(--spacing-4, 4px) + 1px)); -} - -.b08-earthwork__total th, -.b08-earthwork__total td { - position: sticky; - /* 칸 테두리는 표(border-collapse) 것이라 붙은 칸과 같이 안 움직임 — 그림자로 선을 긋고 - 아래로 1px 더 내려 뒤 줄이 비치지 않게 */ - bottom: -1px; - z-index: 2; - font-weight: 700; - background: var(--color-surface-raised, var(--color-surface)); - box-shadow: - inset 0 2px 0 var(--color-text-muted), - 0 1px 0 var(--color-surface-raised, var(--color-surface)); -} - -.b08-earthwork__total th { - z-index: 3; -}