- 인력 한 줄 저장 7.0초 → 0.9초 · 자재품목 7.2초 → 2.0초 · 소요량 표 1.7초 · 자재품목 저장 뒤 목록 2.5초 → 0.45초 · 켠 뒤 첫 목록 3.4초 → 1.6초 - 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모음 — 키 · 「ID:원문번호」 · 없는 키를 물은 것까지 · 재료 고르기 줄은 자재품목 묶음 통째 - 저장은 그 지도로 흔들리는 줄만 검사(고친 키 · 고치기 전후 원문번호 · 지운 줄) · 요소를 고친 저장은 연결·준용 검사를 그대로 돌림 - 저장 뒤 목록은 흔들린 자리를 알려 받아(`stale(names, marks)`) 그 줄만 다시 셈 · 다른 창이 고친 파일이면 예전처럼 통째로 다시 셈 - 자재품목을 「구분」 으로 미리 갈라 둠(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑지 않음 · 가른 차례는 파일 차례 그대로라 고르는 줄은 안 바뀜 - 전수 확인 — 요소·표 파일 90 개마다 줄을 통째로 비우고 로직 1,354 줄을 다시 검사, 달라진 줄이 모두 지도 안(지도 밖 0) - 시험 `test_m01_cache.py` 에 셋 더함(열둘) — 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
225 lines
11 KiB
Python
225 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""품셈재료 단가 — 연결을 따라 자재품목 줄의 값 열 다섯 가운데 낮은 값 (`_틀.md` 4장 재료 값).
|
||
|
||
⚠ 낮은 값 고르기는 **시험 계산용**. 최저가·견적 고르기는 프로젝트 몫 — 마스터는 값을 모으기만 함.
|
||
|
||
`master_formula` 가 요소 값을 풀 때 부름. 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import unicodedata
|
||
from decimal import Decimal
|
||
|
||
LINKED = "MP" # 품셈재료 테이블ID
|
||
MARKET = "MT" # 자재품목 테이블ID
|
||
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
|
||
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
|
||
PICK_KEYS = ("구분", "상세구분", "규격", "대표") # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
|
||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||
|
||
|
||
def plain(s) -> str:
|
||
"""이름 맞대기 — NFKC · 소문자 · 빈칸 없앰 · 지름 기호 하나로."""
|
||
s = unicodedata.normalize("NFKC", str(s or "")).lower()
|
||
return re.sub(r"\s+", "", re.sub(r"[φΦ∅⌀]", "ø", s))
|
||
|
||
|
||
def plain_spec(s) -> str:
|
||
return plain(s).replace("×", "x").replace("*", "x")
|
||
|
||
|
||
def plain_unit(s) -> str:
|
||
s = plain(s).replace("ℓ", "l")
|
||
return _UNITS.get(s, s)
|
||
|
||
|
||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||
"""줄 하나의 (값, 출처) — 자재품목은 값이 있는 열마다 하나 · 그 밖(유가전력)은 「값」 한 칸."""
|
||
row = master.get(key)
|
||
if key[:2] != MARKET:
|
||
return [(row["값"], f"{row['키']}.값")] if row.get("값") is not None else []
|
||
return [(row[slot], f"{row['키']}.{slot}") for slot in SLOTS if row.get(slot) is not None]
|
||
|
||
|
||
def _lowest(master: mf.Master, key: str) -> tuple[object, str | None]:
|
||
"""자재품목 줄의 낮은 값 — ⚠ 시험 계산용."""
|
||
offers = _offers(master, key)
|
||
return min(offers, key=lambda x: x[0]) if offers else (None, None)
|
||
|
||
|
||
def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]:
|
||
"""(품명 뿌리, 이름, 규격, 단위, 키) — 「레미콘(관급)-경상북도(울진군)」 뿌리 = 레미콘."""
|
||
if not hasattr(master, "_market"):
|
||
master._market = []
|
||
for key, row in master.index.get("MT", {}).items():
|
||
name = plain(row.get("이름"))
|
||
root = re.sub(r"\(.*?\)", "", name.split(",")[0]).split("-")[0]
|
||
master._market.append(
|
||
(root, name, plain_spec(row.get("규격")), plain_unit(row.get("단위")), key)
|
||
)
|
||
return master._market
|
||
|
||
|
||
def _tariff(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||
"""후보 조건{이름 · 계약종별: 입력} — 유가전력 전력 줄 가운데 그 계약종별·이름으로 시작하는 줄."""
|
||
cond = row["연결"]
|
||
if TARIFF not in env:
|
||
raise mf.FormulaError(f"「{master.label(ref)}」 입력 「{TARIFF}」 없음")
|
||
want, kind = plain(cond["이름"]), plain(env[TARIFF])
|
||
hits = [
|
||
key
|
||
for key, one in master.index.get(PRICED, {}).items()
|
||
if one.get("구분") == "전력"
|
||
and plain(one.get("상세구분")) == kind
|
||
and plain(one.get("이름")).startswith(want)
|
||
and one.get("값") is not None
|
||
]
|
||
if not hits:
|
||
raise mf.FormulaError(f"「{master.label(ref)}」 계약종별 값 없음 — {env[TARIFF]}")
|
||
return hits
|
||
|
||
|
||
def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||
"""후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음."""
|
||
cond = row["연결"]
|
||
if cond.get("계약종별") == "입력":
|
||
return _tariff(master, ref, row, env)
|
||
if cond.get("지역") == "입력" and REGION not in env:
|
||
raise mf.FormulaError(f"「{master.label(ref)}」 입력 「{REGION}」 없음")
|
||
region = plain(env.get(REGION, "")) if cond.get("지역") == "입력" else ""
|
||
want, spec, unit = plain(cond["이름"]), plain_spec(cond.get("규격")), plain_unit(row["단위"])
|
||
hits = [
|
||
(s, key)
|
||
for root, name, s, u, key in _market_index(master)
|
||
if root.endswith(want) and region in name and u == unit and (not spec or s == spec)
|
||
]
|
||
hits = [(s, key) for s, key in hits if _offers(master, key)]
|
||
if not hits:
|
||
raise mf.FormulaError(f"「{master.label(ref)}」 지역 값 없음 — {env.get(REGION)}")
|
||
if len({s for s, _ in hits}) > 1:
|
||
raise mf.FormulaError(f"「{master.label(ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
|
||
return [key for _, key in hits]
|
||
|
||
|
||
# ── 재료 고르기 ────────────────────────────────────────────────────────
|
||
def static(cond: dict) -> dict:
|
||
"""검사용 조건 — 값에 `{입력}` 이 든 칸은 뺌(계산 때 채워짐)."""
|
||
return {k: v for k, v in cond.items() if not (isinstance(v, str) and "{" in v)}
|
||
|
||
|
||
def cond_text(cond: dict) -> str:
|
||
"""고르기 조건 한 글자 열쇠 — 「구분|상세구분|규격|대표」 · 화면 단가 미리보기가 줄을 찾는 이름."""
|
||
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS)
|
||
|
||
|
||
def by_class(master: mf.Master) -> dict[str, dict]:
|
||
"""자재품목을 「구분」 으로 미리 갈라 둠(마스터마다 한 번) — 고르기 후보를 찾을 때
|
||
3만 줄을 다 훑지 않게. 가른 차례는 파일 차례 그대로."""
|
||
got = getattr(master, "_by_class", None)
|
||
if got is None:
|
||
got = {}
|
||
for key, row in master.index.get(MARKET, {}).items():
|
||
got.setdefault(row.get("구분"), {})[key] = row
|
||
master._by_class = got
|
||
return got
|
||
|
||
|
||
def candidates(
|
||
master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None
|
||
) -> list[str]:
|
||
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
|
||
`unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건.
|
||
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""
|
||
want = [plain_spec(w) for w in str(cond.get("규격") or "").split()]
|
||
detail = cond.get("상세구분")
|
||
need = plain_unit(unit) if unit else ""
|
||
hits = [
|
||
key
|
||
for key, row in by_class(master).get(cond.get("구분"), {}).items()
|
||
if (not detail or row.get("상세구분") == detail)
|
||
and all(w in plain_spec(row.get("규격")) for w in want)
|
||
and (not need or plain_unit(row.get("단위")) == need)
|
||
]
|
||
region = plain((env or {}).get(REGION, ""))
|
||
near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else []
|
||
return near or hits
|
||
|
||
|
||
def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str:
|
||
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만)."""
|
||
if cond.get("대표"):
|
||
return str(cond["대표"])
|
||
keys = candidates(master, cond, env, unit)
|
||
keys = [k for k in keys if _offers(master, k)] or keys
|
||
if not keys:
|
||
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
|
||
return keys[0]
|
||
|
||
|
||
def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]:
|
||
"""단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름.
|
||
틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`)."""
|
||
cond = static(cond)
|
||
rows = master.index.get(MARKET, {})
|
||
rep = str(cond.get("대표") or "")
|
||
if not unit or set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||
return [] # 구분·규격이 `{입력}` 이면 좁힐 것이 없어 여기서 뺌
|
||
if rep in rows:
|
||
if plain_unit(rows[rep].get("단위")) != plain_unit(unit):
|
||
return [
|
||
f"{where} · 대표 줄 단위 「{rows[rep].get('단위')}」 이 줄 단위 「{unit}」 과 다름"
|
||
]
|
||
return []
|
||
if candidates(master, cond) and not candidates(master, cond, None, unit):
|
||
return [f"{where} · 단위 「{unit}」 후보 0 — 조건 안 줄은 모두 다른 단위"]
|
||
return []
|
||
|
||
|
||
def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]:
|
||
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0.
|
||
값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌."""
|
||
if set(cond) - set(PICK_KEYS) or not cond.get("구분"):
|
||
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
|
||
cond = static(cond)
|
||
if not cond.get("구분"): # 구분 자체가 `{입력}` — 계산 때 채워져 여기서 좁힐 것이 없음
|
||
return []
|
||
out, rows = [], master.index.get(MARKET, {})
|
||
for slot in ("구분", "상세구분"):
|
||
if cond.get(slot) and not any(r.get(slot) == cond[slot] for r in rows.values()):
|
||
out.append(f"{where} · 자재품목에 없는 {slot} 「{cond[slot]}」")
|
||
rep = cond.get("대표")
|
||
if rep and str(rep) not in rows:
|
||
out.append(f"{where} · 대표 줄 「{rep}」 이 자재품목에 없음")
|
||
elif rep and str(rep) not in candidates(master, cond):
|
||
out.append(f"{where} · 대표 줄 「{rep}」 이 고르기 조건 밖")
|
||
if not out and not candidates(master, cond):
|
||
out.append(f"{where} · 재료 고르기 후보 0")
|
||
return out
|
||
|
||
|
||
def element(
|
||
master: mf.Master, ref, env: dict, unit: str | None = None
|
||
) -> tuple[object, str | None]:
|
||
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`.
|
||
`ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂."""
|
||
if isinstance(ref, dict):
|
||
ref = pick(master, ref, env, unit)
|
||
row = master.get(ref)
|
||
if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄
|
||
return _lowest(master, ref)
|
||
if ref[:2] != LINKED:
|
||
return master.value(ref)
|
||
link = row.get("연결")
|
||
if not link:
|
||
return None, None
|
||
keys = [link] if isinstance(link, str) else _candidates(master, ref, row, env)
|
||
offers = [x for key in keys for x in _offers(master, key)]
|
||
return min(offers, key=lambda x: x[0]) if offers else (None, None)
|
||
|
||
|
||
import master_formula as mf # noqa: E402 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음
|