- 행정구역 표 = 시도 17(짧은 이름) + 전국 · 시도별 시군구 229(자치 227 + 제주 행정시 2 · 2026-07-01 인천 제물포 · 영종 · 검단 반영) · 옛 이름(강원도 · 전라북도 · 청원 · 마산 · 진해 · 연기 · 인천 남구 · 중구 · 동구 · 경북 군위) · 별칭(영종도 · 용유도 · 세종시 · 제주도) - 풀이 master_region.split — 시도만 = 시군구 「전체」 · 시군구만 = 시도 찾음 · 읍면 = 시군구(읍면) · 여러 시도 = 쉼표 · 제외 조건 = 끝에 「 제외」 · 조합 이름 = 비고 · 잘린 끝 낱말 = 하나뿐인 시군구(동두 → 동두천 · 금 → 금천) - 지역 글 242 종 모두 풀림(못 푼 것 0) · 옛 이름 바꾼 줄 648 · 줄 · 키 · 값 그대로(24,027 · 비고만 조합 121 줄) - 쓰는 곳: 지역 글 = material_merge.where(시도 + 시군구) — M01 찾기(label · 검색어 · 자재지역) · 표 찾기 · 후보 글 · 불러오기(M02_Mat_Place · Hopyo_Match · Hopyo_Material — 가져온 지역 낱말도 짧은 이름으로 · 시군구가 있으면 그것으로 맞댐) - M01 화면: 시도 = 고르기(목록 밖 값 「대전,충남」 은 끝에) · 시군구 = 글 · 새 줄 - · - - 로직 1,520 시험 계산 전후 다름 0 · check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음) - 빌더 adopt 가 로직 장 파일 「차례」 를 하나씩 미는 것은 이번 일 밖 — 되돌림(로직 파일 손대지 않음) - 새 지킴 test_830_11_material_sido · 낡은 시험 고침: test_830_10 · test_830_2(열 · 지역 기대를 두 칸으로 · 830-11 새 겹침 38 묶음 = 청원 → 청주 4 · 조합 비고 34) · test_m02_hopyo_match(강원도(강릉시) → 강원 강릉) - 틀 문서 _틀.md 11 절 행정구역 표 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
10 KiB
Python
220 lines
10 KiB
Python
# -*- 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
|
||
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
|
||
|
||
REGIONS = (
|
||
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
|
||
" 전남광주"
|
||
).split()
|
||
|
||
CHOSEN: ContextVar[dict | None] = ContextVar("고름", default=None) # {호표 줄 차례(0부터): 자재 키}
|
||
EQ: ContextVar = 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 _eq(master=None):
|
||
"""기계를 찾을 마스터 — 셈에 넘어온 것(기계 줄 있음) › 지은 문맥의 것(`EQ` · 다른 스레드는 빔)."""
|
||
return master if (getattr(master, "numbers", None) or {}).get("EQ") else EQ.get()
|
||
|
||
|
||
def _numbers(master=None) -> dict:
|
||
return _eq(master).numbers.get("EQ", {}) if _eq(master) else {}
|
||
|
||
|
||
def resolve(spec: dict, value, master=None):
|
||
"""기계 입력이 EQ 키로 오면 원문 분류번호로 풂 — 고르기 목록(원문번호)과 같은 꼴."""
|
||
if not (isinstance(value, str) and value.strip()[:2] == "EQ" and _eq(master)):
|
||
return value
|
||
row = _eq(master).index.get("EQ", {}).get(value.strip())
|
||
number = str(row.get("원문번호") or "") if row else ""
|
||
return number if number and machine(spec, number, master) else value
|
||
|
||
|
||
def machine(spec: dict, value, master=None) -> bool:
|
||
"""기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음 · 빈 고르기(직접 값 식
|
||
GC001199 · 822-2) = 어느 기계든."""
|
||
known = _numbers(master)
|
||
picks = spec.get("고르기")
|
||
if picks == [] and spec.get("이름") == "기계":
|
||
return str(value).strip() in known
|
||
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
|
||
|
||
|
||
# ── 찾기 ───────────────────────────────────────────────────────────────
|
||
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]:
|
||
"""`_search` 를 마스터 색인 하나에 조건마다 한 번만(792-2) —
|
||
답은 마스터 · 조건 · 지역 · 단위만 따름."""
|
||
place = mm.plain(env[mm.REGION]) if (env or {}).get(mm.REGION) else ""
|
||
memo = getattr(master, "_found", None)
|
||
if memo is None:
|
||
memo = master._found = {}
|
||
key = tuple(str(cond.get(k) or "") for k in ("검색어", "구분", "상세구분")) + (place, str(unit))
|
||
if key not in memo:
|
||
memo[key] = _search(master, cond, env, unit)
|
||
return list(memo[key])
|
||
|
||
|
||
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.label(one) # 이름 + 지역 칸(830-10)
|
||
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):
|
||
"""재료 줄 단가 — (단가, 출처, 까닭, 단위경고). 고른 품목(`고름`)이 있으면 그것 · 검색어가 있으면 찾기 ·
|
||
없으면 옛 길. 자재 단가는 줄 단위로 환산 · 성격이 다르면 그대로 두고 경고.
|
||
넷째 = 줄에 덧붙일 칸 — `단위경고` · `품목`(잡힌 품목 키 · 이름 · 규격 · 단위 · 지역 · 상하차 · 값 칸)."""
|
||
price, source, why, warn = _priced(master, item, ref, env, index)
|
||
extra = {"단위경고": warn} if warn else {}
|
||
key, _, slot = (source or "").partition(".")
|
||
try:
|
||
row = master.get(key) if key else None
|
||
except mm.mf.FormulaError:
|
||
row = None
|
||
if row:
|
||
extra["품목"] = {
|
||
"키": key,
|
||
"이름": row.get("이름"),
|
||
"규격": row.get("규격"),
|
||
"단위": row.get("단위"),
|
||
**(
|
||
{"지역": mm.mmerge.where(row), "상하차": row.get("상하차")} if "시도" in row else {}
|
||
), # fmt: skip
|
||
"값칸": slot,
|
||
}
|
||
return price, source, why, extra
|
||
|
||
|
||
def _priced(master, item: dict, ref, env: dict, index: int | None):
|
||
if item.get("단가") is not None: # 손 단가(715-1b · M02 단가산출 덮개 · 더한줄) — 찾기 없음
|
||
return Decimal(str(item["단가"])), HAND, None, None
|
||
unit = item.get("단위")
|
||
picked = (CHOSEN.get() or {}).get(str(index)) if index is not None else None
|
||
if not picked and empty_term(ref): # 자재표에 없는 품목 — 구분 안 첫 품목으로 잇지 않음
|
||
return None, None, NO_ITEM, 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
|
||
|
||
|
||
HAND = "손 단가"
|
||
|
||
|
||
def bundled(item: dict, base):
|
||
"""로직 줄 노 · 재 · 경 묶음 — 덧칸 `값묶음`(715-1b · M02 단가산출)이 셋 다면 그것 ·
|
||
일부면 `base()`(엔진 값) 위에 · 계 = 셋 합 · 덧칸 없음 = `base()` 그대로."""
|
||
given = item.get("값묶음")
|
||
if not given:
|
||
return base()
|
||
costs = mf.COST_ITEMS
|
||
have = {k: Decimal(str(v)) for k, v in given.items() if k in costs}
|
||
got = have if len(have) == len(costs) else {**base(), **have}
|
||
return {**got, "계": sum(got[k] for k in costs)}
|
||
|
||
|
||
NO_ITEM = "자재표에 없는 품목 — 검색어 비움(결정 목록) · 품목을 골라 넣음"
|
||
|
||
|
||
def empty_term(ref) -> bool:
|
||
"""검색어 칸은 있고 비었음 = 자재표에 없는 품목(`M01_결정목록` 「자재표에 없는 품목」) —
|
||
단가 없음 · 까닭만(고름으로 고를 수 있음) · 검사는 이 줄을 틀림으로 세지 않음."""
|
||
return isinstance(ref, dict) and "검색어" in ref and not str(ref["검색어"] or "").strip()
|
||
|
||
|
||
# ── 검사 ───────────────────────────────────────────────────────────────
|
||
def check_zero(master, where: str, cond: dict) -> list[str]:
|
||
"""검색어 후보 0 — `{입력}` 이 든 검색어는 계산 때 채워져 여기서 못 봄 ·
|
||
빈 검색어는 `empty_term`(틀림 아님)."""
|
||
term = str(cond.get("검색어") or "")
|
||
if not term.strip() or "{" in term or any("{" in str(v) for v in cond.values()):
|
||
return []
|
||
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 not term.strip() 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 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음
|