From 165bfed0a8e90be6fa79351c999d0ba598d2e280 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 22:55:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EA=B0=88=EB=9E=98=20=EB=8A=90?= =?UTF-8?q?=EC=8A=A8=ED=95=9C=20=EB=A7=9E=EC=B6=A4=C2=B7=EC=88=98=EB=9F=89?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC=20=EB=AA=BB=20=EB=A7=9E=EC=B6=A4=EC=9D=84?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EB=A1=9C=20=E2=80=94=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=9E=88=20=EB=8B=A4=EB=A5=B8=20=EA=B0=88=EB=9E=98=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EB=8A=94=20=EC=9E=90=EB=A6=AC=20=EB=93=9C=EB=9F=AC?= =?UTF-8?q?=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - find_variant_code 가 글자 대신 수의 짝·구간으로 고른 자리를 한 번씩 로그(명세 14장 정정, 판정 (나)) - 수량 소수자리는 이름 그대로 두고 종목 이름을 못 맞춘 줄만 로그 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B09_Estimation_QuantityDigits.py | 22 +++++++++++++++- B09_Estimation/B09_Estimation_UnitPrice.py | 26 +++++++++++++++++-- resources/tester/test_b09_bill_code_link.py | 24 +++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py index 0141c6a1..edd3363c 100644 --- a/B09_Estimation/B09_Estimation_QuantityDigits.py +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -23,6 +23,7 @@ from __future__ import annotations +import logging from decimal import ROUND_HALF_UP, Decimal #: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다. @@ -68,13 +69,22 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( #: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. _UNIT_ONLY = {"㎥": 2, "m3": 2} +logger = logging.getLogger(__name__) +#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게. +_UNMATCHED_LOGGED: set[tuple[str, str]] = set() + def _tight(text: str) -> str: return "".join(str(text or "").split()) def digits_for(name: str, unit: str, spec: str = "") -> int | None: - """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.""" + """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다. + + ⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음 + (금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가 + 서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄). + """ haystack = _tight(name) + _tight(spec) unit_tight = _tight(unit) for words, units, digits in _DIGITS: @@ -82,6 +92,16 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None: continue if any(word in haystack for word in words): return digits + key = (_tight(name), unit_tight) + if key not in _UNMATCHED_LOGGED: + _UNMATCHED_LOGGED.add(key) + fallback = _UNIT_ONLY.get(unit_tight) + logger.info( + "B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s", + name, + unit, + "단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음", + ) return _UNIT_ONLY.get(unit_tight) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 87eabfd8..fd843ccf 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -17,6 +17,7 @@ from __future__ import annotations +import logging import re from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace @@ -66,6 +67,7 @@ from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_Transport import parse_distance_km +logger = logging.getLogger(__name__) _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. @@ -512,14 +514,34 @@ def find_variant_code( if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers ] if len(hits) == 1: - return hits[0] + return _loose(work_item_code, variant_value, hits[0], "수의 짝") if len(numbers) == 1: # 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는 # 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다. - return _bracket_for(numbers[0], candidates, prefix) + picked = _bracket_for(numbers[0], candidates, prefix) + return _loose(work_item_code, variant_value, picked, "구간") if picked else None return None +#: 느슨한 맞춤을 이미 알린 짝 — 내역을 그릴 때마다 같은 줄이 로그를 채우지 않게. +_LOOSE_LOGGED: set[tuple[str, str, str]] = set() + + +def _loose(work_item_code: str, variant_value: str, picked: str, how: str) -> str: + """글자가 아니라 **수·구간으로** 갈래를 고른 자리 — 로그로 남기고 그대로 돌려줌. + + ⚠ 조용히 다른 갈래에 붙는 자리가 바로 여기라 드러내 둠(명세 14장 정정 · 브레인 판정 (나)). + 갈래의 정본은 지금 **B09 단가표 제목**이고 마스터 `variant_key` 는 첫 열만 담아 못 씀(1장 몫). + """ + key = (work_item_code, str(variant_value), picked) + if key not in _LOOSE_LOGGED: + _LOOSE_LOGGED.add(key) + logger.info( + "B09 갈래 느슨한 맞춤(%s): %s 「%s」 → %s", how, work_item_code, variant_value, picked + ) + return picked + + def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None: """그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다.""" best: tuple[Decimal, str] | None = None diff --git a/resources/tester/test_b09_bill_code_link.py b/resources/tester/test_b09_bill_code_link.py index e93736c4..37e8670c 100644 --- a/resources/tester/test_b09_bill_code_link.py +++ b/resources/tester/test_b09_bill_code_link.py @@ -65,3 +65,27 @@ def test_총_절취량은_코드로_센다(monkeypatch: pytest.MonkeyPatch) -> N build_bill(payload, build=_build()) assert calls and calls[0]["total_cut_volume_m3"] == Decimal(10) assert calls[0]["haul_volume_total_m3"] == Decimal(5) + + +def test_느슨한_갈래_맞춤과_못_맞춘_수량_자리는_로그로_드러난다( + caplog: pytest.LogCaptureFixture, +) -> None: + """명세 14장 정정(브레인 판정 (나)) — 조용히 다른 갈래에 붙는 자리를 로그로.""" + import B09_Estimation.B09_Estimation_QuantityDigits as digits_module + import B09_Estimation.B09_Estimation_UnitPrice as unit_price_module + + unit_price_module._LOOSE_LOGGED.clear() + digits_module._UNMATCHED_LOGGED.clear() + caplog.set_level("INFO") + build = _build() + # 글자 그대로 맞으면 로그 없음. + assert unit_price_module.find_variant_code("FP-13-04-05", "55cm이하", build) + assert "느슨한 맞춤" not in caplog.text + # 한 값 45 → 구간 「55cm이하」 — 느슨한 맞춤이라 로그. + assert unit_price_module.find_variant_code("FP-13-04-05", "45", build).endswith("#55cm이하") + assert "느슨한 맞춤(구간): FP-13-04-05 「45」" in caplog.text + # 수량 자리 — 종목 이름을 맞추면 조용하고, 못 맞추면 단위 자리로 가며 로그. + assert digits_module.digits_for("돌쌓기", "㎡") == 1 + assert "종목 이름을 못 맞춤" not in caplog.text + assert digits_module.digits_for("사토 운반", "㎥") == 2 + assert "종목 이름을 못 맞춤: 「사토 운반」 ㎥ → 단위만으로 2자리" in caplog.text