Files
Aislo/M02_MasterTemplete/M02_Sk_AutoRenew.py
T
eomsangdonandClaude Opus 5.5 e8d5534c11 feat(842-3): 기계 시간당 셈 한 길 — 식 문서 계산기 + 기계 줄 인력 · 잡품 목록 바탕 덮음
- 읽기 view(Master.value) = 인력 · 잡품 목록 → 식이 읽는 조종원 수 · 직종 · 잡재료율(옛 꼴 = 그대로)
- 목록이 식 한 줄로 안 풀리면 row_entry 바탕 덮음 · 단가산출 기계덮음은 merge 로 부분별 위에(짝 TS)
- M02_Machine_Hourly.hourly · brief · M02_MyMachine.hourly = 문서 엔진(doc_sums) · 식 문서 없는 식만 옛 로직 엔진
- pick · _operated · why · logic_key · 호표 기계 고름 · 불러오기 기계 수 = 인력 줄로
- 내 기계 clean = 인력 · 잡품 목록 받음(옛 칸 = from_old) · M01 fix · check_master = 목록 꼴 검사
- 사본 자동 따라옴 = 옛 꼴 사본 줄 대 새 꼴 M01 줄은 새 꼴로 바꿔 대조(덮음 안 지움)
- 시험 836_1 몸 검사: 조종원 빈 키 = 식 입력 직종으로 받음 → 틀림 꼴을 「키 없음」으로 고침
- 전후: 655 대 로직 엔진 = 문서 엔진 611 같음 · 다름 0 · 코드만 바꾼 뒤 655 대 hourly · brief · 문서 셈 전후 같음

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

267 lines
12 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) 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: " × ".join(str(z[k]) for k in ("수량", "단가") if z and z.get(k)) 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 _same_new(a: dict, b: dict) -> bool:
"""옛 꼴 사본 줄 대 새 꼴 M01 줄(842-3 인력 · 잡품 목록 옮김) — 새 꼴로 바꿔 같으면 안 바뀜."""
import master_machine_crew as mcrew
return skc._fp(mcrew.from_old_row(a)) == skc._fp(mcrew.from_old_row(b))
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) or _same_new(a, 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))