From db0b38d826127fd6ddee0c779c2c4df9eb5d709c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:56:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EA=B0=88=EB=9E=98=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=AA=BD=EC=97=90=EC=84=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A6=20=E2=80=94=20=EB=AC=BC=EA=B2=B0=ED=91=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20+=20=EA=B5=AC=EA=B0=84=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 창 합의 — B08 은 **의미**만 보내고(`work_item_code` + 저장 제원 원본값), 품셈 원문 표기를 키로 옮기는 것은 **원문을 읽는 이쪽 몫** - 품셈이 물결표를 두 종류로 섞어 씀(`∼` U+223C 26건 · `~` U+FF5E 8건) — 같은 뜻인데 키가 두 벌이었음. 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존** - ⚠ 규칙은 둘뿐 — **내부 공백 제거 + 물결표 통일**. 키에 쓰인 글자를 세어 그 밖에는 소수점·괄호뿐임을 확인(하이픈·곱셈표 없음) - 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없어**(사이에 「㎝이상」이 낌) 글자 포함으로는 못 맞춤 → **수의 짝**으로 견줌 - 저장 제원이 한 값으로 오는 경우(뒷길이 45㎝)는 **그 값을 담는 가장 좁은 구간**을 고름 — 45 → `#55cm이하`, 25 → `#35cm이하` - ⚠ 담을 갈래가 없으면 `None` — **가까운 것을 임의로 고르지 않음**(95 → 없음) - 조판이 `variant_axis`·`variant_value` 를 받아 코드를 고르고, 못 고르면 종전대로 후보를 보임 검증: pytest 198 통과(신규 5 — 짝 시험 포함) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 19 ++++- B09_Estimation/B09_Estimation_UnitPrice.py | 77 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index fc760edc..72d85a48 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Guards import ( ) from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build +from B09_Estimation.B09_Estimation_UnitPrice import ( + UnitPriceBuild, + cached_build, + find_variant_code, +) _ZERO = Decimal(0) @@ -84,6 +88,9 @@ class HandoffWorkItem: #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. blocked_kind: str = "" + #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). + variant_axis: str = "" + variant_value: str = "" @property def display_name(self) -> str: @@ -227,6 +234,8 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[ structure_kind=row.get("structure_kind") or "", blocked_reason=row.get("blocked_reason") or "", blocked_kind=row.get("blocked_kind") or "", + variant_axis=row.get("variant_axis") or "", + variant_value=str(row.get("variant_value") or ""), ) for row in payload["work_items"] ] @@ -587,6 +596,14 @@ def _leaf_row( return row price_code = f"B-{node.code}" + if item.variant_value and price_code not in unit_prices.book.titles: + # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. + # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. + picked = find_variant_code(node.code, item.variant_value, unit_prices) + if picked is not None: + price_code = picked + row.spec = f"{row.spec} {item.variant_value}".strip() + if price_code not in unit_prices.book.titles: # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f680097b..f60b44ac 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -224,6 +224,81 @@ def load_basis_missing( } +#: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과 +#: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측: +#: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다. +#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다 +#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다). +_TILDE_CHARS = "∼~〜~" + + +def normalize_variant_key(text: str) -> str: + """갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다.""" + tight = "".join(str(text).split()) + return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight) + + +def find_variant_code( + work_item_code: str, + variant_value: str, + build: UnitPriceBuild | None = None, +) -> str | None: + """B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다. + + 갈래 키는 품셈 원문에서 나오고 **그 원문을 읽는 쪽이 여기**다(2026-09-08 두 창 합의). + 못 맞추면 `None` — **가까운 갈래를 임의로 고르지 않는다.** + """ + prices = build or cached_build() + wanted = normalize_variant_key(variant_value) + if not wanted: + return None + + prefix = f"B-{work_item_code}#" + candidates = [code for code in prices.book.titles if code.startswith(prefix)] + for code in candidates: + if normalize_variant_key(code[len(prefix) :]) == wanted: + return code + # ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다** + # (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80]. + numbers = _numbers_of(wanted) + if not numbers: + return None + hits = [ + code + for code in candidates + if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers + ] + if len(hits) == 1: + return hits[0] + if len(numbers) == 1: + # 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는 + # 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다. + return _bracket_for(numbers[0], candidates, prefix) + return None + + +def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None: + """그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다.""" + best: tuple[Decimal, str] | None = None + for code in candidates: + label = normalize_variant_key(code[len(prefix) :]) + bounds = _numbers_of(label) + if len(bounds) == 1: + if "이하" in label and value <= bounds[0]: + if best is None or bounds[0] < best[0]: + best = (bounds[0], code) + elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]: + width = bounds[1] - bounds[0] + if best is None or width < best[0]: + best = (width, code) + return best[1] if best else None + + +def _numbers_of(text: str) -> list[Decimal]: + """그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80].""" + return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)] + + def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -254,7 +329,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로** # (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해 # 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다. - variant_key = "".join(variant.split()) + variant_key = normalize_variant_key(variant) title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "") if title_code in build.book.titles: continue