feat(master_data): 기계 요소 병합 — 원격 틀 판정(조종원직종 비고 · 단위·출처 묶음 · 검사 도구) 반영해 재생성

- 기계_건설품셈.json — 충돌은 스크립트 재생성으로 해소 · 0201 아홉 줄의 비고 유지 · 값 null 칸 출처는 읽는 절
- 스크립트 자리를 resources/master_data/scripts 로 옮김

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-19 16:09:23 +09:00
co-authored by Claude Sonnet 5
8 changed files with 3254 additions and 869 deletions
@@ -0,0 +1,639 @@
"""건설품셈 공통 제8장 8-3(손료) · 8-4(운전경비) · 8-5(기계가격) 표 → resources/master_data/기계_건설품셈.json
본문 md(확정) 표를 읽어 기계 한 줄로 합침. 값은 md 에서만 · 계산·추정 없음.
실행: python resources/master_data/scripts/build_기계_건설품셈.py [--dry]
"""
import json
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[3]
DIR = (
REPO
/ "resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계"
)
OUT = REPO / "resources/master_data/기계_건설품셈.json"
SRC = "건설품셈 공통 "
NUM = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
# ---------------------------------------------------------------- md 읽기
def cell_join(s, name=False):
"""<br> 줄바꿈 이음. 숫자·연산자 사이는 붙이고 그 밖은 한 칸."""
parts = s.split("<br>")
out = parts[0].strip()
for p in parts[1:]:
p = p.strip()
if not out or not p:
out += p
continue
a, b = out[-1], p[0]
if (a.isdigit() and b.isdigit()) or b in "×)" or a in "(×∼~" or (name and b == "("):
out += p
else:
out += " " + p
return out
def read_md(path):
"""표 목록 [{h3, h4, rows, notes}] — rows 는 구분줄 뺀 셀 목록."""
L = path.read_text(encoding="utf8").split("\n")
L = L[L.index("---", 1) + 1 :]
tabs, h3, h4, i = [], "", "", 0
while i < len(L):
ln = L[i]
if ln.startswith("### "):
h3, h4 = ln[4:].strip(), ""
elif ln.startswith("#### "):
h4 = ln[5:].strip()
elif ln.startswith("|"):
rows = []
while i < len(L) and L[i].startswith("|"):
if not re.match(r"^\|[\s\-|:]+\|$", L[i]):
rows.append([c.strip() for c in L[i].strip()[1:-1].split("|")])
i += 1
notes, comments = [], []
while i < len(L) and not L[i].startswith(("#", "|")):
t = L[i].strip()
if t.startswith("<!--"):
comments.append(t)
elif t:
notes.append(t.replace("\\*", "*"))
i += 1
tabs.append(
{
"h3": h3,
"h4": h4,
"rows": rows,
"notes": notes,
"comments": comments,
"file": path.name,
}
)
continue
i += 1
return tabs
def num(s):
"""쉼표 뺀 수 · '-' 와 빈칸은 None · 그 밖 글은 그대로."""
s = s.strip()
if s in ("", "-"):
return None
if NUM.match(s):
t = s.replace(",", "")
f = float(t)
return int(f) if f == int(f) and "." not in t else float(t)
return s
def sec_no(h3):
return h3.split()[0] # '8-3-1'
def full_code(raw, prefix):
raw = raw.strip()
if re.fullmatch(r"\d{4}-\d{4}", raw):
return raw, raw[:4]
if re.fullmatch(r"-?\d{4}", raw) and prefix: # 원문에 「-0003」 꼴도 있음
return f"{prefix}-{raw.lstrip('-')}", prefix
return None, prefix
# ---------------------------------------------------------------- 8-3
def unit_of(label):
m = re.search(r"\(([^()]*)\)$", label)
return m.group(1) if m else None
def build_83(tabs, rows_out, tables_out, notes_out, anomalies):
for t in tabs:
h1 = t["rows"][0]
c1 = [cell_join(c) for c in h1]
code = re.match(r"\((\d{4})\)", t["h4"])
code = code.group(1) if code else None
key_note = f"{sec_no(t['h3'])} ({code})"
if c1[0].replace(" ", "") == "공기압축기":
tables_out.append(("관계", t, key_note))
notes_out.setdefault(key_note, []).extend(t["notes"])
continue
has2 = len(t["rows"]) > 1 and t["rows"][1][0] == ""
c2 = [cell_join(c) for c in t["rows"][1]] if has2 else None
data = t["rows"][2:] if has2 else t["rows"][1:]
H = [c.replace(" ", "") for c in c1]
iN = next((i for i, h in enumerate(H) if h.startswith("내용")), None)
iS = next(i for i, h in enumerate(H) if h.startswith("시간당"))
sp_end = iN if iN is not None else iS
spec = [] # (index, label)
for i in range(1, sp_end):
lab = (c2[i] if c2 and c2[i] else c1[i]).replace(" ", "")
if c2 is None and lab == "규격" and i > 1 and c1[i - 1] and False:
pass
spec.append((i, lab))
mid = [(i, H[i]) for i in range(sp_end, iS)]
if c2:
sons = [(i, c2[i].replace(" ", "")) for i in range(iS, len(c1))]
else:
sons = [(iS, "손료계수")]
sons = [(i, "손료계수" if l == "" else l) for i, l in sons]
merged = {
i
for i in range(1, len(c1))
if any(re.search(rf"병합: {i + 1}\d+~\d+행", c) for c in t["comments"])
}
prefix, inherit, group = None, {}, ""
for r in data:
r = r + [""] * (len(c1) - len(r))
raw = r[0].strip()
if re.fullmatch(r"\d{4}-", raw): # 「9020-」 접두 줄
prefix = raw[:4]
group = " ".join(cell_join(r[i], True) for i, _ in spec if r[i].strip())
continue
fc, prefix = full_code(raw, prefix)
if not fc:
anomalies.append(f"8-3 코드 해석 실패 {t['file']} {raw!r}")
continue
parts, appended = ([group] if group else []), False
for i, lab in spec:
if r[i].strip():
inherit[i] = v = cell_join(r[i])
else:
v = inherit.get(i, "") if i in merged else ""
if not v:
continue
u = unit_of(lab)
base = re.sub(r"\(.*", "", lab) if "(" in lab else lab
if len(spec) == 1:
simple = u and lab.count("(") == 1
appended = bool(simple and NUM.match(v.replace(",", "")))
parts.append(v + u if appended else v)
else:
parts.append(f"{base} {v}{u if u and NUM.match(v.replace(',', '')) else ''}")
unit_note = None
if len(spec) == 1 and not appended and unit_of(spec[0][1]):
unit_note = re.sub(r"^규격", "", spec[0][1])
elif len(spec) > 1 and any(unit_of(lab) for _, lab in spec):
unit_note = " · ".join(lab for _, lab in spec)
vals, odd = {}, {}
for i, lab in mid + sons:
n = num(r[i])
if isinstance(n, str) and re.fullmatch(
r"\d[\d,]*", n
): # 쉼표 자리가 어긋난 원문(3,0000)
odd[f"{sec_no(t['h3'])} {lab}"] = n
n = int(n.replace(",", ""))
vals[lab] = n
rows_out.setdefault(fc, {}).update(
{
"이름83": re.sub(r"\s*\('\d\d[^()]*\)$", "", t["h4"])
.split(") ", 1)[-1]
.strip(),
"규격": " · ".join(parts),
"규격단위": unit_note,
"83": vals,
"원문83": odd or None,
"sec83": sec_no(t["h3"]),
}
)
notes_out.setdefault(key_note, []).extend(t["notes"])
# ---------------------------------------------------------------- 8-4 (분류번호 줄 표)
def parse_fuel(s):
"""주연료 칸 → (값, 종류, 원문표시 필요)"""
raw = cell_join(s)
if raw in ("", "-"):
return None, None, False
m = re.fullmatch(r"(휘발유|중유|경유)\s*(\d+(?:\.\d+)?)", raw)
if m:
return num(m.group(2)), m.group(1), True
if NUM.match(raw.replace(",", "")):
return num(raw), "경유", False
return raw, None, True
def build_84(tabs, rows_out, notes_out, anomalies):
for t in tabs:
H = [cell_join(c).replace(" ", "") for c in t["rows"][0]]
if H[0] != "분류번호":
continue
prefix, name = None, ""
sec = sec_no(t["h3"])
for r in t["rows"][1:]:
fc, prefix = full_code(r[0], prefix)
if not fc:
anomalies.append(f"8-4 코드 해석 실패 {r[0]!r}")
continue
if r[1].strip():
name = cell_join(r[1], True)
fuel, kind, orig = parse_fuel(r[3])
jam = r[4].strip().rstrip("%")
op = num(r[5])
rows_out.setdefault(fc, {}).update(
{
"이름84": name,
"규격84": cell_join(r[2]),
"84": {
"주연료": fuel,
"주연료종류": kind,
"잡재료율": num(jam) if jam else None,
"조종원": op,
},
"원문84": {
k: v
for k, v in (
("주연료", cell_join(r[3]) if orig else None),
("잡재료", cell_join(r[4]) if isinstance(num(jam), str) else None),
)
if v
}
or None,
"sec84": sec,
}
)
notes_out.setdefault(sec, []).extend(t["notes"])
# ---------------------------------------------------------------- 8-4-9 해상 (가로 표)
def build_849(tabs, rows_out, notes_out, anomalies):
for t in tabs:
H = [cell_join(c).replace(" ", "") for c in t["rows"][0]]
if H[0] != "명칭":
continue
m = re.match(r"\((\d{4})\)", t["h4"])
grp = m.group(1)
sec = sec_no(t["h3"])
specs = [cell_join(c) for c in t["rows"][1][2:-1]]
cols = sorted(k for k, v in rows_out.items() if k.startswith(grp + "-") and "83" in v)
if len(cols) != len(specs):
anomalies.append(f"8-4-9 {grp}{len(specs)} ≠ 8-3 줄 {len(cols)}")
continue
# 규격 글의 수와 8-3 규격 수가 같은 순서인지 눈으로 볼 수 있게 남김
for k, s in zip(cols, specs):
a = re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", s)
b = re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", rows_out[k]["규격"])
if not (set(a) & set(b) or not a):
anomalies.append(
f"8-4-9 {grp} 열-줄 대응 의심 {k} 84={s!r} 83={rows_out[k]['규격']!r}"
)
for ci, k in enumerate(cols):
v84 = {"주연료": None, "주연료종류": None, "잡재료율": None, "조종원": None}
extra, orig = {}, {}
for r in t["rows"][2:]:
lab = cell_join(r[0]).replace(" ", "")
cell = cell_join(r[2 + ci])
if lab == "주연료":
val = num(cell)
v84["주연료"] = val
v84["주연료종류"] = "경유" if isinstance(val, (int, float)) else None
elif lab == "잡재료":
val = num(cell)
v84["잡재료율"] = val
if isinstance(val, str):
orig["잡재료"] = cell_join(cell)
elif lab in ("준설선운전사", "건설기계운전사"):
v84["조종원"] = num(cell)
v84["조종원직종"] = "1054" if lab == "준설선운전사" else "1048"
else:
extra[lab] = num(cell)
rows_out[k].update(
{
"이름84": rows_out[k]["이름83"],
"84": v84,
"extra84": extra,
"원문84": orig or None,
"sec84": sec,
"직종고정": "조종원직종" in v84 and v84.get("조종원직종") is not None,
}
)
notes_out.setdefault(f"{sec} ({grp})", []).extend(t["notes"])
# ---------------------------------------------------------------- 8-5
def build_85(tabs, rows_out, anomalies):
for t in tabs:
H = [cell_join(c).replace(" ", "") for c in t["rows"][0]]
if H[0] != "기종":
continue
prefix, name = None, ""
for r in t["rows"][1:]:
if r[0].strip():
name = cell_join(r[0], True)
fc, prefix = full_code(r[1], prefix)
if not fc:
anomalies.append(f"8-5 코드 해석 실패 {r[1]!r}")
continue
p = num(r[2])
if not isinstance(p, int):
anomalies.append(f"8-5 가격 이상 {fc} {r[2]!r}")
rows_out.setdefault(fc, {}).update(
{"이름85": name, "가격": p, "sec85": sec_no(t["h3"])}
)
# ---------------------------------------------------------------- 조종원 직종 (8-1-3 5 에 이름이 나온 기종만)
K1048 = [
"불도저",
"굴착기",
"굴삭기",
"로더",
"지게차",
"스크레이퍼",
"모터그레이더",
"롤러",
"스테이빌라이저",
"노상안정기",
"배치플랜트",
"피니셔",
"스프레더",
"아스팔트믹싱플랜트",
"골재살포기",
"슬러리실",
"천공기",
"사리채취기",
"노면파쇄기",
]
K1049 = ["살수차", "화물트럭", "트랙터", "제설차", "노면청소차", "트럭탑재형크레인"]
K1050 = ["양수기", "윈치", "발전기", "래머", "콤팩터", "벨트컨베이어", "콘크리트파쇄기"]
JOB_NAME = {
"1048": "건설기계운전사",
"1049": "화물차운전사",
"1050": "일반기계운전사",
"1054": "준설선운전사",
}
def job_of(name):
n = re.sub(r"\s", "", name)
for k, code in ((K1048, "1048"), (K1049, "1049"), (K1050, "1050")):
if any(w in n for w in k):
return code
return None
def job_for_row(name, spec):
n = re.sub(r"\s", "", name)
m = re.match(r"\d+(?:\.\d+)?", spec.replace(",", ""))
q = float(m.group(0)) if m else None
if "덤프트럭" in n:
return None if q is None else ("1048" if q >= 12 else "1049")
if "공기압축기" in n and "이동식" in n:
return "1048" if q is not None and q >= 2.83 else None
return job_of(name)
# ---------------------------------------------------------------- 실행
def load():
t83, t84, t85 = [], [], []
for p in sorted(DIR.glob("8-03-0[1-9]*.md")) + sorted(DIR.glob("8-03-10*.md")):
t83 += read_md(p)
t84 = read_md(next(DIR.glob("8-04_*.md")))
t85 = read_md(next(DIR.glob("8-05_*.md")))
return t83, t84, t85
UNIT = {
"가격": "천원",
"내용시간": "시간",
"연간표준가동시간": "시간",
"상각비율": "",
"정비비율": "",
"연간관리비율": "",
"상각비계수": "10^-7/시간",
"정비비계수": "10^-7/시간",
"관리비계수": "10^-7/시간",
"손료계수": "10^-7/시간",
"주연료": "/hr",
"주연료종류": "",
"잡재료율": "주연료의 %",
"조종원": "인/일",
"조종원직종": "",
}
K83 = [
"내용시간",
"연간표준가동시간",
"상각비율",
"정비비율",
"연간관리비율",
"상각비계수",
"정비비계수",
"관리비계수",
"손료계수",
]
FUEL_NOTE = SRC + "8-4 주①㉰"
def note_lines(lines):
out = []
for ln in lines:
ln = re.sub(r"^\[주\]\s*", "", ln)
if ln:
out.append(ln)
return out
def compose(rows):
out = []
for k in sorted(rows):
v = rows[k]
r83, r84 = v.get("83"), v.get("84")
name = v.get("이름83") or v.get("이름84") or v.get("이름85")
spec = v.get("규격") or v.get("규격84") or ""
# 값이 null 인 칸의 출처 = 그 칸을 읽는 절(틀 검사가 출처 문자열을 요구) · 표에 줄이 있으면 그 표의 절
s83, s84 = SRC + v.get("sec83", "8-3"), SRC + v.get("sec84", "8-4")
val = {"가격": v.get("가격")}
src = {"가격": SRC + v.get("sec85", "8-5")}
for f in K83:
val[f] = (r83 or {}).get(f)
src[f] = s83
for f in ("주연료", "주연료종류", "잡재료율", "조종원"):
val[f] = (r84 or {}).get(f)
src[f] = FUEL_NOTE if f == "주연료종류" and val[f] == "경유" else s84
fixed = (r84 or {}).get("조종원직종")
job = fixed or (job_for_row(name, spec) if val["조종원"] is not None else None)
val["조종원직종"] = job
src["조종원직종"] = SRC + "8-1-3 5" + (" · 건설노임 " + job if job else "")
for lab, x in (v.get("extra84") or {}).items():
val[lab] = x
src[lab] = s84
unit = dict(UNIT)
for lab in v.get("extra84") or {}:
unit[lab] = ""
row = {"열쇠": k, "이름": name, "규격": spec, "단위": unit, "": val, "출처": src}
memo = ["조종원직종 원문 이름: " + JOB_NAME[job]] if job else []
if v.get("규격단위"):
memo.append("규격 단위: " + v["규격단위"])
if memo:
row["비고"] = " · ".join(memo)
org = dict(v.get("원문83") or {})
org.update({f"{v['sec84']} {a}": b for a, b in (v.get("원문84") or {}).items()})
if org:
row["원문"] = org
out.append(row)
return out
def dumps_row(r):
j = lambda o: json.dumps(o, ensure_ascii=False)
head = f'{{ "열쇠": {j(r["열쇠"])}, "이름": {j(r["이름"])}, "규격": {j(r["규격"])},'
tail = [k for k in ("비고", "원문") if k in r]
body = [f' "단위": {j(r["단위"])},', f' "": {j(r[""])},']
body.append(f' "출처": {j(r["출처"])}' + ("," if tail else " }"))
for n, k in enumerate(tail):
body.append(f' "{k}": {j(r[k])}' + ("," if n < len(tail) - 1 else " }"))
return " " + head + "\n" + "\n".join(body)
# ---------------------------------------------------------------- 관계표 · 글 속 계산용 수
def relation_table(t, key):
R = t["rows"]
grp = [cell_join(c) for c in R[0]]
spec = [cell_join(c) for c in R[1]]
hose = [cell_join(c) for c in R[2]]
names, cur = [], ""
for j in range(2, len(R[0])):
if grp[j]:
cur = grp[j]
names.append(f"{cur} {spec[j]}".strip())
lines = []
for r in R[3:]:
size = num(r[0])
for j, nm in enumerate(names, start=2):
c = cell_join(r[j]) if j < len(r) else ""
if c in ("", "-"):
continue
m = re.fullmatch(r"(\d+)\((\d+)\)", c)
row = {"공기압축기 규격": size, "부수물": nm}
if hose[j].strip():
row["사용에어호스경"] = num(hose[j])
row["사용가능대수"] = int(m.group(1)) if m else num(c)
if m:
row["괄호값"] = int(m.group(2))
lines.append(row)
return {
"열쇠": f"8-3-6 ({key[-5:-1]}) 공기압축기·부수물 관계",
"이름": "공기압축기 규격별 부수물 사용가능 대수",
"그룹": "계수",
"기준": "",
"출처": SRC + "8-3-6 (5210)",
"조건": {"공기압축기 규격": "", "부수물": "고르기", "사용에어호스경": ""},
"값칸": {"사용가능대수": "", "괄호값": "대(수중 4m 이하)"},
"": lines,
"": t["notes"],
}
def text_tables(notes):
def tb(key, name, src, cond, vc, lines, note):
return {
"열쇠": key,
"이름": name,
"그룹": "계수",
"기준": "",
"출처": SRC + src,
"조건": cond,
"값칸": vc,
"": lines,
"": note,
}
n83 = notes["8-3-3 (2208)"]
n84 = notes["8-4-8"]
g = lambda L, s: next(x for x in L if x.startswith(s))
return [
tb(
"8-3-3 (2208) 주③ 권상용 와이어",
"타워크레인 권상용 와이어 소모",
"8-3-3 (2208) 주③",
{"구분": "고르기"},
{"": "", "단위": ""},
[
{"구분": "와이어 규격", "": "18㎜×120m", "단위": "1set"},
{"구분": "시간당 소모율", "": 0.003, "단위": "1set 기준"},
],
[g(n83, "")],
),
tb(
"8-3-3 (2210) 주③ 동력",
"건설용리프트 동력",
"8-3-3 (2210) 주③",
{"구분": "고르기"},
{"": "", "대수": ""},
[{"구분": "동력", "": 7.5, "대수": 2}],
[g(notes["8-3-3 (2210)"], "")],
),
tb(
"8-4 주①㉮ 연료 소비량 산정 기준",
"시간당 주연료 소비량 산정 기준",
"8-4 주①㉮",
{"구분": "고르기"},
{"최소": "%", "최대": "%", "적용식": ""},
[
{"구분": "엔진부하율", "최소": 70, "최대": 80},
{"구분": "실작업시간", "적용식": "50/60", "원문": "실작업시간은 50/60"},
],
[g(n84, "")],
),
tb(
"8-4 주⑤ 잡재료 비율",
"불도저·굴착기 부착기계 조합 시 잡재료 비율",
"8-4 주⑤",
{"구분": "고르기"},
{"": "주연료의 %"},
[{"구분": "불도저 및 굴착기에 리퍼·브레이커·부착용집게 조합", "": 16}],
[g(n84, "")],
),
]
def main():
t83, t84, t85 = load()
rows, special, notes, anom = {}, [], {}, []
build_83(t83, rows, special, notes, anom)
build_84(t84, rows, notes, anom)
build_849(t84, rows, notes, anom)
build_85(t85, rows, anom)
body = compose(rows)
tables = [relation_table(t, key) for _, t, key in special] + text_tables(notes)
jn = {k: note_lines(v) for k, v in notes.items() if note_lines(v)}
j = lambda o, ind=None: json.dumps(o, ensure_ascii=False, indent=ind)
parts = [
'{\n "그룹": "기계",\n "원문": "건설품셈",\n "": "2026",\n',
' "": ' + json.dumps(jn, ensure_ascii=False, indent=2).replace("\n", "\n ") + ",\n",
' "": [\n' + ",\n".join(dumps_row(r) for r in body) + "\n ],\n",
' "": '
+ json.dumps(tables, ensure_ascii=False, indent=2).replace("\n", "\n ")
+ "\n}\n",
]
text = "".join(parts)
json.loads(text)
return text, rows, body, anom, tables, jn
if __name__ == "__main__":
text, rows, body, anom, tables, jn = main()
for a in anom:
print("이상:", a)
print("기계", len(body), "", len(tables), "주 묶음", len(jn), "줄수", text.count("\n"))
if "--dry" not in sys.argv:
with open(OUT, "w", encoding="utf8", newline="\n") as f:
f.write(text)
print("", OUT)
@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""마스터 데이터 검사 도구 (`resources/master_data/_틀.md`).
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …]
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 …
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조
(4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
"""
from __future__ import annotations
import json
import re
import sys
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
MASTER = HERE.parent
ROOT = MASTER.parents[1]
COST = ROOT / "resources/knowledge/original/원가계산"
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import master_formula as mf # noqa: E402
from pum_md_tool import _NUM, md_lines # noqa: E402
BOOKS = (
"산림품셈",
"건설품셈",
"건설노임",
"제조노임",
"나라장터자재",
"오피넷유가",
"한국은행환율",
"조달청제비율",
"자체",
)
DIVISIONS = {
"공통": "01_공통부문",
"토목": "02_토목부문",
"건축": "03_건축부문",
"기계설비": "04_기계설비부문",
"유지관리": "05_유지관리부문",
}
_FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$")
_SOURCE = re.compile(
rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$"
)
_IDENT = re.compile(r"\d+-\d+(?:-\d+)*")
ROW_KEYS = {
"요소": ("열쇠", "이름", "", "출처"),
"": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "", ""),
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
}
def load(names: list[str] | None = None) -> dict[str, dict]:
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal."""
out = {}
for path in sorted(MASTER.glob("*.json")):
if path.name.startswith("_") or (
names and path.name not in names and path.stem not in names
):
continue
out[path.name] = json.loads(
path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal
)
return out
def master() -> mf.Master:
return mf.Master(load())
# ── (1) 틀 ────────────────────────────────────────────────────────────
def _source_ok(source) -> bool:
if isinstance(source, dict):
return all(_source_ok(v) for v in source.values())
return isinstance(source, str) and bool(_SOURCE.match(source))
def check_form(name: str, data: dict) -> list[str]:
out = []
m = _FILE.match(name)
if not m:
out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님")
elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)):
out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름")
for head in ("그룹", "원문", ""):
if head not in data:
out.append(f"{name} · 머리 「{head}」 없음")
table = data.get("그룹") in mf.TABLE_GROUPS
kind = "" if table else "로직" if data.get("그룹") == "로직" else "요소"
rows = data.get("" if table else "")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「{'' if table else ''}」 목록 없음"]
seen = set()
for row in rows:
key = str(row.get("열쇠"))
where = f"{name} · {key}"
if key in seen:
out.append(f"{where} · 열쇠 겹침")
seen.add(key)
for need in ROW_KEYS[kind]:
if need not in row:
out.append(f"{where} · 칸 「{need}」 없음")
if "출처" in row and not _source_ok(row["출처"]):
out.append(f"{where} · 출처 모양 「{row['출처']}")
if kind == "요소" and isinstance(row.get(""), dict):
for bundle in ("단위", "출처"):
if isinstance(row.get(bundle), dict) and set(row[bundle]) != set(row[""]):
out.append(f"{where} · 「{bundle}」 묶음 칸이 값 묶음과 다름")
if kind == "":
out += _check_table(where, row)
if kind == "로직" and "결과" not in row and "호표" not in row:
out.append(f"{where} · 「호표」 도 「결과」 도 없음")
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}
for kind in conds.values():
if kind not in ("", "고르기", "범위"):
out.append(f"{where} · 조건 종류 「{kind}")
if "범위" in conds.values() and "범위규칙" not in table:
out.append(f"{where} · 범위 조건에 「범위규칙」 없음")
for number, row in enumerate(table.get("", []), start=1):
extra = set(row) - allowed
if extra:
out.append(f"{where}{number} · 모르는 칸 {sorted(extra)}")
if not set(row) & set(cols):
out.append(f"{where}{number} · 값 칸 없음")
for cond, kind in conds.items():
if cond not in row:
continue
value = row[cond]
if kind == "범위" and not (isinstance(value, list) and len(value) == 2):
out.append(f"{where}{number} · 범위 조건 「{cond}」 이 [아래, 위] 아님")
if kind == "" and not isinstance(value, Decimal):
out.append(f"{where}{number} · 수 조건 「{cond}」 이 수 아님")
for col in cols:
value = row.get(col)
if isinstance(value, list) and len(value) != 2:
out.append(f"{where}{number} · 값 칸 「{col}」 범위가 [아래, 위] 아님")
return out
# ── (2) 본문 ──────────────────────────────────────────────────────────
def _key(parts: str) -> tuple[int, ...]:
return tuple(int(p) for p in parts.split("-"))
def section_of(source: str) -> tuple[Path | None, str, str]:
"""(본문 md, 절 번호, 절 글) — 못 찾으면 md 나 글이 빔."""
ident = _IDENT.search(source)
if not ident:
return None, "", ""
ident = ident.group()
parts = _key(ident)
if source.startswith("산림품셈"):
chapters = COST / "산림_표준품셈/본문"
elif source.startswith("건설품셈"):
chapters = COST / "건설공사_표준품셈/본문" / DIVISIONS.get(source.split()[1], "")
else:
return None, ident, ""
best = None
for md in chapters.glob(f"{parts[0]:02d}장_*/*.md"):
head = md.name.split("_", 1)[0]
if not _IDENT.fullmatch(head):
continue
own = _key(head)
if (
own[0]
and own == parts[: len(own)]
and (best is None or len(own) > len(_key(best.name.split("_", 1)[0])))
):
best = md
if best is None:
return None, ident, ""
lines = best.read_text(encoding="utf-8").split("\n")
heading = re.compile(rf"^(#+)\s*{re.escape(ident)}\.?(\s|$)")
for at, line in enumerate(lines):
m = heading.match(line)
if m:
level, end = len(m.group(1)), len(lines)
for later in range(at + 1, len(lines)):
h = re.match(r"^(#+)\s", lines[later])
if h and len(h.group(1)) <= level:
end = later
break
return best, ident, "\n".join(lines[at:end])
return best, ident, ""
def _canon(token: str) -> str:
return format(Decimal(token.replace(",", "")).normalize(), "f")
def numbers_in(text: str) -> set[str]:
return {_canon(t) for _, line in md_lines(text) for t in _NUM.findall(line)}
def _flat(value) -> set[str]:
if isinstance(value, Decimal):
return {_canon(str(abs(value)))}
if isinstance(value, str):
return {_canon(t) for t in _NUM.findall(value)}
if isinstance(value, list):
return set().union(*map(_flat, value)) if value else set()
if isinstance(value, dict):
return set().union(*map(_flat, value.values())) if value else set()
return set()
def check_body(files: dict[str, dict]) -> list[str]:
"""표마다 허구 · 절마다 결손."""
out, by_section = [], {}
for name, data in files.items():
for table in (data.get("") or []) if data.get("그룹") in mf.TABLE_GROUPS else []:
where = f"{name} · {table.get('열쇠')}"
md, ident, text = section_of(str(table.get("출처", "")))
if not text:
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}")
continue
mine = _flat(table.get("")) | _flat(table.get("기준")) | _flat(table.get(""))
fake = mine - numbers_in(text)
if fake:
out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}")
slot = by_section.setdefault((md, ident), [text, set(), []])
slot[1] |= mine
slot[2].append(where)
for (md, ident), (text, mine, tables) in by_section.items():
table_text = "\n".join(line for line in text.split("\n") if line.lstrip().startswith("|"))
lack = numbers_in(table_text) - mine
if lack:
out.append(
f"{md.name} {ident} · 결손 {len(lack)} {sorted(lack, key=Decimal)}{' · '.join(tables)}"
)
return out
# ── (3) 로직 ──────────────────────────────────────────────────────────
def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
out, graph = [], {}
for name, data in files.items():
if data.get("그룹") != "로직":
continue
for row in data.get("", []):
found, calls = mf.check_logic(whole, data.get("원문"), row)
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
# ── (4) 계산 ──────────────────────────────────────────────────────────
def show(result: dict) -> str:
if "결과" in result:
return f"결과 {result['결과']}"
rows = [
f" {r['이름']} | {r['단위']} | 수량 {r['수량']:.6f} | 단가 {r['단가']} | 금액 {r['금액']:.2f}"
for r in result[""]
]
rows += [f" {k} {result[k]:.2f}" for k in (*mf.COST_ITEMS, "")]
return "\n".join(rows)
def _value(text: str):
try:
return Decimal(text)
except ArithmeticError:
return text
def main(argv: list[str]) -> int:
mode = argv[0] if argv else "전부"
if mode == "계산":
given = dict(arg.split("=", 1) for arg in argv[2:])
print(show(mf.run(master(), argv[1], {k: _value(v) for k, v in given.items()})))
return 0
names = argv[1:] or None
files, whole = load(names), master()
report = {}
if mode in ("", "전부"):
report[""] = [x for name, data in files.items() for x in check_form(name, data)]
if mode in ("본문", "전부"):
report["본문"] = check_body(files)
if mode in ("로직", "전부"):
report["로직"] = check_logics(files, whole)
for title, found in report.items():
print(f"({title}) {len(found)}")
for line in found:
print(" " + line)
return 1 if any(report.values()) else 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,562 @@
# -*- coding: utf-8 -*-
"""마스터 로직 식 — 읽기 · 검사 · 시험 계산 (`_틀.md` 6~7장).
식은 틀 7장 범위만 읽음 — eval 쓰지 않음. 수는 Decimal(끝수 사사오입이 흔들리지 않게).
`check_master.py` 가 부름.
"""
from __future__ import annotations
import itertools
import re
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직")
TABLE_GROUPS = ("소요량", "계수")
COST_ITEMS = ("노무비", "재료비", "경비")
BUNDLE = (*COST_ITEMS, "")
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
_NAME = re.compile(r"[\w가-힣]+")
_NUMBER = re.compile(r"\d+(?:\.\d+)?")
_HOLE = re.compile(r"\{([^{}]+)\}")
_OPS = ("<=", ">=", "==", "!=", "<", ">")
class FormulaError(ValueError):
pass
# ── 읽기 ──────────────────────────────────────────────────────────────
class _Parser:
def __init__(self, text: str):
self.s, self.i = text, 0
def fail(self, why: str):
raise FormulaError(f"{why} — 「{self.s}{self.i}")
def ws(self):
while self.i < len(self.s) and self.s[self.i].isspace():
self.i += 1
def peek(self, word: str) -> bool:
self.ws()
return self.s.startswith(word, self.i)
def take(self, word: str) -> bool:
if self.peek(word):
self.i += len(word)
return True
return False
def need(self, word: str):
if not self.take(word):
self.fail(f"{word}」 없음")
def parse(self):
node = self.expr()
self.ws()
if self.i != len(self.s):
self.fail("식 끝에 남은 글자")
return node
def expr(self):
node = self.both()
while self.take("또는"):
node = ("bin", "또는", node, self.both())
return node
def both(self):
node = self.compare()
while self.take("그리고"):
node = ("bin", "그리고", node, self.compare())
return node
def compare(self):
node = self.add()
for op in _OPS:
if self.take(op):
return ("bin", op, node, self.add())
return node
def add(self):
node = self.mul()
while True:
if self.take("+"):
node = ("bin", "+", node, self.mul())
elif self.take("-"):
node = ("bin", "-", node, self.mul())
else:
return node
def mul(self):
node = self.unary()
while True:
if self.take("*"):
node = ("bin", "*", node, self.unary())
elif self.take("/"):
node = ("bin", "/", node, self.unary())
else:
return node
def unary(self):
if self.take("-"):
return ("neg", self.unary())
node = self.primary()
while self.take("."):
node = ("field", node, self.name())
return node
def name(self) -> str:
self.ws()
m = _NAME.match(self.s, self.i)
if not m:
self.fail("이름 없음")
self.i = m.end()
return m.group()
def ref(self) -> str:
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감)."""
start, depth = self.i, 0
while self.i < len(self.s):
ch = self.s[self.i]
if ch in "({":
depth += 1
elif ch in ")}":
if depth == 0:
break
depth -= 1
elif depth == 0 and ch == ",":
break
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
break
elif depth == 0 and re.match(r"\s[-+*/<>=!]", self.s[self.i : self.i + 2]):
break
self.i += 1
return self.s[start : self.i].strip()
def args(self) -> list[tuple[str, object]]:
out = []
while self.take(","):
key = self.name()
self.need("=")
out.append((key, self.expr()))
self.need(")")
return out
def primary(self):
self.ws()
if self.take("("):
node = self.expr()
self.need(")")
return node
if self.take("'"):
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return ("str", text)
m = _NUMBER.match(self.s, self.i)
if m:
self.i = m.end()
return ("num", Decimal(m.group()))
word = self.name()
if word == "찾기":
self.need("(")
head = self.name()
self.need(":")
return ("find", f"{head}:{self.ref()}", self.args())
if word == "로직":
self.need("(")
return ("logic", self.ref(), self.args())
if word in FUNCS:
self.need("(")
items = [self.expr()]
while self.take(","):
items.append(self.expr())
self.need(")")
return ("call", word, items)
if word in GROUPS and self.s.startswith(":", self.i):
self.i += 1
return ("ref", f"{word}:{self.ref()}")
return ("name", word)
def parse(text) -> tuple:
if isinstance(text, (int, float, Decimal)):
return ("num", Decimal(str(text)))
return _Parser(str(text)).parse()
def walk(node):
"""식 나무의 모든 마디."""
yield node
kind = node[0]
if kind == "bin":
yield from walk(node[2])
yield from walk(node[3])
elif kind in ("neg",):
yield from walk(node[1])
elif kind == "field":
yield from walk(node[1])
elif kind == "call":
for item in node[2]:
yield from walk(item)
elif kind in ("find", "logic"):
for _, item in node[2]:
yield from walk(item)
# ── 마스터 모음 ────────────────────────────────────────────────────────
class Master:
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal)."""
def __init__(self, files: dict[str, dict]):
self.index: dict[tuple[str, str], dict[str, dict]] = {}
for data in files.values():
rows = data.get("") if data.get("그룹") in TABLE_GROUPS else data.get("")
book = self.index.setdefault((data.get("그룹"), data.get("원문")), {})
for row in rows or []:
book.setdefault(str(row.get("열쇠")), row)
def get(self, ref: str) -> dict:
group, book, key = (ref.split(":", 2) + ["", ""])[:3]
row = self.index.get((group, book), {}).get(key)
if row is None:
raise FormulaError(f"없는 요소 「{ref}")
return row
def logic(self, ref: str) -> dict:
return self.get(f"로직:{ref}")
def fill(template: str, env: dict) -> str:
def one(m):
if m.group(1) not in env:
raise FormulaError(f"열쇠의 {{{m.group(1)}}} 값 없음")
value = env[m.group(1)]
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
return _HOLE.sub(one, template)
# ── 계산 ──────────────────────────────────────────────────────────────
def _round(x: Decimal, places, mode) -> Decimal:
return x.quantize(Decimal(1).scaleb(-int(places)), rounding=mode)
def _match(table: dict, row: dict, cond: str, want) -> bool:
if cond not in row: # 빈 조건 칸은 무엇이든 맞음
return True
have = row[cond]
if table.get("조건", {}).get(cond) == "범위":
low, high = have
if table.get("범위규칙", "초과·이하") != "초과·이하":
raise FormulaError(f"모르는 범위규칙 「{table.get('범위규칙')}")
return (low is None or want > low) and (high is None or want <= high)
return have == want
def find(table: dict, conds: dict) -> dict:
for cond in conds:
if cond not in table.get("조건", {}):
raise FormulaError(f"{table['열쇠']}」 에 없는 조건 「{cond}")
hits = [row for row in table[""] if all(_match(table, row, c, v) for c, v in conds.items())]
if len(hits) != 1:
raise FormulaError(f"{table['열쇠']}{conds} 맞는 줄 {len(hits)}")
return hits[0]
def evaluate(node, env: dict, master: Master, depth: int = 0):
kind = node[0]
if kind in ("num", "str"):
return node[1]
if kind == "name":
if node[1] not in env:
raise FormulaError(f"모르는 이름 「{node[1]}")
return env[node[1]]
if kind == "ref":
value = master.get(fill(node[1], env)).get("")
if isinstance(value, list):
raise FormulaError(f"{node[1]}」 값이 범위 — 입력으로 받아야 함")
return value
if kind == "field":
base = evaluate(node[1], env, master, depth)
if not isinstance(base, dict) or node[2] not in base:
raise FormulaError(f"칸 「{node[2]}」 없음")
value = base[node[2]]
if isinstance(value, list):
raise FormulaError(f"칸 「{node[2]}」 값이 범위 — 입력으로 받아야 함")
return value
if kind == "neg":
return -evaluate(node[1], env, master, depth)
if kind == "bin":
op, a = node[1], evaluate(node[2], env, master, depth)
if op == "그리고":
return a and evaluate(node[3], env, master, depth)
if op == "또는":
return a or evaluate(node[3], env, master, depth)
b = evaluate(node[3], env, master, depth)
return {
"+": lambda: a + b,
"-": lambda: a - b,
"*": lambda: a * b,
"/": lambda: a / b,
"<": lambda: a < b,
"<=": lambda: a <= b,
">": lambda: a > b,
">=": lambda: a >= b,
"==": lambda: a == b,
"!=": lambda: a != b,
}[op]()
if kind == "call":
name, items = node[1], node[2]
if name == "만약":
test = evaluate(items[0], env, master, depth)
return evaluate(items[1] if test else items[2], env, master, depth)
values = [evaluate(x, env, master, depth) for x in items]
if name in ("최소", "최대"):
return (min if name == "최소" else max)(values)
mode = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}[name]
return _round(values[0], values[1] if len(values) > 1 else 0, mode)
if kind == "find":
table = master.get(fill(node[1], env))
return find(table, {k: evaluate(v, env, master, depth) for k, v in node[2]})
if kind == "logic":
if depth > 20:
raise FormulaError("로직 부르기가 너무 깊음 — 돌고 도는 참조")
given = {k: evaluate(v, env, master, depth) for k, v in node[2]}
result = run(master, fill(node[1], env), given, depth + 1)
return result["결과"] if "결과" in result else {k: result[k] for k in BUNDLE}
raise FormulaError(f"모르는 마디 {kind}")
def _inputs(row: dict, given: dict) -> dict:
env = {}
for spec in row.get("입력", []):
name = spec["이름"]
if name not in given:
raise FormulaError(f"{row['열쇠']}」 입력 「{name}」 없음")
value = given[name]
if isinstance(value, (int, float)):
value = Decimal(str(value))
if "고르기" in spec and value not in spec["고르기"]:
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
if "범위" in spec:
low, high = spec["범위"]
if not low <= value <= high:
raise FormulaError(f"입력 「{name}」={value} 범위 [{low}, {high}] 밖")
env[name] = value
unknown = set(given) - set(env)
if unknown:
raise FormulaError(f"{row['열쇠']}」 모르는 입력 {sorted(unknown)}")
return env
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
row = master.logic(ref)
env = _inputs(row, given)
for step in row.get("중간", []):
env[step["이름"]] = evaluate(parse(step[""]), env, master, depth)
if "결과" in row:
return {"결과": evaluate(parse(row["결과"][""]), env, master, depth), "중간": env}
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for item in row.get("호표", []):
qty = evaluate(parse(item["수량"]), env, master, depth)
if item["종류"] == "로직":
bundle = evaluate(parse(item["요소"]), env, master, depth)
price = bundle[""]
split = (
{k: qty * bundle[k] for k in COST_ITEMS}
if "비목" not in item
else {item["비목"]: qty * price}
)
else:
price = master.get(fill(item["요소"], env))[""]
split = {item["비목"]: qty * price}
for k, v in split.items():
sums[k] += v
lines.append(
{
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": qty,
"단가": price,
"금액": qty * price,
"비목": split,
}
)
for extra in row.get("덧줄", []):
env.update(sums)
value = evaluate(parse(extra[""]), env, master, depth)
env[extra["이름"]] = value
sums[extra["비목"]] += value
lines.append(
{
"이름": extra["이름"],
"단위": "",
"수량": Decimal(1),
"단가": value,
"금액": value,
"비목": {extra["비목"]: value},
}
)
return {"": lines, **sums, "": sum(sums.values()), "중간": env}
# ── 검사 ──────────────────────────────────────────────────────────────
def _choices(row: dict, name: str, master: Master):
"""이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None."""
for spec in row.get("입력", []):
if spec["이름"] == name:
return spec.get("고르기")
for step in row.get("중간", []):
if step["이름"] == name:
node = parse(step[""])
if node[0] == "field" and node[1][0] == "ref":
out = set()
for ref in _expand(row, node[1][1], master) or []:
try:
out.add(str(master.get(ref)[""][node[2]]))
except (FormulaError, KeyError, TypeError):
pass
return sorted(out)
return None
def _expand(row: dict, template: str, master: Master):
"""열쇠 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
holes = _HOLE.findall(template)
options = [_choices(row, h, master) for h in holes]
if any(o is None for o in options):
return None
return [
fill(template, {h: v for h, v in zip(holes, combo)})
for combo in itertools.product(*options)
]
def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set):
for part in walk(node):
kind = part[0]
if kind == "name" and part[1] not in known:
out.append(f"{where} · 모르는 이름 「{part[1]}")
if kind in ("ref", "find", "logic"):
for hole in _HOLE.findall(part[1]):
if hole not in known:
out.append(f"{where} · 열쇠 {{{hole}}} 가 입력·중간 아님")
if kind == "ref":
refs = _expand(row, part[1], master) or []
for ref in refs:
try:
master.get(ref)
except FormulaError as e:
out.append(f"{where} · {e}")
if kind == "field" and part[1][0] == "ref":
for ref in _expand(row, part[1][1], master) or []:
try:
value = master.get(ref).get("")
except FormulaError:
continue
if not isinstance(value, dict) or part[2] not in value:
out.append(f"{where} · 「{ref}」 에 칸 「{part[2]}」 없음")
if kind == "find":
if part[1].split(":")[0] not in TABLE_GROUPS:
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
continue
try:
table = master.get(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
for cond, _ in part[2]:
if cond not in table.get("조건", {}):
out.append(f"{where} · 「{part[1]}」 에 조건 「{cond}」 없음")
if kind == "field" and part[1][0] == "find":
try:
table = master.get(part[1][1])
except FormulaError:
continue
if part[2] not in table.get("값칸", {}):
out.append(f"{where} · 「{part[1][1]}」 에 값 칸 「{part[2]}」 없음")
if kind == "logic":
calls.add(part[1])
try:
target = master.logic(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
names = {s["이름"] for s in target.get("입력", [])}
given = {k for k, _ in part[2]}
for extra in sorted(given - names):
out.append(f"{where} · 「{part[1]}」 에 입력 「{extra}」 없음")
for lack in sorted(names - given):
out.append(f"{where} · 「{part[1]}」 입력 「{lack}」 안 줌")
if kind == "field" and part[1][0] == "logic":
try:
target = master.logic(part[1][1])
except FormulaError:
continue
if "결과" in target:
out.append(
f"{where} · 돈 아닌 로직 「{part[1][1]}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
)
elif part[2] not in BUNDLE:
out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함")
def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]:
"""(문제 목록, 부르는 로직 열쇠) — 변수 · 찾기 조건 · 로직 입력."""
out, calls, key = [], set(), f"{book}:{row.get('열쇠')}"
known = {s["이름"] for s in row.get("입력", [])}
def one(text, where, names):
try:
_check_node(row, parse(text), names, master, where, out, calls)
except FormulaError as e:
out.append(f"{where} · 식 못 읽음: {e}")
for step in row.get("중간", []):
one(step[""], f"{key} 중간 {step['이름']}", known)
known = known | {step["이름"]}
if "결과" in row:
one(row["결과"][""], f"{key} 결과", known)
for item in row.get("호표", []):
where = f"{key} 호표 {item.get('이름', '')}"
one(item["수량"], where + " 수량", known)
if item.get("종류") not in ("인력", "재료", "기계", "로직"):
out.append(f"{where} · 종류 「{item.get('종류')}")
if item.get("비목") not in (*COST_ITEMS, None) or (
item.get("종류") != "로직" and "비목" not in item
):
out.append(f"{where} · 비목 「{item.get('비목')}")
one(item["요소"], where + " 요소", known)
if item.get("종류") != "로직" and not str(item["요소"]).startswith(f"{item.get('종류')}:"):
out.append(f"{where} · 요소 그룹이 종류와 다름")
names = known | set(COST_ITEMS)
for extra in row.get("덧줄", []):
one(extra[""], f"{key} 덧줄 {extra['이름']}", names)
names = names | {extra["이름"]}
if extra.get("비목") not in COST_ITEMS:
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}")
return out, calls
def find_loops(graph: dict[str, set]) -> list[list[str]]:
"""로직 부르기의 돌고 도는 길."""
loops, state = [], {}
def visit(node, path):
state[node] = 1
for nxt in sorted(graph.get(node, ())):
if state.get(nxt) == 1:
loops.append(path[path.index(nxt) :] + [nxt])
elif nxt not in state:
visit(nxt, path + [nxt])
state[node] = 2
for node in sorted(graph):
if node not in state:
visit(node, [node])
return loops