"""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.+)\*(?P[^*]+)/\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 ''}", "입력": [], "중간": [], "호표": [], "덧줄": [], }