Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3

This commit is contained in:
2026-11-07 18:42:02 +09:00
37 changed files with 816 additions and 183 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()
@@ -28,6 +28,7 @@ from pathlib import Path
import master_auto as ma
import master_cut as mcut
import master_equation_group as grp
import master_equation_view as view
import master_formula as mf
import master_material as mm
@@ -401,36 +402,30 @@ def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict:
def subtotals(logic: dict, cut: dict, total: dict) -> dict:
"""호표 줄 → 소계(799-1 · 보이기만) — 하위 로직 줄 = 블록마다 · 그 밖 자원 · 덧줄은 한 줄씩 + 끝수.
"""호표 줄 → 소계 묶음(799-1 · 801-3 · 보이기만) — 부른 단가산출 = 묶음 하나씩 · 직접 줄 = 한 묶음 + 끝수.
값 = `master_cut.engine` 이 버린 줄 비목(0.1 원) 그대로 · 합계 = 엔진 비목 합(1 원 버림) ·
끝수 = 합계 − 줄 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함).
끝수 = 합계 − 묶음 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함). 묶음 꼴 = `master_equation_group`.
"""
items, adds = logic.get("호표") or [], logic.get("덧줄") or []
items = logic.get("호표") or []
skip = mcut.ms.skipped(logic) if mcut.ms.uses(logic) else set()
rows: list[dict] = []
pool: dict[str, dict] = {}
for n, line in enumerate(cut.get("줄") or []):
if n in skip:
continue
split = {k: line.get("비목", {}).get(k) or Decimal(0) for k in mf.COST_ITEMS}
if n < len(items) and items[n].get("종류") == "로직":
rows.append({"갈래": "블록", "이름": str(items[n].get("이름") or ""), **split})
continue
kind = "덧줄" if n >= len(items) else "자원"
one = pool.setdefault(
kind, {"갈래": kind, "이름": kind, **dict.fromkeys(mf.COST_ITEMS, Decimal(0))}
item = items[n] if n < len(items) else {}
kind = "블록" if item.get("종류") == "로직" else "자원" if item else "덧줄"
rows.append(
{"갈래": kind, "이름": str(item.get("이름") or ""), "종류": item.get("종류"), **split}
| {"계": sum(split.values(), Decimal(0))}
)
for k in mf.COST_ITEMS:
one[k] += split[k]
rows += [pool[k] for k in ("자원", "덧줄") if k in pool]
for one in rows:
one["계"] = sum((one[k] for k in mf.COST_ITEMS), Decimal(0))
got = grp.bundles(rows)
rest = {
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in rows), Decimal(0))
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in got["줄"]), Decimal(0))
for k in mf.BUNDLE
}
return {"줄": rows, "끝수": rest}
return {"줄": got["줄"], "끝수": rest}
def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
@@ -0,0 +1,68 @@
"""수식 탭 소계 묶음(PLAN 801-3 · 요구 662-3) — 돈 묶음 한 곳 · M01(`master_equation`) · M02(`M02_Sangeun_Equation_Full`) 같은 꼴.
묶음 = 부른 · 더한 단가산출(갈래 블록) 하나씩 + 직접 줄(인력 · 재료 · 기계 · 덧줄 · 율) 전부 한 묶음(갈래 직접).
돈 없는 줄(계 0 · 바탕) · 뺀 줄 = 묶음 아님(식 줄로만 보임) · 합계 밖 줄 = `밖` 으로만.
머리 · 소계 · 합계 · 계 글자 판정 = 화면 부품(`ui_template_equation_sum.ts`) — 묶음 둘 이상 = 머리 · 소계 ·
단가산출(블록) 둘 이상 = 합계 · 아니면 계.
시험 `resources/tester/test_801_3_groups.py`.
"""
from __future__ import annotations
from decimal import Decimal
COSTS = ("노무비", "재료비", "경비")
_WORDS = ("인력", "재료", "기계") # 직접 묶음 이름 차례
def _zero(row: dict) -> bool:
"""돈 없는 바탕 줄 · 뺀 줄 — 묶음 아님."""
if row.get("상태") == "뺌":
return True
return row["계"] == 0 and row.get("상태") in (None, "바탕")
def _name(direct: list[dict]) -> str:
"""직접 묶음 이름 — 자원 갈래 말(인력 · 재료 · 기계) · 율 줄 = 이름 (율 %) · 덧줄뿐 = 비목 말."""
words = [w for w in _WORDS if any(r.get("종류") == w for r in direct)]
words += [
f"{r['이름']} ({r['수량']}%)" if r.get("수량") not in (None, "") else str(r["이름"])
for r in direct
if r["갈래"] == "율"
]
if not words:
words = [k for k in COSTS if any(r[k] != 0 for r in direct)]
return " · ".join(words)
def bundles(rows: list[dict]) -> dict:
"""줄(갈래 블록 | 자원 | 덧줄 | 율 · 비목 칸 · 계) → `{줄: 묶음들, 밖: 합계 밖 줄들}` — 금액은 더하기만(셈 안 바꿈)."""
blocks: list[dict] = []
direct: list[dict] = []
out: list[dict] = []
for row in rows:
if row.get("합밖"):
out.append({"이름": row["이름"], "밖금액": row.get("밖금액")})
elif _zero(row):
continue
elif row["갈래"] == "블록":
blocks.append(row)
else:
direct.append(row)
lines = list(blocks)
if direct:
one = {
"갈래": "직접",
"이름": _name(direct),
**{k: sum((r[k] for r in direct), Decimal(0)) for k in COSTS},
}
one["계"] = sum((one[k] for k in COSTS), Decimal(0))
mine = [ # 더한 · 덮은 줄 — 묶음 속에 한 줄씩(고친 줄을 숨기지 않음)
{k: r[k] for k in ("이름", "상태", "계", "원래") if r.get(k) is not None}
for r in direct
if r.get("상태") in ("더함", "덮음")
]
if mine:
one["낱줄"] = mine
lines.append(one)
return {"줄": lines, "밖": out}
@@ -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)