# -*- coding: utf-8 -*- """재료 줄의 기본 검색어 찾기 · 고른 품목 · 단위 환산 · 단위 경고 (PLAN 8-1 계약). `master_formula.run` 이 재료 줄 단가를 풀 때 부름 — 요소 객체에 `검색어` 가 있으면 여기서 찾고, 없으면 옛 길(`master_material` 조건 · 대표). 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음. """ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar from decimal import Decimal import master_material as mm # 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴) UNITS = { "무게": {"g": 1, "kg": 1000, "톤": 1000000}, "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, "넓이": {"cm2": 1, "m2": 10000}, "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, } _SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} REGIONS = ( "전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주" " 전남광주" ).split() CHOSEN: ContextVar[dict | None] = ContextVar("고름", default=None) # {호표 줄 차례(0부터): 자재 키} EQ: ContextVar[dict | None] = ContextVar("기계번호", default=None) # 기계 마스터 {원문번호: 줄} @contextmanager def choosing(picks: dict | None): """`/calc` · `/text` 몸의 `고름` — 그 요청 동안만 · 저장 안 함.""" token = CHOSEN.set({str(k): str(v) for k, v in (picks or {}).items()}) try: yield finally: CHOSEN.reset(token) def machine(spec: dict, value) -> bool: """기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음.""" known = EQ.get() or {} picks = spec.get("고르기") or [] return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) # ── 단위 ─────────────────────────────────────────────────────────────── def kind_of(unit) -> str | None: got = _SCALE.get(mm.plain_unit(unit)) return got[0] if got else None def same_kind(a, b) -> bool: """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피).""" pa, pb = mm.plain_unit(a), mm.plain_unit(b) return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b)) def convert(price, have, want): """`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로.""" if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want): return price if not same_kind(have, want) or kind_of(have) is None: return price return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1]) # ── 찾기 ─────────────────────────────────────────────────────────────── def _words(term) -> tuple[list[str], list[str]]: """(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임.""" every = [mm.plain_spec(w) for w in str(term or "").split()] region = [w for w in every if w in REGIONS] return [w for w in every if w not in REGIONS], region def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: """검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) · 단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례: 값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례.""" words, region = _words(cond.get("검색어")) region += [mm.plain(env[mm.REGION])] if (env or {}).get(mm.REGION) else [] rows = master.index.get(mm.MARKET, {}) hits = [ key for _, name, spec, _, key in mm._market_index(master) if all(w in name.replace("×", "x") or w in spec for w in words) and (not cond.get("구분") or rows[key].get("구분") == cond["구분"]) and (not cond.get("상세구분") or rows[key].get("상세구분") == cond["상세구분"]) ] if unit: kin = [k for k in hits if same_kind(rows[k].get("단위"), unit)] hits = kin or hits want = mm.plain_unit(unit) if unit else None def rank(key): one = rows[key] name = mm.plain(one.get("이름")) return ( not mm._offers(master, key), bool(want) and mm.plain_unit(one.get("단위")) != want, not (region and all(r in name for r in region)), ) return sorted(hits, key=rank) # 정렬은 안정 — 남는 차례는 파일 차례 def pick(master, cond: dict, env: dict, unit: str | None = None) -> str: keys = search(master, cond, env, unit) if not keys: raise mm.mf.FormulaError(f"재료 검색어 후보 없음 — {cond.get('검색어')}") return keys[0] def priced(master, item: dict, ref, env: dict, index: int | None): """재료 줄 단가 — (단가, 출처, 까닭, 단위경고). 고른 품목(`고름`)이 있으면 그것 · 검색어가 있으면 찾기 · 없으면 옛 길. 자재 단가는 줄 단위로 환산 · 성격이 다르면 그대로 두고 경고.""" unit = item.get("단위") picked = (CHOSEN.get() or {}).get(str(index)) if index is not None else None try: if picked or (isinstance(ref, dict) and ref.get("검색어")): ref = picked or pick(master, ref, env, unit) have = master.get(ref).get("단위") if ref[:2] == mm.MARKET else None price, source = mm.element(master, ref, env, None) warn = None if have and unit and not same_kind(have, unit): warn = f"줄 {unit} · 자재 {have}" price = convert(price, have, unit) if have and unit else price else: price, source = mm.element(master, ref, env, unit) warn = None except mm.mf.FormulaError as e: return None, None, str(e), None why = None if price is not None else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값" return price, source, why, warn # ── 검사 ─────────────────────────────────────────────────────────────── def check_zero(master, where: str, cond: dict) -> list[str]: """검색어 후보 0 — `{입력}` 이 든 검색어는 계산 때 채워져 여기서 못 봄.""" term = str(cond.get("검색어") or "") if not term.strip() or "{" in term or any("{" in str(v) for v in cond.values()): return [] if term.strip() else [f"{where} · 검색어 비어 있음"] return [] if search(master, cond) else [f"{where} · 검색어 「{term}」 후보 0"] def check_warn(master, where: str, cond: dict, unit: str | None) -> list[str]: """단위 경고 줄 — 검색어 후보가 있으나 줄 단위와 같은 성격이 하나도 없음.""" term = str(cond.get("검색어") or "") if not unit or "{" in term or "{" in str(cond.get("구분") or ""): return [] keys = search(master, cond) rows = master.index.get(mm.MARKET, {}) if keys and not any(same_kind(rows[k].get("단위"), unit) for k in keys): return [f"{where} · 검색어 「{term}」 후보 단위가 줄 단위 「{unit}」 과 성격이 다름"] return [] import master_formula as mf # noqa: E402, F401 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음