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):"],