Files
Aislo/resources/master_data/scripts/master_material.py
T
eomsangdonandClaude Sonnet 5 3e3bb8ef09 feat(M01): PLAN 7-4b 뒤 절반 ③ — 고르기 조건에 지역·계약종별 자리를 넓혀 모래·자갈·전력 17줄을 품셈재료 간접 없이 품
master_material.py — PICK_KEYS 에 「지역」·「계약종별」 더함. `_region`·`_tariff`
두 함수를 품셈재료 행이 아니라 조건 묶음 자체를 받게 고쳐 옛길(품셈재료 「연결」
간접)과 새길(호표 「요소」 에 조건을 바로 박음) 이 같은 코드를 씀(sub3 가 분석해
둔 방식 그대로 — 이름 뿌리 endswith·단위 같음·지역 이름 포함 매칭). check_pick —
지역·계약종별 조건은 구분 없이 이름만 확인 · 값이 "입력" 아닌 채 섞이면 틀로 잡음.

build_로직_재료_고르기.py — 품셈재료 연결이 {이름·지역:입력} 이나 {이름·계약종별:입력}
인 줄(모래 MP000004·자갈 MP000006·전력 MP000358)도 그 조건 묶음을 그대로 호표에
박도록 넓힘 — 다시 돌려 로직 4개 파일 17줄 반영(모래 8·자갈 1·전력 8). 전력은
지시대로 재료_유가전력.json(MO 테이블)을 그대로 가리킴 — 자재품목은 안 거침.

test_masterdata_check.py — PICK_KEYS 늘어난 만큼 cond_text 글자 열쇠 시험 고침 ·
지역 값이 "입력" 아닌 조건은 틀로 잡히는 것 확인.
test_m01_api.py — 전력 계약종별 시험을 mf.element 직접 호출로 바꿈(화면 단가
미리보기 `_pick_brief`(M01_MasterData_Store.py, 담당 밖)는 구분·상세구분 자리로만
찾아 지역·계약종별 조건은 아직 못 다뤄 None — 아는 구멍으로 남김 · 계산 자체는
정상 확인).

typecheck 통과 · 관련 시험 108 통과 · 로직 1354개 전수 대조 금액 달라짐 0·새 멈춤 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-22 13:53:19 +09:00

249 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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, label_ref, cond: dict, env: dict) -> list[str]:
"""후보 조건{이름 · 계약종별: 입력} — 유가전력 전력 줄 가운데 그 계약종별·이름으로 시작하는 줄.
`label_ref` 는 알림 글에 쓸 자리(호표 줄의 고르기 조건 · 또는 품셈재료 옛 키)."""
if TARIFF not in env:
raise mf.FormulaError(f"「{master.label(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(label_ref)}」 계약종별 값 없음 — {env[TARIFF]}")
return hits
def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[str]:
"""후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음."""
if cond.get("지역") == "입력" and REGION not in env:
raise mf.FormulaError(f"「{master.label(label_ref)}」 입력 「{REGION}」 없음")
region = plain(env.get(REGION, "")) if cond.get("지역") == "입력" else ""
want, spec, unit_p = plain(cond["이름"]), plain_spec(cond.get("규격")), plain_unit(unit)
hits = [
(s, key)
for root, name, s, u, key in _market_index(master)
if root.endswith(want) and region in name and u == unit_p 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(label_ref)}」 지역 값 없음 — {env.get(REGION)}")
if len({s for s, _ in hits}) > 1:
raise mf.FormulaError(f"「{master.label(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()]
want_name = [plain(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(row.get("이름")) for w in want_name)
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.
값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌.
지역·계약종별 조건은 구분 없이 이름 하나로 자재품목·유가전력을 넓게 찾음(_region · _tariff)
— 여기서는 이름 칸만 있는지 봄(후보 0 은 계산 때 판단)."""
if set(cond) - set(PICK_KEYS):
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
if ("지역" in cond and cond["지역"] != "입력") or (
"계약종별" in cond and cond["계약종별"] != "입력"
):
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — 지역·계약종별은 늘 「입력」"]
if cond.get("지역") == "입력" or cond.get("계약종별") == "입력":
return [] if cond.get("이름") else [f"{where} · 재료 고르기 조건 「{cond}」 — 이름 없음"]
if 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` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂.
조건에 지역·계약종별 이 「입력」 이면 구분·상세구분 없이 이름 하나로 넓게 찾음(_region · _tariff)
— 호표 줄에 바로 써도 품셈재료 간접 없이 풀림(PLAN 7-4b 뒤 절반)."""
if isinstance(ref, dict) and (ref.get("계약종별") == "입력" or ref.get("지역") == "입력"):
keys = (
_tariff(master, ref, ref, env)
if ref.get("계약종별") == "입력"
else _region(master, ref, ref, env, unit)
)
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)
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
if isinstance(link, str):
keys = [link]
elif link.get("계약종별") == "입력":
keys = _tariff(master, ref, link, env)
else:
keys = _region(master, ref, link, env, row["단위"])
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 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음