feat(802-2): 옛 M01 로직 1,519 → 수식 문서 변환 빌더 · 금액 전수 대조 · 같은 것만 저장 · 다름 목록

- 빌더 build_수식문서_802_2.py(다시 돌리면 같음 · --check) — 기호 = 수식 탭 기호(800-1 · 800-5) · 기본 옵션 = 수식 탭 기본값
- 대조 = 새 계산기 대 옛 엔진 노 · 재 · 경 · 계(결과) 원 단위: 기본 옵션 1,519/1,519 같음 · 옵션 표본 18,716 회(로직마다 고르기 갈래 모두) 중 같음 18,715 · 다름 1 · 둘 다 못 셈 134(옵션 바꾸면 표에 맞는 줄 없는 기계 작업량)
- 다름 1 = GC000100 지역=경북 재료비 1 원 — 식 속 단가산출 부름 값이 끝수 뒤(옛 엔진 = 끝수 전 날 값) · 보정 없음
- 저장 = 같은 것만 1,518 문서 — resources/master_data/수식문서/수식_<로직 파일 꼬리>.json 58 파일(옛 로직 JSON 안 씀)
- 목록 = docs/raw/요구_1104/_목록_802-2_변환대조.xlsx(다름 · 요약 · 둘 다 못 셈 · 변환 알림)
- 시험 test_802_2_convert 9(--check · 전수 기본 · 옵션 표본 모든 갈래 · 목록 · 저장본 다시 셈 · 791 실무 견본 넷 = 엑셀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 18:41:50 +09:00
co-authored by Claude Opus 5.5
parent f7038cc280
commit 154a044427
61 changed files with 56025 additions and 0 deletions
@@ -0,0 +1,320 @@
# -*- coding: utf-8 -*-
"""옛 M01 로직 → 수식 문서 빌더 + 금액 전수 대조 (PLAN 802-2 · 요구서_664 1 장 3).
python resources/master_data/scripts/build_수식문서_802_2.py # 변환 · 대조 · 저장
· 목록
python resources/master_data/scripts/build_수식문서_802_2.py --check # 다시 돌려 저장본
· 목록과 같은지
- 변환 = `master_doc_convert.convert`(로직마다 · 기본 옵션 = 수식 탭 기본값)
· 셈 = `master_doc_calc.calc`.
- 대조 = 새 계산기 대 옛 엔진(`master_formula.run` → `master_cut.engine`) 노 · 재 · 경
· 계(돈 아닌 로직 = 결과)
원 단위 — 기본 옵션 전부 + 옵션 표본(로직마다 고르기 입력의 다른 갈래 하나씩 · 나머지 기본).
둘 다 못 셈 = 같음(따로 셈). 보정 없음 — 다르면 목록에 까닭.
- 저장 = 같은 것만(요구서_664 1 장 3) — 모든 대조가 같고 부르는 단가산출도 저장되는 문서 ·
`resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json`(로직 파일마다
· 옛 로직 JSON 은 안 씀).
- 목록 = `docs/raw/요구_1104/_목록_802-2_변환대조.xlsx`(로직 · 옵션 · 옛 · 새 · 차이 · 까닭 + 요약).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
for p in (str(ROOT), str(Path(__file__).resolve().parent)):
if p not in sys.path:
sys.path.insert(0, p)
import master_cut as mcut # noqa: E402 — 옛 엔진(대조 쪽만)
import master_doc as md # noqa: E402
import master_doc_calc as mc # noqa: E402
import master_doc_convert as cv # noqa: E402
import master_doc_link as ml # noqa: E402
import master_equation as meq # noqa: E402
import master_formula as mf # noqa: E402
LIST = ROOT / "docs/raw/요구_1104/_목록_802-2_변환대조.xlsx"
KEYS = ("노무비", "재료비", "경비", "계")
HEAD = ("로직", "이름", "옵션", "옛", "새", "차이", "까닭")
RAW_SUB = (
"식 속 단가산출 부름 = 끝수 뒤 값"
"(옛 엔진 = 끝수 전 날 값 · `master_formula.run` 결과를 식에 씀)"
)
def rows_of(files: dict) -> list[tuple[str, dict]]:
"""(옛 로직 파일 이름, 로직 줄) — 파일 · 줄 차례."""
return [
(name, row)
for name, data in sorted(files.items())
if data.get("그룹") == "로직"
for row in data.get("줄") or []
]
def old_answer(whole, row: dict, given: dict):
try:
got = mcut.engine(whole, row["키"], mf.run(whole, row["키"], mf.fill_empty(row, given)))
except Exception as e: # 옛 엔진 멈춤도 대조 값
return f"못 셈 — {e}"
return {"결과": got["결과"]} if "결과" in got else {k: got.get(k) for k in KEYS}
def new_answer(links, doc: dict, given: dict | None = None):
got = mc.calc(doc, links, given)
if not got["셈됨"]:
return "못 셈 — " + (" · ".join(m["글"] for m in got["표지"]) or got.get("까닭") or "")
return {"결과": got["결과"]} if doc.get("결과") else {k: got.get(k) for k in KEYS}
def _text(v) -> str:
if isinstance(v, str):
return v
return " · ".join(f"{k} {mc.plain_text(x) if x is not None else '—'}" for k, x in v.items())
def _gap(a, b) -> str:
if isinstance(a, str) or isinstance(b, str):
return "한쪽 못 셈"
return " · ".join(
f"{k} {mc.plain_text((b.get(k) or 0) - (a.get(k) or 0))}" for k in a if a.get(k) != b.get(k)
)
def _uses_raw_sub(doc: dict) -> bool:
"""식 줄이 단가산출 부름 기호를 씀(금액 줄 단가가 아닌 자리)."""
subs = {
md.var_id(v)
for v in doc.get("변수") or []
if (v.get("연결") or {}).get("갈래") == "단가산출"
}
for line in doc.get("식") or []:
try:
if subs & set(md.names(md.parse(line.get("식")))):
return True
except md.DocError:
pass
return False
def why(doc: dict, old, new) -> str:
"""다름 까닭 — 문서 꼴에서 바로 보이는 것만 · 아니면 「까닭 모름」(시험이 막음)."""
if isinstance(old, str) and not isinstance(new, str):
return f"옛 엔진 멈춤 · 새 계산기 셈 — {old}"
if isinstance(new, str) and not isinstance(old, str):
return f"새 계산기 못 셈 — {new}"
if _uses_raw_sub(doc):
return RAW_SUB
return "까닭 모름"
def build(log=print) -> dict:
"""변환 · 대조 전부 — {문서, 알림, 파일, 대조, 저장, 요약}."""
from M01_MasterData import M01_MasterData_Store as m01
files, whole = m01.loaded()
items = rows_of(files)
docs, notes, by_file = {}, {}, {}
for name, row in items:
key = str(row["키"])
docs[key], notes[key] = cv.convert(whole, row)
by_file.setdefault(name, []).append(key)
log(f"변환 {len(docs)}")
links = ml.Links(whole, docs)
runs, both_out = [], 0
for _, row in items:
key, base = str(row["키"]), meq.given_of(whole, row)
cases = [("", base, None)] # (옵션 글, 옛 엔진 입력, 새 문서에 넘길 바뀐 입력)
for spec in row.get("입력") or []:
for opt in spec.get("고르기") or []:
if str(opt) != str(base.get(spec["이름"])):
one = {spec["이름"]: opt}
cases.append((f"{spec['이름']}={opt}", {**base, **one}, one))
for label, given, changed in cases:
old = old_answer(whole, row, given)
new = new_answer(links, docs[key], changed)
same = old == new or (isinstance(old, str) and isinstance(new, str))
both_out += isinstance(old, str) and isinstance(new, str)
runs.append(
{
"로직": key,
"이름": row.get("이름") or "",
"옵션": label,
"옛": old,
"새": new,
"같음": same,
}
)
for one in runs:
if not one["같음"]:
one["까닭"] = why(docs[one["로직"]], one["옛"], one["새"])
keep = _keep(docs, runs)
for one in runs: # 같아도 못 넣는 것 — 부르는 단가산출이 빠짐
if one["같음"] and one["로직"] not in keep and not one["옵션"]:
one["빠짐"] = "부르는 단가산출이 빠짐 — " + " · ".join(
sorted(_calls(docs[one["로직"]]) - keep)
)
summary = {
"로직": len(docs),
"기본 같음": sum(1 for x in runs if not x["옵션"] and x["같음"]),
"기본 다름": sum(1 for x in runs if not x["옵션"] and not x["같음"]),
"옵션 표본": sum(1 for x in runs if x["옵션"]),
"옵션 같음": sum(1 for x in runs if x["옵션"] and x["같음"]),
"옵션 다름": sum(1 for x in runs if x["옵션"] and not x["같음"]),
"둘 다 못 셈": both_out,
"저장": len(keep),
"변환 알림": sum(1 for v in notes.values() if v),
}
log(" · ".join(f"{k} {v}" for k, v in summary.items()))
return {
"문서": docs,
"알림": notes,
"파일": by_file,
"대조": runs,
"저장": keep,
"요약": summary,
}
def _calls(doc: dict) -> set[str]:
out = set()
for v in doc.get("변수") or []:
link = v.get("연결") or {}
if link.get("갈래") == "단가산출" and "{" not in str(link.get("키")):
out.add(str(link["키"]))
return out
def _keep(docs: dict, runs: list[dict]) -> set[str]:
"""저장할 문서 — 모든 대조 같음 + 부르는 단가산출도 저장(닫힘).
키 구멍 부름은 그 로직 대조로 판정."""
keep = set(docs) - {x["로직"] for x in runs if not x["같음"]}
while True:
drop = {k for k in keep if not _calls(docs[k]) <= keep}
if not drop:
return keep
keep -= drop
def files_of(result: dict) -> dict[str, str]:
"""저장 파일 이름 → 글."""
out = {}
for name, keys in result["파일"].items():
kept = [result["문서"][k] for k in keys if k in result["저장"]]
body = {"그룹": "수식문서", "로직파일": name, "줄": kept}
out[ml.file_of(name)] = ml.dump(body)
return out
def table_rows(result: dict) -> list[list[str]]:
rows = []
for one in result["대조"]:
if one["같음"] and not one.get("빠짐"):
continue
rows.append(
[
one["로직"],
one["이름"],
one["옵션"] or "기본",
_text(one["옛"]),
_text(one["새"]),
"" if one["같음"] else _gap(one["옛"], one["새"]),
one.get("까닭") or one.get("빠짐") or "",
]
)
return rows
def write_list(result: dict, path: Path = LIST) -> None:
from openpyxl import Workbook
book = Workbook()
sheet = book.active
sheet.title = "다름"
sheet.append(list(HEAD))
for row in table_rows(result):
sheet.append(row)
for col, width in zip("ABCDEFG", (11, 30, 26, 46, 46, 22, 70)):
sheet.column_dimensions[col].width = width
for title, rows in list(_sheets(result).items())[1:]:
sheet = book.create_sheet(title)
for row in rows:
sheet.append(row)
path.parent.mkdir(parents=True, exist_ok=True)
book.save(path)
def read_list(path: Path = LIST) -> dict[str, list[list[str]]]:
from openpyxl import load_workbook
book = load_workbook(path, read_only=True)
out = {
s.title: [
["" if c is None else str(c) for c in row] for row in s.iter_rows(values_only=True)
]
for s in book.worksheets
}
book.close()
return out
def _sheets(result: dict) -> dict[str, list[list[str]]]:
notes = [["로직", "알림"]] + [[k, t] for k, v in sorted(result["알림"].items()) for t in v]
both = [["로직", "이름", "옵션", "옛", "새"]] + [
[x["로직"], x["이름"], x["옵션"] or "기본", x["옛"], x["새"]]
for x in result["대조"]
if isinstance(x["옛"], str) and isinstance(x["새"], str)
]
return {
"다름": [list(HEAD), *table_rows(result)],
"요약": [[k, str(v)] for k, v in result["요약"].items()],
"둘 다 못 셈": both,
"변환 알림": notes,
}
def check(result: dict) -> list[str]:
"""저장본 · 목록이 지금 빌드와 같은지 — 다른 것 목록."""
out = []
want = files_of(result)
have = {p.name for p in ml.FOLDER.glob("수식_*.json")}
for name in sorted(have - set(want)):
out.append(f"남은 파일 {name}")
for name, text in sorted(want.items()):
path = ml.FOLDER / name
if not path.exists() or path.read_text(encoding="utf-8") != text:
out.append(f"다른 파일 {name}")
if not LIST.exists() or read_list() != _sheets(result):
out.append(f"다른 목록 {LIST.name}")
return out
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--check", action="store_true", help="저장본 · 목록과 같은지만")
args = parser.parse_args()
result = build()
if args.check:
bad = check(result)
for line in bad:
print(line)
print("같음" if not bad else f"다름 {len(bad)}")
return 1 if bad else 0
ml.FOLDER.mkdir(parents=True, exist_ok=True)
want = files_of(result)
for path in ml.FOLDER.glob("수식_*.json"):
if path.name not in want:
path.unlink()
for name, text in want.items():
(ml.FOLDER / name).write_text(text, encoding="utf-8", newline="\n")
write_list(result)
print(f"저장 {len(want)} 파일 · 목록 {LIST}")
return 0
if __name__ == "__main__":
raise SystemExit(main())