From 6e4cfc7dd22d0e901be272339f18c2ba0b1650f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:31:35 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(B08):=20=EC=82=AC=EB=A9=B4=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=9C=A0=EB=8F=84=C2=B7=EC=82=AC=EB=A9=B4=204?= =?UTF-8?q?=EA=B3=84=EC=97=B4=20=EB=A9=B4=EC=A0=81=20=EC=97=94=EC=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 8-4b 의 「사면적 없음」 구멍을 메움. 일감 3 의 엔진 덩어리. ⚠ B06 무접촉 — 저장분만으로 유도 설계 엔진이 cut_slope_segments 를 내기는 하나 **정본에 저장되지 않음** (실측: route 150 측점 20.0 의 저장 design 키 33개에 그 키 없음. 저장되는 것은 화면이 보낸 설계 지정이고 조회는 저장분을 그대로 실음). 그 값을 원천으로 쓰면 사면적이 조용히 0 이 됨. 대신 design_line + 저장된 경사비로 유도함 — 노체 끝 바깥으로 나아가며 기울기가 설계 경사비와 맞는 연속 구간이 사면이고, 원지반은 경사비가 안 맞아 저절로 끊김. 성토도 같은 원리로 잡힘(원지반선이 없어도 됨). 2단 사면(암/토사)은 경사비가 달라 그대로 갈림. ⚠ 측구를 사면으로 세지 않음 — 개발 중 실제로 났던 결함 측구 벽 경사가 n=1.0 으로 토사 절토비와 같아 그대로 걸렸음. 사면 시작점을 노체 끝이 아니라 **측구 바깥 끝**(road_edges ± ditch.top_width_m)으로 옮겨 고침. 그 형상을 실측 그대로 시험에 세워 재발을 막음. 사면 4계열 x 성토면/절토면 = (거리, 면적) 7쌍 층따기[성토면만] · 면고르기 · 법면보호공 · 지장목제거. 거리 = 그 측점 사면길이, 면적 = 토적표와 **같은 평균단면적법**(계산을 두 벌로 안 짬). 법면보호공은 면고르기를 참조하되 끊을 수 있게 둠 — 실무 시트가 값이 같았던 것은 엑셀 복사였고 오솔길 산출에는 보호공 4열이 0 이었음. 반영률은 법정값 아님 — 기본 100 %, 설계자 입력(PLAN 8-11 · 8-10 법대로). TODO(미결) — 실무 관측 80/50/80 중 지장목제거는 밑수가 안 맞아(14,061 != 10,782) 쫓지 않고 100 % 로 둠. 잘린 측점을 감추지 않음 — slope_unclosed 를 물고 와 unclosed_stations 목록으로 냄. 사면이 원지반을 못 만나면 사면길이도 잘려 있으므로 화면이 드러내야 함. 검증 — tmp/tests/test_b08_slope_area.py 15건 통과. 실측 설계선을 그대로 세워 측구 제외·2단 암토사 분리·성토 유도·원지반 끊김을 못 박음. 실데이터(route 150, 65측점) — 면적이 있는데 사면길이가 0 인 경우 0건, 잘린 측점 17곳 검출. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_SlopeArea.py | 165 +++++++++++++ .../B08_Quantity_Engine_SlopeLength.py | 225 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_SlopeArea.py create mode 100644 B08_Quantity/B08_Quantity_Engine_SlopeLength.py diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py new file mode 100644 index 00000000..d5df001e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py @@ -0,0 +1,165 @@ +"""사면 4계열 면적 — 실무 토적표의 오른쪽 절반 (B08 일감 3 · PLAN 8-4b). + +무엇을 내나 + 실무 토적표 V~AI 열에 해당한다. 계열 넷 × 성토면/절토면 2벌 = **(거리, 면적) 7쌍** + (층따기는 성토면만이라 7쌍이다). + + 층따기[성토면] · 면고르기[성토면·절토면] · 법면보호공[성토면·절토면] · 지장목제거[성토면·절토면] + + 여기서 「거리」는 그 측점의 **사면길이**이고, 면적은 토적표와 **같은 평균단면적법**으로 + 낸다 — 계산을 두 벌로 짜지 않는다. + +법면보호공은 면고르기를 참조한다 (PLAN 8-4b) + 실무 시트에서 둘의 값이 완전히 같았는데, 그것은 **엑셀에서 면고르기 열을 복사한 것**이고 + 오솔길 산출(`1.BOM`)에는 보호공 4열이 **0** 으로 비어 있었다. 즉 산출값이 아니라 참조다. + 그래서 기본은 참조로 두되 **끊을 수 있게** 한다 — 실제 보호 대상이 면고르기 대상과 + 다를 수 있기 때문이다. + +⚠ 반영률은 법정값이 아니다 (PLAN 8-11 · 8-10 ★) + 실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **프로그램 기본은 + 100 %** 이고 설계자가 바꾼다. 실무 관측치(80/50/80)는 기본값 후보가 아니라 참고다. + +⚠ 소단 평탄부는 사면적에 넣지 않는다 + 면고르기·종자파종의 대상은 「사면」이고 소단은 평평한 턱이다. `SlopeSegment` 자체가 + 평탄부를 빼고 나오므로 여기서 다시 거를 것이 없다. 다만 **소단이 늘수록 사면적이 줄어드는 + 것이 눈에 보여야** 하므로 측점마다 소단 폭을 함께 싣는다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope + +# 계열 이름 — 실무 토적표 머리글 그대로. `fill`/`cut` 은 성토면/절토면이다. +SERIES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("bench_cut", ("fill",)), # 층따기 — 성토면만(원지반이 급한 곳을 계단으로 깎는다) + ("face_dressing", ("fill", "cut")), # 면고르기 + ("slope_protection", ("fill", "cut")), # 법면보호공(종자파종) + ("tree_removal", ("fill", "cut")), # 지장목제거 +) + +# 법면보호공이 참조하는 계열 — 기본은 면고르기다(위 설명 참조). +PROTECTION_SOURCE = "face_dressing" + + +@dataclass(slots=True) +class SlopeRatios: + """계열별 반영률(0~1). 기본 100 % — 실무 관측치는 참고일 뿐 기본값이 아니다. + + ⚠ TODO(미결 · PLAN 8-11) — 실무 관측 80/50/80 중 **지장목제거는 밑수가 안 맞는다** + (성토+절토 합의 80 % = 14,061 ≠ 시트값 10,782). 밑수를 못 찾았으므로 쫓지 않고 + 100 % 로 둔다. 근거가 나오면 이 값만 바꾼다. + """ + + bench_cut: float = 1.0 + face_dressing: float = 1.0 + slope_protection: float = 1.0 + tree_removal: float = 1.0 + + def of(self, series: str) -> float: + return float(getattr(self, series, 1.0)) + + +@dataclass(slots=True) +class SlopeAreaRow: + """측점 하나의 사면 계열 값. `lengths` 는 거리(사면길이), `areas` 는 면적.""" + + chainage_m: float + distance_m: float = 0.0 + berm_width_m: float = 0.0 + unclosed: bool = False + lengths: dict[str, float] = field(default_factory=dict) + areas: dict[str, float] = field(default_factory=dict) + + +def _key(series: str, face: str) -> str: + return f"{series}_{face}" + + +def _length_of(slope: StationSlope, series: str, face: str) -> float: + """계열·면별 「거리」 = 그 측점의 사면길이. + + 법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다. + 층따기는 성토면만 대상이다. + """ + if series == "bench_cut" and face != "fill": + return 0.0 + return slope.fill_length_m if face == "fill" else slope.cut_length_m + + +def build_rows( + slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None +) -> list[SlopeAreaRow]: + """측점별 사면길이 → 계열별 (거리, 면적). 면적은 토적표와 같은 평균단면적법.""" + rates = ratios or SlopeRatios() + ordered = sorted(slopes, key=lambda s: s.chainage_m) + rows: list[SlopeAreaRow] = [] + previous: SlopeAreaRow | None = None + + for slope in ordered: + row = SlopeAreaRow( + chainage_m=slope.chainage_m, + berm_width_m=slope.berm_width_m, + unclosed=slope.unclosed, + ) + for series, faces in SERIES: + for face in faces: + row.lengths[_key(series, face)] = _length_of(slope, series, face) + if previous is not None: + distance = slope.chainage_m - previous.chainage_m + row.distance_m = distance + for key, length in row.lengths.items(): + series = key.rsplit("_", 1)[0] + before = previous.lengths.get(key, 0.0) + # 평균단면적법 — 토적표와 같은 식이다(체적 대신 면적을 낸다). + row.areas[key] = (before + length) / 2.0 * distance * rates.of(series) + else: + row.areas = {key: 0.0 for key in row.lengths} + rows.append(row) + previous = row + return rows + + +def totals(rows: list[SlopeAreaRow]) -> dict[str, float]: + """계열별 면적 합계. 거리(사면길이)는 합이 뜻이 없어 싣지 않는다.""" + keys = [_key(series, face) for series, faces in SERIES for face in faces] + return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys} + + +def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]: + """사면이 원지반을 못 만나 **면적이 잘린** 측점 목록. + + 조용히 적게 내면 안 되는 값이라 화면이 이 목록을 그대로 보인다(PLAN 8-4b). + 같은 사유로 토적표의 절·성토 면적도 잘려 있다. + """ + return [row.chainage_m for row in rows if row.unclosed] + + +def build_table( + slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양.""" + rates = ratios or SlopeRatios() + rows = build_rows(slopes, rates) + return { + "method": "average_end_area", + "series": [{"name": name, "faces": list(faces)} for name, faces in SERIES], + "protection_source": PROTECTION_SOURCE, + "ratios": {name: rates.of(name) for name, _ in SERIES}, + "rows": [ + { + "chainage_m": row.chainage_m, + "distance_m": row.distance_m, + "berm_width_m": row.berm_width_m, + "unclosed": row.unclosed, + "lengths": row.lengths, + "areas": row.areas, + } + for row in rows + ], + "totals": totals(rows), + "unclosed_stations": unclosed_stations(rows), + "station_count": len(rows), + } diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py new file mode 100644 index 00000000..8f6a3d28 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -0,0 +1,225 @@ +"""사면길이 유도 — 저장된 횡단 설계선에서 절토·성토 사면 구간을 가려낸다 (B08 일감 3). + +왜 유도하나 (B06 무접촉) + 설계 엔진이 `cut_slope_segments` 를 내기는 하나 **정본에 저장되지 않는다**(실측: route 150 + 측점 20.0 의 저장 `design` 키 33개에 그 키가 없음). 저장되는 것은 화면이 보낸 설계 지정이고 + 조회는 저장분을 그대로 싣는다. 그래서 그 값을 원천으로 쓰면 사면적이 조용히 0 이 된다. + + 대신 **`design_line`(설계선 폴리라인) + 저장된 경사비**로 유도한다. 필요한 입력이 전부 + 정본에 있어 B06 을 고치지 않아도 된다. + +가려내는 방법 + 노체 끝(`road_edges`)에서 바깥으로 나아가며, 구간 기울기가 **저장된 설계 경사비와 맞는 + 동안**이 사면이다. 원지반은 기울기가 안 맞아 저절로 끊긴다. 2단 사면(암/토사)도 경사비가 + 달라 그대로 갈린다. + + 실측(측점 20.0, 절토 0.4 · 토사절토 1.0 · 성토 1.2, 노체 끝 ±2.0): + -2.90 → -2.60 n=1.0 측구 바깥 벽 + -3.50 → -2.90 n=0.4 절토 사면(암) + -4.50 → -3.51 n=1.0 절토 사면(토사) + -5.00 → -4.85 n=1.63 원지반 — 여기서 멈춘다 + +⚠ 두 가지를 조심한다 + · **끝 조각은 딱 안 떨어진다** — 샘플 격자에 걸려 잘리면 `n=1.025` 처럼 나온다. 허용오차를 둔다. + · **지형이 우연히 같은 경사면** 사면이 길게 잡힐 수 있다. 노체에서 바깥으로 **연속**인 + 구간만 세고 끊기면 멈추는 것으로 막는다. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Iterable + +# 경사비 일치 허용오차(비율). 끝 조각이 격자에 잘려 생기는 오차를 덮는 크기다. +_RATIO_TOLERANCE = 0.12 +# 평탄부로 볼 기울기 — 소단·측구 바닥은 오름이 거의 없다. +_FLAT_RISE_M = 1e-6 + + +@dataclass(slots=True) +class SlopeSegment: + """사면 한 조각. `side` 는 `left`/`right`, `role` 은 `cut`/`fill`.""" + + side: str + role: str + from_offset_m: float + to_offset_m: float + rise_m: float + length_m: float + ratio: float + material: str | None = None + + +@dataclass(slots=True) +class StationSlope: + """측점 하나의 사면길이 묶음. 면적 적분이 이 값을 거리로 쓴다.""" + + chainage_m: float + cut_length_m: float = 0.0 + fill_length_m: float = 0.0 + berm_width_m: float = 0.0 + segments: tuple[SlopeSegment, ...] = () + # 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점. + # 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은 + # 사면길이도 같이 잘려 있으므로 **조용히 적게 내지 말고 화면에 드러내야 한다.** + unclosed: bool = False + + +def _num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) else None + + +def _ratios(design: dict[str, Any]) -> dict[str, list[float]]: + """역할별로 받아들일 경사비 목록. 2단 사면이면 암·토사 둘 다 절토로 본다.""" + cut = [ + value + for value in ( + _num(design.get("cut_slope_ratio")), + _num(design.get("soil_cut_slope_ratio")), + ) + if value is not None and value > 0 + ] + fill = [value for value in (_num(design.get("fill_slope_ratio")),) if value and value > 0] + return {"cut": cut, "fill": fill} + + +def _match(ratio: float, candidates: list[float]) -> float | None: + """구간 경사비가 후보 중 하나와 맞으면 그 후보를 돌려준다.""" + for candidate in candidates: + if abs(ratio - candidate) <= max(_RATIO_TOLERANCE * candidate, _RATIO_TOLERANCE): + return candidate + return None + + +def _outward( + line: list[dict[str, float]], edge_offset: float, side: str +) -> list[tuple[float, float, float, float]]: + """노체 끝에서 **바깥으로** 향하는 구간 목록 `(시작오프셋, 끝오프셋, run, rise)`. + + 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다(설계선 좌표 관례). + """ + points = sorted( + ((float(p["offset_m"]), float(p["elevation_m"])) for p in line), key=lambda p: p[0] + ) + if side == "left": + outer = [p for p in points if p[0] >= edge_offset] + else: + outer = [p for p in points if p[0] <= edge_offset][::-1] + return [ + ( + outer[i - 1][0], + outer[i][0], + abs(outer[i][0] - outer[i - 1][0]), + outer[i][1] - outer[i - 1][1], + ) + for i in range(1, len(outer)) + ] + + +def slope_start_offset(design: dict[str, Any], side: str) -> float | None: + """사면이 시작하는 오프셋 — 노체 끝, 측구가 있으면 **측구 바깥 끝**. + + ⚠ 이것이 없으면 **측구 바깥 벽이 사면으로 잡힌다.** 실측(측점 20.0)에서 측구 벽 경사가 + n=1.0 으로 토사 절토비와 같아 그대로 걸렸다. 측구는 노체 배수 시설이지 사면이 아니므로 + 그 바깥 끝에서부터 세어야 한다. + """ + edges = design.get("road_edges") or {} + edge = _num((edges.get(side) or {}).get("offset_m")) + if edge is None: + return None + if not design.get("ditch_enabled"): + return edge + ditch_side = design.get("ditch_side") + if ditch_side not in (side, "both", None): + return edge + width = _num((design.get("ditch") or {}).get("top_width_m")) or 0.0 + # 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. + return edge + width if side == "left" else edge - width + + +def _side_segments( + design: dict[str, Any], side: str, ratios: dict[str, list[float]] +) -> list[SlopeSegment]: + """한쪽 사면 구간 목록. 경사비가 안 맞는 구간을 만나면 거기서 멈춘다.""" + line = design.get("design_line") or [] + edge = slope_start_offset(design, side) + if not line or edge is None: + return [] + + berm = design.get("berm") or {} + berm_width = _num(berm.get("width_m")) or 0.0 + + segments: list[SlopeSegment] = [] + started = False + for start, end, run, rise in _outward(line, float(edge), side): + if run <= 1e-9: + continue + if abs(rise) <= _FLAT_RISE_M: + # 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 소단으로 보고 이어 간다. + if started and berm_width > 0 and abs(run - berm_width) < 0.05: + continue + if started: + break # 사면이 끝나고 평지를 만난 것이다 + continue + ratio = run / abs(rise) + # 절토는 바깥으로 갈수록 오르고, 성토는 내려간다. + role = "cut" if rise > 0 else "fill" + matched = _match(ratio, ratios[role]) + if matched is None: + if started: + break # 원지반에 닿았다 + continue # 아직 노체·측구 구간이다 + started = True + segments.append( + SlopeSegment( + side=side, + role=role, + from_offset_m=start, + to_offset_m=end, + rise_m=rise, + length_m=math.hypot(run, rise), + ratio=matched, + material=_material(design, matched), + ) + ) + return segments + + +def _material(design: dict[str, Any], ratio: float) -> str | None: + """경사비로 재료를 가른다 — 그린 대로 적는다(B06 `cut_slope_segments` 주석과 같은 규칙).""" + if not design.get("two_stage_slope"): + return None + rock = _num(design.get("cut_slope_ratio")) + soil = _num(design.get("soil_cut_slope_ratio")) + if rock is None or soil is None or abs(rock - soil) < 1e-9: + return None + return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil" + + +def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: + """측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다.""" + ratios = _ratios(design) + segments: list[SlopeSegment] = [] + for side in ("left", "right"): + segments.extend(_side_segments(design, side, ratios)) + berm = design.get("berm") or {} + return StationSlope( + chainage_m=float(chainage_m), + cut_length_m=sum(s.length_m for s in segments if s.role == "cut"), + fill_length_m=sum(s.length_m for s in segments if s.role == "fill"), + berm_width_m=_num(berm.get("width_m")) or 0.0, + segments=tuple(segments), + unclosed=bool(design.get("slope_unclosed")), + ) + + +def station_slopes(records: Iterable[dict[str, Any]]) -> list[StationSlope]: + """`[{chainage_m, design}]` → 측점별 사면길이. 이정 순으로 낸다.""" + result = [ + station_slope(item["chainage_m"], item.get("design") or {}) + for item in records + if item.get("chainage_m") is not None + ] + result.sort(key=lambda s: s.chainage_m) + return result From fbd7380b8352d509f95a3672f285e9da8dfa9e54 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:43:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(B08):=20=ED=86=A0=EC=A0=81=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EB=A9=B4=204=EA=B3=84=EC=97=B4=20?= =?UTF-8?q?=EC=97=B4=C2=B7=EC=9E=98=EB=A6=BC=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 3 의 API·화면 덩어리. 토적표 오른쪽 절반(실무 V~AI)이 화면에 섬. 한 응답으로 냄 실무 토적표가 한 장이라 화면도 한 장임. 나눠 부르면 두 번 왕복하고 같은 측점 목록을 두 벌로 들게 됨. earthwork-table 응답에 slope 를 함께 실음. 열 구성 — 계열 4 x 성토면/절토면 = (거리, 면적) 7쌍 층따기[성토면] · 면고르기 · 법면보호공(종자파종) · 지장목제거. 거리 = 그 측점 사면길이, 면적은 토적표와 같은 평균단면적법. 합계행은 면적만 — 거리(사면길이)는 합이 뜻이 없음(실무 시트도 비움). ⚠ 잘린 측점을 감추지 않음 (PLAN 8-4b) 사면이 원지반을 못 만난 측점은 사면길이·면적이 그 지점에서 잘려 있음. 경고 문구에 **무엇이 잘렸는지**를 적고(「실제 값은 이보다 큼」), 측점 단추를 누르면 그 줄로 가서 잠깐 강조됨 — 17곳을 눈으로 찾게 하지 않음. 잘린 줄은 측점 칸 왼쪽에 표시가 남음. 검증 — 공용 브라우저 실화면. 타입검사 오류 0. 머리글 3단 13/12/28셀, 대분류에 층따기·면고르기·법면보호공·지장목제거, 3단에 거리/면적 쌍. 본문 65행 x 34열(토공 20 + 사면 14). NO.1 사면값 6.4/64.4 · 3.5/93.0 등이 엔진값과 일치. 잘린 줄 17개 표시 · 경고 문구 · 측점 단추 17개 확인. 합계 면고르기 성토 13,518.6㎡ · 절토 5,433.7㎡ 로 엔진 합계와 같음. 회귀 404 passed (실패 1건은 B05 코리도 기존 깨짐). 조작한 화면은 사용자가 보던 주소로 되돌려 놓음. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Router_Earthwork.py | 10 +- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 148 +++++++++++++++++- .../B08_Quantity_UI_EarthworkGrid_Style.ts | 33 ++++ 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index ff91d278..509ef116 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -24,6 +24,8 @@ from B06_Section.B06_Section_Repository import ( get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table +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 config.config_db import run_with_connection logger = logging.getLogger(__name__) @@ -38,7 +40,11 @@ def _stations(designs: list[dict[str, Any]]) -> list[StationArea]: @router.get("/{project_id}/quantity/{route_id}/earthwork-table") async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: - """토적표 — 측점별 단면적을 평균단면적법으로 체적화한 표.""" + """토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다. + + 실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을 + 두 벌로 들게 된다. + """ try: designs = await run_with_connection(get_cross_section_designs, route_id) except Exception: @@ -48,6 +54,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: content={"status": "error", "message": "토적표를 만들지 못했습니다."}, ) table = build_table(_stations(designs)) + # 사면 계열은 저장된 설계선에서 유도한다 — 반영률은 기본 100 %(설계자 입력은 후속). + table["slope"] = build_slope_table(station_slopes(designs)) table["route_id"] = route_id return JSONResponse(content=table) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 41e571fc..76aec807 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -36,12 +36,31 @@ export interface EarthworkRow { cumulative_m3: number; } +/** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */ +export interface SlopeRow { + chainage_m: number; + distance_m: number; + berm_width_m: number; + unclosed: boolean; + lengths: Record; + areas: Record; +} + +export interface SlopeTable { + rows: SlopeRow[]; + totals: Record; + ratios: Record; + unclosed_stations: number[]; +} + export interface EarthworkTable { method: string; station_count: number; route_id?: number; rows: EarthworkRow[]; totals: Record; + conversion_factors?: Record; + slope?: SlopeTable; } /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ @@ -119,12 +138,46 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] }, ]; +/** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다. + * 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */ +const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [ + { label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] }, + { + label: "면고르기", + faces: [ + { key: "face_dressing_fill", label: "성 토 면" }, + { key: "face_dressing_cut", label: "절 토 면" }, + ], + }, + { + label: "법 면 보 호 공", + faces: [ + { key: "slope_protection_fill", label: "종자파종(성토)" }, + { key: "slope_protection_cut", label: "종자파종(절토)" }, + ], + }, + { + label: "지 장 목 제 거", + faces: [ + { key: "tree_removal_fill", label: "성 토 면" }, + { key: "tree_removal_cut", label: "절 토 면" }, + ], + }, +]; + +/** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */ +const SLOPE_LABELS = ["거 리", "면 적"]; + /** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; 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); @@ -178,13 +231,35 @@ function buildHead(): HTMLTableSectionElement { r1.append(th); } } + + // 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다. + for (const group of SLOPE_GROUPS) { + const th = document.createElement("th"); + th.colSpan = group.faces.length * 2; + th.textContent = group.label; + r1.append(th); + for (const face of group.faces) { + const th2 = document.createElement("th"); + th2.colSpan = 2; + th2.textContent = face.label; + r2.append(th2); + for (const label of SLOPE_LABELS) { + const th3 = document.createElement("th"); + th3.textContent = label; + r3.append(th3); + } + } + } + head.append(r1, r2, r3); return head; } -function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement { +function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement { const body = document.createElement("tbody"); const columns = flatColumns(); + const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row])); + for (const row of rows) { const tr = document.createElement("tr"); columns.forEach((column, index) => { @@ -194,12 +269,25 @@ function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement { if (index === 0) td.className = "b08-grid__station"; tr.append(td); }); + + const slopeRow = slopeByChainage.get(row.chainage_m); + // 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b). + if (slopeRow?.unclosed) tr.classList.add("is-unclosed"); + for (const group of SLOPE_GROUPS) { + for (const face of group.faces) { + for (const source of [slopeRow?.lengths, slopeRow?.areas]) { + const td = document.createElement("td"); + td.textContent = cell(source?.[face.key], 1); + tr.append(td); + } + } + } body.append(tr); } return body; } -function buildFoot(totals: Record): HTMLTableSectionElement { +function buildFoot(totals: Record, slope?: SlopeTable): HTMLTableSectionElement { const foot = document.createElement("tfoot"); const tr = document.createElement("tr"); flatColumns().forEach((column, index) => { @@ -208,10 +296,55 @@ function buildFoot(totals: Record): HTMLTableSectionElement { else if (column.sum) td.textContent = cell(totals[column.key], column.digits); tr.append(td); }); + // 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다. + for (const group of SLOPE_GROUPS) { + for (const face of group.faces) { + tr.append(document.createElement("td")); + const td = document.createElement("td"); + td.textContent = cell(slope?.totals?.[face.key], 1); + tr.append(td); + } + } foot.append(tr); return foot; } +/** 잘린 측점 경고 — 무엇이 잘렸는지 적고, 측점을 눌러 그 줄로 가게 한다. */ +function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null { + const stations = slope.unclosed_stations ?? []; + if (!stations.length) return null; + + const box = document.createElement("div"); + box.className = "b08-grid__warning"; + const text = document.createElement("span"); + text.textContent = + `사면이 원지반을 만나지 못해 ${stations.length}개 측점에서 ` + + "사면길이·면적이 그 지점에서 잘렸습니다. 실제 값은 이보다 큽니다. " + + "같은 사유로 절·성토 면적도 잘려 있습니다."; + box.append(text); + + for (const chainage of stations) { + const link = document.createElement("button"); + link.type = "button"; + link.className = "b08-grid__warning-station"; + link.textContent = stationLabel(chainage); + link.addEventListener("click", () => { + const row = table.querySelector( + `tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`, + ); + row?.scrollIntoView({ block: "center", behavior: "smooth" }); + row?.classList.add("is-highlighted"); + window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600); + }); + box.append(link); + } + return box; +} + +function slopeRowIndex(slope: SlopeTable, chainage: number): number { + return slope.rows.findIndex((row) => row.chainage_m === chainage); +} + /** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { const wrap = document.createElement("div"); @@ -226,7 +359,16 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { scroller.className = "b08-grid__scroll"; const element = document.createElement("table"); element.className = "b08-grid__table"; - element.append(buildHead(), buildBody(table.rows), buildFoot(table.totals)); + element.append( + buildHead(), + buildBody(table.rows, table.slope), + buildFoot(table.totals, table.slope), + ); + + if (table.slope) { + const notice = buildUnclosedNotice(table.slope, element); + if (notice) wrap.append(notice); + } scroller.append(element); wrap.append(scroller); return wrap; diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index a3577ff0..ca3e4ee3 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -63,6 +63,39 @@ const CSS = ` font-weight: var(--font-weight-medium, 600); } +/* 잘린 측점 경고 — 조용히 적게 내지 않고 눈에 보이게 한다(PLAN 8-4b). */ +.b08-grid__warning { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + padding: 6px 10px; + font-size: 12px; + color: var(--color-text-body); + background: var(--color-surface-raised); + border-left: 3px solid var(--color-danger); +} + +.b08-grid__warning-station { + font-size: 11px; + padding: 0 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + font-variant-numeric: tabular-nums; +} + +/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다. */ +.b08-grid__table tbody tr.is-unclosed .b08-grid__station { + border-left: 3px solid var(--color-danger); +} + +.b08-grid__table tbody tr.is-highlighted td { + background: var(--color-royal-amethyst, #d8ccff); + color: #1b2220; +} + .b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } .b08-quantity__tab { From 0cc3c1bd5e4b096768a8915ebb816081eb7784fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:45:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(B08):=20=EC=9E=98=EB=A6=BC=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A5=BC=20=E3=80=8C=EC=A3=BC=EC=9D=98=20+=20?= =?UTF-8?q?=EC=A0=91=ED=9E=88=EB=8A=94=20=EB=AA=A9=EB=A1=9D=E3=80=8D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EB=8A=98=20=EB=9C=A8=EB=8A=94?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=A0=EB=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B06 담당(랩탑) 실측 회신 반영. 미교차는 드문 예외가 아니라 상시임 — route 169 는 22/105(21 %), 이 노선은 17/65(26 %). 매번 요란하면 곧 무시당함. 고친 것 셋 · 붉은 오류 → **주의 표시**(테두리를 danger 에서 muted 로). 잘린 줄 표시도 같게. · 목록을 `details` 로 **접어 둠**. 필요할 때만 폄. · 문구를 **한 덩어리**로 묶음 — 절·성토 면적 · 사면길이 · 사면적이 전부 같은 사유로 잘리므로 항목마다 따로 띄우지 않음. 안 넓히는 까닭 (주석에 남김) 미교차 절반 이상이 계곡·절벽처럼 지형이 설계 사면에서 멀어지는 자리라 반폭을 늘려도 영원히 안 닫힘. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는 값이 안 남. 2026-09-03 사용자 확정 「경고로 대체」가 그대로 유효함. 정본 키 대조 — StationSlope.unclosed 가 design.slope_unclosed 에서 나오는 것 확인 (B06_Section_Engine_Design.py:807 이 내는 그 값). 검증 — 공용 브라우저 실화면. 타입검사 오류 0. 접힌 상태 `details[open=false]` · 요약 문구 한 줄 · 측점 단추 17개. 펴면 목록이 보이고, 첫 단추(NO.13)를 누르면 그 줄로 가서 강조됨. 조작한 화면은 사용자가 보던 주소로 되돌려 놓음. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 36 ++++++++++++++----- .../B08_Quantity_UI_EarthworkGrid_Style.ts | 22 +++++++----- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 76aec807..02431449 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -309,20 +309,37 @@ function buildFoot(totals: Record, slope?: SlopeTable): HTMLTabl return foot; } -/** 잘린 측점 경고 — 무엇이 잘렸는지 적고, 측점을 눌러 그 줄로 가게 한다. */ +/** 잘린 측점 안내 — 한 덩어리로 묶고, 목록은 접어 둔다. + * + * 왜 붉은 오류가 아닌가 + * 실측 발생률이 21~26 %(랩탑 route 169 는 22/105, 이 노선은 17/65)라 **늘 뜨는 안내**다. + * 매번 요란하면 곧 무시당한다. 그래서 **주의 표시 + 접히는 목록**으로 둔다. + * + * 왜 한 덩어리인가 + * 절·성토 면적 · 사면적 · 사면길이가 **전부 같은 사유로** 잘린다. 항목마다 따로 띄우면 + * 사용자가 세 번 읽게 된다. + * + * 왜 안 넓히나 (B06 담당 확인, 2026-09-07) + * 미교차의 절반 이상이 계곡·절벽처럼 **지형이 설계 사면에서 멀어지는 자리**라 반폭을 + * 늘려도 영원히 안 닫힌다. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는 + * 값이 안 나온다. 그래서 경고로 대체한다(2026-09-03 사용자 확정). + */ function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null { const stations = slope.unclosed_stations ?? []; if (!stations.length) return null; - const box = document.createElement("div"); + const box = document.createElement("details"); box.className = "b08-grid__warning"; - const text = document.createElement("span"); - text.textContent = - `사면이 원지반을 만나지 못해 ${stations.length}개 측점에서 ` + - "사면길이·면적이 그 지점에서 잘렸습니다. 실제 값은 이보다 큽니다. " + - "같은 사유로 절·성토 면적도 잘려 있습니다."; - box.append(text); + const summary = document.createElement("summary"); + summary.className = "b08-grid__warning-summary"; + summary.textContent = + `주의 — ${stations.length}개 측점에서 사면이 원지반을 만나지 못했습니다. ` + + "그 측점의 절·성토 면적 · 사면길이 · 사면적이 함께 잘려 있어 실제보다 작습니다."; + box.append(summary); + + const list = document.createElement("div"); + list.className = "b08-grid__warning-list"; for (const chainage of stations) { const link = document.createElement("button"); link.type = "button"; @@ -336,8 +353,9 @@ function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLEl row?.classList.add("is-highlighted"); window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600); }); - box.append(link); + list.append(link); } + box.append(list); return box; } diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index ca3e4ee3..2b629acc 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -63,17 +63,23 @@ const CSS = ` font-weight: var(--font-weight-medium, 600); } -/* 잘린 측점 경고 — 조용히 적게 내지 않고 눈에 보이게 한다(PLAN 8-4b). */ +/* 잘린 측점 안내 — 발생률이 21~26 % 로 늘 뜨는 것이라 붉은 오류가 아니라 **주의**로 둔다. + 요란하면 곧 무시당한다. 목록은 접어 두고 필요할 때만 편다. */ .b08-grid__warning { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 6px; padding: 6px 10px; font-size: 12px; color: var(--color-text-body); background: var(--color-surface-raised); - border-left: 3px solid var(--color-danger); + border-left: 3px solid var(--color-text-muted); +} + +.b08-grid__warning-summary { cursor: pointer; } + +.b08-grid__warning-list { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + padding-top: 6px; } .b08-grid__warning-station { @@ -86,9 +92,9 @@ const CSS = ` font-variant-numeric: tabular-nums; } -/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다. */ +/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다 — 눈에 띄되 요란하지 않게. */ .b08-grid__table tbody tr.is-unclosed .b08-grid__station { - border-left: 3px solid var(--color-danger); + border-left: 3px solid var(--color-text-muted); } .b08-grid__table tbody tr.is-highlighted td { From 9badcbf80ec82a7569421323abf540649aae0a3c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:54:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=C2=B7=EC=9A=B4=EB=B0=98=20=EA=B0=80=EC=A4=91?= =?UTF-8?q?=ED=8F=89=EA=B7=A0=20=EC=97=94=EC=A7=84=20+=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 4·5 의 엔진 덩어리. PLAN 8-11·8-3·8-7. 프로젝트 설정 (common_util/common_util_project_settings.py) 자리 = /project_settings.json, 루트의 project_manifest.json 옆. 매니페스트 stages 는 단계 산출물 목록이고 설정은 두 페이지가 함께 읽는 프로젝트 값이라 단계 폴더에 넣으면 주인이 애매해짐. 구획 quantity(B08) · estimation(B09) — 남의 구획은 읽기만. ⚠ 경계를 코드로 막음 — save_section() 이 이름 붙은 한 구획만 갈아 끼우고 통째로 쓰는 함수를 두지 않음. 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라짐(오늘 main.py 에서 같은 모양의 사고를 이미 겪음). ⚠ *_override 기본은 None — 「안 정했으면 config 정본을 씀」. 기본값을 복사해 넣으면 정본이 바뀌어도 옛 프로젝트가 안 따라옴. ⚠ dataset_versions 는 기록이지 정본이 아님 — 되살릴 때 쓰는 정본은 프로젝트 스냅샷이고 어긋나면 스냅샷이 이김. estimation.rate_dataset 은 「연도」가 아니라 판을 가리킴 — 제비율은 연중에도 개정됨(현행판 2026-04-13). B09 확정 반영. 토공집계표 (열 = 구분·공종·규격·단위·계·비고, 거창 실무 시트 그대로) ⚠ 암 갈래 개수를 코드에 안 박음 — 울진 2 · 거창 5 · BOM 1 로 공사마다 다름. 프로젝트 설정의 세트를 받아 그만큼 줄을 냄. 비율이 아직 없으면 지어내지 않고 「암」 한 줄로 냄. 비율 합이 100 이 아니어도 준 비율끼리 안분해 총량 보존. ⚠ 반영률 기본 100 % — 실무 관측 80/50/80 은 참고이지 기본값 아님(법대로). 바꾼 경우에만 비고에 「80 % 반영」이 남음 — 누가 정한 값인지 보이게. 운반 가중평균 ((운반수단 x 지반유형)별 1줄) 실무 울진이 도자 토사/암 · 덤프 토사/암 4줄을 올림. 가중평균 = Σ(토량x거리)/Σ토량 으로 단순평균이 아님. 개별 구간은 근거로 함께 냄 — 어느 구간이 그 평균을 만들었는지 되짚게. 입력은 이미 있는 HaulPlan — 띠·장거리이동에 equipment·haul_distance_m· 지반유형별 물량(ea/rr/br)이 다 있어 다시 세지 않음. ⚠ 무대(free_haul)는 in_bill=False — 품셈 1-2-7 로 품에 포함이고 인력운반 10-6 도 「소운반 20m 초과분」이라 붙일 단가가 없음. 값은 내되 내역 줄만 뺌 — 「무대+도자+덤프 = 총 운반토량」 검산에 씀. 검증 — 28건 통과 (토공집계·설정 19 + 운반 9). 가중평균이 단순평균과 다름을 못 박음(도자 토사 600@40+400@50 → 44m, 45 아님). 울진2·거창5 두 세트가 같은 코드로 서는 것, 무대가 내역 줄이 아닌 것, 구획을 따로 저장해도 상대 값이 안 지워지는 것을 각각 시험함. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_EarthworkSummary.py | 235 ++++++++++++++++++ .../B08_Quantity_Engine_HaulSummary.py | 217 ++++++++++++++++ common_util/common_util_project_settings.py | 158 ++++++++++++ 3 files changed, 610 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py create mode 100644 B08_Quantity/B08_Quantity_Engine_HaulSummary.py create mode 100644 common_util/common_util_project_settings.py diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py new file mode 100644 index 00000000..37b5c20a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -0,0 +1,235 @@ +"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (B08 일감 4 · PLAN 8-11). + +열 구성 (거창 실무 `토공집계표` 시트 그대로) + `구분 · 공종 · 규격 · 단위 · 계 · 비고` + +암 갈래는 **개수를 코드에 박지 않는다** (PLAN 8-13) + 울진 2갈래(연암·발파암) · 거창 5갈래(토사·풍화암·연암·보통암·경암) · 오솔길 BOM 1갈래로 + 공사마다 다르다. 프로젝트 설정의 세트를 받아 그만큼 줄을 낸다. + + ⚠ 측점별 암질 판정에 기대지 않는다(PLAN 8-1 사용자 확정) — 절토량은 기하에서 나오고 + **암/토사 나눔과 갈래 비율은 설계자 입력**이다. 그래서 여기서는 토적표의 「암」 총량을 + 설계자가 준 비율(%)로 나눠 줄을 만든다. + +⚠ 반영률은 법정값이 아니다 (PLAN 8-11) + 실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **기본 100 %** 이고 + 설계자가 바꾼다. 실무 관측치(80/50/80)는 참고이지 기본값이 아니다. + +⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 되지 않는다** (PLAN 8-7 ㉡) + 품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다. + 그래서 `in_bill=False` 로 표시해 넘긴다 — 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다. +GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기") + +# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %). +RATIO_OF_ROW = { + "성토면다짐": "fill_slope_compaction", + "초류종자살포": "seed_spray_fill", # 성토면 몫에만 걸린다 — 절토면은 별도 키 + "지장목제거": "obstacle_removal", +} + + +@dataclass(slots=True) +class SummaryRow: + """집계표 한 줄. 이름·단위는 거창 실무 시트 문구를 따른다.""" + + group: str # 구분 (흙깎기·성토·…) + item: str = "" # 공종 (토사·연암·…) + spec: str = "" # 규격 (기계(굴삭기)·백호우·…) + unit: str = "㎥" + amount: float = 0.0 + note: str = "" + # 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡). + in_bill: bool = True + + +@dataclass(slots=True) +class SummaryInput: + """집계에 필요한 값 묶음. 토적표·사면표·운반계획에서 이미 나온 것만 받는다.""" + + earthwork_totals: dict[str, float] = field(default_factory=dict) + slope_totals: dict[str, float] = field(default_factory=dict) + haul_rows: list[dict[str, Any]] = field(default_factory=list) + rock_classes: list[str] = field(default_factory=list) + rock_ratios_pct: dict[str, float] = field(default_factory=dict) + application_ratios: dict[str, float] = field(default_factory=dict) + + +def _ratio(source: SummaryInput, key: str) -> float: + """반영률(0~1). 없으면 1.0 — 실무 관측치를 기본값으로 쓰지 않는다.""" + value = source.application_ratios.get(key) + return float(value) if isinstance(value, (int, float)) else 1.0 + + +def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float]]: + """암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. + + 비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다. + """ + classes = [name for name in source.rock_classes if name != "토사"] + ratios = {name: float(source.rock_ratios_pct.get(name, 0) or 0) for name in classes} + given = sum(ratios.values()) + if given <= 0: + return [("암", total)] + return [(name, total * ratios[name] / given) for name in classes if ratios[name] > 0] + + +def build_rows(source: SummaryInput) -> list[SummaryRow]: + """토공집계표 줄 목록. 값이 0 인 갈래도 줄은 남긴다(실무 시트가 그렇다).""" + earth = source.earthwork_totals + slope = source.slope_totals + rows: list[SummaryRow] = [] + + # ── 흙깎기 · 측구터파기 — 토사 한 줄 + 암 갈래만큼 ────────────── + for group, soil_key, rock_key in ( + ("흙깎기", "cut_soil_volume_m3", "cut_rock_volume_m3"), + ("측구터파기", "ditch_soil_volume_m3", "ditch_rock_volume_m3"), + ): + rows.append( + SummaryRow( + group=group, item="토사", spec="기계(굴삭기)", amount=earth.get(soil_key, 0.0) + ) + ) + for name, amount in _split_by_rock(earth.get(rock_key, 0.0), source): + rows.append(SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount)) + + rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0))) + rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0))) + + # ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ──────── + rows.extend(_haul_rows(source)) + + # ── 사면 계열 — 반영률이 여기서 걸린다 ──────────────────────── + fill_face = slope.get("face_dressing_fill", 0.0) + cut_face = slope.get("face_dressing_cut", 0.0) + rows.append( + SummaryRow( + group="성토면다짐", + unit="㎡", + amount=fill_face * _ratio(source, "fill_slope_compaction"), + note=_ratio_note(source, "fill_slope_compaction", "성토면"), + ) + ) + seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio( + source, "seed_spray_cut" + ) + rows.append( + SummaryRow( + group="초류종자살포", + spec="씨드스프레이", + unit="㎡", + amount=seed, + note=_seed_note(source), + ) + ) + removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0) + rows.append( + SummaryRow( + group="지장목제거", + unit="㎡", + amount=removal * _ratio(source, "obstacle_removal"), + note=_ratio_note(source, "obstacle_removal", "성토면+절토면"), + ) + ) + rows.append( + SummaryRow( + group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0) + ) + ) + return rows + + +def _ratio_note(source: SummaryInput, key: str, base: str) -> str: + ratio = _ratio(source, key) + return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영" + + +def _seed_note(source: SummaryInput) -> str: + fill = _ratio(source, "seed_spray_fill") + cut = _ratio(source, "seed_spray_cut") + if abs(fill - 1.0) < 1e-9 and abs(cut - 1.0) < 1e-9: + return "" + return f"성토면 {fill * 100:g} % 반영 + 절토면 {cut * 100:g} % 반영" + + +# 운반수단 표기 — `HaulPlan` 의 키를 실무 시트 문구로 옮긴다. +HAUL_LABELS = {"free_haul": "무대(종방향유용토)", "dozer": "도자운반", "dump_truck": "덤프운반"} + + +def _haul_rows(source: SummaryInput) -> list[SummaryRow]: + """운반 — (운반수단 × 지반유형)별 가중평균 줄 (PLAN 8-3). + + 무대는 `in_bill=False` — 품셈 1-2-7 로 품에 포함돼 단가가 없다. 값은 검산에 쓴다. + """ + rows: list[SummaryRow] = [] + for item in source.haul_rows: + key = str(item.get("equipment") or "") + label = HAUL_LABELS.get(key, key or "운반") + ground = str(item.get("ground") or "") + distance = item.get("average_distance_m") + note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" + if key == "free_haul": + note = (note + " · 내역 제외(품에 포함)").strip(" ·") + rows.append( + SummaryRow( + group=label, + item=ground, + amount=float(item.get("volume_m3") or 0.0), + note=note, + in_bill=key != "free_haul", + ) + ) + return rows + + +def build_table(source: SummaryInput) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양.""" + rows = build_rows(source) + return { + "columns": ["구분", "공종", "규격", "단위", "계", "비고"], + "rock_classes": list(source.rock_classes), + "rock_ratios_pct": dict(source.rock_ratios_pct), + "application_ratios": dict(source.application_ratios), + "rows": [ + { + "group": row.group, + "item": row.item, + "spec": row.spec, + "unit": row.unit, + "amount": row.amount, + "note": row.note, + "in_bill": row.in_bill, + } + for row in rows + ], + "row_count": len(rows), + } + + +def haul_check(source: SummaryInput, earthwork_totals: dict[str, float]) -> dict[str, Any]: + """검산 — `무대 + 도자 + 덤프` 합이 총 운반토량과 맞는가 (PLAN 8-7 ㉡). + + 무대를 안 내면 이 검산이 안 된다. 그래서 값은 내되 내역 줄만 빼는 것이다. + """ + hauled = sum(float(item.get("volume_m3") or 0.0) for item in source.haul_rows) + diverted = float(earthwork_totals.get("diverted_m3") or 0.0) + return { + "hauled_total_m3": hauled, + "diverted_total_m3": diverted, + "difference_m3": hauled - diverted, + } + + +def totals_by_unit(rows: Iterable[SummaryRow]) -> dict[str, float]: + """단위별 합계 — ㎥ 와 ㎡ 를 섞어 더하지 않는다.""" + result: dict[str, float] = {} + for row in rows: + result[row.unit] = result.get(row.unit, 0.0) + row.amount + return result diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py new file mode 100644 index 00000000..5653ffc4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -0,0 +1,217 @@ +"""운반 가중평균 — 내역 줄이 되는 4줄과 그 근거 (B08 일감 5 · PLAN 8-3·8-7). + +무엇을 내나 + 실무는 **(운반수단 × 지반유형)별 가중평균 1개**를 내역에 올린다. 울진 실측 — + 「도자 토사 1,170㎥ 평균 43.66m · 도자 암 1,554㎥ 39.07m · 덤프 토사 1,667㎥ 293.78m · + 덤프 암 1,714㎥ 318.6m」로 **4줄**이다. 오솔길도 분류별 가중평균 1개를 낸다 + (거창 무대: 10,399 ÷ 871 = 11.94m). + + 개별 구간 줄은 버리지 않고 **근거**로 함께 낸다 — 어느 구간이 그 평균을 만들었는지 + 되짚을 수 있어야 한다. + +가중평균 = Σ(토량 × 거리) ÷ Σ(토량) + 실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식이다. 단순평균이 아니다. + +⚠ 무대(`free_haul`)는 내역 줄이 되지 않는다 (PLAN 8-7 ㉡) + 품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다 — + 인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고 + 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. + +입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다) + 띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이 + 들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다. +GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} +# 무대 — 품에 포함이라 내역 줄이 되지 않는다. +FREE_HAUL_KEY = "free_haul" + + +@dataclass(slots=True) +class HaulLeg: + """근거 줄 하나 — 어느 구간을 얼마나 몇 m 옮겼나.""" + + equipment: str + ground: str + volume_m3: float + distance_m: float + from_m: float + to_m: float + source: str # `band` 또는 `transfer` + + +@dataclass(slots=True) +class HaulSummaryRow: + """내역 줄 — (운반수단 × 지반유형) 하나.""" + + equipment: str + ground: str + volume_m3: float = 0.0 + work_m3m: float = 0.0 # Σ(토량 × 거리) — 가중평균의 분자 + legs: int = 0 + in_bill: bool = True + + @property + def average_distance_m(self) -> float: + return self.work_m3m / self.volume_m3 if self.volume_m3 else 0.0 + + +def _legs_of(plan: dict[str, Any]) -> list[HaulLeg]: + """`HaulPlan` → 근거 줄 목록. 띠와 장거리 이동을 같은 모양으로 편다.""" + legs: list[HaulLeg] = [] + + def push( + item: dict[str, Any], + equipment: str | None, + distance: Any, + source: str, + from_m: Any, + to_m: Any, + ) -> None: + if not equipment or not isinstance(distance, (int, float)): + return + for key, label in GROUND_LABELS.items(): + volume = item.get(key) + if not isinstance(volume, (int, float)) or volume <= 0: + continue + legs.append( + HaulLeg( + equipment=str(equipment), + ground=label, + volume_m3=float(volume), + distance_m=float(distance), + from_m=float(from_m or 0.0), + to_m=float(to_m or 0.0), + source=source, + ) + ) + + for block in plan.get("blocks") or []: + for band in block.get("bands") or []: + push( + band, + band.get("equipment"), + band.get("haul_distance_m"), + "band", + band.get("haul_from_m"), + band.get("haul_to_m"), + ) + for transfer in plan.get("transfers") or []: + push( + transfer, + transfer.get("equipment"), + transfer.get("haul_distance_m"), + "transfer", + transfer.get("from_m"), + transfer.get("to_m"), + ) + return legs + + +def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]: + """(운반수단 × 지반유형)별 가중평균. 실무 내역이 이 줄들을 그대로 쓴다.""" + grouped: dict[tuple[str, str], HaulSummaryRow] = {} + for leg in legs: + key = (leg.equipment, leg.ground) + row = grouped.get(key) + if row is None: + row = HaulSummaryRow( + equipment=leg.equipment, + ground=leg.ground, + in_bill=leg.equipment != FREE_HAUL_KEY, + ) + grouped[key] = row + row.volume_m3 += leg.volume_m3 + row.work_m3m += leg.volume_m3 * leg.distance_m + row.legs += 1 + # 수단 → 지반유형 순으로 안정 정렬 — 화면·내역 줄 순서가 매번 같아야 한다. + order = {FREE_HAUL_KEY: 0, "dozer": 1, "dump_truck": 2} + labels = list(GROUND_LABELS.values()) + return sorted( + grouped.values(), + key=lambda row: ( + order.get(row.equipment, 9), + labels.index(row.ground) if row.ground in labels else 9, + ), + ) + + +def build_table(plan: dict[str, Any] | None) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.""" + legs = _legs_of(plan or {}) + rows = summarize(legs) + return { + "method": "volume_weighted_average", + "rows": [ + { + "equipment": row.equipment, + "ground": row.ground, + "volume_m3": row.volume_m3, + "average_distance_m": row.average_distance_m, + "work_m3m": row.work_m3m, + "legs": row.legs, + "in_bill": row.in_bill, + } + for row in rows + ], + # 근거 — 어느 구간이 그 평균을 만들었나. 내역에는 안 오른다. + "legs": [ + { + "equipment": leg.equipment, + "ground": leg.ground, + "volume_m3": leg.volume_m3, + "distance_m": leg.distance_m, + "from_m": leg.from_m, + "to_m": leg.to_m, + "source": leg.source, + } + for leg in legs + ], + "bill_row_count": sum(1 for row in rows if row.in_bill), + } + + +def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: + """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.""" + return [ + { + "equipment": row["equipment"], + "ground": row["ground"], + "volume_m3": row["volume_m3"], + "average_distance_m": row["average_distance_m"], + } + for row in table.get("rows") or [] + ] + + +@dataclass(slots=True) +class HaulCheck: + """검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡).""" + + hauled_total_m3: float = 0.0 + plan_total_m3: float = 0.0 + difference_m3: float = 0.0 + details: dict[str, float] = field(default_factory=dict) + + +def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck: + """`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.""" + hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or []) + plan = plan or {} + planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0) + by_equipment: dict[str, float] = {} + for row in table.get("rows") or []: + key = str(row.get("equipment")) + by_equipment[key] = by_equipment.get(key, 0.0) + float(row.get("volume_m3") or 0.0) + return HaulCheck( + hauled_total_m3=hauled, + plan_total_m3=planned, + difference_m3=hauled - planned, + details=by_equipment, + ) diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py new file mode 100644 index 00000000..7e229376 --- /dev/null +++ b/common_util/common_util_project_settings.py @@ -0,0 +1,158 @@ +"""프로젝트 설정 — 수량(B08)·원가(B09) 두 페이지가 함께 읽는 값 (PLAN 8-7). + +자리 + `/project_settings.json` — 루트, `project_manifest.json` 옆. + 매니페스트의 `stages` 는 **단계 산출물** 목록이고, 설정은 산출물이 아니라 **프로젝트 값**이다. + 단계 폴더에 넣으면 주인이 애매해진다. + +구획 — 페이지마다 자기 것만 쓴다 + `quantity` = B08 · `estimation` = B09. 남의 구획은 **읽기만** 한다. + `dataset_versions` 도 구획마다 따로 둔다 — 한 칸을 둘이 쓰면 저장할 때마다 서로 지운다. + + ⚠ **경계를 코드로 막는다** — `save_section()` 은 이름 붙은 한 구획만 갈아 끼우고 나머지는 + 원본 그대로 둔다. 통째로 덮는 길을 두지 않는 까닭은, 두 페이지가 같은 파일을 쓰기 때문이다 + (오늘 `main.py` 에서 같은 모양의 사고를 이미 겪었다). + +⚠ `dataset_versions` 는 **기록**이지 정본이 아니다 + 여기 적히는 것은 「저장 시점에 무엇을 고른 상태였나」이고, 계산을 되살릴 때 쓰는 정본은 + **프로젝트 스냅샷**이다. 둘이 어긋나면 **스냅샷이 이긴다.** + +⚠ `*_override` 는 기본이 `None` 이다 + 「프로젝트가 안 정했으면 `config` 정본을 쓴다」는 뜻이다. 기본값을 복사해 넣으면 나중에 + 정본이 바뀌어도 옛 프로젝트가 안 따라온다. 값을 넣는 것은 **설계자가 일부러 바꿨을 때만**이다. + +⚠ 반영률 기본은 100 이다 (PLAN 8-11 · 8-10 ★법대로) + 실무 관측 80/50/80 은 설계자가 비고란에 손으로 적은 값이지 법정값이 아니다. 기본값으로 + 넣지 않는다. + +작업본 3층 (CLAUDE.md 5장) + 조작은 캐시(sessionStorage)에 쌓이고 [저장]·[확정]에서 이 파일로 간다. 자동저장은 만들지 않는다. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +SETTINGS_FILENAME = "project_settings.json" +SCHEMA_VERSION = 1 + +# 반영률 키 — 사면 계열과 짝이다. 값은 퍼센트이고 기본은 전부 100. +APPLICATION_RATIO_KEYS = ( + "fill_slope_compaction", # 성토면다짐 + "seed_spray_fill", # 초류종자살포(성토면) + "seed_spray_cut", # 초류종자살포(절토면) + "obstacle_removal", # 지장목제거 +) + +# 암 갈래 세트 — **개수를 코드에 박지 않는다**(PLAN 8-13). +# 울진 2 · 거창 5 · 오솔길 BOM 1 로 공사마다 다르다. 프로젝트가 하나를 고른다. +ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = { + "single": ("토사", "암"), + "uljin2": ("토사", "연암", "발파암"), + "geochang5": ("토사", "풍화암", "연암", "보통암", "경암"), +} +DEFAULT_ROCK_CLASS_SET = "geochang5" + + +def default_settings() -> dict[str, Any]: + """빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다.""" + return { + "schema_version": SCHEMA_VERSION, + "quantity": { + "rock_class_set": DEFAULT_ROCK_CLASS_SET, + "rock_classes": list(ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]), + # 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 — + # 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다. + "rock_ratios_pct": {}, + "conversion_factors_override": None, + "haul_limits_m_override": None, + "application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS}, + "dataset_versions": {}, + }, + "estimation": { + # ⚠ 「연도」가 아니라 **판**을 가리킨다 — 조달청 제비율은 연중에도 개정된다 + # (현행판 2026-04-13). 「2026년」만으로는 어느 판인지 안 정해진다. + # 값은 `dataset_id` + `effective_date` + `sha256` 세 쪽. + "rate_dataset": None, + "price_slot_names": {}, + "dataset_versions": {}, + }, + } + + +def settings_path(project_root: str | Path) -> Path: + return Path(project_root) / SETTINGS_FILENAME + + +def load_settings(project_root: str | Path) -> dict[str, Any]: + """설정을 읽는다. 파일이 없거나 깨졌으면 기본값을 돌려준다(예외를 올리지 않는다). + + 읽기가 실패해도 화면은 서야 한다 — 설정은 계산을 **거드는** 값이지 없으면 못 도는 값이 아니다. + """ + path = settings_path(project_root) + if not path.exists(): + return default_settings() + try: + stored = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return default_settings() + if not isinstance(stored, dict): + return default_settings() + return _merge(default_settings(), stored) + + +def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]: + """저장분을 기본값 위에 얹는다. **새로 생긴 키가 빠지지 않게** 한 겹만 재귀한다.""" + merged = dict(base) + for key, value in stored.items(): + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = _merge(current, value) + else: + merged[key] = value + return merged + + +SECTIONS = ("quantity", "estimation") + + +def save_section(project_root: str | Path, section: str, values: dict[str, Any]) -> dict[str, Any]: + """한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**. + + 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는 + 함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다. + """ + if section not in SECTIONS: + raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})") + settings = load_settings(project_root) + settings[section] = _merge(settings.get(section) or {}, values) + settings["schema_version"] = SCHEMA_VERSION + atomic_write_json(settings_path(project_root), settings) + return settings + + +def quantity_settings(project_root: str | Path) -> dict[str, Any]: + """B08 구획만 꺼낸다.""" + return load_settings(project_root).get("quantity") or {} + + +def rock_classes(settings: dict[str, Any]) -> list[str]: + """이 프로젝트의 암 갈래 목록. 세트 이름이 낯설면 저장된 목록을 그대로 쓴다.""" + stored = settings.get("rock_classes") + if isinstance(stored, list) and stored: + return [str(item) for item in stored] + name = str(settings.get("rock_class_set") or DEFAULT_ROCK_CLASS_SET) + return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET])) + + +def application_ratio(settings: dict[str, Any], key: str) -> float: + """반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다.""" + raw = (settings.get("application_ratios_pct") or {}).get(key, 100) + try: + return float(raw) / 100.0 + except (TypeError, ValueError): + return 1.0 From 28e4aed499af597183b420e866084d6aa98b7418 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 20:58:34 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=C2=B7=EC=9A=B4=EB=B0=98=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?API=20=EC=97=90=20=EB=B6=99=EC=9D=B4=EA=B3=A0=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=A1=B0=EA=B1=B4=20=EC=A0=80=EC=9E=A5=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 4·5 의 API 덩어리. 한 응답에 실음 earthwork-table 이 토적표(체적) · slope(사면 4계열) · haul(운반 가중평균) · summary(토공집계표) · settings 를 함께 냄. 실무 산출서가 한 벌로 움직이는 값이라 나눠 부르면 같은 측점 목록을 여러 벌 들게 됨. 운반계획이 없으면 빈 표가 정직함 운반계획은 [저장]·[확정]에서 정본에 남는 값임(longitudinal.data.mass_haul). 아직 안 돌린 프로젝트는 없음 — haul_available 로 알려 화면이 「확정하면 생김」을 말할 수 있게 함. 지어내지 않음. 산출 조건 저장 — PUT /quantity/settings ⚠ 자동저장이 아님(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어감. ⚠ quantity 구획만 씀 — estimation 은 B09 것이고 모듈이 구조로 막고 있음. 보내지 않은 칸은 저장분을 그대로 둠. 프로젝트 경로를 못 찾아도 조회는 기본값으로 서게 함 — 설정은 계산을 거드는 값이지 없으면 못 도는 값이 아님. 암 비율 안분을 비고에 드러냄 (조율 창 지적) 설계자가 60/30 을 넣으면 총량 보존을 위해 66.7/33.3 으로 안분됨. 값이 말없이 바뀌는 것이라 「입력 합 90 % → 100 % 로 안분」을 비고에 남김. 합이 100 이면 비고를 비움 — 제대로 넣었는데 안내가 뜨면 잡음이 됨. 검증 — 회귀 433 passed (실패 1건은 B05 코리도 기존 깨짐). 엔드포인트 3종 등록 확인(earthwork-table 2 + settings 1). 안분 비고는 시험으로 못 박음(합 90 이면 비고 있음 · 100 이면 없음). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_EarthworkSummary.py | 23 +++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 105 +++++++++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 37b5c20a..36c3afcd 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -68,17 +68,24 @@ def _ratio(source: SummaryInput, key: str) -> float: return float(value) if isinstance(value, (int, float)) else 1.0 -def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float]]: - """암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. +def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float, str]]: + """암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. `(이름, 물량, 비고)`. 비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다. + + ⚠ 합이 100 이 아니어도 **총량은 보존**한다 — 준 비율끼리 안분한다. 물량이 조용히 + 사라지면 안 되기 때문이다. 다만 값이 말없이 바뀌는 것이므로 **비고에 드러낸다** + (60/30 을 넣으면 실제로는 66.7/33.3 으로 돈다). """ classes = [name for name in source.rock_classes if name != "토사"] ratios = {name: float(source.rock_ratios_pct.get(name, 0) or 0) for name in classes} given = sum(ratios.values()) if given <= 0: - return [("암", total)] - return [(name, total * ratios[name] / given) for name in classes if ratios[name] > 0] + return [("암", total, "")] + note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분" + return [ + (name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0 + ] def build_rows(source: SummaryInput) -> list[SummaryRow]: @@ -97,8 +104,12 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]: group=group, item="토사", spec="기계(굴삭기)", amount=earth.get(soil_key, 0.0) ) ) - for name, amount in _split_by_rock(earth.get(rock_key, 0.0), source): - rows.append(SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount)) + for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source): + rows.append( + SummaryRow( + group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note + ) + ) rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0))) rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0))) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 509ef116..e9b9b314 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -12,20 +12,35 @@ from __future__ import annotations +import asyncio import logging from typing import Any from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B06_Section.B06_Section_Repository import ( get_cross_section_designs, + get_longitudinal_section, get_workflow_route_context, ) +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_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 ( + application_ratio, + quantity_settings, + rock_classes, + save_section, +) +from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection logger = logging.getLogger(__name__) @@ -54,12 +69,98 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: content={"status": "error", "message": "토적표를 만들지 못했습니다."}, ) table = build_table(_stations(designs)) - # 사면 계열은 저장된 설계선에서 유도한다 — 반영률은 기본 100 %(설계자 입력은 후속). - table["slope"] = build_slope_table(station_slopes(designs)) + # 사면 계열은 저장된 설계선에서 유도한다. + slope = build_slope_table(station_slopes(designs)) + table["slope"] = slope + + settings, project_root = await _project_settings(project_id) + plan = await _stored_haul_plan(project_id, route_id) + haul = build_haul_table(plan) + table["haul"] = haul + # 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다. + table["haul_available"] = bool(plan) + + table["summary"] = build_summary_table( + SummaryInput( + earthwork_totals=table.get("totals") or {}, + slope_totals=slope.get("totals") or {}, + haul_rows=summary_input_rows(haul), + rock_classes=rock_classes(settings), + rock_ratios_pct=settings.get("rock_ratios_pct") or {}, + application_ratios={ + key: application_ratio(settings, key) + for key in (settings.get("application_ratios_pct") or {}) + }, + ) + ) + table["settings"] = settings + table["project_root_known"] = project_root is not None table["route_id"] = route_id return JSONResponse(content=table) +async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]: + """프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다.""" + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + except Exception: + logger.warning("B08 프로젝트 경로를 못 찾음: project_id=%s", project_id) + from common_util.common_util_project_settings import default_settings + + return default_settings()["quantity"], None + return quantity_settings(root), root + + +async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | None: + """정본에 남은 운반계획. [확정]을 아직 안 돌렸으면 없다.""" + try: + row = await run_with_connection(get_longitudinal_section, project_id, route_id) + except Exception: + logger.exception("B08 운반계획 조회 실패: route_id=%s", route_id) + return None + data = (row or {}).get("data") or {} + plan = data.get("mass_haul") if isinstance(data, dict) else None + return plan if isinstance(plan, dict) and plan else None + + +class QuantitySettingsBody(BaseModel): + """[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다.""" + + rock_class_set: str | None = None + rock_classes: list[str] | None = None + rock_ratios_pct: dict[str, float] | None = None + application_ratios_pct: dict[str, float] | None = None + + +@router.put("/{project_id}/quantity/settings") +async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse: + """산출 조건을 정본에 남긴다 — [저장]이 부르는 자리. + + ⚠ 자동저장이 아니다(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어간다. + ⚠ `quantity` 구획만 쓴다 — `estimation` 은 B09 것이라 손대지 않는다(모듈이 막고 있다). + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 설정 저장 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + values = {key: value for key, value in body.model_dump().items() if value is not None} + try: + saved = await asyncio.to_thread(save_section, root, "quantity", values) + except Exception: + logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}}) + + @router.get("/{project_id}/quantity/earthwork-table") async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse: """경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다.""" From 1789b2656346e63ae9c99a18c96d0d51388b2fb6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:12:32 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(B08):=20=ED=86=A0=EA=B3=B5=EC=A7=91?= =?UTF-8?q?=EA=B3=84=ED=91=9C=C2=B7=EC=9A=B4=EB=B0=98=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=AA=A8=EB=93=88=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20(=EC=95=84=EC=A7=81=20=EB=AF=B8=EB=93=B1=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 4·5 화면의 표 부품. 이 커밋 시점에는 **아무 데서도 import 하지 않음** — 「신규 파일 먼저, 등록 줄 나중」 두 걸음 규칙(공용 파일 동시 편집 사고 예방). 토공집계표 — 열은 거창 실무 시트 그대로(구분·공종·규격·단위·계·비고). 같은 구분이 이어지면 한 번만 적음(실무 시트가 병합해 두는 자리). 비고에는 설계자가 정한 값만 남음 — 반영률을 바꿨을 때, 비율 합이 100 이 아닐 때. 기본값 그대로면 비움. 안내가 매번 뜨면 잡음이 됨. 운반거리 — 내역 줄(가중평균)과 근거 구간 수를 함께 보임. ⚠ 무대 줄에 「내역 제외」 표시 + 「품셈 1-2-7 소운반 20m 이내는 품에 포함」을 적음. 「제외」만 있으면 빠뜨린 것으로 오해됨. 규칙이 코드에만 있으면 잊히므로 화면에 남김. 운반계획이 아직 없으면(=[확정] 전) 빈 표 대신 「종단설계에서 확정하면 생김」을 보임. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 176 ++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_UI_SummaryGrid.ts diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts new file mode 100644 index 00000000..439817e6 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * B08_Quantity_UI_SummaryGrid.ts + * 토공집계표·운반거리 그리드 (PLAN 8-11·8-3). + * + * 토공집계표 열은 거창 실무 시트 그대로 — 구분·공종·규격·단위·계·비고. + * 비고에는 **설계자가 정한 값만** 남는다(반영률을 바꿨을 때·비율 합이 100 이 아닐 때). + * 기본값 그대로면 비워 둔다 — 안내가 매번 뜨면 잡음이 된다. + * + * ⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 아니다**(품셈 1-2-7). 그 줄에 + * 「내역 제외」를 붙여 화면에서도 보이게 한다 — 규칙이 코드에만 있으면 잊힌다. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface SummaryRow { + group: string; + item: string; + spec: string; + unit: string; + amount: number; + note: string; + in_bill: boolean; +} + +export interface SummaryTable { + columns: string[]; + rows: SummaryRow[]; + rock_classes: string[]; +} + +export interface HaulRow { + equipment: string; + ground: string; + volume_m3: number; + average_distance_m: number; + legs: number; + in_bill: boolean; +} + +export interface HaulTable { + rows: HaulRow[]; + legs: { equipment: string; ground: string; volume_m3: number; distance_m: number; from_m: number; to_m: number }[]; + bill_row_count: number; +} + +/** 운반수단 표기 — 서버 키를 실무 시트 문구로. */ +const HAUL_LABELS: Record = { + free_haul: "무대(종방향유용토)", + dozer: "도자운반", + dump_truck: "덤프운반", +}; + +function num(value: number | undefined, digits: number): string { + if (value === undefined || value === null || Number.isNaN(value) || value === 0) return ""; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function textCell(text: string, className?: string): HTMLTableCellElement { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + return td; +} + +/** 토공집계표 — 실무 시트와 같은 여섯 열. */ +export function renderSummaryGrid(table: SummaryTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + 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.spec)); + tr.append(textCell(row.unit, "b08-grid__unit")); + tr.append(textCell(num(row.amount, row.unit === "㎥" ? 2 : 1))); + const note = textCell(row.note, "b08-grid__note"); + if (!row.in_bill) { + const tag = document.createElement("span"); + tag.className = "b08-grid__tag"; + tag.textContent = L("B08_Quantity_Haul_Excluded"); + note.prepend(tag); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} + +/** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */ +export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + if (!available || !table.rows.length) { + const message = document.createElement("p"); + message.className = "b08-quantity__message"; + message.textContent = L("B08_Quantity_Haul_Missing"); + wrap.append(message); + return wrap; + } + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `내역 줄 ${table.bill_row_count}개 · 근거 구간 ${table.legs.length}개 · 토량 가중평균`; + 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 ["운반수단", "지반유형", "토량(㎥)", "평균운반거리(m)", "근거 구간", "비고"]) { + const th = document.createElement("th"); + th.textContent = label; + headRow.append(th); + } + head.append(headRow); + + const body = document.createElement("tbody"); + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(HAUL_LABELS[row.equipment] ?? row.equipment, "b08-grid__station")); + tr.append(textCell(row.ground)); + tr.append(textCell(num(row.volume_m3, 2))); + tr.append(textCell(num(row.average_distance_m, 2))); + tr.append(textCell(String(row.legs))); + const note = textCell("", "b08-grid__note"); + if (!row.in_bill) { + const tag = document.createElement("span"); + tag.className = "b08-grid__tag"; + tag.textContent = L("B08_Quantity_Haul_Excluded"); + note.append(tag); + // 왜 빠지는지 같이 적는다 — 「제외」만 있으면 빠뜨린 것으로 오해된다. + note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함")); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} From 4756f0fe727da90a902866860b9316727083dbad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:12:41 +0900 Subject: [PATCH 7/7] auto: 2026-09-07 23:12 (EOMSANGDON-HOME) --- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 14 ++ .../B08_Quantity_UI_EarthworkGrid_Style.ts | 29 +++ B08_Quantity/B08_Quantity_UI_Page.ts | 191 ++++++++++++++++-- ui_template/ui_template_locale_b2.ts | 17 ++ 4 files changed, 233 insertions(+), 18 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 02431449..87b97a84 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -53,6 +53,14 @@ export interface SlopeTable { unclosed_stations: number[]; } +/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */ +export interface QuantitySettings { + rock_class_set?: string; + rock_classes?: string[]; + rock_ratios_pct?: Record; + application_ratios_pct?: Record; +} + export interface EarthworkTable { method: string; station_count: number; @@ -61,6 +69,12 @@ export interface EarthworkTable { totals: Record; conversion_factors?: Record; slope?: SlopeTable; + /** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */ + summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable; + haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable; + /** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */ + haul_available?: boolean; + settings?: QuantitySettings; } /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index 2b629acc..9e06c426 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -121,6 +121,35 @@ const CSS = ` } .b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } +.b08-quantity__pane { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; } + +/* 집계·운반표는 열이 적어 왼쪽 정렬이 읽기 좋다 — 숫자 칸만 오른쪽으로 둔다. */ +.b08-grid__table--summary th, +.b08-grid__table--summary td { text-align: left; } +.b08-grid__table--summary td:nth-child(5), +.b08-grid__table--summary td:nth-child(4) { text-align: right; } +.b08-grid__unit { text-align: center; } +.b08-grid__note { white-space: normal; max-width: 26rem; } + +/* 「내역 제외」 같은 표시 — 규칙이 코드에만 있으면 잊힌다. 화면에 남긴다. */ +.b08-grid__tag { + display: inline-block; + margin-right: 4px; + padding: 0 6px; + font-size: 11px; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +.b08-quantity__input { + width: 5rem; + font-size: 12px; + text-align: right; + font-variant-numeric: tabular-nums; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} .b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); } .b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; } .b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 147265b1..ece0199e 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -19,6 +19,7 @@ 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"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -46,6 +47,24 @@ async function fetchEarthworkTable(projectId: string): Promise { return (await response.json()) as EarthworkTable; } +/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */ +async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/settings`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rock_class_set: draft.rock_class_set ?? null, + rock_ratios_pct: draft.rock_ratios_pct, + application_ratios_pct: draft.application_ratios_pct, + }), + }, + ); + if (!response.ok) throw new Error(`quantity settings save failed: ${response.status}`); +} + /** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ function field(label: string, value: string): HTMLElement { const row = document.createElement("div"); @@ -59,10 +78,43 @@ function field(label: string, value: string): HTMLElement { return row; } -/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */ +/** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */ +function numberField( + label: string, + value: number, + onInput: (value: number) => void, +): HTMLElement { + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.className = "b08-quantity__input"; + input.min = "0"; + input.step = "1"; + input.value = String(value); + // 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장). + input.addEventListener("input", () => onInput(Number(input.value))); + row.append(name, input); + return row; +} + +/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */ +interface DraftSettings { + rock_class_set?: string; + rock_ratios_pct: Record; + application_ratios_pct: Record; + dirty: boolean; +} + +/** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행. + * `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */ function buildQuantitySidePanel( projectId: string | null, table: EarthworkTable | null, + draft: DraftSettings, + reload: () => void, ): HTMLElement { const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; @@ -77,6 +129,57 @@ function buildQuantitySidePanel( } } + // ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ── + const classes = table?.summary?.rock_classes ?? []; + 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; + }), + ); + } + } + + // ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ── + const ratios = table?.settings?.application_ratios_pct ?? {}; + if (Object.keys(ratios).length) { + panel.append(field(L("B08_Quantity_Side_Ratios"), "")); + for (const key of Object.keys(ratios)) { + panel.append( + numberField(key, draft.application_ratios_pct[key] ?? 100, (value) => { + draft.application_ratios_pct[key] = value; + draft.dirty = true; + }), + ); + } + } + + const saveButton = createButton({ + label: L("B08_Quantity_Btn_Save"), + variant: "outlined", + onClick: () => { + if (!projectId) { + showToast(L("B08_Quantity_Save_Failed"), "error"); + return; + } + saveButton.disabled = true; + saveQuantitySettings(projectId, draft) + .then(() => { + draft.dirty = false; + showToast(L("B08_Quantity_Save_Success"), "success"); + // 조건이 바뀌면 집계·운반 값이 달라진다 — 표를 다시 받아 그린다. + reload(); + }) + .catch(() => { + showToast(L("B08_Quantity_Save_Failed"), "error"); + saveButton.disabled = false; + }); + }, + }); + const confirmButton = createButton({ label: L("B08_Quantity_Btn_Confirm"), variant: "filled", @@ -105,35 +208,67 @@ function buildQuantitySidePanel( return panel; } -/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */ +/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement { const body = document.createElement("div"); body.className = "b08-quantity__body"; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs"; - const tab = document.createElement("button"); - tab.type = "button"; - tab.className = "b08-quantity__tab is-active"; - tab.textContent = L("B08_Quantity_Tab_Earthwork"); - tabs.append(tab); - body.append(tabs); + const pane = document.createElement("div"); + pane.className = "b08-quantity__pane"; + + const message = (text: string): HTMLElement => { + const element = document.createElement("p"); + element.className = "b08-quantity__message"; + element.textContent = text; + return element; + }; if (failed) { - const message = document.createElement("p"); - message.className = "b08-quantity__message"; - message.textContent = L("B08_Quantity_Grid_Failed"); - body.append(message); + body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); return body; } if (!table || !table.rows?.length) { - const message = document.createElement("p"); - message.className = "b08-quantity__message"; - message.textContent = L("B08_Quantity_Grid_Empty"); - body.append(message); + body.append(tabs, message(L("B08_Quantity_Grid_Empty"))); return body; } - body.append(renderEarthworkGrid(table)); + + const sheets: { label: string; build: () => HTMLElement }[] = [ + { label: L("B08_Quantity_Tab_Earthwork"), build: () => renderEarthworkGrid(table) }, + { + label: L("B08_Quantity_Tab_Summary"), + build: () => + table.summary + ? renderSummaryGrid(table.summary) + : message(L("B08_Quantity_Grid_Empty")), + }, + { + label: L("B08_Quantity_Tab_Haul"), + build: () => + table.haul + ? renderHaulGrid(table.haul, Boolean(table.haul_available)) + : message(L("B08_Quantity_Haul_Missing")), + }, + ]; + + const buttons: HTMLButtonElement[] = []; + const show = (index: number): void => { + buttons.forEach((button, i) => button.classList.toggle("is-active", i === index)); + pane.replaceChildren(sheets[index].build()); + }; + sheets.forEach((sheet, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b08-quantity__tab"; + button.textContent = sheet.label; + button.addEventListener("click", () => show(index)); + buttons.push(button); + tabs.append(button); + }); + + body.append(tabs, pane); + show(0); return body; } @@ -155,6 +290,26 @@ export async function renderB08Quantity(root: HTMLElement): Promise { } } + // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장). + const stored = table?.settings ?? {}; + const draft: DraftSettings = { + rock_class_set: stored.rock_class_set, + rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, + application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, + dirty: false, + }; + const reload = (): void => { + root.replaceChildren(); + void renderB08Quantity(root); + }; + // 저장 안 한 값이 조용히 사라지지 않게 나갈 때 알린다 — 이 구조의 대가다. + const warnUnsaved = (event: BeforeUnloadEvent): void => { + if (!draft.dirty) return; + event.preventDefault(); + event.returnValue = L("B08_Quantity_Unsaved"); + }; + window.addEventListener("beforeunload", warnUnsaved); + let workflowState: Awaited> | undefined; if (projectId) { try { @@ -168,7 +323,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, - leftPanel: buildQuantitySidePanel(projectId, table), + leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), mainContent: buildQuantityBody(table, failed), stages: workflowState?.stages, currentStage: workflowState?.current_stage, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 75fceeee..123f55c1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -619,6 +619,23 @@ export const ui_locales_b2 = { "토적표를 불러오지 못했습니다.", "Failed to load the earthwork table.", ], + B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], + B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], + B08_Quantity_Haul_Missing: [ + "운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.", + "No haul plan yet. Press [Confirm] on the profile design to build it.", + ], + B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], + B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], + B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], + B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"], + B08_Quantity_Btn_Save: ["저장", "Save"], + B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], + B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Unsaved: [ + "저장하지 않은 변경이 있습니다.", + "You have unsaved changes.", + ], B08_Quantity_Side_Method: ["산출법", "Method"], B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],