"""M02 단가산출(SK) M01 자동 따라옴(PLAN 836-2 · 요구 854) — 서버 단독. 프로젝트 층 밖 SK(마스터 · 회사 · 개인) = 셈 · 열기 · 목록 · 일위대가 펼침이 모두 사본 줄 = 지금 M01(+ 층 내 기계) 줄(`rows` · 지운 키 = 사본 마지막 줄) · 부름 문서 = 지금 M01 수식 문서(`form`)로 봄 — 묻지 않음 · [단가 갱신] · [재생성] 없음. 파일(작업본)은 [저장] · 알림 [확인](= 저장) 때만 새 것으로 씀 — 읽기 길은 안 씀 · 저장 파일의 사본 = 마지막 저장 때 받은 M01(= 고침 판정의 「복사 당시 원본」). 고침 판정 = 내용 대조(표지 아님 · 바꿨다 되돌림 = 안 고침) — 셈에 닿는 칸(`LOGIC`)만 견줌: 끊음[키] 대 부름[키](복사 당시) · 따로 문서 대 `처음`(따로가 된 때의 문서 · 없으면 고친 것). M01 에서 바뀐 항목에 고침이 얹혀 있으면 그 고침을 지우고 다시 복사 + 알림 `갱신` 한 줄 {키, 이름, 갈래, M01 [전 → 후], 내고침 [지워진 값]} · 고침 없는 항목 = 조용히 따라옴 · 안 바뀐 항목의 고침 = 그대로. 따로 문서를 지우면 파생(옛 칸 + 새 M01)으로 돌아감. 가져온 식 SK = 값 줄만 따라옴(가져온 줄 문서 · 식 안 건드림 · 단가기준 「엑셀」 은 엑셀 값 그대로). 프로젝트 층 · B09 사본 = 그대로(사용자가 고르는 [단가 갱신] · [재생성]). """ from __future__ import annotations import copy as cp import json from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Imported as imp import master_doc_cover as mcv # noqa: E402 — 기계 덮음(836-1 · scripts 길 = dc) COVER = mcv.COVER # 문서 칸 기계덮음(836-1) — 따로 문서를 다시 복사할 때 `수식` 머리에 맡겨 둠 FIRST = "처음" # `수식` 칸 — 따로가 된 때의 문서(고침 판정 원본) LOGIC = ( "변수", "식", "금액", "끝수", "결과단위", ) # 셈에 닿는 칸 — 쌓기(차림) · 이름 · 판은 안 견줌 MAX_LINES = 8 _MEMO: dict[tuple, dict] = {} _LIVE: list[Any] = [None] def on() -> bool: """자동 따라옴 — 프로젝트 층 밖.""" from M02_MasterTemplete import M02_Labor_Ref as refs return refs.here()[0] != "project" def is_sk(doc: Any) -> bool: return isinstance(doc, dict) and doc.get("종류") == sd.TITLE # ── 값 줄 ────────────────────────────────────────────────────────────── def _fresh(row: dict, live: Any) -> dict: key = str(row.get("키")) got = live.index.get(key[:2], {}).get(key) if got is None: return row # M01 에서 지운 키 = 마지막 값 new = prices.to_json(got) return row if skc._fp(new) == skc._fp(row) else new def rows(copy: Any) -> Any: """값 사본(`skc` 꼴) → 줄마다 지금 M01(+ 층 내 기계) · 바뀐 몫만 판 다시 · 프로젝트 층 = 그대로.""" if not (isinstance(copy, dict) and isinstance(copy.get("로직"), list)) or not on(): return copy live = skc.whole_() if live is not _LIVE[0]: _MEMO.clear() _LIVE[0] = live tag = (copy.get("판"), copy.get("단가판"), len(copy["로직"]), len(copy.get("단가") or [])) hit = _MEMO.get(tag) if hit is not None: return {**copy, **hit} got: dict = {} for part, mark in (("로직", "판"), ("단가", "단가판")): old = copy.get(part) or [] new = [_fresh(r, live) if isinstance(r, dict) else r for r in old] if any(a is not b for a, b in zip(old, new)): got |= {part: new, mark: skc._fp(new)} if len(_MEMO) > 64: # ponytail: 통째 비움 · SK 가 많아 느려지면 LRU 로 _MEMO.clear() _MEMO[tag] = got return {**copy, **got} # ── 수식 사본(부름 · 끊음 · 따로 문서) ────────────────────────────────── def _logic(doc: Any) -> str: from M02_MasterTemplete import M02_Doc_Sk as dsk doc = doc if isinstance(doc, dict) else {} return dc.fp(dsk._strip({k: doc.get(k) for k in LOGIC})) def _say(v: dict) -> str: link = v.get("연결") or {} for slot in ("값", "식", "키"): if link.get(slot) not in (None, ""): return str(link[slot]) return json.dumps(link, ensure_ascii=False, default=str)[:60] def _name(v: dict) -> str: return str(v.get("이름") or f"{v.get('기호') or ''}{v.get('아래') or ''}") def diff(a: Any, b: Any) -> list[str]: """두 문서의 셈 칸 다른 곳 — 「이름 전 → 후」(변수 · 금액 줄) · 많으면 앞 몇 줄.""" a, b = (x if isinstance(x, dict) else {} for x in (a, b)) out: list[str] = [] va = {dc.md.var_id(v): v for v in a.get("변수") or []} vb = {dc.md.var_id(v): v for v in b.get("변수") or []} for vid in [*va, *(k for k in vb if k not in va)]: x, y = va.get(vid), vb.get(vid) if x and y and _say(x) == _say(y): continue name = _name(y or x) out.append(f"{name} {_say(x) if x else '—'} → {_say(y) if y else '—'}") la, lb = a.get("금액") or [], b.get("금액") or [] for n in range(max(len(la), len(lb))): x, y = (la[n] if n < len(la) else None), (lb[n] if n < len(lb) else None) if dc.fp(x) == dc.fp(y): continue name = str((y or x or {}).get("이름") or f"금액 {n + 1}") show = lambda z: "—" if not z else f"{z.get('수량') or ''} × {z.get('단가') or ''}" # noqa: E731 out.append(f"{name} {show(x)} → {show(y)}") for slot in ("식", "끝수", "결과단위"): if dc.fp(a.get(slot)) != dc.fp(b.get(slot)): out.append(slot) return out[:MAX_LINES] + ([f"… {len(out) - MAX_LINES}"] if len(out) > MAX_LINES else []) def form(sk: Any, got: Any, notes: list | None = None) -> Any: """수식 사본 → 지금 M01 으로 본 것(값 줄 · 부름) · M01 에서 바뀐 항목의 고침(끊음 · 따로 문서)은 지우고 `notes` 에 한 줄 · 안 바뀐 항목 고침 · 프로젝트 층 = 그대로.""" if not isinstance(got, dict) or not on() or not is_sk(sk): return got out = rows(got) if "로직" in got else dict(got) out = _covers(got, out, notes) if imp.is_imported(sk): return out # 가져온 식 = 값만(식 · 가져온 줄 그대로) live = dc.folder() calls = dict(out.get("부름") or {}) changed = {k for k, d in calls.items() if k in live and dc.fp(d) != dc.fp(live[k])} if not changed and not out.get("따로"): return out for k in changed: calls[k] = cp.deepcopy(live[k]) cut = dict(out.get("끊음") or {}) for k in sorted(changed & set(cut)): mine, was = cut.pop(k), (got.get("부름") or {}).get(k) if _logic(mine) != _logic(was) and notes is not None: notes.append(_note(k, was, live[k], diff(was, mine), "부름")) out |= {"부름": calls, "끊음": cut} base = out.get("원본") or {} key = str(base.get("키") or "") if out.get("따로") and key in live and dc.fp(live[key]) != base.get("판"): first = out.get(FIRST) mine = out.get("문서") or {} if first is None or _logic(mine) != _logic(first): mark = ( diff(first, mine) if first is not None else ["수식 문서 고침(옛 저장본 — 원본 없음)"] ) if notes is not None: notes.append(_note(key, first, live[key], mark, "문서", mine.get("이름"))) keep = {COVER: mine[COVER]} if mine.get(COVER) else {} # 기계 덮음 = 다른 항목(그대로) out = {k: v for k, v in out.items() if k not in ("따로", "문서", FIRST, COVER)} | keep if changed and "문서" in out: # 새 문서가 부르는 키 = 닫힌 묶음 keys = [*dc.calls(out["문서"]), *(c for d in cut.values() for c in dc.calls(d))] out["부름"] = dc.closure(dc.over(out), keys, calls) return out # ── 기계 덮음(836-1) — 그 기계 줄이 M01 에서 바뀌면 덮음 지움 ───────────── def _eq_rows(copy: dict) -> dict[str, dict]: """사본의 기계 줄 {원문번호 · 키: 줄}.""" out: dict[str, dict] = {} for r in [*(copy.get("로직") or []), *(copy.get("단가") or [])]: if isinstance(r, dict) and str(r.get("키"))[:2] == "EQ": out[str(r.get("키"))] = r if r.get("원문번호"): out[str(r["원문번호"])] = r return out def _mine(entry: dict) -> list[str]: vals = [f"{k} {v}" for k, v in (entry.get("값") or {}).items()] crew = [f"조종원 {x.get('키')} {x.get('인')}인" for x in entry.get("조종원") or []] misc = [ f"잡품 {x.get('이름') or x.get('키') or x.get('줄')} {x.get('율')}%" for x in entry.get("잡품") or [] ] return [*vals, *crew, *misc] or ["덮음"] def _row_diff(a: dict, b: dict) -> list[str]: va, vb = a.get("값") or {}, b.get("값") or {} if not isinstance(va, dict) or not isinstance(vb, dict): return [f"{a.get('값')} → {b.get('값')}"] keys = [*va, *(k for k in vb if k not in va)] return [f"{k} {va.get(k, '—')} → {vb.get(k, '—')}" for k in keys if va.get(k) != vb.get(k)] def _covers(got: dict, out: dict, notes: list | None) -> dict: """문서(따로) · 맡긴 기계 덮음 가운데 그 기계 줄이 M01 에서 바뀐 것 = 지움 + 알림.""" doc = out.get("문서") if isinstance(out.get("문서"), dict) else None cover = dict(mcv.cover_of(doc) if doc is not None else out.get(COVER) or {}) if not cover: return out was, now = _eq_rows(got), _eq_rows(out) for text in list(cover): a, b = was.get(text), now.get(text) if a is None or b is None or skc._fp(a) == skc._fp(b): continue entry = cover.pop(text) if notes is not None: name = f"{b.get('이름') or ''} {b.get('규격') or ''}".strip() or text notes.append( { "키": str(b.get("키")), "이름": name, "갈래": "기계", "M01": _row_diff(a, b) or ["바뀜"], "내고침": _mine(entry), } # fmt: skip ) if doc is not None: rest = {k: v for k, v in doc.items() if k != COVER} return {**out, "문서": {**rest, COVER: cover} if cover else rest} return {k: v for k, v in out.items() if k != COVER} | ({COVER: cover} if cover else {}) def carry(got: dict, root: dict) -> dict: """파생 문서에 맡긴 기계 덮음을 다시 얹음(따로 문서를 다시 복사한 뒤).""" return {**root, COVER: got[COVER]} if isinstance(got.get(COVER), dict) and got[COVER] else root def _note(key: str, was: Any, now: dict, mine: list[str], kind: str, name: Any = None) -> dict: return { "키": key, "이름": str(name or now.get("이름") or key), "갈래": kind, "M01": diff(was, now) or ["바뀜"], "내고침": mine, } def notes(sk: Any) -> list[dict]: """열 때 알림 — 이 SK 저장본에서 지울 고침(없으면 []) · 가져온 식 · 프로젝트 층 = [].""" if not is_sk(sk) or not dc.has(sk) or imp.is_imported(sk): return [] out: list[dict] = [] form(sk, sk[dc.KEY], out) return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str))