Files
Aislo/resources/master_data/scripts/master_doc_cover.py
T
eomsangdonandClaude Opus 5.5 7435bd30e2 feat(842-3): M01 기계 자료 옮김 — 조종원 · 조종원직종 · 잡재료율 칸 → 인력 · 잡품 목록(619 대)
- 빌더 build_기계_인력줄_842_3.py(규칙 = master_machine_crew.from_old_row · 다시 돌려도 같음)
- 인력 줄 301 대(1 인 282 · 2 인 18 · 7 인 1 · 직종 빈 줄 42 = 빈 키 = 식 입력 직종 고르기 그대로)
- 잡품 줄 328 대(잡재료율 > 0 = 주연료 · 전력 줄마다 그 율 · 「73.7+」 글 연료도 줄) · 범위 글 「13∼18」 2 대 = 잡품 없음(값원문 그대로)
- 선원류 칸 79 대 · 비활성 표지 79 대 그대로 · 옛 칸 남음 0
- 바탕 덮음 = 식에 그 줄이 있을 때만(손료만 식을 일부러 부른 단가산출 GC000324 → GC000273 크롤러드릴 = 조종원 안 더함)
- 식 고름 = 그 줄 인력도 조종원 있음으로 봄 · 로직 검사 · 값 모으기 = 기계 칸을 목록 읽기로(master_formula_check)
- 시험 고침: 821_2 · 822_2 미리보기 = 목록 읽은 값으로 · 822_1 노임 키 = 인력 줄 · 822_2 범위 까닭 글 = 「잡재료율 없음」(범위 글은 값원문)
- 전후: 655 대 시간당(hourly · brief · 문서 셈) 653 같음 · 다름 2 = 펌프 준설선 EQ000552 · 553(비활성 · 범위 글) 까닭 글만

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

448 lines
19 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_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]
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)
entry = merge(base, (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 []
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 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
base = row_entry(sub, lists_of(links, dict(given))) if sub else None
if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}, base))):
kept[m] = one
return {**rest, COVER: kept} if kept else rest