feat(836-1): 기계 덮음 꼴 · 계산기 짝 규칙 · 서버 저장 정리 — SK 수식 문서 칸 기계덮음
- 자리 = SK 수식.문서.기계덮음 {기계 입력 글: {값 · 조종원 줄 · 잡품 줄}} · 사본 줄 그대로
- 계산기(파이썬 · TS 짝)가 기계 식 부를 때 그 기계 덮음을 얹음 · 덮음 없음 = 문서 그대로
- 서버 저장 · 미리 셈 = 몸 검사 + 원본과 같은 칸 · 안 부르는 기계 뺌
- 거울 · 손셈(굴착기 1.0㎥ 108,958) · 저장 길 지킴 시험 test_836_1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import math
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
|
||||
|
||||
import master_doc as md
|
||||
import master_doc_cover as mcv
|
||||
|
||||
COSTS, BUNDLE = md.COSTS, md.BUNDLE
|
||||
EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음
|
||||
@@ -79,8 +80,9 @@ def number(v):
|
||||
|
||||
# ── 셈 한 벌 ──────────────────────────────────────────────────────────
|
||||
class _Run:
|
||||
def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict):
|
||||
def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict, cover=None):
|
||||
self.doc, self.links, self.stack, self.memo = doc, links, stack, memo
|
||||
self.cover = cover # 기계 덮음(836-1) — 맨 위 문서 칸 · 부름에 얹음
|
||||
self.given = given or {}
|
||||
self.vars = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []}
|
||||
@@ -296,7 +298,10 @@ class _Run:
|
||||
given = payload.get("입력") or {}
|
||||
memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items())))
|
||||
if memo_key not in self.memo:
|
||||
self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo)
|
||||
doc = mcv.cover_doc(doc, given, self.cover)
|
||||
self.memo[memo_key] = calc(
|
||||
doc, self.links, given, (*self.stack, key), self.memo, self.cover
|
||||
)
|
||||
got = self.memo[memo_key]
|
||||
if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
|
||||
why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"])
|
||||
@@ -505,14 +510,19 @@ def rules_of(doc: dict) -> dict:
|
||||
|
||||
|
||||
def calc(
|
||||
doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None
|
||||
doc: dict,
|
||||
links,
|
||||
given: dict | None = None,
|
||||
stack: tuple = (),
|
||||
memo: dict | None = None,
|
||||
cover: dict | None = None,
|
||||
) -> dict:
|
||||
"""수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과,
|
||||
묶음, 머리, 단계?, 표지, 셈됨, 끝수}.
|
||||
given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음."""
|
||||
given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · cover = 기계 덮음(없음 = 문서 칸) · 예외 없음."""
|
||||
memo = {} if memo is None else memo
|
||||
stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환
|
||||
run = _Run(doc, links, given, stack, memo)
|
||||
run = _Run(doc, links, given, stack, memo, mcv.cover_of(doc) if cover is None else cover)
|
||||
for one in md.check(doc):
|
||||
if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"):
|
||||
run.marks.append(one)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# -*- 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}
|
||||
|
||||
|
||||
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, "")}
|
||||
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 [
|
||||
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": 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
|
||||
Reference in New Issue
Block a user