diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index beaf9e4df..0d532ecbc 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -27,6 +27,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sk_AutoRenew as renew KEY = dc.KEY OLD_FIELDS = ( @@ -119,6 +120,7 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None: got = sk.get(KEY) if dc.has(sk) else seed(sk) if got is None: return None + got = renew.form(sk, got) # M01 자동 따라옴(836-2) if got.get("따로") or ctx is None: return got if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎) @@ -133,6 +135,7 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None: except dc._ERR as e: root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)} values = dc.values_of(sk[skc.KEY]) if skc.has(sk) else dc.values_of(got) # 서버가 정한 값 + values = renew.rows(values) if renew.is_sk(sk) else values # 836-2 whole = dc.over(values) calls = dc.closure(whole, dc.calls(root), got.get("부름")) base = str((sk.get("바탕") or {}).get("로직") or "") @@ -191,6 +194,7 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: return {**out, KEY: norm(mine["사본"])} file = stored(out.get("키")) or {} have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None) + have = renew.form(out, have) if have else have # 836-2 — 바뀐 항목 고침 지움 · 저장 때 굳음 if have and have.get("따로") and job != skc.REGEN: if file and old_fp(file) != old_fp(out): raise sd.DocError( @@ -210,6 +214,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: calc.run(out, whole, catch=box) calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls} + if job != skc.REGEN and (have or {}).get("끊음"): # 안 바뀐 항목 고침 = 그대로(836-2) + first["끊음"] = have["끊음"] copy = copy_of({**out, KEY: first}, box[-1] if box else None) if ( copy and skc.has(out) and skc.READS.get() is None and (keys := gap.gaps(copy)) diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index e740a5101..b1408ca5b 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sk_AutoRenew as renew MAX = 2_000_000 # 문서 글 크기(바이트) 상한 JOBS = (skc.PRICE, skc.REGEN) @@ -77,7 +78,7 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]: """{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준.""" got = dsk.run(sk, copy) head = {k: copy.get(k) for k in ("시점", "단가시점")} - mark = {**head, "따로": bool(copy.get("따로"))} + mark = {**head, "따로": bool(copy.get("따로")), "자동": renew.on()} # 836-2 M01 자동 따라옴 mark |= ( {"단가기준": dim.basis_of(sk)} if dim.is_imported(sk) @@ -97,7 +98,9 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]: def read(key: str) -> dict[str, Any]: """읽기 — {문서, 부름, 끊음, 셈, 사본, 판}.""" got = _file(key) - return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]} + out = {**answer(current(got["문서"]), got["문서"]), "판": got["판"]} + out["사본"]["갱신"] = renew.notes(got["문서"]) # 836-2 열 때 알림(지울 고침) + return out # ── 몸 검사 ─────────────────────────────────────────────────────────── @@ -137,6 +140,8 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict: out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름")) out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1) out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1) + if not copy.get("따로"): # 고침 판정 원본(836-2) = 따로가 되기 전 문서 + out[renew.FIRST] = copy["문서"] out["따로"] = True return out diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 65e3038c6..590cd571a 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -125,11 +125,18 @@ def _forget(whole: Any) -> None: _LIVE[0] = whole +def _auto(doc: Any, copy: dict) -> dict: + """SK 사본 = 프로젝트 층 밖이면 지금 M01 줄(836-2 · `M02_Sk_AutoRenew`).""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + return renew.rows(copy) if renew.is_sk(doc) else copy + + def over(doc: Any, whole: Any) -> Any: """사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길).""" if not has(doc): return whole - copy = doc[KEY] + copy = _auto(doc, doc[KEY]) tag = (copy.get("판"), copy.get("단가판")) if isinstance(whole, Copy) and whole._tag == tag: return whole @@ -290,6 +297,8 @@ def settle(doc: Any, save: bool = False) -> Any: copy = refresh(copy, whole_())[0] if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []): copy = rejoin(copy, out["더한줄"]) + if copy: + copy = _auto(doc, copy) # M01 자동 따라옴(836-2) — 저장 때 굳음 if save and copy and READS.get() is None: # 읽은 줄만(826-3) = 저장 관문이 더함 copy = grow(copy, stamp(out)) out = _formula_job(out, job) @@ -305,7 +314,8 @@ def _formula_job(out: dict, job: Any) -> dict: if got is None or job not in (PRICE, REGEN): return out if job == REGEN: - got = {k: v for k, v in got.items() if k not in ("문서", "끊음", "따로")} | {"부름": {}} + drop = ("문서", "끊음", "따로", "처음") + got = {k: v for k, v in got.items() if k not in drop} | {"부름": {}} elif got.get("따로"): got = dc.refresh(got)[0] return {**out, dc.KEY: got} diff --git a/M02_MasterTemplete/M02_Sk_AutoRenew.py b/M02_MasterTemplete/M02_Sk_AutoRenew.py new file mode 100644 index 000000000..3aed98f79 --- /dev/null +++ b/M02_MasterTemplete/M02_Sk_AutoRenew.py @@ -0,0 +1,190 @@ +"""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 + +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) + 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("이름"))) + out = {k: v for k, v in out.items() if k not in ("따로", "문서", FIRST)} + 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 + + +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)) diff --git a/resources/tester/test_795_1_sk_copy.py b/resources/tester/test_795_1_sk_copy.py index f0148bf0a..a7724b31f 100644 --- a/resources/tester/test_795_1_sk_copy.py +++ b/resources/tester/test_795_1_sk_copy.py @@ -23,6 +23,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Imported as imp from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 MAN = "LB000002" # 보통인부 diff --git a/resources/tester/test_796_2b_m02_equation.py b/resources/tester/test_796_2b_m02_equation.py index 5bddc1dcf..6b0a8e65b 100644 --- a/resources/tester/test_796_2b_m02_equation.py +++ b/resources/tester/test_796_2b_m02_equation.py @@ -32,6 +32,15 @@ from resources.tester.test_795_1_sk_copy import ( ) from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공 DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}] diff --git a/resources/tester/test_802_4a_m02_doc.py b/resources/tester/test_802_4a_m02_doc.py index db29f9e22..87d4157f6 100644 --- a/resources/tester/test_802_4a_m02_doc.py +++ b/resources/tester/test_802_4a_m02_doc.py @@ -28,6 +28,15 @@ from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor, _sc from resources.tester.test_795_2_sc_copy import _calc as _sc_calc from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + FORM = "GC000211" # 합판거푸집(옵션 많음) BASIC = "GF000089" # 토사깍기(기계) — 굴착기 시간당 × 1 / 작업량(813-2 GC001148 지움 뒤) ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"} diff --git a/resources/tester/test_821_1_my_machine.py b/resources/tester/test_821_1_my_machine.py index 108d6c920..fedef9179 100644 --- a/resources/tester/test_821_1_my_machine.py +++ b/resources/tester/test_821_1_my_machine.py @@ -26,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음 PCT = "GC001109" # M01 만 쓰는 SK 바탕 NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로) diff --git a/resources/tester/test_822_1_copy_gap.py b/resources/tester/test_822_1_copy_gap.py index 04d56e83f..a1b4fef11 100644 --- a/resources/tester/test_822_1_copy_gap.py +++ b/resources/tester/test_822_1_copy_gap.py @@ -22,6 +22,15 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_Copy as skc from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + DIG, DIG_NO, DIG_LOGIC = "EQ000022", "0201-0100", "GC000267" # 굴착기(무한궤도) 1.0㎥ DIG2_NO = "0201-0070" # 바꿔 끼울 굴착기 MAN = "LB000002" # 보통인부 diff --git a/resources/tester/test_823_1_imported_swap.py b/resources/tester/test_823_1_imported_swap.py index 3704b9561..5d79d1d7d 100644 --- a/resources/tester/test_823_1_imported_swap.py +++ b/resources/tester/test_823_1_imported_swap.py @@ -14,6 +14,8 @@ import json import os import subprocess +import pytest + from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_Doc_Sk as dsk @@ -24,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Imported as imp from resources.tester.test_822_1_copy_gap import _move from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + HERE = os.path.dirname(__file__) DIG = "GC000267" # 굴착기 시간당 경비 NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥ diff --git a/resources/tester/test_826_3_copy_slim.py b/resources/tester/test_826_3_copy_slim.py index 0961ef5cc..47faba6b8 100644 --- a/resources/tester/test_826_3_copy_slim.py +++ b/resources/tester/test_826_3_copy_slim.py @@ -14,6 +14,8 @@ import copy import json from pathlib import Path +import pytest + from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Doc_Sk_Path as path from M02_MasterTemplete import M02_Item_Prices as prices @@ -36,6 +38,15 @@ from resources.tester.test_822_1_copy_gap import ( ) from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + DIG_BASE = "GC000088" # 터파기(기계) 보통토사 — 「기계」 고르기 후보를 닫힌 묶음이 다 담음 diff --git a/resources/tester/test_827_1_machine_window.py b/resources/tester/test_827_1_machine_window.py index 0c08055ed..a3259c05d 100644 --- a/resources/tester/test_827_1_machine_window.py +++ b/resources/tester/test_827_1_machine_window.py @@ -25,6 +25,15 @@ from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_MyMachine_Router as route from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +@pytest.fixture(autouse=True) +def _manual(monkeypatch): + """836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2.""" + from M02_MasterTemplete import M02_Sk_AutoRenew as renew + + monkeypatch.setattr(renew, "on", lambda: False) + + DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음 NAME = "sub1임시 기계" USER = {"role": "USER", "company_id": 7, "user_id": 42} diff --git a/resources/tester/test_836_2_sk_auto_renew.py b/resources/tester/test_836_2_sk_auto_renew.py new file mode 100644 index 000000000..6b52cf995 --- /dev/null +++ b/resources/tester/test_836_2_sk_auto_renew.py @@ -0,0 +1,213 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""836-2 M01 → M02 단가산출 자동 따라옴(요구 854) — 임시 storage + M01 메모리 사본(정본 파일 안 고침). + +㉠ 노임 · 재료 · 기계 값 바뀜 = 누르지 않고 새 금액(수식 길 열기 · 옛 셈 길 목록 · 일위대가 펼침 같은 답) +㉡ 부른 M01 수식 문서 바뀜 = 따라옴 ㉢ 고친 문서 + 그 문서 M01 바뀜 = 다시 복사 + 알림(전 · 후 · 내 고침) +㉣ 고침 + 다른 항목만 바뀜 = 고침 그대로 ㉤ 바꿨다 되돌림 = 안 고침(알림 없음) +㉥ 프로젝트 층 = 그대로 ㉦ 가져온 식(우리) = 값만 · 식 그대로 · 열기 = 파일 안 고침 · 알림 [확인] = 저장. +""" + +from __future__ import annotations + +import copy +import shutil + +import pytest + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_822_1_copy_gap import ( + DIG, + MACHINE, + MAN, + PCT, + _at, + _live, + _move, + _sk, + _stack, + _sum, +) +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +TOOL = 2 # PCT 「공구손료 및 경장비」 금액 줄 차례 + + +def _doc_move(monkeypatch, key: str, fn) -> None: + """M01 수식 문서 하나 고침(메모리 · 폴더 그대로).""" + live = dc.folder() + new = copy.deepcopy(live[key]) + fn(new) + fake = {**live, key: new} + monkeypatch.setattr(dc, "folder", lambda: fake) + + +def _rate(text: str): + def fn(d: dict) -> None: + d["금액"][TOOL]["단가"] = text + + return fn + + +def _edit(me, key: str, text: str) -> None: + got = _at(me, path.read, key) + doc = copy.deepcopy(got["문서"]) + doc["금액"][TOOL]["단가"] = text + _at(me, path.save, key, {"문서": doc, "판": got["판"]}) + + +def _base(me) -> str: + return _sk(me, 바탕={"로직": PCT}, 단위="㎡") + + +def _labor_unit(me, key: str): + """일위대가 줄이 SK 참조를 펼치는 길(`Expand` → `calc.unit`) 1 단위 계.""" + from M02_MasterTemplete.M02_Labor_Expand import Expand + + with store.at(me): + ref = {"층": "personal", "주인": "7/42", "키": key} + whole = _live() + got = calc.unit(refs.read_ref(ref), whole, Expand(whole), ()) + return got["기준"]["계"] if got["기준"] else None + + +def _list_unit(me, key: str): + """목록 · 옛 셈 길(`calc_doc`) 1 단위 계.""" + with store.at(me): + got = calc.calc_doc(store.read("sangeun", key)["문서"], None, False) + return got["1단위"] + + +@pytest.mark.parametrize("what", ["노임", "재료", "기계"]) +def test_values_follow(world, monkeypatch, what): + """㉠ 값 바뀜 = 묻지 않고 새 금액 · 세 길 같은 답 · 열기 = 파일 안 고침 · 알림 없음.""" + me = world["personal"] + key = _sk(me) + res = {"노임": MAN, "재료": next(iter(_live().index["MT"])), "기계": DIG}[what] + link = MACHINE if what == "기계" else {"갈래": "요소", "키": res} + part = {"노임": "노무비", "재료": "재료비", "기계": "묶음"}[what] + _stack(me, key, [("자원", link, part)]) + was = _sum(me, key) + file = me / "sangeun" / f"{key}.json" + before = file.read_bytes() + _move(monkeypatch, res) + got = _at(me, path.read, key) + assert got["셈"]["계"] > was + assert got["사본"]["자동"] is True and got["사본"]["갱신"] == [] + assert not got["사본"]["단가새값"] and not got["사본"]["M01새판"] + unit = _list_unit(me, key) + assert unit["계"] == pytest.approx(got["셈"]["계"]) + assert _labor_unit(me, key) == pytest.approx(got["셈"]["계"]) + assert file.read_bytes() == before # 읽기 = 작업본 안 씀 + + +def test_logic_follows(world, monkeypatch): + """㉡ 부른 M01 수식 문서(기계 시간당) 바뀜 = 따라옴 · 알림 없음.""" + me = world["personal"] + key = _sk(me) + _stack(me, key, [("기계", MACHINE, "묶음")]) + was = _sum(me, key) + + def twice(d: dict) -> None: + for line in d["금액"]: + line["수량"] = f"({line.get('수량') or 1}) × 2" + + _doc_move(monkeypatch, MACHINE["키"], twice) + got = _at(me, path.read, key) + assert got["셈"]["계"] > was * 1.5 # 율 줄은 누계를 받아 두 배 넘음 + assert got["사본"]["갱신"] == [] and not got["사본"]["문서새판"] + + +def test_edited_item_recopied(world, monkeypatch): + """㉢ 고친 문서 + 그 M01 문서 바뀜 = 고침 지우고 다시 복사 + 알림(전 · 후 · 내 고침) · + [확인](저장) 뒤 알림 없음 · 따로 아님.""" + me = world["personal"] + key = _base(me) + plain = _sum(me, key) + _edit(me, key, "노무비 × 5%") + assert _sum(me, key) > plain + _doc_move(monkeypatch, PCT, _rate("노무비 × 4%")) + got = _at(me, path.read, key) + labor = got["셈"]["노무비"] + assert got["셈"]["경비"] == pytest.approx(int(labor * 0.04), abs=1) + assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%" + [note] = got["사본"]["갱신"] + assert note["키"] == PCT and note["갈래"] == "문서" + assert any("3%" in x and "4%" in x for x in note["M01"]) + assert any("3%" in x and "5%" in x for x in note["내고침"]) + _at(me, path.save, key, {"할일": "갱신확인", "판": got["판"]}) + after = _at(me, path.read, key) + assert after["사본"]["갱신"] == [] and after["사본"]["따로"] is False + assert after["셈"]["계"] == got["셈"]["계"] + + +def test_edit_kept_when_other_item_moves(world, monkeypatch): + """㉣ 고침 + 다른 항목(노임 값)만 바뀜 = 고침 그대로 · 값은 따라옴.""" + me = world["personal"] + key = _base(me) + _edit(me, key, "노무비 × 5%") + was = _at(me, path.read, key)["셈"] + _move(monkeypatch, MAN) + got = _at(me, path.read, key) + assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%" + assert got["사본"]["따로"] is True and got["사본"]["갱신"] == [] + assert got["셈"]["노무비"] > was["노무비"] + + +def test_reverted_is_not_edit(world, monkeypatch): + """㉤ 바꿨다 되돌려 저장 = 안 고침 — M01 바뀌면 조용히 따라옴(알림 없음).""" + me = world["personal"] + key = _base(me) + _edit(me, key, "노무비 × 5%") + _edit(me, key, "노무비 × 3%") + _doc_move(monkeypatch, PCT, _rate("노무비 × 4%")) + got = _at(me, path.read, key) + assert got["사본"]["갱신"] == [] + assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%" + + +def test_project_layer_keeps(world, monkeypatch, tmp_path): + """㉥ 프로젝트 층 사본 = 그대로(M01 바뀌어도 금액 같음 · 고침 그대로).""" + me = world["personal"] + key = _base(me) + _edit(me, key, "노무비 × 5%") + proj = me.parent / "p836" / "templates" + (proj / "sangeun").mkdir(parents=True) + shutil.copy(me / "sangeun" / f"{key}.json", proj / "sangeun" / f"{key}.json") + with store.at(proj): + assert refs.here()[0] == "project" + was = _at(proj, path.read, key) + _move(monkeypatch, MAN) + _doc_move(monkeypatch, PCT, _rate("노무비 × 4%")) + got = _at(proj, path.read, key) + assert got["셈"]["계"] == was["셈"]["계"] and got["사본"]["자동"] is False + assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%" and got["사본"]["갱신"] == [] + + +def test_imported_values_only(world, monkeypatch): + """㉦ 가져온 식 단가기준 우리 = 이음 노임만 따라옴 · 가져온 줄 식 그대로 · 엑셀 기준 = 엑셀 값.""" + from M02_MasterTemplete import M02_Sangeun_Imported as imp + + me = world["personal"] + man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"} + row = { + "이름": "sub2임시 가져온", + "결과단위": "원/㎡", + "호표": [{**man, "단가": 165545, "이음": MAN}], + } + key = _at(me, lambda: imp.create(row, None, "sub2임시 가져온")["이름"]) + excel = _sum(me, key) + doc = _at(me, lambda: store.read("sangeun", key)["문서"]) + ours = _at(me, lambda: imp.to_price(doc, "우리")["문서"]) + _at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], ours)) + was = _at(me, path.read, key) + _move(monkeypatch, MAN) + got = _at(me, path.read, key) + assert got["셈"]["계"] == pytest.approx(was["셈"]["계"] * 2, abs=2) + assert got["문서"]["금액"] == was["문서"]["금액"] and got["사본"]["갱신"] == [] + back = _at(me, lambda: imp.to_price(store.read("sangeun", key)["문서"], "엑셀")["문서"]) + _at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], back)) + assert _sum(me, key) == excel