Files
Aislo/resources/master_data/scripts/master_doc_cover.py
T
eomsangdonandClaude Opus 5.5 a53fae747c feat(846-1): 기계 줄 시간당 작업량 — 기계 값 칸 작업량 · 덮음 일당 | 시간당 · 계산기 짝(금액 불변)
- 기계 자료: M01 기계 221 대 값 칸 `작업량` = [{식 · 단위 · 변수(규격에서 정해지는 칸)}] (건설 공통 8-2 장 작업량 틀 · 빌더 build_기계_작업량_846_1.py · --check 0 · 다른 칸 · 시간당 비용 그대로)
- 계산기 짝 master_doc_work.py ↔ common_util_formula_doc_work.ts: 기계 줄 수량에서 시간당 작업량 자리 읽기(일당 = 시공량 ÷ (8 × 대수) · 식 = …/hr 나눔 변수) · 셈 답 줄 칸 `작업량` · 덮음 `작업량: {방식 시간당, 식, 변수?}` = 그 자리를 1 ÷ Q(기계 작업량 식)로 바꿔 셈 · 부착 기계(브레이커 · 집게) 줄 따로 · 식 없는 기계 · 단위 안 맞음 = 시간당 거절
- 덮음 짝(master_doc_cover · common_util_formula_doc_cover): 작업량은 기계 식 문서에 안 얹음 · prune 이 작업량 정리(일당 · 기본과 같은 변수 = 지움) · 몸 검사 · CoverEntry · CoverBase 꼴
- 저장 길: 사본 부름 묶음에 시간당 식 키(M02_Doc_Copy.calls) · 내 기계 clean 이 작업량 받음 · 연결 읽기 작업량 칸(master_doc_link)
- 기계 보기: 작업량 칸 = 제어 구역 몫(ownedCols — 바탕 칸 숫자 입력으로 안 그림 · 그리기는 846-2)
- 지킴: 1,520 문서 · 기계 655 대 · 견본 SK 금액 전후 다름 0 · 빌더 802-2 --check 1,520 같음(옵션 표본 18,873) · 둘 다 가능 줄 전수 시간당 셈 멈춤 0
- 시험: test_846_1_machine_work(일당 · 시간당 손셈 · E · K 덮음 · 정리 · 거절 · 부착 · 거울 · 저장 길 · 내 기계) 신설 · test_816_1 기대값 고침(까닭 — 4륜 덤프 1톤 기계 줄에 작업량 칸이 생겨 값 칸 비교에서 뺌)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-16 00:00:06 +09:00

480 lines
21 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`)가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 —
덮음 없음 = 문서 그대로(금액 전후 같음).
기계 줄 목록(842-3 · `master_machine_crew`) = 바탕 덮음(`row_entry` — 식 한 줄로 안 풀리는 부분만) ·
단가산출 덮음이 그 위에 부분별로 덮음(`merge` — 값 = 칸별 · 조종원 · 잡품 = 목록 통째).
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_cover.ts` · 거울 시험
`resources/tester/test_836_1_machine_cover.py` · `test_842_3_machine_crew.py`.
"""
from __future__ import annotations
import copy as cp
import json
import re
from decimal import Decimal
import master_doc as md
import master_doc_work as mw
import master_machine_crew as mcrew
COVER = "기계덮음"
NOT_VALUE = (
"조종원",
"조종원직종",
"잡재료율",
) # 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸
HOUR = "Q_시간환산"
_HOUR_VARS = [
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
{"열쇠": "Q_분자2^표", "기호": "Q", "아래": "분자2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모2^표", "기호": "Q", "아래": "분모2", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'상여계수'"}, "칸": "분모"}},
{"열쇠": "Q_분자3^표", "기호": "Q", "아래": "분자3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분자"}},
{"열쇠": "Q_분모3^표", "기호": "Q", "아래": "분모3", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'휴지계수'"}, "칸": "분모"}},
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}},
] # fmt: skip
_HOUR_LINE = {
"기호": HOUR,
"식": "Q_분자^표 ÷ Q_분모^표 × Q_분자2^표 ÷ Q_분모2^표 × Q_분자3^표 ÷ Q_분모3^표",
}
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, base: dict | None = None) -> dict:
"""원본(단가산출 덮음 없음) 기본값 — `vals` = 그 기계로 셈한 원본 답 `값` ·
`base` = 기계 줄 바탕 덮음(`row_entry`) — 있는 부분은 그 목록(빈 직종 키 = 식이 고른 직종)."""
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 []
base = base or {}
if base.get("조종원") is not None:
own = crew[0]["키"] if crew else ""
crew = [{**x, "키": x.get("키") or own} for x in base["조종원"] if x.get("키") or own]
if base.get("잡품") is not None:
misc = base["잡품"]
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]
if work := mw.prune_work(entry.get(mw.WORK), base.get(mw.WORK)): # 846-1 시간당 작업량
out[mw.WORK] = work
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, own: str = "") -> list[dict]:
"""조종원 줄 — 빈 키 줄 = 식의 조종원 단가 기호(`own` · 식 입력 직종) · 없으면 뺌."""
rows = [r for r in rows if r.get("키") or own]
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["키"]) if r.get("키") else own}
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, base: dict | None = None) -> dict:
"""기계 식 문서에 그 기계 덮음을 얹음(새 문서) — `base` = 기계 줄 바탕 덮음(`row_entry`) ·
덮음 없음 = 그대로(같은 물건)."""
key = machine_key(given)
top = {k: v for k, v in ((cover or {}).get(key) or {}).items() if k != mw.WORK} if key else None
entry = merge(base, top or None) # 작업량(846-1) = 부른 쪽 기계 줄 수량 몫 — 식 문서는 그대로
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 []
own = str(lines[parts["crew"][0]].get("단가") or "") if parts["crew"] else ""
crew = _crew_lines(out, entry["조종원"], own) 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
# ── 기계 줄 목록 → 바탕 덮음(842-3) ─────────────────────────────────────
def is_machine(doc) -> bool:
"""기계 시간당 식 — 결과 원/시간 · 요소 `EQ:{S_기계}` 칸을 읽음."""
return (
isinstance(doc, dict)
and doc.get("결과단위") == "원/시간"
and any(
(v.get("연결") or {}).get("갈래") == "요소"
and "{S_기계}" in str((v.get("연결") or {}).get("키") or "")
for v in doc.get("변수") or []
)
)
def row_entry(doc: dict, vals) -> dict | None:
"""기계 줄 목록(`인력` · `잡품`) 가운데 식 한 줄로 안 풀리는 부분 = 바탕 덮음 · 다 풀림 = None.
풀림 = 인력 직종 하나 · 잡품 = 식 밑 재료 줄마다 같은 율(키 · 수량 없음)(식이 `master_machine_crew.view`
로 읽는 조종원 수 · 직종 · 잡재료율 = 같은 금액) · 식에 그 줄이 없음 = 안 얹음(손료만 식을 일부러 부른 단가산출)."""
if not isinstance(vals, dict) or not is_machine(doc):
return None
parts, lines = parts_of(doc), doc.get("금액") or []
out: dict = {}
crew = vals.get(mcrew.CREW)
if isinstance(crew, list) and parts["crew"]:
jobs = {mcrew.code(r.get("키")) for r in crew if isinstance(r, dict)}
if not (crew and len(jobs) == 1):
out["조종원"] = crew
misc = vals.get(mcrew.MISC)
if isinstance(misc, list) and misc and parts["misc"] is not None:
names = sorted(str(lines[i].get("이름") or "") for i in parts["base"])
plain = (
all(isinstance(r, dict) and r.get("줄") and not r.get("키") for r in misc)
and all(r.get("수량") is None for r in misc)
and len({_canon(r.get("율")) for r in misc}) == 1
and sorted(str(r["줄"]) for r in misc) == names
)
if not plain:
out["잡품"] = misc
return out or None
def merge(base: dict | None, top: dict | None) -> dict | None:
"""바탕 덮음 위에 단가산출 덮음 — 값 = 칸별 · 조종원 · 잡품 = 목록 통째(있는 부분만)."""
if not base:
return top or None
if not top:
return base
out = {**base, **{k: v for k, v in top.items() if k != "값"}}
vals = {**(base.get("값") or {}), **(top.get("값") or {})}
return {**out, "값": vals} if vals else out
def lists_of(links, given) -> dict:
"""셈 연결로 그 기계 줄 목록 읽기 — {인력?, 잡품?} · 못 읽음 = 빈 묶음(요소 `EQ:<기계>` 칸)."""
import master_doc_calc as mc
key, out = machine_key(given), {}
for col in (mcrew.CREW, mcrew.MISC) if key else ():
try:
out[col] = links.element({"키": f"EQ:{key}", "칸": col})
except (mc.Gap, mc.Broken):
pass
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 isinstance(r.get("키"), str): # 빈 글 = 식 입력 직종
raise ValueError(f"{COVER} 「{key}」 조종원 줄 — 키 필요")
if part == "잡품" and not (r.get("줄") or r.get("키")):
raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요")
one[part] = rows
if entry.get(mw.WORK) is not None:
one[mw.WORK] = mw.clean_work(entry[mw.WORK], f"{COVER} 「{key}」")
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 = {}
top = 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 not sub or not is_machine(sub):
continue
base = defaults_of(sub, ans.get("값") or {}, row_entry(sub, lists_of(links, dict(given))))
if one := prune({k: v for k, v in cover[m].items() if k != mw.WORK}, base):
kept[m] = one
works = { # 작업량(846-1) = 기계 줄 답이 읽은 작업 기계(부착 기계 포함)
w["기계"] for a in (top, *memo.values()) for x in a.get("줄") or [] if (w := x.get(mw.WORK))
}
for m in sorted(works & set(cover)):
want = cover[m].get(mw.WORK)
if want and (one := mw.prune_work(want, _work_base(links, m, want))):
kept[m] = {**kept.get(m, {}), mw.WORK: one}
return {**rest, COVER: kept} if kept else rest
def _work_base(links, machine: str, want: dict) -> dict:
"""시간당 작업량 덮음 기본 변수(식 문서 기본 › 기계 변수) · 기계에 그 식 없음 = ValueError(거절)."""
import master_doc_calc as mc
if want.get("방식") != mw.HOURLY:
return {}
try:
specs = mw.specs_of(links.element({"키": f"EQ:{machine}", "칸": mw.WORK}))
except (mc.Gap, mc.Broken):
specs = []
spec = next((s for s in specs if s["식"] == want.get("식")), None)
if spec is None:
what = f"작업량 식 「{want.get('식')}」 없음" if specs else "작업량 식 없는 기계"
raise ValueError(f"{COVER} 「{machine}」 시간당 — {what}")
return mw.defaults(links.doc(spec["식"]), spec)