merge: origin/sub_laptop_1 받기 — 노임 준용 + 품셈재료 연결 양쪽 살림

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-19 23:42:11 +09:00
co-authored by Claude Sonnet 5
49 changed files with 25264 additions and 24935 deletions
@@ -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)
+40 -1
View File
@@ -45,6 +45,7 @@ BOOKS = (
"시중물가",
"한국은행환율",
"조달청제비율",
"품셈재료",
"자체",
)
DIVISIONS = {
@@ -63,6 +64,7 @@ ROW_KEYS = {
"요소": ("열쇠", "이름", "", "출처"),
"": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "", ""),
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
"품셈재료": ("열쇠", "이름", "규격", "단위", "요구절", "연결"),
}
@@ -122,6 +124,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 ''}」 목록 없음"]
@@ -140,6 +144,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[""]):
@@ -151,6 +157,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}
@@ -294,7 +333,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]}")
+19 -10
View File
@@ -321,7 +321,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.value(fill(node[1], env))[0]
value = element(master, fill(node[1], env), env)[0]
if value is None:
raise FormulaError(f"{node[1]}」 값 없음 — 관리자가 채울 값")
if isinstance(value, list):
@@ -426,8 +426,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for item in row.get("호표", []):
alias = None
qty = evaluate(parse(item["수량"]), env, master, depth)
source = None
if item["종류"] == "로직":
bundle = evaluate(parse(item["요소"]), env, master, depth)
price = bundle[""]
@@ -437,15 +437,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, alias = master.value(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
@@ -457,7 +459,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"단가": price,
"금액": qty * price,
"비목": split,
**({"출처": f"준용: {alias}"} if alias else {}),
**({"출처": source} if source else {}),
}
)
for extra in row.get("덧줄", []):
@@ -523,9 +525,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:
@@ -633,3 +638,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,99 @@
# -*- 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):
value, alias = master.value(ref)
return value, f"준용: {alias}" if alias else 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 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음