feat(801-5): M01 로직 단위 글 건설품셈 원문 꼴 — 시간당 ㎥ · ㎡ → hr · 초 → sec · mm · cm · kg → ㎜ · ㎝ · ㎏ · TON · t → ton · km/hr → ㎞/hr

- 대응표 master_unit_text.RULES(규칙마다 원문 근거 절) · 빌더 build_단위표기_801_5(--check · --list · 셈 지문 전후 같을 때만 씀)
- M01 로직 53 개 · 칸 79 개 글만 바꿈(변수 입력 · 중간 단위 · 결과단위) · 계수 · 소요량 표 값칸 · 호표 줄 단위 · 원문이 두 꼴을 다 쓰는 단위(㎞ · km · 분 · min)는 안 건드림
- M02 사본 새로 씀 도구 M02_Copy_Units(M02_Copy_Symbols 의 fill 만 바꿔 끼움) — 사본 16 문서 28 칸 · 금액 전후 같음
- 검토 목록 docs/raw/요구_1104/_목록_801-5_단위표기.xlsx
- 옛 시험 기대 고침: test_796_3 굴착기 cm 단위 초 → sec(원문 표 머리 「싸이클시간(Sec)」)
- 새 test_801_5_units 6

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 18:00:30 +09:00
co-authored by Claude Sonnet 5.5
parent 4baf69803e
commit d7e6fe5cf8
29 changed files with 522 additions and 98 deletions
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""M01 로직 단위 글을 건설품셈 원문 꼴로 (PLAN 801-5 · 요구 663-2) — 글만 바꿈 · 셈 지문 그대로.
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/build_단위표기_801_5.py [--check | --list 파일.xlsx]
- 대상 = M01 로직 줄의 입력 · 중간 변수 `단위` · `결과단위` — 규칙 · 근거 절 = `master_unit_text.RULES`.
- 계수 · 소요량 표 값칸 · 호표 줄 단위 · 사용자가 이미 원문 꼴로 쓴 칸은 안 건드림(이미 같으면 그대로 — 두 번 돌려도 같음).
- 쓰기 전 지문 대조 = 줄마다 엔진 셈 · 끝수 지문이 전후 같을 때만 씀(다르면 멈춤).
- `--check` = 쓰지 않고 쓸 것이 남았는지만(남았으면 종료 코드 1) · `--list` = 검토 목록 xlsx 만 씀(마스터 안 씀).
"""
from __future__ import annotations
import copy
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[2]))
import master_auto as ma # noqa: E402
import master_cut as mcut # noqa: E402
import master_formula as mf # noqa: E402
import master_unit_text as ut # noqa: E402
from M01_MasterData import M01_MasterData_Store as st # noqa: E402
def plan(files: dict[str, dict]) -> tuple[list[dict], list[dict], dict[str, dict]]:
"""(저장 묶음, 목록 줄들, 새 files) — 읽기 전용."""
batch, rows, new_files = [], [], {}
for name, data in files.items():
if data.get("그룹") != "로직":
new_files[name] = data
continue
changes, new_data = [], copy.deepcopy(data)
for row in new_data.get("줄", []):
hit = False
for kind in ("입력", "중간"):
for spec in row.get(kind) or []:
now = ut.canon(spec.get("단위"))
if now != (spec.get("단위") or ""):
rows.append(_item(row, kind, spec["이름"], spec["단위"], now))
spec["단위"], hit = now, True
if row.get("결과단위"):
now = ut.canon(row["결과단위"])
if now != row["결과단위"]:
rows.append(_item(row, "결과단위", "", row["결과단위"], now))
row["결과단위"], hit = now, True
if hit:
changes.append({"op": "edit", "key": row["키"], "row": row})
new_files[name] = new_data
if changes:
batch.append({"file": name, "version": st.read(name)[1], "changes": changes})
return batch, rows, new_files
def _item(row: dict, kind: str, var: str, before: str, after: str) -> dict:
return {
"로직": row["키"],
"이름": row.get("이름", ""),
"칸": kind,
"변수": var,
"지금": before,
"원문 꼴": after,
"규칙": ut.rule_of(before),
}
def fingerprint(whole: mf.Master, row: dict) -> str:
"""줄 하나의 엔진 셈 · 끝수 지문 — 멈춤도 지문."""
try:
key = str(row["키"])
raw = mf.run(
whole,
key,
{
**ma.values(whole, row),
**{k: v for k, v in ma.defaults(whole, row).items() if v is not None},
},
)
text = json.dumps(raw, default=str, sort_keys=True, ensure_ascii=False)
text += json.dumps(
mcut.engine(whole, key, raw), default=str, sort_keys=True, ensure_ascii=False
)
except Exception as e: # noqa: BLE001
text = f"ERR {type(e).__name__} {e}"
return hashlib.sha256(text.encode()).hexdigest()[:16]
def differing(old: dict[str, dict], new: dict[str, dict]) -> list[str]:
"""지문이 바뀐 로직 키들 — 고친 줄만 대조."""
before, after = mf.Master(old), mf.Master(new)
out = []
for name, data in new.items():
if data.get("그룹") != "로직":
continue
was = {r["키"]: r for r in old[name]["줄"]}
for row in data["줄"]:
if row != was[row["키"]] and fingerprint(before, was[row["키"]]) != fingerprint(
after, row
):
out.append(row["키"])
return out
def write_list(rows: list[dict], path: Path) -> None:
import openpyxl
book = openpyxl.Workbook()
sheet = book.active
sheet.title = "바뀜"
cols = ["로직", "이름", "칸", "변수", "지금", "원문 꼴", "규칙"]
sheet.append(cols)
for r in rows:
sheet.append([r[c] for c in cols])
rule = book.create_sheet("대응표")
rule.append(["규칙", "바꿀 글", "근거 절(건설공사 표준품셈 본문 아래)", "바뀐 칸 수"])
for name, _, to, _, files in ut.RULES:
rule.append([name, to, " · ".join(files), sum(name in r["규칙"] for r in rows)])
path.parent.mkdir(parents=True, exist_ok=True)
book.save(path)
def main() -> None:
files, _ = st.loaded()
batch, rows, new_files = plan(files)
print(f"쓸 것 — 로직 {len({r['로직'] for r in rows})} · 칸 {len(rows)}")
if "--list" in sys.argv:
write_list(rows, Path(sys.argv[sys.argv.index("--list") + 1]))
return
if "--check" in sys.argv:
raise SystemExit(1 if batch else 0)
if batch:
bad = differing(files, new_files)
if bad:
raise SystemExit(f"셈 지문이 달라지는 로직 {len(bad)} — 멈춤: {bad[:10]}")
print(st.save(batch))
if __name__ == "__main__":
main()
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""M01 로직 단위 글 → 건설품셈 원문 꼴(PLAN 801-5 · 요구 663-2) — 글만 바꿈 · 셈 · 환산 안 바꿈.
`canon(단위 글)` = 아래 `RULES` 를 차례로 적용(이미 원문 꼴이면 그대로 · 두 번 돌려도 같음).
`RULES` 한 줄 = (이름, 지금 글 정규식, 바꿀 글, 원문 글, 근거 절들) — 근거 절 = `EVIDENCE_ROOT` 아래 파일 ·
그 파일 안에 원문 글이 실제로 있어야 함(시험 `test_801_5_units`). 원문이 두 꼴을 다 쓰는 단위(㎞ · km · 분 · min)는 안 건드림.
단위 칸 중 원문 표를 그대로 옮긴 계수 · 소요량 표 값칸 · 호표 줄 단위(요소 단위)는 대상 아님.
"""
from __future__ import annotations
import re
from pathlib import Path
EVIDENCE_ROOT = Path("resources/knowledge/original/원가계산/건설공사_표준품셈/본문")
_A = r"(?<![A-Za-z])" # 영문 낱말 한가운데는 안 건드림
_Z = r"(?![A-Za-z])"
# (이름, 지금 글 정규식, 바꿀 글, 원문 글, 근거 절들)
RULES: list[tuple[str, str, str, str, list[str]]] = [
(
"시간당 ㎥ · ㎡ → hr",
r"(?<=[㎥㎡])/시간(?![가-힣])",
"/hr",
"/hr",
[
"01_공통부문/제08장_건설기계/8-03-05_[40]콘크리트기계.md", # ㎥/hr
"02_토목부문/제07장_항만공사/7-02_사석.md", # ㎡/hr
],
),
(
"사이클 시간 초 → sec",
r"(?<![가-힣])초(?![가-힣])",
"sec",
"sec",
[
"01_공통부문/제05장_기초공사/5-02_연약지반처리.md", # cm : 1회 싸이클 타임(sec)
"01_공통부문/제08장_건설기계/8-02-02_리퍼(유압식).md",
],
),
(
"mm → ㎜",
rf"{_A}mm{_Z}",
"㎜",
"㎜",
["04_기계설비부문/제13장_플랜트설비공사/13-01_플랜트_배관.md"],
),
(
"cm → ㎝",
rf"{_A}cm{_Z}",
"㎝",
"㎝",
["01_공통부문/제08장_건설기계/8-03-06_[50]골재생산기계_등.md"],
),
("kg → ㎏", rf"{_A}kg{_Z}", "㎏", "㎏", ["01_공통부문/제01장_적용기준/1-02_설계_및_수량.md"]),
(
"km/hr → ㎞/hr",
rf"{_A}km/hr",
"㎞/hr",
"㎞/hr",
["01_공통부문/제08장_건설기계/8-02-09_롤러.md"],
),
(
"TON · t → ton",
rf"{_A}(?:TON|t){_Z}",
"ton",
"ton",
["01_공통부문/제08장_건설기계/8-02-12_크러셔.md"],
),
]
_COMPILED = [(name, re.compile(pat), to, tok, files) for name, pat, to, tok, files in RULES]
def canon(unit) -> str:
"""원문 꼴 단위 글 — 규칙에 안 걸리면 그대로."""
text = str(unit or "")
for _, pat, to, _, _ in _COMPILED:
text = pat.sub(to, text)
return text
def rule_of(before: str) -> str:
"""바뀐 단위가 걸린 규칙 이름들(검토 목록 · 대응표용)."""
out, text = [], str(before or "")
for name, pat, to, _, _ in _COMPILED:
if pat.search(text):
out.append(name)
text = pat.sub(to, text)
return " · ".join(out)