Files
Aislo/resources/master_data/scripts/master_doc_cover.py
T
eomsangdonandClaude Opus 5.5 9f8e54f6eb feat(836-1): 단가산출 수식 보기 기계 제어 구역 — 값 칸 · 조종원 인력 줄 · 잡품 재료 줄 · 기계 창 찾기만
- 왼쪽 기계 줄 ▸ = 접는 구역(값 칸 단위 · 고침 표지 · 원래 값 · 조종원 노임 고르기 · 잡품 재료 · 유가 고르기 · 율 %)
- 고침 = 문서 칸 기계덮음(캐시 · 저장 때 서버 다시 셈) · 원본과 같으면 지움 · 같은 기계 줄 한 벌
- 오른쪽 산출근거 펼침 = 덮은 값으로 셈(읽기 그대로) · M01 기초 = 칸 잠김
- 기계 창 = 찾기 · 바꾸기만(양식 · 고쳐 쓰기 → 내 기계 초안 길 걷음)
- 덮음 원본 대조에서 이름 · 단위(화면 글) 뺌 · 조종원 줄 규격 = 직종 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 14:01:21 +09:00

355 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""기계 덮음 (PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침.
자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}}
기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌) — 같은 기계 줄 여럿 = 한 벌 · 원본과 같은 칸 = 없음(`prune`).
셈 규칙(`cover_doc`) = 계산기(`master_doc_calc.calc`)가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 —
덮음 없음 = 문서 그대로(금액 전후 같음).
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_cover.ts` · 거울 시험
`resources/tester/test_836_1_machine_cover.py`.
"""
from __future__ import annotations
import copy as cp
import json
import re
from decimal import Decimal
import master_doc as md
COVER = "기계덮음"
NOT_VALUE = (
"조종원",
"조종원직종",
"잡재료율",
) # 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸
HOUR = "Q_시간환산"
_HOUR_VARS = [
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}},
] # fmt: skip
_HOUR_LINE = {"기호": HOUR, "식": "Q_분자^표 ÷ Q_분모^표"}
MAX_ROWS = 30
def _plain(n) -> str:
d = Decimal(str(n)).normalize()
return format(d, "f")
def cover_of(doc) -> dict:
got = doc.get(COVER) if isinstance(doc, dict) else None
return got if isinstance(got, dict) else {}
def machine_key(given) -> str:
"""부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = ""."""
v = (given or {}).get("기계")
if v is None:
return ""
t = str(v).strip()
return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t
def _names(text) -> list[str]:
if text is None or str(text).strip() == "":
return []
try:
return md.names(md.parse(text))
except md.DocError:
return []
def parts_of(doc: dict) -> dict:
"""기계 식 짜임 — {fields [{칸, ids}], crew [줄 차례], crew_qty {차례: 기호}, misc, rate, base}."""
eq: dict[str, str] = {}
for v in doc.get("변수") or []:
link = v.get("연결") or {}
key = str(link.get("키") or "")
if (
link.get("갈래") == "요소"
and key.startswith("EQ:")
and "{S_기계}" in key
and link.get("칸")
):
eq[md.var_id(v)] = link["칸"]
fields: list[dict] = []
for id_, col in eq.items():
if col in NOT_VALUE:
continue
at = next((f for f in fields if f["칸"] == col), None)
if at:
at["ids"].append(id_)
else:
fields.append({"칸": col, "ids": [id_]})
lines = doc.get("금액") or []
crew, crew_qty, misc, rate = [], {}, None, None
for i, line in enumerate(lines):
q = next((n for n in _names(line.get("수량")) if eq.get(n) == "조종원"), None)
if line.get("비목") == "노무비" and q:
crew.append(i)
crew_qty[i] = q
p = _names(line.get("단가"))
other = [n for n in p if n != "재료비"]
if misc is None and line.get("비목") == "재료비" and "재료비" in p and len(other) == 1:
misc, rate = i, other[0]
base = (
[]
if misc is None
else [
i
for i, x in enumerate(lines)
if i < misc and x.get("비목") == "재료비" and not x.get("합밖")
]
)
return {"fields": fields, "crew": crew, "crew_qty": crew_qty, "misc": misc, "rate": rate, "base": base} # fmt: skip
def _num(v):
if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)):
return None
return v
def _fill_key(text: str, vals: dict) -> str:
def one(m):
v = vals.get(m.group(1))
return _plain(v) if _num(v) is not None else str("" if v is None else v)
return re.sub(r"\{([^{}]+)\}", one, text)
def defaults_of(doc: dict, vals: dict) -> dict:
"""원본(덮음 없음) 기본값 — `vals` = 그 기계로 셈한 원본 답 `값`."""
parts = parts_of(doc)
lines = doc.get("금액") or []
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
values = {}
for f in parts["fields"]:
v = vals.get(f["ids"][0])
if _num(v) is not None or isinstance(v, str):
values[f["칸"]] = v
crew = []
for i in parts["crew"]:
n = _num(vals.get(parts["crew_qty"][i]))
sym = (_names(lines[i].get("단가")) or [""])[0]
key = str(((vars_.get(sym) or {}).get("연결") or {}).get("키") or "")
if n and key:
crew.append({"키": _fill_key(key, vals), "인": n})
r = _num(vals.get(parts["rate"])) if parts["rate"] else None
misc = [{"줄": str(lines[i].get("이름") or ""), "율": r} for i in parts["base"]] if r else []
return {"값": values, "조종원": crew, "잡품": misc}
_LABEL = ("이름", "단위") # 화면 글 — 원본 대조 안 봄
def _canon(v) -> str:
def norm(x):
if isinstance(x, dict):
return {
k: norm(y) for k, y in sorted(x.items()) if y not in (None, "") and k not in _LABEL
}
if isinstance(x, list):
return [norm(y) for y in x]
if _num(x) is not None:
return float(f"{float(x):.12g}")
return x
return json.dumps(norm(v), ensure_ascii=False, sort_keys=True)
def _same(a, b) -> bool:
if _num(a) is not None and _num(b) is not None:
a, b = float(a), float(b)
return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b))
return str(a) == str(b)
def prune(entry: dict, base: dict) -> dict | None:
"""원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 None."""
out: dict = {}
vals = {
k: v
for k, v in (entry.get("값") or {}).items()
if v not in (None, "") and not (k in base["값"] and _same(v, base["값"][k]))
}
if vals:
out["값"] = vals
for part in ("조종원", "잡품"):
if entry.get(part) is not None and _canon(entry[part]) != _canon(base[part]):
out[part] = entry[part]
return out or None
# ── 셈 규칙 ───────────────────────────────────────────────────────────
def _add_var(doc: dict, sign: str, low: str, name: str, key: str) -> str:
have = {md.var_id(v) for v in doc.get("변수") or []} | {
str(x.get("기호") or "") for x in doc.get("식") or []
}
n = 1
while md.sym_id(sign, f"{low}{n}") in have:
n += 1
under = f"{low}{n}"
id_ = md.sym_id(sign, under)
doc.setdefault("변수", []).append(
{"열쇠": id_, "기호": sign, "아래": under, "위": "", "이름": name, "단위": "",
"연결": {"갈래": "요소", "키": key}}
) # fmt: skip
return id_
def _crew_lines(doc: dict, rows: list) -> list[dict]:
have = any(md.var_id(v) == HOUR for v in doc.get("변수") or []) or any(
x.get("기호") == HOUR for x in doc.get("식") or []
)
if rows and not have:
ids = {md.var_id(v) for v in doc.get("변수") or []}
doc.setdefault("변수", []).extend(
cp.deepcopy(v) for v in _HOUR_VARS if md.var_id(v) not in ids
)
doc.setdefault("식", []).append(dict(_HOUR_LINE))
return [
{"이름": "조종원", **({"규격": r["이름"]} if r.get("이름") else {}), "단위": "인",
"비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
"단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])}
for r in rows
] # fmt: skip
def _misc_lines(doc: dict, rows: list, lines: list) -> list[dict]:
out = []
for r in rows:
label = r.get("이름") or ""
if r.get("키"):
price = _add_var(doc, "P", "잡품", label or r["키"], r["키"])
qty = _plain(r["수량"] if r.get("수량") is not None else 1)
else:
b = next((x for x in lines if str(x.get("이름") or "") == r.get("줄")), None)
if not b or not b.get("단가"):
continue
price = str(b["단가"])
qty = _plain(r["수량"]) if r.get("수량") is not None else f"({b.get('수량') or 1})"
label = label or str(b.get("이름") or "")
out.append(
{"이름": "잡품", "규격": label, "단위": "", "비목": "재료비",
"수량": f"{qty} × {_plain(r['율'])} ÷ 100", "단가": price}
) # fmt: skip
return out
def cover_doc(doc: dict, given, cover) -> dict:
"""기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건)."""
key = machine_key(given)
entry = (cover or {}).get(key) if key else None
if not entry:
return doc
out = cp.deepcopy(doc)
out.pop("쌓기", None) # 금액 차례가 바뀜 — 기본 차림
parts = parts_of(out)
values = entry.get("값") or {}
for f in parts["fields"]:
if f["칸"] in values:
for v in out.get("변수") or []:
if md.var_id(v) in f["ids"]:
v["연결"] = {"갈래": "숫자", "값": values[f["칸"]]}
lines = out.get("금액") or []
crew = _crew_lines(out, entry["조종원"]) if entry.get("조종원") is not None else None
want = entry.get("잡품")
misc = (
_misc_lines(out, want, lines)
if want is not None and (parts["misc"] is not None or want)
else None
)
gone = set(parts["crew"] if crew is not None else []) | (
{parts["misc"]} if misc is not None and parts["misc"] is not None else set()
)
tail = next((i for i, x in enumerate(lines) if x.get("비목") == "경비"), len(lines))
crew_at = parts["crew"][0] if parts["crew"] else tail
misc_at = parts["misc"] if parts["misc"] is not None else tail
new = []
for i in range(len(lines) + 1):
if crew is not None and i == crew_at:
new += crew
if misc is not None and i == misc_at:
new += misc
if i < len(lines) and i not in gone:
new.append(lines[i])
out["금액"] = new
return out
# ── 서버 검사 · 정리 ──────────────────────────────────────────────────
def _nonneg(v, where: str):
if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)) or v < 0:
raise ValueError(f"{where} — 0 이상 수")
return v
def clean(raw) -> dict:
"""몸 검사 — 틀리면 ValueError(까닭) · 빈 칸 · 빈 기계 = 뺌."""
if raw is None:
return {}
if not isinstance(raw, dict):
raise ValueError(f"{COVER} — 묶음 아님")
out: dict = {}
for key, entry in raw.items():
if not isinstance(entry, dict):
raise ValueError(f"{COVER} 「{key}」 — 묶음 아님")
one: dict = {}
values = entry.get("값")
if values is not None:
if not isinstance(values, dict):
raise ValueError(f"{COVER} 「{key}」 값 — 묶음 아님")
got = {}
for col, v in values.items():
if v in (None, ""):
continue
if col in NOT_VALUE:
raise ValueError(f"{COVER} 「{key}」 값 「{col}」 — 줄로 고침")
got[col] = v.strip() if isinstance(v, str) else _nonneg(v, f"「{key}」 {col}")
if got:
one["값"] = got
for part, need in (("조종원", ("키", "인")), ("잡품", ("율",))):
rows = entry.get(part)
if rows is None:
continue
if not isinstance(rows, list) or len(rows) > MAX_ROWS:
raise ValueError(f"{COVER} 「{key}」 {part} — 목록({MAX_ROWS} 줄까지)")
for r in rows:
if not isinstance(r, dict) or any(r.get(k) in (None, "") for k in need):
raise ValueError(f"{COVER} 「{key}」 {part} 줄 — {' · '.join(need)} 필요")
for k in ("인", "율", "수량"):
if r.get(k) is not None:
_nonneg(r[k], f"「{key}」 {part} {k}")
if part == "잡품" and not (r.get("줄") or r.get("키")):
raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요")
one[part] = rows
if one:
out[str(key)] = one
return out
def prune_doc(doc: dict, links) -> dict:
"""서버 정리 — 덮음을 검사하고 원본과 같은 칸 · 줄 · 기계를 뺀 문서(새 문서 · 덮음 없음 = 그대로).
원본 = 덮음 뺀 문서 셈의 부름 메모(그 기계 식 · 입력 · 원본 답)."""
import master_doc_calc as mc
cover = clean(doc.get(COVER))
rest = {k: v for k, v in doc.items() if k != COVER}
if not cover:
return rest
memo: dict = {}
mc.calc(rest, links, None, (), memo)
kept: dict = {} # 문서가 안 부르는 기계 덮음 = 뺌
for (key, given), ans in memo.items():
m = machine_key(dict(given))
sub = links.doc(key) if m in cover and m not in kept else None
if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}))):
kept[m] = one
return {**rest, COVER: kept} if kept else rest