Files
Aislo/M02_MasterTemplete/M02_Sangeun_Doc.py
T
2026-11-01 14:44:30 +09:00

388 lines
18 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.
"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13).
문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 —
{종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬,
바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만),
입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?,
고름 {cid: MT키}, 자재표시 {cid: {세 칸}},
덮개 {cid: {qty | 배율, labor, material, expense, 원값}},
더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?}
| {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}],
뺀줄 [cid], 바탕판}
줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) ·
키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) ·
n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄.
고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈.
덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense =
줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때
서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림.
엔진 입력 덧칸(715-1b · `master_pick`) = 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}`
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any
KIND = "sangeun"
TITLE = "단가산출"
PREFIX = "SK"
KEY = re.compile(r"^SK-\d{6}$")
PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"}
COVER_KEYS = ("qty", "배율", *PARTS)
KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"}
ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"}
_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$")
_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})")
_PCT = re.compile(r"^(?P<x>.+)\*(?P<y>[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」
_NAME = re.compile(r"^[\w가-힣]+$")
class DocError(ValueError):
def __init__(self, text: str, status: int = 400) -> None:
super().__init__(text)
self.status = status
def _num(raw: Any, where: str) -> Decimal:
try:
got = Decimal(str(raw).replace(",", "").strip())
except (InvalidOperation, TypeError):
got = None
if raw is None or isinstance(raw, bool) or got is None or not got.is_finite():
raise DocError(f"{where} · 수가 아님 「{raw}」")
return got
def plain(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def text(value: Any) -> str:
"""식 안에 넣을 수 글 — 지수 꼴 없이."""
got = value if isinstance(value, Decimal) else Decimal(str(value))
return format(got.normalize(), "f")
# ── 줄 정체 ───────────────────────────────────────────────────────────
def _head(item: dict, extra: bool) -> str:
if extra:
return f"pct:{item.get('이름') or ''}"
kind = KINDS.get(str(item.get("종류")), "logic")
elem = item.get("요소")
if isinstance(item.get("참조"), dict):
key = str(item["참조"].get("키") or "")
elif isinstance(item.get("고정"), dict):
key = str(item["고정"].get("이름") or "")
elif kind == "logic":
found = _CALL.match(str(elem or ""))
key = found.group(1) if found else str(item.get("이름") or "")
else:
key = elem if isinstance(elem, str) else str(item.get("이름") or "")
return f"{kind}:{key}"
def line_ids(row: dict) -> list[str]:
"""로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`."""
seen: dict[str, int] = {}
out = []
pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []]
for item, extra in pairs:
head = _head(item, extra)
seen[head] = seen.get(head, -1) + 1
out.append(f"{head}#{seen[head]}")
return out
# ── 정리 · 검사 ───────────────────────────────────────────────────────
def _cid(raw: Any, where: str) -> str:
if not isinstance(raw, str) or not _CID.match(raw):
raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」")
return raw
def _base(raw: Any) -> dict | None:
if raw is None:
return None
if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip():
return {"로직": raw["로직"].strip()}
if isinstance(raw, dict) and isinstance(raw.get("참조"), dict):
ref = raw["참조"]
if not KEY.match(str(ref.get("키") or "")):
raise DocError("바탕 참조 키는 SK-######")
return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}}
raise DocError("바탕은 {로직} · {참조} · null")
def _given(raw: Any, where: str) -> dict:
if raw is None:
return {}
if not isinstance(raw, dict):
raise DocError(f"{where} · 입력값이 묶음 아님")
return {str(k): v for k, v in raw.items() if v not in (None, "")}
def _cover(raw: Any, where: str, opened: bool) -> dict:
if not isinstance(raw, dict):
raise DocError(f"{where} · 덮개는 묶음")
unknown = set(raw) - {*COVER_KEYS, "원값"}
if unknown:
raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}")
out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None}
if "qty" in out and "배율" in out:
raise DocError(f"{where} · 품은 qty · 배율 중 하나")
if opened and "qty" in out:
raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만")
if any(v < 0 for v in out.values()):
raise DocError(f"{where} · 음수 덮개")
if not out:
raise DocError(f"{where} · 빈 덮개")
return out
def _added(raw: Any, where: str, whole: Any) -> dict:
"""더한줄 하나 — 수량은 숫자(식 안 받음)."""
if not isinstance(raw, dict):
raise DocError(f"{where} · 묶음 아님")
qty = plain(_num(raw.get("수량"), f"{where} 수량"))
name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip()
if isinstance(raw.get("참조"), dict):
ref = raw["참조"]
if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")):
raise DocError(f"{where} · 참조 키는 SK · SC")
head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}}
elif isinstance(raw.get("고정"), dict):
from M02_MasterTemplete import M02_Labor_Fixed as fixed
try:
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
raise DocError(str(getattr(e, "detail", e))) from e
if head["고정"].get("비율"):
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
elif raw.get("종류") == "로직":
code = str(raw.get("로직") or "")
row = _logic(whole, code, where)
names = {str(s.get("이름")) for s in (row or {}).get("입력") or []}
given = _given(raw.get("입력값"), where)
head = {"종류": "로직", "로직": code, "입력값": given}
if row is not None:
head["입력값"] = {k: v for k, v in given.items() if k in names}
name = name or str(row.get("이름") or code)
elif raw.get("종류") in ADD_PART:
key = str(raw.get("요소") or "")
got = _element(whole, key, where)
part = str(raw.get("비목") or ADD_PART[raw["종류"]])
if part not in PARTS.values():
raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비")
head = {"종류": raw["종류"], "요소": key, "비목": part}
if raw.get("단가") is not None:
head["단가"] = plain(_num(raw["단가"], f"{where} 단가"))
name = name or str((got or {}).get("이름") or key)
unit = unit or str((got or {}).get("단위") or "")
else:
raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정")
return {**head, "이름": name, "단위": unit, "수량": qty}
def _logic(whole: Any, code: str, where: str) -> dict | None:
if whole is None:
return None
try:
return whole.logic(code)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함)
raise DocError(f"{where} · {e}") from e
def _element(whole: Any, key: str, where: str) -> dict | None:
if not key:
raise DocError(f"{where} · 요소 키가 빔")
if whole is None:
return None
try:
return whole.get(key)
except Exception as e: # noqa: BLE001
raise DocError(f"{where} · 없는 요소 「{key}」") from e
def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]:
"""서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 ·
열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) ·
`원값` · `바탕판` 은 서버 몫이라 버림."""
if not isinstance(doc, dict):
raise DocError("단가산출 문서가 묶음 아님")
key = str(doc.get("키") or "")
if not KEY.match(key):
raise DocError("단가산출 키는 SK-######")
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
if not name:
raise DocError("단가산출 이름이 빔")
if not unit:
raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위")
base = _base(doc.get("바탕"))
if base and "로직" in base and whole is not None and base_row is None:
base_row = _logic(whole, base["로직"], "바탕")
names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []}
given = _given(doc.get("입력값"), "입력값")
opened = [str(x) for x in doc.get("열린입력") or []]
if base_row is not None:
given = {k: v for k, v in given.items() if k in names}
bad = [x for x in opened if x not in names or x in given]
if bad:
raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}")
out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base}
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
out["입력값"], out["열린입력"] = given, opened
if doc.get("켜쌓기율") is not None:
out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율"))
picks = {}
for cid, mt in (doc.get("고름") or {}).items():
mt = str(mt or "")
if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None):
raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」")
picks[_cid(cid, "고름")] = mt
out["고름"] = picks
out["자재표시"] = _marks(doc.get("자재표시"))
out["덮개"] = {
_cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened))
for cid, one in (doc.get("덮개") or {}).items()
}
out["더한줄"] = [
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
]
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
if len(set(drop)) != len(drop):
raise DocError("뺀줄에 같은 줄 두 번")
out["뺀줄"] = drop
return out
def _marks(raw: Any) -> dict[str, dict[str, str]]:
from M02_MasterTemplete import M02_Labor_Doc as labor
out = {}
for cid, one in (raw or {}).items() if isinstance(raw, dict) else ():
got = labor.marks(one)
if got:
out[_cid(cid, "자재표시")] = got
return out
# ── 고친 줄 ───────────────────────────────────────────────────────────
def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]:
"""덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭)."""
item = dict(item)
if extra: # 덧줄 = 율(qty) · 배율만
if any(k in cover for k in PARTS):
return item, "덧줄은 율만 고침"
formula = str(item.get("식") or "")
if "배율" in cover:
item["식"] = f"({formula}) * {text(cover['배율'])}"
return item, None
found = _PCT.match(formula)
if not found:
return item, "덧줄 식이 「X * 율 / 100」 꼴 아님"
item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100"
return item, None
if "qty" in cover:
item["수량"] = text(cover["qty"])
elif "배율" in cover:
item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}"
prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover}
if not prices:
return item, None
if item.get("종류") == "로직":
item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices}
return item, None
part = str(item.get("비목") or "")
if set(prices) != {part}:
return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}"
item["단가"] = prices[part]
return item, None
def _add_item(one: dict) -> dict:
"""더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움."""
head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""}
qty = text(one["수량"])
if "참조" in one or "고정" in one:
kept = {k: one[k] for k in ("참조", "고정") if k in one}
return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None}
if one.get("종류") == "로직":
args = "".join(
f", {k if _NAME.match(k) else repr(k)}="
+ (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'")
for k, v in (one.get("입력값") or {}).items()
)
return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty}
item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]}
return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})}
def patched(
row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None
) -> dict[str, Any]:
"""바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름).
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
짝잃음 [{cid, 까닭}]}."""
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
ids = line_ids(row)
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
mine = doc.get("고름") or {}
marks = doc.get("자재표시") or {}
lost = [
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
]
new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {}
for at, cid in enumerate(ids):
if cid in drop:
continue
extra = at >= len(items)
item = extras[at - len(items)] if extra else items[at]
if cid in covers:
item, why = _covered(item, covers[cid], extra)
if why:
lost.append({"cid": cid, "까닭": why})
if extra:
new_extras.append(item)
continue
n = str(len(new_items))
old = (picks or {}).get(str(at))
if cid in mine and item.get("종류") != "재료":
lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"})
elif cid in mine or old:
out_picks[n] = mine.get(cid) or old
if (shown or {}).get(str(at)) or cid in marks:
out_marks[n] = marks.get(cid) or shown[str(at)]
new_items.append(item)
cids.append(cid)
for k, one in enumerate(doc.get("더한줄") or []):
new_items.append(_add_item(one))
cids.append(f"add:#{k}")
cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop]
for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)):
lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids]
return {
"줄": {**row, "호표": new_items, "덧줄": new_extras},
"고름": out_picks,
"자재표시": out_marks,
"정체": cids,
"짝잃음": lost,
}
def empty_row(doc: dict) -> dict[str, Any]:
"""바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈)."""
return {
"키": doc.get("키"),
"이름": doc.get("이름"),
"결과단위": f"원/{doc.get('단위') or ''}",
"입력": [],
"중간": [],
"호표": [],
"덧줄": [],
}