feat(B09): 갈래 키를 이쪽에서 만듦 — 물결표 통일 + 구간 판정

두 창 합의 — 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 04:56:51 +09:00
co-authored by Claude Opus 5
parent d8cbd48baf
commit db0b38d826
2 changed files with 94 additions and 2 deletions
@@ -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` 와 같은 태도).
+76 -1
View File
@@ -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