From 364c28a700eff75d2818f0aba1b8208835cffa30 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 00:44:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(b08):=20=EC=A4=80=EB=B9=84=EA=B3=B5=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B9=B8=20=E3=80=8C=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=9D=B4=20=ED=95=84=EC=9A=94=ED=95=A8=E3=80=8D=EA=B3=BC=20?= =?UTF-8?q?=E3=80=8C=EA=B0=92=EC=9D=84=20=EB=82=BC=20=EA=B7=BC=EA=B1=B0?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=8C=E3=80=8D=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EC=9D=B8=EA=B3=84=20=EB=A7=89=ED=9E=98=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=EB=8F=84=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=94=B0=EB=A6=84(=E3=89=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력이면 서는 줄(표토 두께·거리 · 임목폐기물 조사값 · 부대시설 개소 · 임목파쇄 부피 · 앞 단계 횡단·사면표) → 「입력이 필요함」 · 인계 input_missing - 자료·산식이 없는 줄(뿌리 운반 부피 · 사방 원단위 · 사면표에 성토고 칸 없음) → 「근거 없음」 · 인계 unit_data_missing(종전 input_missing = 반대 방향 거짓 사유) - 사면표가 섰는데 비탈 규준틀 0 개소 → 「해당 없음」 - 품셈 공종이 없는 부대시설은 개소를 넣어도 인계 input_missing 그대로(별도 단가) - 표 머리 「입력이 필요한 줄 N · 근거 없는 줄 M」 · 700줄 — 규준틀 재료를 `_Preparation_FrameMaterial` 로 뗌(719 → 651) - 936be972: 15줄 중 입력 7 · 근거 없음 1(뿌리 운반) · 내역 「원단위가 없습니다(우리가 만들 것)」 · 금액 불변 155,207,971 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_Handoff_Rows_Prep.py | 17 ++- .../B08_Quantity_Engine_Preparation.py | 109 ++++-------------- ...8_Quantity_Engine_Preparation_Ancillary.py | 5 +- ...antity_Engine_Preparation_FrameMaterial.py | 81 +++++++++++++ .../B08_Quantity_Engine_Preparation_Status.py | 6 + ...8_Quantity_Engine_Preparation_TreeWaste.py | 4 +- B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 4 +- .../tester/test_b08_prep_status_input.py | 105 +++++++++++++++++ resources/tester/test_b08_topsoil_haul.py | 5 +- resources/tester/test_b08_wood_chipping.py | 5 +- 10 files changed, 246 insertions(+), 95 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Preparation_FrameMaterial.py create mode 100644 resources/tester/test_b08_prep_status_input.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py index fa938f37..a6a76c91 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py @@ -21,6 +21,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, ) +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_NEEDS_INPUT as PREP_NEEDS_INPUT, +) from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, ) @@ -79,7 +82,9 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] "composite_parts": None, "structure_kind": None, # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. - "blocked_kind": None if ready else _prep_blocked_kind(status), + "blocked_kind": None + if ready + else row.get("blocked_kind") or _prep_blocked_kind(status), "blocked_reason": "" if ready else str(row.get("reason") or status), # ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라 # 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다. @@ -105,9 +110,15 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] def _prep_blocked_kind(status: str) -> str | None: - """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" - if status == PREP_PENDING: + """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 줄이 갈래를 적어 오면 그것이 이긴다. + + ⚠ 「근거 없음」을 `input_missing` 으로 보내면 사방 원단위처럼 **우리가 만들 줄**이 B09 에 + 「입력이 필요합니다」로 뜬다 — 사용자가 넣을 칸을 찾아 헤맨다(2026-09-14 브레인 ㉴). + """ + if status == PREP_NEEDS_INPUT: return BLOCKED_INPUT_MISSING + if status == PREP_PENDING: + return BLOCKED_UNIT_DATA_MISSING if status == PREP_COUNTED_ELSEWHERE: # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. return None diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 198369a8..d81114d8 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -50,15 +50,21 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E4 ANCILLARY_ITEMS, ancillary_rows, ) +from B08_Quantity.B08_Quantity_Engine_Preparation_FrameMaterial import ( # noqa: E402 + FRAME_MATERIAL_SUGGESTED, + frame_material_rows, +) from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402 STATUS_COUNTED_ELSEWHERE, + STATUS_NEEDS_INPUT, STATUS_NOT_APPLICABLE, STATUS_PENDING, STATUS_READY, ) from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402 -__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게 +# 갈라 나간 뒤에도 여기서 읽을 수 있게(부대시설 2026-09-09 · 규준틀 재료 2026-09-14). +__all__ = ["ANCILLARY_ITEMS", "FRAME_MATERIAL_SUGGESTED", "ancillary_rows", "frame_material_rows"] def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: @@ -170,7 +176,7 @@ def _topsoil_row( return { **base, "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, "reason": ( "노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. " f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음" @@ -184,7 +190,7 @@ def _topsoil_row( return { **base, "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, "reason": ( "대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. " "0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다" @@ -309,7 +315,7 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) - "amount": area if area > 0 else None, # ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다. # 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상). - "status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING, + "status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_NEEDS_INPUT, "reason": " · ".join(reasons), "reference_amount": area, "work_item_code": None, @@ -364,7 +370,7 @@ def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]: "item": CHIPPING_ITEM, "unit": "㎥", "amount": amount, - "status": STATUS_READY if amount and amount > 0 else STATUS_PENDING, + "status": STATUS_READY if amount and amount > 0 else STATUS_NEEDS_INPUT, "reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)", "work_item_code": CHIPPING_CODE, } @@ -382,7 +388,7 @@ def _root_steps_rows( "item": "뿌리 적재", "unit": "㎡", "amount": area if area > 0 else None, - "status": STATUS_READY if area > 0 else STATUS_PENDING, + "status": STATUS_READY if area > 0 else STATUS_NEEDS_INPUT, "reason": ( f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}" " · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고" @@ -434,7 +440,7 @@ def _topsoil_haul_row( "item": "표토 운반·적치", "unit": "㎥", "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, "reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈", "work_item_code": None, } @@ -444,7 +450,7 @@ def _topsoil_haul_row( "item": "표토 운반·적치", "unit": "㎥", "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, "reason": ( f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지" f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)" @@ -472,7 +478,7 @@ def _topsoil_haul_row( "item": "표토 운반·적치", "unit": "㎥", "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, "reason": ( f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다" f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다" @@ -505,88 +511,17 @@ def _topsoil_haul_row( } -#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09). -#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 -#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다 -#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」). -#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8 -#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」. -#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다. -#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**. -FRAME_MATERIAL_SUGGESTED = { - "각재 50×50": (0.0044, "㎥"), - "판재 T12": (0.0029, "㎥"), - "못": (0.03, "㎏"), -} -FRAME_MATERIAL_SOURCE = ( - "⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**." - " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" -) -FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} -#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음 -#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로. -FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")} - - -def frame_material_rows( - frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None -) -> list[dict[str, Any]]: - """규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.** - - ⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다). - ⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다. - """ - given = overrides or {} - rows: list[dict[str, Any]] = [] - for frame in frame_rows: - count = frame.get("amount") - if not count: - continue - loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None) - for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items(): - raw = given.get(name) - try: - per_ea = ( - float(raw) if raw is not None and str(raw).strip() != "" else float(default) - ) - except (TypeError, ValueError): - per_ea = float(default) - picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" - material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, "")) - rows.append( - { - "name": material, - "spec": spec, - "unit": unit, - "amount": float(count) * per_ea, - "destination": "material", - "source": str(frame.get("item") or "규준틀"), - "basis": ( - f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" - f" ({picked}) · {FRAME_MATERIAL_SOURCE}" - # ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던** - # 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사). - + ( - " · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함" - if str(frame.get("item")) == "비탈 규준틀" - else "" - ) - + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") - ), - } - ) - return rows - - def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) + # 사면표가 있는데 0 개소면 **이 노선엔 필요 없는 것**이다 — 「근거 없음」이 아니다(㉴). + idle = STATUS_NOT_APPLICABLE if slope_rows else STATUS_NEEDS_INPUT return { "group": "준비공", "item": "비탈 규준틀", "unit": "개소", "amount": float(count) if count else None, - "status": STATUS_READY if count else STATUS_PENDING, + "status": STATUS_READY if count else idle, "reason": ( "; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —" @@ -604,7 +539,12 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: "item": "수평 규준틀", "unit": "개소", "amount": float(count) if count is not None else None, - "status": STATUS_READY if count is not None else STATUS_PENDING, + # 사면표가 안 섰으면 앞 단계 몫(입력) · 섰는데 성토고 칸이 없으면 우리 자료가 없는 것. + "status": STATUS_READY + if count is not None + else STATUS_PENDING + if slope_rows + else STATUS_NEEDS_INPUT, "reason": "; ".join(notes) + ( " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —" @@ -705,6 +645,7 @@ def build_table( "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], "rows": rows, "ready_count": sum(1 for row in rows if row["status"] == STATUS_READY), + "input_count": sum(1 for row in rows if row["status"] == STATUS_NEEDS_INPUT), "pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING), "row_count": len(rows), } diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py b/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py index a30d9b55..07ab5e1f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_Ancillary.py @@ -11,6 +11,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( REASON_NO_WORK_ITEM, + STATUS_NEEDS_INPUT, STATUS_PENDING, STATUS_READY, ) @@ -94,7 +95,7 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]] reasons.append(REASON_NO_WORK_ITEM) if amount is None: reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") - status = STATUS_PENDING + status = STATUS_NEEDS_INPUT else: status = STATUS_READY if spec["code"] else STATUS_PENDING rows.append( @@ -106,6 +107,8 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]] "status": status, "work_item_code": spec["code"], "legal_required": bool(spec["legal"]), + # 품셈에 공종이 없는 줄 — 금액은 별도 단가(사람 입력)로만 서서 인계는 늘 입력 갈래. + **({} if spec["code"] else {"blocked_kind": "input_missing"}), "reason": " · ".join(reasons), } ) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_FrameMaterial.py b/B08_Quantity/B08_Quantity_Engine_Preparation_FrameMaterial.py new file mode 100644 index 00000000..4463b1ef --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_FrameMaterial.py @@ -0,0 +1,81 @@ +"""준비공 — 규준틀 재료 줄 (`B08_Quantity_Engine_Preparation` 에서 갈라냄 · 2026-09-14 700줄 제한). + +내용·규칙은 그대로 옮겼다 — 개소 × 개소당 수량을 자재 축으로 보내고, +값은 제안값이며 산출 조건이 이긴다. +""" + +from __future__ import annotations + +from typing import Any + +#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09). +#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 +#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다 +#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」). +#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8 +#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」. +#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다. +#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**. +FRAME_MATERIAL_SUGGESTED = { + "각재 50×50": (0.0044, "㎥"), + "판재 T12": (0.0029, "㎥"), + "못": (0.03, "㎏"), +} +FRAME_MATERIAL_SOURCE = ( + "⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**." + " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" +) +FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} +#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음 +#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로. +FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")} + + +def frame_material_rows( + frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.** + + ⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다). + ⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다. + """ + given = overrides or {} + rows: list[dict[str, Any]] = [] + for frame in frame_rows: + count = frame.get("amount") + if not count: + continue + loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None) + for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items(): + raw = given.get(name) + try: + per_ea = ( + float(raw) if raw is not None and str(raw).strip() != "" else float(default) + ) + except (TypeError, ValueError): + per_ea = float(default) + picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" + material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, "")) + rows.append( + { + "name": material, + "spec": spec, + "unit": unit, + "amount": float(count) * per_ea, + "destination": "material", + "source": str(frame.get("item") or "규준틀"), + "basis": ( + f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" + f" ({picked}) · {FRAME_MATERIAL_SOURCE}" + # ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던** + # 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사). + + ( + " · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함" + if str(frame.get("item")) == "비탈 규준틀" + else "" + ) + + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") + ), + } + ) + return rows diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py b/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py index 79c3f49a..54759640 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_Status.py @@ -8,6 +8,12 @@ from __future__ import annotations STATUS_READY = "값 있음" +#: 사람이 넣으면 서는 줄 — 산출 조건 칸, 또는 앞 단계(횡단·사면표)를 마치면 섬. +#: 인계 `input_missing`. +#: ⚠ 「근거 없음」과 가른다 — 한 낱말로 덮으면 입력만 넣으면 서는 줄이 「못 세움」으로 읽힌다 +#: (2026-09-14 브레인 ㉴ · 인계는 반대로 사방 원단위까지 「입력이 필요합니다」로 보냈다). +STATUS_NEEDS_INPUT = "입력이 필요함" +#: 자료·산식이 우리에게 없는 줄 — 입력으로는 안 풀림. 인계 `unit_data_missing`. STATUS_PENDING = "값을 낼 근거가 없음" STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" STATUS_NOT_APPLICABLE = "해당 없음" diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py index 9ff72b71..3d8c8af9 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py @@ -27,7 +27,7 @@ from __future__ import annotations import math from typing import Any -from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_NEEDS_INPUT, STATUS_READY TREE_WASTE_ITEM = "임목폐기물 처리" STEM_FORM_FACTOR = 0.5 # k @@ -139,7 +139,7 @@ def tree_waste_rows( { **base, "amount": None, - "status": STATUS_PENDING, + "status": STATUS_NEEDS_INPUT, # 조사값 칸 · 앞 단계 사면표 — 둘 다 사람 몫 "reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}", "reference_amount": area, } diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index a3942681..7219c38f 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -243,6 +243,8 @@ export interface PreparationRow { export interface PreparationTable { columns: string[]; rows: PreparationRow[]; + /** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */ + input_count?: number; pending_count: number; row_count: number; } @@ -261,7 +263,7 @@ export function renderPreparationGrid( const caption = document.createElement("p"); caption.className = "b08-grid__caption"; - caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`; + caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}개`; const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0); if (unconfirmed) { const badge = document.createElement("span"); diff --git a/resources/tester/test_b08_prep_status_input.py b/resources/tester/test_b08_prep_status_input.py new file mode 100644 index 00000000..666cce31 --- /dev/null +++ b/resources/tester/test_b08_prep_status_input.py @@ -0,0 +1,105 @@ +"""준비공 상태 칸 — 「입력이 필요함」과 「값을 낼 근거가 없음」을 가른다 (2026-09-14 브레인 ㉴). + +훑기에서 잡은 거짓 사유: 상태 칸이 **입력만 넣으면 서는 줄**(표토 두께·거리 · 임목폐기물 조사값 · +부대시설 개소 · 임목파쇄 부피)까지 「값을 낼 근거가 없음」으로 덮었다. 반대로 인계는 +**우리가 만들어야 하는 줄**(사방 원단위 · 뿌리 부피)까지 「입력이 필요합니다」로 보냈다. + + ① 입력이면 풀리는 줄 → 상태 「입력이 필요함」 · 인계 `input_missing` + ② 자료·산식이 없는 줄 → 상태 「값을 낼 근거가 없음」 · 인계 `unit_data_missing` + ③ 앞 단계(횡단·사면표)가 안 선 줄도 사람이 할 일이라 ① + ④ 규준틀이 필요 없는 노선(비탈길이 10m 이상 구간 없음)은 「해당 없음」 +""" + +from __future__ import annotations + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff +from B08_Quantity.B08_Quantity_Engine_Preparation import build_table +from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( + STATUS_NEEDS_INPUT, + STATUS_NOT_APPLICABLE, + STATUS_PENDING, +) + +SLOPE = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0, "face_dressing_cut": 300.0} +SHORT_SLOPES = [{"lengths": {"fill": 4.0}, "distance_m": 20.0, "fill_height_m": 2.0}] + + +def _table(**overrides: object) -> dict: + args: dict = { + "slope_totals": SLOPE, + "structures": [{"type_id": "erosion_check"}], + "slope_rows": SHORT_SLOPES, + "road_surface_area_m2": 1000.0, + "chipping_enabled": True, + "tree_waste": {}, + "ancillary_counts": {}, + } + args.update(overrides) + return build_table(**args) + + +def _by_item(table: dict) -> dict[str, dict]: + return {row["item"]: row for row in table["rows"]} + + +def test_입력이면_서는_줄은_입력이_필요함() -> None: + rows = _by_item(_table()) + for item in ( + "표토 운반·적치", + "임목파쇄", + "임목폐기물 처리", + "국가지점번호판", + "가설창고(컨테이너)", + ): + assert rows[item]["status"] == STATUS_NEEDS_INPUT, item + # 두께를 넣으면 거리 칸이 남는다 — 여전히 입력이 필요함. + with_thickness = _by_item(_table(topsoil_thickness_m=0.2)) + assert with_thickness["표토 운반·적치"]["status"] == STATUS_NEEDS_INPUT + + +def test_자료나_산식이_없는_줄은_근거가_없음() -> None: + rows = _by_item(_table()) + assert rows["뿌리 운반"]["status"] == STATUS_PENDING + assert ( + rows[next(k for k, r in rows.items() if r["group"] == "사방공")]["status"] == STATUS_PENDING + ) + + +def test_앞_단계가_안_섰으면_입력이_필요함() -> None: + rows = _by_item(_table(slope_totals={}, slope_rows=[], road_surface_area_m2=None)) + assert rows["표토제거"]["status"] == STATUS_NEEDS_INPUT + assert rows["뿌리 적재"]["status"] == STATUS_NEEDS_INPUT + assert rows["비탈 규준틀"]["status"] == STATUS_NEEDS_INPUT + assert rows["수평 규준틀"]["status"] == STATUS_NEEDS_INPUT + + +def test_규준틀이_필요_없는_노선은_해당_없음() -> None: + assert _by_item(_table())["비탈 규준틀"]["status"] == STATUS_NOT_APPLICABLE + + +def test_인계_막힘_갈래가_상태를_따른다() -> None: + table = _table() + handed = {row["name"]: row for row in build_handoff(preparation_table=table)["work_items"]} + assert handed["표토 운반·적치"]["blocked_kind"] == "input_missing" + assert handed["임목파쇄"]["blocked_kind"] == "input_missing" + # 우리가 만들어야 하는 줄 — 「입력이 필요합니다」로 보내면 사용자가 헛걸음한다. + assert handed["뿌리 운반"]["blocked_kind"] == "unit_data_missing" + assert handed["골막이"]["blocked_kind"] == "unit_data_missing" + # 해당 없음은 막힘이 아니다. + assert handed["비탈 규준틀"]["blocked_kind"] is None + # 공종이 품셈에 없는 부대시설은 개소를 넣어도 종전대로 「입력」(별도 단가) 갈래. + entered = _table(ancillary_counts={"national_point_sign": 3}) + sign = next( + row + for row in build_handoff(preparation_table=entered)["work_items"] + if row["name"] == "국가지점번호판" + ) + assert sign["blocked_kind"] == "input_missing" + + +def test_표_머리가_두_갈래를_따로_센다() -> None: + table = _table() + rows = table["rows"] + assert table["input_count"] == sum(1 for r in rows if r["status"] == STATUS_NEEDS_INPUT) + assert table["pending_count"] == sum(1 for r in rows if r["status"] == STATUS_PENDING) + assert table["input_count"] > 0 and table["pending_count"] > 0 diff --git a/resources/tester/test_b08_topsoil_haul.py b/resources/tester/test_b08_topsoil_haul.py index 369e90a7..59f1b060 100644 --- a/resources/tester/test_b08_topsoil_haul.py +++ b/resources/tester/test_b08_topsoil_haul.py @@ -23,7 +23,7 @@ sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 STATUS_COUNTED_ELSEWHERE, - STATUS_PENDING, + STATUS_NEEDS_INPUT, STATUS_READY, build_table, preparation_rows, @@ -61,7 +61,8 @@ def test_20m_이내면_제거_품에_들어_따로_안_셈() -> None: def test_거리를_안_넣으면_막고_사유() -> None: """⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다.""" row = 줄(0.2, None)["표토 운반·적치"] - assert row["amount"] is None and row["status"] == STATUS_PENDING + # 거리만 넣으면 서는 줄 — 「근거 없음」이 아니라 「입력이 필요함」(2026-09-14 ㉴). + assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT assert "운반거리가 아직 입력되지 않았습니다" in row["reason"] assert row["reference_amount"] == 200.0 # 값을 버리지 않는다 diff --git a/resources/tester/test_b08_wood_chipping.py b/resources/tester/test_b08_wood_chipping.py index 46689078..7dfc2c39 100644 --- a/resources/tester/test_b08_wood_chipping.py +++ b/resources/tester/test_b08_wood_chipping.py @@ -21,7 +21,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 - STATUS_PENDING, + STATUS_NEEDS_INPUT, STATUS_READY, build_table, ) @@ -43,7 +43,8 @@ def test_켜면_줄이_선다() -> None: def test_켜도_부피는_지어내지_않는다() -> None: row = 표(True)["임목파쇄"] - assert row["amount"] is None and row["status"] == STATUS_PENDING + # 부피를 넣으면 서는 줄 — 「입력이 필요함」(2026-09-14 ㉴). + assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT assert "부피" in row["reason"]