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
+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