From 3ecc23ac93050a971cd9da6dd6610ed1f14558b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 20:25:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b08):=20=EC=B4=88=EB=A5=98=EC=A2=85?= =?UTF-8?q?=EC=9E=90=EC=82=B4=ED=8F=AC=20=EC=9E=8E=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B9=B8(=EB=B8=8C=EB=A0=88=EC=9D=B8=20=E3=89=AE)?= =?UTF-8?q?=20=E2=80=94=20=EB=A7=A4=ED=95=91=EC=9D=B4=20=EB=B6=80=EB=AA=A8?= =?UTF-8?q?=205-24(=EA=B0=88=EB=9E=98=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=ED=98=95)=EB=9D=BC=20=EA=B3=A0=EB=A5=BC=20=EC=B9=B8=EC=9D=B4?= =?UTF-8?q?=20=EC=97=86=EC=96=B4=20=EA=B8=88=EC=95=A1=EC=9D=B4=20=EC=98=81?= =?UTF-8?q?=EC=98=81=20=EC=95=88=20=EC=84=9C=EB=8D=98=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산출 조건 「초류종자살포 비탈면 토질」(5-24-1 기계/일반 · 5-24-2 기계/마사토 · 서버 선택지 · 제안값 없음) · 비면 금액 없이 입력 사유 - 매핑 `leaf_from`·`leaf_codes` 가 설정값으로 잎 코드를 고름(인계 한 곳 · 갈래 없는 매핑은 종전과 같음) - 936be972 일반 고름 → 인계 FP-05-24-01 막힘 풀림 · B09 는 다음 막힘 「트럭 4.5ton(장비 줄) 카탈로그에 없음 · 종자 규격 미정」으로 아직 금액 없음 · 면고르기 성토면 12,350.32㎡ 40,373,196원 · 절토면 3,087.51㎡ 7,731,123원(파종 면적 그대로 따라감) · 되돌림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_EarthworkSummary.py | 3 + B08_Quantity/B08_Quantity_Engine_Handoff.py | 3 + .../B08_Quantity_Engine_Handoff_Rows.py | 15 +++-- B08_Quantity/B08_Quantity_Router_Earthwork.py | 6 ++ B08_Quantity/B08_Quantity_Router_Material.py | 2 + B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 4 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 16 +++-- .../B08_Quantity_UI_Side_SeedSpray.ts | 40 +++++++++++ .../work_item_mapping_2026-01-01.json | 6 +- resources/tester/test_b08_seed_spray_leaf.py | 66 +++++++++++++++++++ ui_template/ui_template_locale_b2.ts | 6 ++ 11 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_UI_Side_SeedSpray.ts create mode 100644 resources/tester/test_b08_seed_spray_leaf.py diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index fa596476..b7dea09c 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -52,6 +52,9 @@ FACE_DRESSING_FILL_SUGGESTED = ( "영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라" " 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)", ) +#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로. +#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮). +SEED_SPRAY_GROUNDS = ("일반", "마사토") #: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래. #: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정). ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7") diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 677397af..bcbe42cc 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -116,6 +116,7 @@ def build_handoff( face_dressing_cut_class: str | None = None, face_dressing_fill_class: str | None = None, root_removal_excavator_m3: str | None = None, + seed_spray_ground: str | None = None, priced_sheets: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**. @@ -137,6 +138,8 @@ def build_handoff( "face_dressing_fill_class": face_dressing_fill_class, # 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14). "root_removal_excavator_m3": root_removal_excavator_m3, + # 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮). + "seed_spray_ground": seed_spray_ground, } rows, misses = _earthwork_rows( summary_table, table, methods, bench_cut_depth_m, variant_inputs diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index f9c80c27..2b9572c9 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -164,12 +164,17 @@ def _earthwork_rows( template_ready = all(inputs.get(name) for name in needs) if template and template_ready: variant_value = template.format(**inputs) + # 부모(갈래 고르기형)를 가리키는 매핑 — 설정값으로 잎 코드를 고름(초류종자살포 5-24 · 09-14 ㉮). + leaf_from = str((entry or {}).get("leaf_from") or "") + leaf = ( + ((entry or {}).get("leaf_codes") or {}).get(inputs.get(leaf_from)) + if leaf_from + else None + ) + code = leaf or code + missing = not leaf if leaf_from else (not variant_value or not template_ready) # 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ). - if ( - code - and (not variant_value or not template_ready) - and (entry or {}).get("variant_missing_reason") - ): + if code and missing and (entry or {}).get("variant_missing_reason"): blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING blocked_reason = blocked_reason or str(entry["variant_missing_reason"]) if code is None and not is_subtotal: diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 5e367607..ee9a71ee 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -36,6 +36,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( FACE_DRESSING_FILL_SUGGESTED, ROOT_REMOVAL_EXCAVATOR_SIZES, ROOT_REMOVAL_EXCAVATOR_SUGGESTED, + SEED_SPRAY_GROUNDS, SummaryInput, ) from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table @@ -183,6 +184,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: "basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1], }, } + # 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮). + table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)} # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). structures = await _route_structures(project_id) table["preparation"] = build_preparation_table( @@ -490,6 +493,8 @@ class QuantitySettingsBody(BaseModel): face_dressing_cut_class: str | None = None # 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」. root_removal_excavator_m3: str | None = None + # 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」. + seed_spray_ground: str | None = None face_dressing_fill_class: str | None = None # 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ). face_dressing_fill_area_m2: float | None = None @@ -569,6 +574,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - ("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES), ("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES), ("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES), + ("seed_spray_ground", SEED_SPRAY_GROUNDS), ): if key in values and values[key] not in choices: values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침 diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 5e44e799..47cb756f 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -499,6 +499,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse: face_dressing_fill_class=settings.get("face_dressing_fill_class") or None, # 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만). root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None, + # 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮). + seed_spray_ground=settings.get("seed_spray_ground") or None, # 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②). priced_sheets=_priced_sheets(project_root, unit_table, modes, settings), ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 173c3c33..c65b2f2d 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -102,6 +102,8 @@ export interface QuantitySettings { stand_volume_class?: string | null; /** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */ root_removal_excavator_m3?: string | null; + /** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */ + seed_spray_ground?: string | null; /** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */ face_dressing_cut_class?: string | null; face_dressing_fill_class?: string | null; @@ -164,6 +166,8 @@ export interface EarthworkTable { conversion_factor_choices?: Record; /** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */ face_dressing_choices?: { cut: string[]; fill: string[] }; + /** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */ + seed_spray_choices?: { choices: string[] }; /** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */ root_removal_excavator_choices?: { choices: string[]; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index b24cfe04..8f353ac1 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -43,6 +43,7 @@ import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary"; import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste"; import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing"; import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval"; +import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray"; import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut"; /** locale 헬퍼 */ @@ -111,6 +112,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr topsoil_target: draft.topsoil_target, stand_volume_class: draft.stand_volume_class, root_removal_excavator_m3: draft.root_removal_excavator_m3, + seed_spray_ground: draft.seed_spray_ground, // 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길). face_dressing_cut_class: draft.face_dressing_cut_class, face_dressing_fill_class: draft.face_dressing_fill_class, @@ -311,6 +313,8 @@ interface DraftSettings { stand_volume_class: string; // 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`. root_removal_excavator_m3: string; + // 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`. + seed_spray_ground: string; // 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`. face_dressing_cut_class: string; face_dressing_fill_class: string; @@ -525,13 +529,10 @@ function buildQuantitySidePanel( } } - // ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일). - appendFaceDressingFields(panel, draft, table?.face_dressing_choices, { - field, - optionalNumberField, - selectField, - hintRow, - }); + // ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로. + const sideHelpers = { field, optionalNumberField, selectField, hintRow }; + appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers); + appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers); // ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ── // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** @@ -1032,6 +1033,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { topsoil_target: (stored.topsoil_target as string) ?? "", stand_volume_class: (stored.stand_volume_class as string) ?? "", root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "", + seed_spray_ground: (stored.seed_spray_ground as string) ?? "", face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "", face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "", face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null, diff --git a/B08_Quantity/B08_Quantity_UI_Side_SeedSpray.ts b/B08_Quantity/B08_Quantity_UI_Side_SeedSpray.ts new file mode 100644 index 00000000..6ee00568 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_SeedSpray.ts @@ -0,0 +1,40 @@ +/* ============================================================================= + * B08_Quantity_UI_Side_SeedSpray.ts + * 산출 조건 「초류종자살포 비탈면 토질」 칸 — 반영률 밑 · 면고르기 위(2026-09-14 브레인 ㉮). + * + * 매핑이 부모 공종(5-24 씨앗뿜어붙이기 · 갈래 고르기형)을 가리켜 잎(일반·마사토)을 고를 칸이 없었음 + * → 금액이 영영 안 섬. 이 칸이 잎을 고름(서버 매핑 `leaf_from`). + * 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장). + * ⚠ 선택지는 **서버가 내려준 그대로**(`seed_spray_choices`) · 제안값 없음 — 비면 내역 줄이 입력 사유로 섬. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function appendSeedSprayField( + panel: HTMLElement, + draft: { seed_spray_ground: string; dirty: boolean }, + choices: { choices: string[] } | undefined, + h: SideFieldHelpers, +): void { + if (!choices) return; + panel.append( + h.selectField( + L("B08_Quantity_Side_SeedSpray_Label"), + draft.seed_spray_ground, + [ + { value: "", label: L("B08_Quantity_SeedSpray_Unset") }, + ...choices.choices.map((name) => ({ value: name, label: name })), + ], + (picked) => { + draft.seed_spray_ground = picked; + draft.dirty = true; + }, + ), + h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")), + ); +} diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 44c7de30..627aa37a 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -130,7 +130,11 @@ "work_item_code": "FP-05-24", "master_name": "씨앗뿜어붙이기", "basis_unit": "㎡", - "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조)." + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조).", + "leaf_from": "seed_spray_ground", + "leaf_codes": {"일반": "FP-05-24-01", "마사토": "FP-05-24-02"}, + "variant_missing_reason": "초류종자살포 비탈면 토질(일반·마사토)이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 5-24 씨앗뿜어붙이기: 5-24-1 기계/일반 · 5-24-2 기계/마사토)", + "leaf_note": "2026-09-14 브레인 ㉮ — 매핑이 부모(갈래 고르기형)라 B09 가 「잎 미선택」으로 막는데 고를 칸이 없었음 · 산출 조건 칸이 잎 코드를 고름 · 제안값 없음." }, { "group": "되메우기", diff --git a/resources/tester/test_b08_seed_spray_leaf.py b/resources/tester/test_b08_seed_spray_leaf.py new file mode 100644 index 00000000..f57b8072 --- /dev/null +++ b/resources/tester/test_b08_seed_spray_leaf.py @@ -0,0 +1,66 @@ +"""초류종자살포 잎 고르기 — 2026-09-14 브레인 차례 ㉮(B08 나머지 탭 훑기). + +매핑이 부모(FP-05-24 씨앗뿜어붙이기 · 갈래 고르기형)를 가리켜 B09 가 「잎 미선택」으로 막는데 고를 칸이 없었음 +→ 금액이 영영 안 섬(면고르기와 같은 병). 산출 조건 「초류종자살포 비탈면 토질」(5-24-1 일반 · 5-24-2 마사토) +칸 · 매핑 `leaf_from`·`leaf_codes` 가 잎 코드로 잇고 · 비면 금액 없이 입력 사유 · 제안값 없음(지어내지 않음). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SEED_SPRAY_GROUNDS # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 + +SUMMARY = {"rows": [{"group": "초류종자살포", "item": "", "spec": "씨드스프레이", "unit": "㎡", + "amount": 1500.0, "application_ratio_pct": 100.0}]} # fmt: skip + + +def _row(**inputs) -> dict: + rows = build_handoff(summary_table=SUMMARY, **inputs)["work_items"] + return next(r for r in rows if r["name"] == "초류종자살포") + + +def test_토질을_고르면_잎_코드로_감() -> None: + assert _row(seed_spray_ground="일반")["work_item_code"] == "FP-05-24-01" + row = _row(seed_spray_ground="마사토") + assert row["work_item_code"] == "FP-05-24-02" and not row["blocked_kind"], row + + +def test_토질이_비면_부모_코드에_금액_없이_입력_사유() -> None: + row = _row() + assert row["work_item_code"] == "FP-05-24" + assert row["blocked_kind"] == "input_missing", row + assert "일반·마사토" in row["blocked_reason"] and "산출 조건" in row["blocked_reason"] + + +def test_선택지는_마스터_잎_이름과_같음() -> None: + from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master + + leaves = { + n["work_item_code"]: n["name"].split("/")[-1] + for n in load_work_item_master()["work_items"] + if n.get("parent_code") == "FP-05-24" + } + assert leaves == {"FP-05-24-01": "일반", "FP-05-24-02": "마사토"} + assert set(SEED_SPRAY_GROUNDS) == set(leaves.values()) + + +def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None: + source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") + assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in source + assert 'table["seed_spray_choices"]' in source + material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8") + assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material + + +def test_화면_칸이_있고_저장_읽기에_실림() -> None: + page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text("utf-8") + assert "seed_spray_ground: draft.seed_spray_ground" in page + assert "seed_spray_ground: (stored.seed_spray_ground" in page + assert "appendSeedSprayField(" in page diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d1dba944..9a92cdd0 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -693,6 +693,12 @@ export const ui_locales_b2 = { "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", ], + B08_Quantity_Side_SeedSpray_Label: ["초류종자살포 비탈면 토질", "Seed spray slope soil"], + B08_Quantity_SeedSpray_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], + B08_Quantity_Side_SeedSpray_Hint: [ + "품셈 5-24 씨앗뿜어붙이기가 5-24-1 기계/일반 · 5-24-2 기계/마사토 둘로 갈림 — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다", + "Pumsem 5-24 splits into general / decomposed-granite soil — no suggestion; unset leaves the bill row with an input reason", + ], B08_Quantity_Side_RootRemoval_Label: ["제근 굴착기 크기", "Root removal excavator size"], B08_Quantity_RootRemoval_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], B08_Quantity_RootRemoval_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"], From 27e068aa12b95bec7edc882a6cf3772c1abddc65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 20:54:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b08):=20=E3=89=B1=20(=EA=B0=80)=20?= =?UTF-8?q?=EC=95=94=20=EC=9A=B4=EB=B0=98=EB=9F=89=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=EB=B9=84=EB=A1=9C=20=EA=B0=80=EB=A6=84=20=E2=80=94=20?= =?UTF-8?q?B06=20=EB=A6=AC=ED=95=91=EC=95=94=EC=9D=80=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=ED=91=9C=EC=8B=9C(8-1=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=C2=B7=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=E3=80=8C=EA=B5=AC=EC=84=B1=EB=B9=84?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=EB=B3=B8=E3=80=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운반표를 만든 한 곳(Router_Earthwork)에서 암 줄을 흙깎기와 같은 구성비(_split_by_rock)·갈래별 시공법으로 가름 → 운반거리·토공집계 운반 줄·인계·사토가 한 값 - 구성비가 비면 「암」 한 줄로 막음(깎기와 같은 사유) · 시공법 안 고른 갈래만 막음 · 무대(내역 밖) 줄은 안 막음 - 유토곡선 거리·다짐 부피는 그대로(몫대로 나눔 · 검산 차이 0) — C 까지 구성비로 맞추는 것은 (나) 차례 - 936be972: 구성비 없음 → 운반·적재·사토 암 전부 입력 사유(종전 리핑암 금액 섬) · 연암 60 리핑/보통암 40 발파 → 흙깎기 2,060.043/1,373.362 · 도자 38.123/25.416 · 덤프 112.195/74.797 · 사토 28.858/19.238 같은 몫 · 되돌림 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_EarthworkSummary.py | 2 +- .../B08_Quantity_Engine_Handoff_Rows.py | 20 ++-- .../B08_Quantity_Engine_Handoff_Spoil.py | 19 ++- .../B08_Quantity_Engine_HaulSummary.py | 2 + B08_Quantity/B08_Quantity_Engine_RockSplit.py | 110 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 6 + resources/tester/test_b08_haul_summary.py | 2 + resources/tester/test_b08_rock_haul_split.py | 106 +++++++++++++++++ 8 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_RockSplit.py create mode 100644 resources/tester/test_b08_rock_haul_split.py diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index b7dea09c..fcc33a95 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -376,7 +376,7 @@ def _haul_rows(source: SummaryInput) -> 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 "") + ground = str(item.get("rock_class") or item.get("ground") or "") distance = item.get("average_distance_m") note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" # ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」 diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 2b9572c9..29d792d5 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -166,11 +166,8 @@ def _earthwork_rows( variant_value = template.format(**inputs) # 부모(갈래 고르기형)를 가리키는 매핑 — 설정값으로 잎 코드를 고름(초류종자살포 5-24 · 09-14 ㉮). leaf_from = str((entry or {}).get("leaf_from") or "") - leaf = ( - ((entry or {}).get("leaf_codes") or {}).get(inputs.get(leaf_from)) - if leaf_from - else None - ) + leaf_codes = (entry or {}).get("leaf_codes") or {} + leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None code = leaf or code missing = not leaf if leaf_from else (not variant_value or not template_ready) # 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ). @@ -247,10 +244,13 @@ def _haul_rows( # ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로 # 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다). # ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것. - haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None - haul_blocked_reason = ( - f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else "" + no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱) + haul_blocked = ( + BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None ) + haul_blocked_reason = str(in_bill and row.get("blocked_reason") or "") + if no_code: + haul_blocked_reason = f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" # ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로 # 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3 # 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 @@ -275,7 +275,9 @@ def _haul_rows( { "work_item_code": code, "name": f"{equipment} 운반", - "spec": str(row.get("ground") or ""), + "spec": " · ".join( + dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground")))) + ), "unit": "㎥", "quantity": quantity, # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py index 98d3c228..5a7f442f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py @@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( WorkItemMapping, ) from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT +from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts SPOIL_NAME = "사토 운반" #: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을 @@ -98,20 +99,28 @@ def spoil_haul_rows( unknown = float(spoil.get("ground_unknown_m3") or 0.0) if by_ground or unknown > 0: rows: list[dict[str, Any]] = [] - for label, amount in sorted(by_ground.items()): - leg = by_distance.get(label) + # 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`). + shares = (haul_table or {}).get("rock_shares") + for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares): + leg = by_distance.get(source) leg_blocked = blocked if leg is None else False rows.append( _spoil_row( code, amount, distance if leg is None else leg, - leg_blocked, - reason if leg_blocked else "", + leg_blocked or bool(share_reason), + share_reason or (reason if leg_blocked else ""), note, ground=label, extra=" · ".join( - (DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note) + part + for part in ( + f"암 갈래 {rock_class}" if rock_class else "", + DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, + state_note, + ) + if part ), ) ) diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py index 3f335150..bd8e2cbe 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: { "equipment": row["equipment"], "ground": row["ground"], + # 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀. + "rock_class": row.get("rock_class"), "volume_m3": row["volume_m3"], "volume_basis": row.get("volume_basis") or "compacted", "natural_m3": row.get("natural_m3"), diff --git a/B08_Quantity/B08_Quantity_Engine_RockSplit.py b/B08_Quantity/B08_Quantity_Engine_RockSplit.py new file mode 100644 index 00000000..75490295 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_RockSplit.py @@ -0,0 +1,110 @@ +"""암 운반량을 **구성비로 가르기** — ㉱ (가) (2026-09-14 브레인 판정 · 8-1 사용자 확정). + +B06 의 암은 **한 종류 자리표시**다 — 토사 토글을 끄면 저장값이 `ripping_rock` 이 되지만 그 뜻은 +「암」이고 「갈라 넣는 것은 설계내역 몫」(`B06_Section_UI_Cross_Design.ts` 머리 주석). 그런데 운반표·사토가 +그 이름(리핑암)을 **값처럼** 넘겨, 깎기 암은 구성비가 비어 막히는데 운반 암은 금액이 섰다. + +⇒ 운반표를 만든 **한 곳**(`Router_Earthwork`)에서 암 줄을 흙깎기와 **같은 구성비**(`_split_by_rock`)와 + 갈래별 시공법으로 가른다. 운반거리 탭·토공집계 운반 줄·인계·사토가 모두 이 표를 읽어 한 값이 된다. +⚠ 유토곡선 거리·다짐 부피는 그대로(자리표시 C 로 쌓은 값) — 부피를 몫대로 나눌 뿐이라 검산이 안 흔들림. + C 까지 구성비로 맞추는 것은 (나) 차례. +⚠ 구성비가 비면 「암」 한 줄로 **막는다** — 깎기와 같은 사유(지어낸 갈래로 금액을 세우지 않음). +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, _split_by_rock +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_INPUT_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + NOTE_ROCK_RATIO_MISSING, +) + +#: 유토곡선이 넘기는 암 이름 — 둘 다 자리표시로 본다(옛 자료 `blasting_rock` 도 「암」). +ROCK_GROUNDS = ("리핑암", "발파암") +#: 몫대로 나누는 수량 칸 — 운반표 줄(`HaulSummary.build_table`). +SHARED_KEYS = ("volume_m3", "natural_m3", "work_m3m") + + +def rock_shares( + classes: Iterable[str], ratios_pct: dict[str, Any], methods: dict[str, str | None] +) -> list[dict[str, Any]]: + """갈래별 몫 — `{rock_class, fraction, ground, blocked_reason, note}`. 흙깎기와 같은 안분.""" + source = SummaryInput(rock_classes=list(classes), rock_ratios_pct=dict(ratios_pct or {})) + shares = [] + for name, fraction, note in _split_by_rock(1.0, source): + ground = "암" if name == "암" else METHOD_TO_GROUND.get(methods.get(name) or "") + if name == "암": + reason = NOTE_ROCK_RATIO_MISSING + else: + reason = "" if ground else NOTE_METHOD_MISSING + shares.append( + { + "rock_class": name, + "fraction": fraction, + "ground": ground or name, + "blocked_reason": reason, + "note": note, + } + ) + return shares + + +def split_rows( + rows: Iterable[dict[str, Any]], shares: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """암 줄을 몫마다 한 줄로 — 토사 줄은 그대로.""" + out: list[dict[str, Any]] = [] + for row in rows: + if row.get("ground") not in ROCK_GROUNDS: + out.append(row) + continue + for share in shares: + numbers = { + key: row[key] * share["fraction"] + for key in SHARED_KEYS + if isinstance(row.get(key), (int, float)) + } + out.append( + { + **row, + **numbers, + "ground": share["ground"], + "rock_class": share["rock_class"], + "rock_split_note": share["note"], + "blocked_kind": BLOCKED_INPUT_MISSING if share["blocked_reason"] else None, + "blocked_reason": share["blocked_reason"], + } + ) + return out + + +def split_amounts( + by_ground: dict[str, float], shares: list[dict[str, Any]] | None +) -> list[tuple[str, float, str, str | None, str]]: + """갈래별 물량(사토) → `(갈래, 물량, 막힘 사유, 암 갈래, 원래 이름)`. 몫이 없으면 그대로.""" + out = [] + for label, amount in sorted(by_ground.items()): + if shares and label in ROCK_GROUNDS: + out.extend( + (s["ground"], amount * s["fraction"], s["blocked_reason"], s["rock_class"], label) + for s in shares + ) + else: + out.append((label, amount, "", None, label)) + return out + + +def apply_rock_split( + haul: dict[str, Any], + classes: Iterable[str], + ratios_pct: dict[str, Any], + methods: dict[str, str | None], +) -> None: + """운반표(`rows`)를 자리에서 가르고 사토가 같은 몫을 쓰게 `rock_shares` 를 싣는다.""" + shares = rock_shares(classes, ratios_pct, methods) + haul["rows"] = split_rows(haul.get("rows") or [], shares) + haul["rock_shares"] = shares diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index ee9a71ee..26640669 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -44,6 +44,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_t from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows +from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes @@ -58,6 +59,7 @@ from common_util.common_util_project_settings import ( haul_limit_choice, quantity_settings, rock_classes, + rock_method, save_section, topsoil_target, ) @@ -115,6 +117,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: plan = await _stored_haul_plan(project_id, route_id) haul = build_haul_table(plan, factors) + # ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인). + classes = rock_classes(settings) + methods = {name: rock_method(settings, name) for name in classes} + apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods) # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. diff --git a/resources/tester/test_b08_haul_summary.py b/resources/tester/test_b08_haul_summary.py index a6f4f954..5f660d0b 100644 --- a/resources/tester/test_b08_haul_summary.py +++ b/resources/tester/test_b08_haul_summary.py @@ -153,6 +153,8 @@ def test_집계표_입력으로_줄임() -> None: "volume_basis", # 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다. "conversion_c", + # 2026-09-14 ㉱ — 구성비로 가른 암 갈래(안 가른 줄은 None) · 집계표 공종 칸이 씀. + "rock_class", } for row in rows ) diff --git a/resources/tester/test_b08_rock_haul_split.py b/resources/tester/test_b08_rock_haul_split.py new file mode 100644 index 00000000..ea8ca7c9 --- /dev/null +++ b/resources/tester/test_b08_rock_haul_split.py @@ -0,0 +1,106 @@ +"""㉱ (가) 암 운반량을 구성비로 가르기 — 2026-09-14 브레인 판정(구성비가 정본 · 8-1 사용자 확정). + +B06 토사 토글 끔 = 저장값 `ripping_rock` 은 **자리표시**(「갈라 넣는 것은 설계내역 몫」)인데 +운반·사토가 그 이름을 값처럼 써서 「깎기 암은 구성비가 비어 막히는데 운반 암은 리핑암으로 금액이 섬」. +⇒ 운반표 한 곳에서 암 줄을 구성비·시공법으로 가름 · 비면 「암」 한 줄로 막음(깎기와 같은 사유). +유토곡선 거리·다짐 부피는 그대로 — 검산이 안 흔들림((나)는 뒤 차례). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as summary # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: E402 + NOTE_METHOD_MISSING, + NOTE_ROCK_RATIO_MISSING, +) +from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table, check_against_plan # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split # noqa: E402 + +CLASSES = ["토사", "풍화암", "연암", "보통암", "경암"] +PLAN = { + "blocks": [ + { + "bands": [ + {"equipment": "dozer", "haul_distance_m": 40.0, "haul_from_m": 0.0, + "haul_to_m": 40.0, "ea_m3": 90.0, "rr_m3": 115.0, "br_m3": 0.0}, + ] + } + ], + "transfers": [], + "hauled_m3": 205.0, + "transferred_m3": 0.0, +} # fmt: skip +SPOIL = {"volume_m3": 23.0, "distance_m": 300.0, "by_ground_m3": {"rr_m3": 23.0}, + "natural_m3_by_ground": {"rr_m3": 20.0}} # fmt: skip + + +def _haul(ratios: dict, methods: dict) -> dict: + haul = build_table(PLAN) + haul["spoil"] = dict(SPOIL) + apply_rock_split(haul, CLASSES, ratios, methods) + return haul + + +def _rock(haul: dict) -> list[dict]: + return [r for r in haul["rows"] if r["ground"] != "토사"] + + +def test_구성비가_비면_운반_암도_한_줄_암으로_막힘() -> None: + haul = _haul({}, {}) + (rock,) = _rock(haul) + assert rock["ground"] == "암" and rock["natural_m3"] == pytest.approx(100.0) + assert rock["blocked_reason"] == NOTE_ROCK_RATIO_MISSING + items = build_handoff(haul_table=haul)["work_items"] + dozer = next(r for r in items if r["name"].endswith("운반") and r["ground_class"] == "암") + assert ( + dozer["blocked_kind"] == "input_missing" + and dozer["blocked_reason"] == NOTE_ROCK_RATIO_MISSING + ) + spoil = next(r for r in items if r["name"] == "사토 운반") + assert ( + spoil["blocked_kind"] == "input_missing" + and NOTE_ROCK_RATIO_MISSING in spoil["blocked_reason"] + ) + + +def test_구성비와_시공법대로_갈리고_검산은_그대로() -> None: + ratios = {"연암": 60.0, "보통암": 40.0} + methods = {"연암": "ripping", "보통암": "blasting"} + haul = _haul(ratios, methods) + got = [(r["rock_class"], r["ground"], round(r["natural_m3"], 6)) for r in _rock(haul)] + assert got == [("연암", "리핑암", 60.0), ("보통암", "발파암", 40.0)] + assert check_against_plan(haul, PLAN).difference_m3 == pytest.approx(0.0) + items = build_handoff(haul_table=haul)["work_items"] + dozer = [r for r in items if r["haul_equipment"] == "dozer" and r["ground_class"] != "토사"] + assert [(r["spec"], r["variant_value"], r["blocked_kind"]) for r in dozer] == [ + ("연암 · 리핑암", "리핑암", None), + ("보통암 · 발파암", "발파암", None), + ] + spoil = [r for r in items if r["name"] == "사토 운반"] + assert [(r["variant_value"], round(r["quantity"], 3)) for r in spoil] == [ + ("리핑암", 12.0), + ("발파암", 8.0), + ] + # 깎기와 같은 몫 — 흙깎기 암 1000 이 같은 구성비로 600 · 400 + rows = summary(SummaryInput(earthwork_totals={"cut_rock_volume_m3": 1000.0}, + rock_classes=CLASSES, rock_ratios_pct=ratios))["rows"] # fmt: skip + cut = {r["item"]: r["amount"] for r in rows if r["group"] == "흙깎기" and r["item"] != "토사"} + assert cut == {"연암": 600.0, "보통암": 400.0} + + +def test_시공법을_안_고른_갈래만_막힘() -> None: + haul = _haul({"연암": 50.0, "경암": 50.0}, {"연암": "ripping"}) + rows = {r["rock_class"]: r for r in _rock(haul)} + assert not rows["연암"]["blocked_reason"] + assert rows["경암"]["blocked_reason"] == NOTE_METHOD_MISSING From 8f4ec487599e8bba19e0c14ddace3e9ff66a6e81 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 21:11:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b06=C2=B7b08):=20=E3=89=B1=20(?= =?UTF-8?q?=EB=82=98)=20=EC=95=94=20=ED=99=98=EC=82=B0=EA=B3=84=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EA=B5=AC=EC=84=B1=EB=B9=84=20=EA=B0=80=EC=A4=91?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=ED=86=A0=EC=A0=81=ED=91=9C?= =?UTF-8?q?=C2=B7=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=C2=B7=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=ED=91=9C=EA=B0=80=20=ED=95=9C=20=ED=95=A8=EC=88=98(mi?= =?UTF-8?q?xed=5Fconversion=5Ffactors)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06 암은 한 종류 자리표시라 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음 · 구성비·갈래별 시공법이 다 서면 암 두 칸을 Σ몫×C(시공법)로 · 비면 종전 값(인계가 막힘 사유) - 계수를 내는 곳 넷(B06 서버 재계산·곡선 문맥 · B08 토적표·사토 계수)이 같은 함수 · 화면 「무엇을 골랐나」는 갈래별 값 그대로 - 936be972 연암 60 리핑/보통암 40 발파 · 서버 재계산: 토적표 깎기 보정량 6,296.81 = 유토곡선 6,296.8 · 암 보정량 4,167.31 → 4,384.74 · 토취 7,259.84 → 7,053.83 · 사토 55.31 → 58.2 · 운반 검산 0 · 되돌려 재계산 원래 값 그대로 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B06_Section_Server_Calc_Prebuild.py | 7 +- B08_Quantity/B08_Quantity_Router_Earthwork.py | 7 +- common_util/common_util_project_settings.py | 30 +++++++++ .../tester/test_rock_mixed_conversion.py | 66 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_rock_mixed_conversion.py diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index d032de2d..be311607 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -39,8 +39,8 @@ from B06_Section.B06_Section_Repository import ( from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from common_util.common_util_node_bundle import run_bundle_json from common_util.common_util_project_settings import ( - earthwork_conversion_factors, haul_equipment_limits, + mixed_conversion_factors, quantity_settings, ) from common_util.common_util_storage import resolve_stored_project_path @@ -96,7 +96,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]] except Exception: logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id) return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()} - return earthwork_conversion_factors(quantity_settings(root)) + # 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수. + return mixed_conversion_factors(quantity_settings(root)) async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]: @@ -235,7 +236,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: "detail": detail, "context": _mass_haul_context( haul_inputs, - earthwork_conversion_factors(quantity_settings(project_root)), + mixed_conversion_factors(quantity_settings(project_root)), haul_equipment_limits(quantity_settings(project_root)), ), }, diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 26640669..842db03c 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -55,7 +55,7 @@ from common_util.common_util_project_settings import ( application_ratio, concrete_placing_method, earthwork_conversion_choices, - earthwork_conversion_factors, + mixed_conversion_factors, haul_limit_choice, quantity_settings, rock_classes, @@ -98,7 +98,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: ) # ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다. settings, project_root = await _project_settings(project_id) - factors = earthwork_conversion_factors(settings) + # 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수. + factors = mixed_conversion_factors(settings) table = build_table(_stations(designs), factors) # 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다. table["conversion_factor_choices"] = earthwork_conversion_choices(settings) @@ -246,7 +247,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_ def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]: """갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다.""" - factors = earthwork_conversion_factors(settings) + factors = mixed_conversion_factors(settings) return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()} diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 80e8ea6d..68c8fcc2 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -323,6 +323,36 @@ def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str return resolved +#: 암 시공법 → 환산계수 갈래 이름(`EARTHWORK_CONVERSION_FACTORS` 키). +ROCK_METHOD_KINDS = {ROCK_METHOD_RIPPING: "ripping_rock", ROCK_METHOD_BLASTING: "blasting_rock"} + + +def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]: + """토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 구성비 가중 C (㉱ (나) · 2026-09-14). + + B06 의 암은 한 종류 **자리표시**(`ripping_rock` · 옛 자료 `blasting_rock`)이고 암질은 설계내역에서 + 구성비로 정함(8-1 사용자 확정). 그래서 구성비와 갈래별 시공법이 **다 서면** 암 두 칸을 + Σ몫 × C(시공법)로 갈음 — 몫은 흙깎기와 같은 안분(준 비율끼리 · 합이 100 이 아니어도 안분). + ⚠ 구성비가 비거나 시공법이 빠진 갈래가 있으면 **종전 값** — 인계가 막힘 사유를 내고, 지어낸 계수를 안 씀. + ⚠ 화면 「무엇을 골랐나」(`earthwork_conversion_choices`)는 갈래별 값 그대로 — 여기 값을 보이면 안 됨. + """ + resolved = earthwork_conversion_factors(settings) + ratios = settings.get("rock_ratios_pct") or {} + shares = { + name: float(ratios.get(name) or 0) + for name in rock_classes(settings) + if name != "토사" and float(ratios.get(name) or 0) > 0 + } + kinds = {name: ROCK_METHOD_KINDS.get(rock_method(settings, name) or "") for name in shares} + if not shares or not all(kinds.values()): + return resolved + total = sum(shares.values()) + mixed = sum(shares[name] / total * resolved[kind]["compacted"] for name, kind in kinds.items()) + for kind in ROCK_METHOD_KINDS.values(): + resolved[kind]["compacted"] = mixed + return resolved + + def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]: """갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다. diff --git a/resources/tester/test_rock_mixed_conversion.py b/resources/tester/test_rock_mixed_conversion.py new file mode 100644 index 00000000..639823a9 --- /dev/null +++ b/resources/tester/test_rock_mixed_conversion.py @@ -0,0 +1,66 @@ +"""㉱ (나) 암 환산계수를 구성비 가중으로 — 2026-09-14 브레인 판정(구성비가 정본 · (가) 뒤 차례). + +B06 의 암은 한 종류 자리표시(`ripping_rock`)라 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음 — +구성비 60/40(리핑·발파)이면 토적표 암 보정량이 217.43㎥ 덜 섬(936be972 실측). ⇒ 계수를 내는 한 곳에서 +암 두 칸을 Σ몫×C(시공법)로 · 구성비나 시공법이 비면 종전 값(인계가 막힘 사유를 냄 · 지어낸 계수 안 씀). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1].parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_project_settings import ( # noqa: E402 + earthwork_conversion_choices, + earthwork_conversion_factors, + mixed_conversion_factors, +) + +MIX = { + "rock_class_set": "geochang5", + "rock_ratios_pct": {"연암": 60, "보통암": 40}, + "rock_methods": {"연암": "ripping", "보통암": "blasting"}, +} + + +def test_구성비와_시공법이_서면_암_두_칸이_가중_C() -> None: + factors = mixed_conversion_factors(MIX) + for kind in ("ripping_rock", "blasting_rock"): + assert factors[kind]["compacted"] == pytest.approx(0.6 * 1.15 + 0.4 * 1.30) + assert factors["soil"] == earthwork_conversion_factors(MIX)["soil"] + + +def test_고른_계수_위에서_가중() -> None: + settings = {**MIX, "conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}} + assert mixed_conversion_factors(settings)["ripping_rock"]["compacted"] == pytest.approx( + 0.6 * 1.0 + 0.4 * 1.30 + ) + + +@pytest.mark.parametrize( + "settings", + [ + {"rock_class_set": "geochang5"}, + {**MIX, "rock_methods": {"연암": "ripping"}}, + ], +) +def test_구성비나_시공법이_비면_종전_값(settings: dict) -> None: + assert mixed_conversion_factors(settings) == earthwork_conversion_factors(settings) + + +def test_화면_고른_값_표시는_갈래별_그대로() -> None: + assert earthwork_conversion_choices(MIX)["ripping_rock"]["compacted"] == pytest.approx(1.15) + + +def test_토적표_유토곡선_운반표가_같은_함수를_씀() -> None: + prebuild = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text("utf-8") + router = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") + assert prebuild.count("mixed_conversion_factors(") == 2 + assert "earthwork_conversion_factors(" not in prebuild + assert router.count("mixed_conversion_factors(settings)") == 2