feat(master_data): 재료 마스터 모양 확정 반영 — 시중물가 시중·조달 두 묶음 · 재료_품셈재료 연결 · 낮은 값과 출처
- 재료_시중물가: 채택 · 견적가 칸 뺌 · 값 = 시중{다섯 칸 + 면수} · 조달{값·출처·기준}
- 관급 줄 가격정보 4,552 → 조달(출처 가격정보 · 기준 = 원천 면수) · 나라장터 온전 짝 54 → 조달 출처 「나라장터:<열쇠>」(값 안 베낌)
- 재료_나라장터자재 머리 「자료」 = 조달 자료
- 재료_자체 → 재료_품셈재료: 값·출처 칸 없음 · 요구절 · 연결(열쇠 5 · 후보 조건 3 · 못 이음 352)
- 엔진(master_material): 연결된 줄의 시중·조달 가운데 낮은 값 · 호표 줄 출처 · 후보 조건은 로직 입력 「자재지역」 — 줄 없으면 「지역 값 없음」 · 규격 여럿이면 「후보 여럿」
- 로직: 재료:자체 → 재료:품셈재료 516줄 · 자재지역 입력 24 로직 · check_master 품셈재료 틀·연결 검사 · M01 prices 출처
- 일괄 시험 계산(울진): 계산됨 1,109 · 값 없음 232 · 후보 여럿 8 · 미공표 2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_시중물가.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
||||
|
||||
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
||||
TC = 채택 · Cost1..6 = 출처 여섯 · Page1..6 = 면수.
|
||||
값 = 시중{가격정보·물가자료·유통물가·물가정보·거래가격 + 면수} · 조달{값 · 출처 · 기준}.
|
||||
Cost1..5 = 시중 다섯 · Page1..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
||||
품명에 「관급」 인 줄의 Cost1 = 조달청 관급 단가 — 조달 묶음으로 옮김(기준 = Page1 「조달 26.7」 · 「~26.5.26」).
|
||||
나라장터 시설공통자재와 이름·규격·단위가 온전히 같고 서로 하나뿐인 줄은 조달 출처 「나라장터:<열쇠>」 — 값은 베끼지 않음.
|
||||
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
||||
ESTX 에 대·중분류 칸이 없어 분류는 두지 않음.
|
||||
|
||||
@@ -9,18 +12,25 @@ ESTX 에 대·중분류 칸이 없어 분류는 두지 않음.
|
||||
쓴 뒤 원본과 대조.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from _migrate_base import dump_master # noqa: E402
|
||||
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
|
||||
from compare_estx import ESTX, ROOT, SLOTS, Estx, bare_spec, base_name # noqa: E402
|
||||
|
||||
OUT = ROOT / "resources/master_data/재료_시중물가.json"
|
||||
NARA = ROOT / "resources/master_data/재료_나라장터자재.json"
|
||||
MARKET = SLOTS[:5] # 견적가(Cost6)는 마스터 아님
|
||||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||||
_UNIT = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
|
||||
def num(v):
|
||||
@@ -29,25 +39,76 @@ def num(v):
|
||||
return int(v) if float(v).is_integer() else v
|
||||
|
||||
|
||||
def build(rows: list[dict]) -> list[dict]:
|
||||
def _base(s) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(s or "")).lower()
|
||||
return re.sub(r"\s+", "", re.sub(r"[φΦ∅⌀]", "ø", s))
|
||||
|
||||
|
||||
def _spec(s) -> str:
|
||||
return _base(s).replace("×", "x").replace("*", "x")
|
||||
|
||||
|
||||
def _unit(s) -> str:
|
||||
s = _base(s).replace("ℓ", "l")
|
||||
return _UNIT.get(s, s)
|
||||
|
||||
|
||||
def _nara_spec(n: dict) -> str:
|
||||
"""나라장터 규격 = 「품명, 규격」 — 앞 품명을 뗌."""
|
||||
head, _, tail = n["규격"].partition(",")
|
||||
return tail if tail and _base(head) == _base(n["이름"]) else n["규격"]
|
||||
|
||||
|
||||
def nara_links(rows: list[dict], nara: list[dict]) -> dict[str, str]:
|
||||
"""시중물가 열쇠 → 나라장터 열쇠 — 품명 뿌리 · 규격 수 모음이 맞는 짝 가운데
|
||||
이름·규격·단위가 온전히 같고 서로 하나뿐인 것만(관급 줄 · 애매한 것은 사람이 화면에서)."""
|
||||
by = collections.defaultdict(list)
|
||||
for e in rows:
|
||||
if e.get("Code") and "관급" not in e.get("Name", ""):
|
||||
by[base_name(e.get("Name"))].append(e)
|
||||
pairs = []
|
||||
for n in nara:
|
||||
if not n.get("값"):
|
||||
continue
|
||||
nums = sorted(_NUM.findall(bare_spec(n["이름"], n["규격"])))
|
||||
for e in by.get(base_name(n["이름"]), []):
|
||||
if (
|
||||
nums == sorted(_NUM.findall(bare_spec(e.get("Name"), e.get("Size"))))
|
||||
and _base(n["이름"]) == _base(e.get("Name"))
|
||||
and _spec(_nara_spec(n)) == _spec(e.get("Size"))
|
||||
and _unit(n["단위"]) == _unit(e.get("Unite"))
|
||||
):
|
||||
pairs.append((n["열쇠"], e["Code"]))
|
||||
cn = collections.Counter(n for n, _ in pairs)
|
||||
ce = collections.Counter(e for _, e in pairs)
|
||||
return {e: n for n, e in pairs if cn[n] == 1 and ce[e] == 1}
|
||||
|
||||
|
||||
def build(rows: list[dict], links: dict[str, str]) -> list[dict]:
|
||||
out = []
|
||||
for r in sorted(rows, key=lambda r: r["Index"]):
|
||||
code = r.get("Code")
|
||||
if not code:
|
||||
continue
|
||||
price = {"채택": num(r.get("TC"))}
|
||||
pages = {}
|
||||
for i, slot in enumerate(SLOTS, 1):
|
||||
price[slot] = num(r.get(f"Cost{i}"))
|
||||
pages[slot] = r.get(f"Page{i}") or None
|
||||
market = {slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 1)}
|
||||
market["면수"] = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 1)}
|
||||
supply = {"값": None, "출처": None, "기준": None}
|
||||
if "관급" in r.get("Name", "") and market["가격정보"] is not None:
|
||||
supply = {
|
||||
"값": market["가격정보"],
|
||||
"출처": "가격정보",
|
||||
"기준": market["면수"]["가격정보"],
|
||||
}
|
||||
market["가격정보"] = market["면수"]["가격정보"] = None
|
||||
elif code in links:
|
||||
supply["출처"] = f"나라장터:{links[code]}"
|
||||
out.append(
|
||||
{
|
||||
"열쇠": code,
|
||||
"이름": r.get("Name", ""),
|
||||
"규격": r.get("Size", ""),
|
||||
"단위": r.get("Unite", ""),
|
||||
"값": price,
|
||||
"면수": pages,
|
||||
"값": {"시중": market, "조달": supply},
|
||||
"출처": f"시중물가 {code}",
|
||||
}
|
||||
)
|
||||
@@ -55,7 +116,7 @@ def build(rows: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def check(rows: list[dict]) -> None:
|
||||
"""원본 대조 — 줄 수 · 출처별 값 합 · 표본 쉰 줄."""
|
||||
"""원본 대조 — 줄 수 · 출처별 값 합(가격정보 = 시중 + 조달) · 표본 쉰 줄."""
|
||||
data = json.loads(OUT.read_text(encoding="utf-8"))
|
||||
got = {r["열쇠"]: r for r in data["줄"]}
|
||||
raw = {r["Code"]: r for r in rows if r.get("Code")}
|
||||
@@ -63,13 +124,18 @@ def check(rows: list[dict]) -> None:
|
||||
print(
|
||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} (코드 없는 빈 줄 {len(rows) - len(raw)} 뺌)"
|
||||
)
|
||||
for i, slot in enumerate(["채택", *SLOTS]):
|
||||
col = "TC" if i == 0 else f"Cost{i}"
|
||||
a = math.fsum(r.get(col) or 0 for r in raw.values())
|
||||
b = math.fsum(r["값"][slot] or 0 for r in got.values())
|
||||
n = sum(1 for r in got.values() if r["값"][slot] is not None)
|
||||
for i, slot in enumerate(MARKET, 1):
|
||||
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
|
||||
b = math.fsum(r["값"]["시중"][slot] or 0 for r in got.values())
|
||||
if slot == "가격정보":
|
||||
b += math.fsum(r["값"]["조달"]["값"] or 0 for r in got.values())
|
||||
n = sum(1 for r in got.values() if r["값"]["시중"][slot] is not None)
|
||||
assert abs(a - b) < 1e-3, (slot, a, b)
|
||||
print(f" {slot}: 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
|
||||
print(f" {slot}: 시중 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
|
||||
supply = collections.Counter(
|
||||
(r["값"]["조달"]["출처"] or "없음").split(":")[0] for r in got.values()
|
||||
)
|
||||
print(f" 조달: {dict(supply)}")
|
||||
random.seed(20260719)
|
||||
for code in random.sample(sorted(raw), 50):
|
||||
r, g = raw[code], got[code]
|
||||
@@ -78,11 +144,13 @@ def check(rows: list[dict]) -> None:
|
||||
r.get("Size", ""),
|
||||
r.get("Unite", ""),
|
||||
)
|
||||
assert (g["값"]["채택"] or 0) == r.get("TC", 0)
|
||||
for i, slot in enumerate(SLOTS, 1):
|
||||
assert (g["값"][slot] or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
|
||||
assert g["면수"][slot] == (r.get(f"Page{i}") or None), (code, slot)
|
||||
print(" 표본 50줄 이름·규격·단위·값 7·면수 6 같음")
|
||||
moved = g["값"]["조달"]["출처"] == "가격정보"
|
||||
for i, slot in enumerate(MARKET, 1):
|
||||
have = g["값"]["조달"]["값"] if moved and i == 1 else g["값"]["시중"][slot]
|
||||
page = g["값"]["조달"]["기준"] if moved and i == 1 else g["값"]["시중"]["면수"][slot]
|
||||
assert (have or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
|
||||
assert page == (r.get(f"Page{i}") or None), (code, slot)
|
||||
print(" 표본 50줄 이름·규격·단위·값 5·면수 5 같음")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -98,9 +166,14 @@ def main():
|
||||
"역할": "primary",
|
||||
}
|
||||
],
|
||||
"비고": ["다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매"],
|
||||
"비고": [
|
||||
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
||||
"값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
||||
"조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<열쇠>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)",
|
||||
],
|
||||
}
|
||||
dump_master(OUT, head, build(rows))
|
||||
nara = json.loads(NARA.read_text(encoding="utf-8"))["줄"]
|
||||
dump_master(OUT, head, build(rows, nara_links(rows, nara)))
|
||||
check(rows)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ BOOKS = (
|
||||
"시중물가",
|
||||
"한국은행환율",
|
||||
"조달청제비율",
|
||||
"품셈재료",
|
||||
"자체",
|
||||
)
|
||||
DIVISIONS = {
|
||||
@@ -57,6 +58,7 @@ ROW_KEYS = {
|
||||
"요소": ("열쇠", "이름", "값", "출처"),
|
||||
"표": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "줄", "주"),
|
||||
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
|
||||
"품셈재료": ("열쇠", "이름", "규격", "단위", "요구절", "연결"),
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +118,8 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{name} · 머리 「{head}」 없음")
|
||||
table = data.get("그룹") in mf.TABLE_GROUPS
|
||||
kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소"
|
||||
if data.get("원문") == "품셈재료":
|
||||
kind = "품셈재료"
|
||||
rows = data.get("표" if table else "줄")
|
||||
if not isinstance(rows, list):
|
||||
return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"]
|
||||
@@ -134,6 +138,8 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
if "출처" in row and not _source_ok(row["출처"]):
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
if kind == "품셈재료":
|
||||
out += _check_linked(where, row)
|
||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||
for bundle in ("단위", "출처"):
|
||||
if isinstance(row.get(bundle), dict) and set(row[bundle]) != set(row["값"]):
|
||||
@@ -145,6 +151,39 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _check_linked(where: str, row: dict) -> list[str]:
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 열쇠 · 후보 조건 · null."""
|
||||
out = [f"{where} · 값 칸을 두지 않음"] if "값" in row else []
|
||||
need = row.get("요구절")
|
||||
if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)):
|
||||
out.append(f"{where} · 요구절 모양 「{need}」")
|
||||
link = row.get("연결")
|
||||
if isinstance(link, dict) and (
|
||||
not link.get("이름")
|
||||
or set(link) - {"이름", "규격", "지역"}
|
||||
or link.get("지역") not in (None, "입력")
|
||||
):
|
||||
out.append(f"{where} · 후보 조건 모양 「{link}」")
|
||||
elif link is not None and not isinstance(link, (str, dict)):
|
||||
out.append(f"{where} · 연결 모양 「{link}」")
|
||||
return out
|
||||
|
||||
|
||||
def check_links(whole: mf.Master) -> list[str]:
|
||||
"""품셈재료 연결 · 시중물가 조달 연결이 가리키는 줄."""
|
||||
out = []
|
||||
for key, row in whole.index.get(("재료", "품셈재료"), {}).items():
|
||||
link = row.get("연결")
|
||||
if isinstance(link, str) and link not in whole.index.get(("재료", "시중물가"), {}):
|
||||
out.append(f"재료:품셈재료:{key} · 연결 「{link}」 이 시중물가에 없음")
|
||||
nara = whole.index.get(("재료", "나라장터자재"), {})
|
||||
for key, row in whole.index.get(("재료", "시중물가"), {}).items():
|
||||
source = str(row["값"]["조달"].get("출처") or "")
|
||||
if source.startswith("나라장터:") and source.split(":", 1)[1] not in nara:
|
||||
out.append(f"재료:시중물가:{key} · 조달 「{source}」 이 나라장터자재에 없음")
|
||||
return out
|
||||
|
||||
|
||||
def _check_table(where: str, table: dict) -> list[str]:
|
||||
out, conds, cols = [], table.get("조건", {}), table.get("값칸", {})
|
||||
allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols}
|
||||
@@ -288,7 +327,7 @@ def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
|
||||
out += [f"{name} · {x}" for x in found]
|
||||
graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls
|
||||
out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)]
|
||||
return out + duplicate_keys(files)
|
||||
return out + duplicate_keys(files) + check_links(whole)
|
||||
|
||||
|
||||
def duplicate_keys(files: dict[str, dict]) -> list[str]:
|
||||
|
||||
@@ -227,8 +227,8 @@ def material(estx: Estx, files: dict):
|
||||
{s: e.get(f"Cost{i}") for i, s in enumerate(SLOTS, 1) if e.get(f"Cost{i}")},
|
||||
)
|
||||
have, near, none = [], [], []
|
||||
for r in files["재료_자체.json"]["줄"]:
|
||||
if r.get("값") is not None:
|
||||
for r in files["재료_품셈재료.json"]["줄"]:
|
||||
if r.get("연결"):
|
||||
continue
|
||||
mine = {base_name(r["이름"]), base_name(r["열쇠"])}
|
||||
hits = [e for m in mine for e in by_name.get(m, [])]
|
||||
@@ -240,7 +240,7 @@ def material(estx: Estx, files: dict):
|
||||
]
|
||||
(near if tail else none).append((r, tail))
|
||||
print(
|
||||
f"### 재료_자체 빈 값 {len(have) + len(near) + len(none)} — ESTX 품명 같음 {len(have)} · 품명 끝 같음 {len(near)} · 없음 {len(none)}"
|
||||
f"### 재료_품셈재료 못 이음 {len(have) + len(near) + len(none)} — ESTX 품명 같음 {len(have)} · 품명 끝 같음 {len(near)} · 없음 {len(none)}"
|
||||
)
|
||||
for r, hits in near:
|
||||
print(f" 비슷 {r['열쇠']} | {sorted({e['Name'] for e in hits})[:4]}")
|
||||
|
||||
@@ -311,7 +311,7 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
raise FormulaError(f"모르는 이름 「{node[1]}」")
|
||||
return env[node[1]]
|
||||
if kind == "ref":
|
||||
value = master.get(fill(node[1], env)).get("값")
|
||||
value = element(master, fill(node[1], env), env)[0]
|
||||
if value is None:
|
||||
raise FormulaError(f"「{node[1]}」 값 없음 — 관리자가 채울 값")
|
||||
if isinstance(value, list):
|
||||
@@ -417,6 +417,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
lines = []
|
||||
for item in row.get("호표", []):
|
||||
qty = evaluate(parse(item["수량"]), env, master, depth)
|
||||
source = None
|
||||
if item["종류"] == "로직":
|
||||
bundle = evaluate(parse(item["요소"]), env, master, depth)
|
||||
price = bundle["계"]
|
||||
@@ -426,15 +427,17 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
else {item["비목"]: qty * price}
|
||||
)
|
||||
else:
|
||||
node = parse(item["요소"])
|
||||
if node[0] == "field": # 값 묶음 요소의 칸 — 「재료:시중물가:M….채택」
|
||||
price = master.get(fill(node[1][1], env))["값"][node[2]]
|
||||
else:
|
||||
price = master.get(fill(item["요소"], env))["값"]
|
||||
ref = fill(item["요소"], env)
|
||||
try:
|
||||
price, source = element(master, ref, env)
|
||||
except FormulaError:
|
||||
if qty != 0:
|
||||
raise
|
||||
price = None
|
||||
if price is None and qty == 0:
|
||||
price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0
|
||||
if price is None:
|
||||
raise FormulaError(f"「{fill(item['요소'], env)}」 값 없음 — 관리자가 채울 값")
|
||||
raise FormulaError(f"「{ref}」 값 없음 — 관리자가 채울 값")
|
||||
split = {item["비목"]: qty * price}
|
||||
for k, v in split.items():
|
||||
sums[k] += v
|
||||
@@ -446,6 +449,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
"단가": price,
|
||||
"금액": qty * price,
|
||||
"비목": split,
|
||||
**({"출처": source} if source else {}),
|
||||
}
|
||||
)
|
||||
for extra in row.get("덧줄", []):
|
||||
@@ -511,9 +515,12 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
refs = _expand(row, part[1], master) or []
|
||||
for ref in refs:
|
||||
try:
|
||||
master.get(ref)
|
||||
link = master.get(ref).get("연결")
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known:
|
||||
out.append(f"{where} · 「{ref}」 는 입력 「{REGION}」 필요")
|
||||
if kind == "field" and part[1][0] == "ref":
|
||||
for ref in _expand(row, part[1][1], master) or []:
|
||||
try:
|
||||
@@ -621,3 +628,7 @@ def find_loops(graph: dict[str, set]) -> list[list[str]]:
|
||||
if node not in state:
|
||||
visit(node, [node])
|
||||
return loops
|
||||
|
||||
|
||||
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
|
||||
from master_material import LINKED, REGION, element # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""품셈재료 단가 — 연결을 따라 시중물가 줄의 시중·조달 값 가운데 낮은 값 (`_틀.md` 4장 재료 값).
|
||||
|
||||
`master_formula` 가 요소 값을 풀 때 부름. 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
LINKED = "재료:품셈재료:"
|
||||
MARKET = ("가격정보", "물가자료", "유통물가", "물가정보", "거래가격")
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
_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]]:
|
||||
"""시중물가 줄 하나의 (값, 출처) — 시중 다섯 칸 · 조달(나라장터 연결은 그 줄 값)."""
|
||||
value = master.get(f"재료:시중물가:{key}")["값"]
|
||||
out = [
|
||||
(value["시중"][slot], f"재료:시중물가:{key}.시중.{slot}")
|
||||
for slot in MARKET
|
||||
if value["시중"].get(slot) is not None
|
||||
]
|
||||
supply = value["조달"]
|
||||
if supply.get("값") is not None:
|
||||
out.append((supply["값"], f"재료:시중물가:{key}.조달"))
|
||||
elif str(supply.get("출처") or "").startswith("나라장터:"):
|
||||
ref = "재료:나라장터자재:" + supply["출처"].split(":", 1)[1]
|
||||
if master.get(ref).get("값") is not None:
|
||||
out.append((master.get(ref)["값"], ref))
|
||||
return out
|
||||
|
||||
|
||||
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(("재료", "시중물가"), {}).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 _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||||
"""후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음."""
|
||||
cond = row["연결"]
|
||||
if cond.get("지역") == "입력" and REGION not in env:
|
||||
raise mf.FormulaError(f"「{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"「{ref}」 지역 값 없음 — {env.get(REGION)}")
|
||||
if len({s for s, _ in hits}) > 1:
|
||||
raise mf.FormulaError(f"「{ref}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
|
||||
return [key for _, key in hits]
|
||||
|
||||
|
||||
def element(master: mf.Master, ref: str, env: dict) -> tuple[object, str | None]:
|
||||
"""요소 값과 출처 — 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값` 그대로(출처 None)."""
|
||||
row = master.get(ref)
|
||||
if not ref.startswith(LINKED):
|
||||
return row.get("값"), None
|
||||
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 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음
|
||||
Reference in New Issue
Block a user