diff --git a/M02_MasterTemplete/M02_Labor_Copy.py b/M02_MasterTemplete/M02_Labor_Copy.py new file mode 100644 index 000000000..e5f805188 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Copy.py @@ -0,0 +1,146 @@ +"""M02 일위대가 · 사전조합(SC) 로직 사본(PLAN 795-2 · 요구 655 · 656 · 요구서_655) — 서버 단독. + +SK 사본(`M02_Sangeun_Copy` · 795-1) 한 벌 — 문서 칸 `사본` 꼴 · 판 · 단가 · 할일 칸 `사본할일` +(단가갱신 | 재생성) · [단가 갱신] = 자원 단가 줄만 · 자동 갱신 없음 · 지워진 M01 키 = 마지막 값. +사본에 담는 것 = 담은로직 로직 줄 · 줄 덮개(`M02_Labor_Patch`) 더한줄(로직 · 요소 · 고정 이음) · +고정 줄 이음 · 사용자 자재(MT) 가 닿는 M01 닫힌 묶음(참조 줄 = 그 문서 제 사본). +셈 = `M02_Labor_Doc.calc` 층 셈(whole 안 줌) 첫머리 `live` — M01 색인 위 사본 줄(`skc.Copy`) · +줄 덮개 = 사본 위 고친 칸(cid 열쇠 · [재생성] 뒤 다시 얹힘 · 못 얹은 칸 = 짝잃음) · +사전조합 1 단위 값(`Expand`) = 그 SC 사본(`combo`) · +프로젝트 층 · B09(단가 책 · 굳힌 참조사본) = 옛 길(795-4). +사본 정함(`settle` · 셈 · 저장 첫머리) = 받은 사본 먼저(열 복사 · 층 복사 = 원본 사본 그대로 · 판은 +서버가 다시 셈) → 없으면 저장본(일위대가 = labor/<열> · SC = precombo/<키>) → [저장] 때 없으면 새로 +찍음 · 사본에 없는 새 줄만 지금 M01 에서 더함 · 사본 없는 옛 문서 = 셈은 옛 길. +길(`M02_Labor_Router`) POST …/labor/{열 | SC 키}/copy/{price|regen} {doc, 수량?} → + {doc(사본할일), 전, 후, 바뀐자원 | 못얹은}(저장 없음). +짝(CLAUDE.md 5장 ①) `M02_MasterTemplete_Labor_Calc.ts` = 서버 기준 값을 받아 수량 · 반영 · 합만 — +사본은 기준 값에만 닿음(짝 안 고침 · 거울 시험 `test_795_2_sc_copy`). +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Copy as skc + +KEY, JOB, PRICE, REGEN = skc.KEY, skc.JOB, skc.PRICE, skc.REGEN +_SC = re.compile(r"^SC-\d{6}$") + + +def _project() -> bool: + from M02_MasterTemplete import M02_Labor_Ref as refs + + return refs.here()[0] == "project" + + +def stamp(doc: dict) -> dict | None: + """지금 M01 로 새 사본 — 닿을 것 없음(참조 줄 · 손 단가 고정 줄만) = None.""" + lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict)] + keys = [str(x["로직"]) for x in lines if x.get("로직")] + links = [str(v) for x in lines for v in ((x.get("고정") or {}).get("이음") or {}).values() if v] + for x in lines: + more = skc.reach((x.get("패치") or {}).get("더한줄")) + keys, links = keys + more[0], links + more[1] + links += [ + str(m["코드"]) for m in doc.get("자재") or [] if isinstance(m, dict) and m.get("코드") + ] + return skc.snap(list(dict.fromkeys(keys)), list(dict.fromkeys(links))) + + +def _stored(doc: dict) -> dict | None: + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + key = str(doc.get("키") or doc.get("열") or "") + kind = "precombo" if _SC.match(key) else "labor" + try: + got = json.loads(store._path(kind, key).read_bytes()) + except (OSError, ValueError, store.StoreError): + return None + return got[KEY] if skc.has(got) else None + + +def settle(doc: Any, save: bool = False) -> Any: + """셈 · 저장 첫머리 — 서버가 정한 사본으로 갈음(할일 · 저장 때 찍음 · 새 줄 · 할일 칸 뗌).""" + if not isinstance(doc, dict) or _project(): + return doc + job = doc.get(JOB) # 모르는 할일 = 없음 + out = {k: v for k, v in doc.items() if k not in (KEY, JOB)} + copy = skc.taken(doc.get(KEY)) or _stored(doc) + if job == REGEN or (copy is None and save): + copy = stamp(out) + elif job == PRICE and copy: + copy = skc.refresh(copy, skc.whole_())[0] + if save and copy: + copy = skc.grow(copy, stamp(out)) + return {**out, KEY: copy} if copy else out + + +def keep(raw: Any, out: dict) -> dict: + """정리한 문서에 사본 · 할일 칸을 실어 보냄(꼴 검사는 `settle` 몫).""" + if not isinstance(raw, dict): + return out + got = {k: raw[k] for k in (KEY, JOB) if raw.get(k) is not None} + return {**out, **got} + + +def live(doc: dict) -> Any: + """층 셈 색인 — 사본 있는 문서 = M01 위 사본 줄 · 없거나 프로젝트 층 = 지금 M01.""" + from M01_MasterData import M01_MasterData_Store as m01 + + whole = m01.loaded()[1] + return whole if _project() else skc.over(settle(doc), whole) + + +def combo(expand: Any, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]: + """사전조합 1 단위 값 — 그 SC 사본 색인으로(층 셈만 · 프로젝트 = 굳힌 단가 그대로).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + was = expand.whole + if expand.read is refs.read_ref and skc.has(doc): + expand.whole = skc.over(doc, was) + try: + return expand._combo(doc, trail) + finally: + expand.whole = was + + +def mark(ans: dict, doc: dict) -> dict: + """셈 답 덧칸 `사본` {시점, 단가시점, M01새판, 지운키, 단가새값} — 사본 없음 = 칸 없음.""" + return skc.mark(ans, doc) + + +# ── [단가 갱신] · [재생성] 미리 셈(저장 없음) ────────────────────────── +def _money(ans: dict) -> dict: + return {k: ans.get(k) for k in ("기준합", "반영합")} + + +def _lost(ans: dict) -> dict[str, dict]: + got = {} + for n, line in enumerate(ans.get("줄") or []): + for x in line.get("짝잃음") or []: + if isinstance(x, dict): + got[f"{n}|{x.get('cid')}"] = {**x, "줄": n} + return got + + +def preview(doc: dict, job: str, given: dict | None = None) -> dict[str, Any]: + """{doc(할일 얹음), 전, 후, 바뀐자원 · 못얹은} — `doc` = 정리한 문서 · 재생성 ⊃ 단가갱신.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + new = REGEN if REGEN in (doc.get(JOB), job) else job + after_doc = {**doc, JOB: new} + before, after = labor.calc(doc, given), labor.calc(after_doc, given) + out: dict[str, Any] = {"doc": after_doc, "전": _money(before), "후": _money(after)} + if job == PRICE: + copy = skc.taken(doc.get(KEY)) or _stored(doc) + out["바뀐자원"] = skc.refresh(copy, skc.whole_())[1] if copy else [] + else: + was = _lost(before) + out["못얹은"] = [ + {"줄": x["줄"], "cid": x.get("cid"), "까닭": x.get("까닭") or "새 사본에 그 줄 없음"} + for c, x in _lost(after).items() + if c not in was + ] + return out diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 0f98b6287..3a9a24006 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -42,7 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs -from M02_MasterTemplete import M02_Labor_Patch as patch +from M02_MasterTemplete import M02_Labor_Copy as lc, M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -102,7 +102,7 @@ def clean(doc: Any) -> dict[str, Any]: 모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것.""" if not isinstance(doc, dict): raise _bad("일위대가 문서가 묶음 아님") - whole = m01.loaded()[1] + whole = lc.live(doc) # 로직 사본(795-2) — 지워진 M01 키도 사본 줄로 lines, seen, here = [], set(), None for at, one in enumerate(doc.get("담은로직") or [], start=1): where = f"단가산출 기초 {at}째" @@ -203,7 +203,7 @@ def clean(doc: Any) -> dict[str, Any]: cols = spec.clean(doc.get("규격"), {spec.line_key(x) for x in [*lines, *mats]}) except spec.SpecError as e: raise _bad(str(e)) from e - return {**out, "규격": cols} if cols else out + return lc.keep(doc, {**out, "규격": cols} if cols else out) # 사본 칸(795-2) def _alias(alias: Any, where: str) -> str | None: @@ -501,7 +501,7 @@ def calc( 프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) · `read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층.""" - whole, have = m01.loaded()[1] if whole is None else whole, _area_of(doc.get("열")) if given is None else given # fmt: skip + whole, have = lc.live(doc) if whole is None else whole, _area_of(doc.get("열")) if given is None else given # fmt: skip expand = refs.Expand(whole, read or refs.read_ref, have) # 사전조합 안 줄 수량 = 알약 값(723-1) if pills is None: pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] @@ -665,7 +665,7 @@ def _json(value: Any) -> Any: def server(doc: Any) -> dict[str, Any]: """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) · 사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈).""" - got = clean(doc) + got = lc.settle(clean(doc), True) # 로직 사본(795-2) — 찍기 · 할일 · 새 줄 out = calc(got) no_twice(got, out) return {**got, "계산값": {**out, **spec_sums(got, out)}} diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 8a8b872f0..7be93b02a 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -13,6 +13,7 @@ from collections.abc import Callable from decimal import Decimal from typing import Any +from M02_MasterTemplete import M02_Labor_Copy as lc from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs @@ -68,8 +69,8 @@ class Expand: self.memo[rid] = self._promote(doc) elif refs.kind_of(ref["키"]) == "sangeun": # 단가산출(715-2) = 고친 줄 셈 1 단위 self.memo[rid] = calc.unit(doc, self.whole, self, (*trail, rid)) - else: - self.memo[rid] = self._combo(doc, (*trail, rid)) + else: # 사전조합 = 그 SC 로직 사본(795-2) + self.memo[rid] = lc.combo(self, doc, (*trail, rid)) return self.memo[rid] @staticmethod diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 169bf4588..e6df8985c 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -214,7 +214,9 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None - return out + from M02_MasterTemplete import M02_Labor_Copy as lc + + return lc.settle(lc.keep(doc, out), True) # 로직 사본(795-2) def is_rate(one: Any) -> bool: diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 9513f0af8..5db9ebb66 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Copy as lc from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access @@ -53,7 +54,34 @@ def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]: def _calc(column: str, body: CalcBody) -> dict[str, Any]: doc = _doc(column, body.doc) - return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)} + got = lc.mark(labor.calc(doc, body.수량), lc.settle(doc)) # 사본 표지(795-2) + return {**got, "알약": labor.pills(doc)} + + +_JOBS = {"price": lc.PRICE, "regen": lc.REGEN} + + +@router.post("/layers/{layer}/labor/{column}/copy/{how}") +def labor_copy( + layer: Layer, + column: str, + how: str, + body: CalcBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """로직 사본(795-2) — price [단가 갱신] · regen [재생성] · 일위대가 열 · 사전조합 SC 키 · + 답 = 할일 얹은 doc(화면 캐시 · [저장] 때 서버가 적용) + 전 · 후 합 + 바뀐자원 | 못얹은.""" + if how not in _JOBS: + raise HTTPException(status_code=404, detail="price · regen") + return _call(_root(session, layer, owner), _copy_job, column, body, _JOBS[how]) + + +def _copy_job(column: str, body: CalcBody, job: str) -> dict[str, Any]: + """미리 셈은 정리한 문서로 · 답 doc = 보낸 화면 문서 + 할일(SC · 일위대가 꼴 그대로).""" + got = lc.preview(_doc(column, body.doc), job, body.수량) + sent = body.doc if body.doc is not None else store.read("labor", column)["문서"] + return {**got, "doc": {**sent, lc.JOB: got["doc"][lc.JOB]}} def _pills(column: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index 006e20cc2..391039095 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -150,6 +150,7 @@ class Same: "담은로직": _sorted([self._line(x, trail, False) for x in doc.get("담은로직") or []]), "자재": _sorted(mats), "규격": norm(doc.get("규격")), + "사본": skc.same_part(doc), # 로직 사본(795-2) — 지금 M01 과 다른 판만 } return {k: v for k, v in body.items() if v not in EMPTY} diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 068903f2b..0501892a5 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -40,7 +40,7 @@ RES = ("LB", "MT", "EQ", "MO", "FX") # 자원 단가 = 단가 기준 시점 묶 _MASTER = prices.mf.Master -def _whole() -> Any: +def whole_() -> Any: return prices.m01.loaded()[1] @@ -142,17 +142,29 @@ def stamp(doc: dict) -> dict | None: base = doc.get("바탕") or {} if "참조" in base: return None - adds = doc.get("더한줄") or [] - keys = [str(base["로직"])] if base.get("로직") else [] - keys += [str(x["로직"]) for x in adds if x.get("종류") == "로직" and x.get("로직")] - links = [v for x in adds for v in ((x.get("고정") or {}).get("이음") or {}).values() if v] + keys, links = reach(doc.get("더한줄")) + keys = [str(base["로직"]), *keys] if base.get("로직") else keys extra = [row] if (row := imp.row_of(doc)) else [] + return snap(keys, links, extra) + + +def reach(adds: Any) -> tuple[list[str], list[str]]: + """더한줄이 닿는 (로직 키, 요소 키) — 로직 줄 · 인력 · 재료 · 기계 요소 · 고정 줄 이음.""" + adds = [x for x in adds or [] if isinstance(x, dict)] + keys = [str(x["로직"]) for x in adds if x.get("종류") == "로직" and x.get("로직")] + links = [str(x["요소"]) for x in adds if x.get("종류") in sd.ADD_PART and x.get("요소")] + links += [str(v) for x in adds for v in ((x.get("고정") or {}).get("이음") or {}).values() if v] + return keys, links + + +def snap(keys: list[str], links: list[str], extra: list[dict] | None = None) -> dict | None: + """키 · 요소 · 마스터 밖 줄이 닿는 M01 닫힌 묶음 → 사본(닿을 것 없음 = None).""" if not (keys or links or extra): return None - got = prices.collect(keys, [str(v) for v in links], extra) + got = prices.collect(keys, links, extra) lost = got.pop("_빠진", None) keys = [str(r.get("키")) for data in got.values() for r in data[prices.m01.items_key(data)]] - whole = _whole() # 줄 = 셈 색인 것(파일 묶음과 같은 판) + whole = whole_() # 줄 = 셈 색인 것(파일 묶음과 같은 판) rows = [whole.index[k[:2]][k] for k in keys if k in whole.index.get(k[:2], {})] out = _split(prices.to_json(rows)) return out | ({"빠진": lost} if lost else {}) @@ -177,9 +189,8 @@ def refresh(copy: dict, whole: Any) -> tuple[dict, list[dict]]: return {**copy, "단가": rows, "단가판": _fp(rows), "단가시점": _now()}, changed -def _grow(copy: dict, doc: dict) -> dict: - """저장 때 — 사본에 없는 키 줄만 지금 M01 에서 더함(있는 줄 = 사본 그대로).""" - fresh = stamp(doc) +def grow(copy: dict, fresh: dict | None) -> dict: + """저장 때 — 사본에 없는 키 줄만 새 사본(`fresh` = 지금 M01)에서 더함(있는 줄 = 그대로).""" have = {str(r.get("키")) for r in [*copy["로직"], *(copy.get("단가") or [])]} new = [ r @@ -193,7 +204,7 @@ def _grow(copy: dict, doc: dict) -> dict: return {**copy, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)} -def _taken(raw: Any) -> dict | None: +def taken(raw: Any) -> dict | None: """받은 사본(층 복사 · 새로) — 줄 꼴만 보고 판은 서버가 다시 셈.""" if not has({KEY: raw}): return None @@ -223,13 +234,13 @@ def settle(doc: Any, save: bool = False) -> Any: out = {k: v for k, v in doc.items() if k not in (KEY, JOB)} if "참조" in (doc.get("바탕") or {}): return out - copy = _stored(doc.get("키")) or _taken(doc.get(KEY)) + copy = _stored(doc.get("키")) or taken(doc.get(KEY)) if job == REGEN or (copy is None and save): copy = stamp(out) elif job == PRICE and copy: - copy = refresh(copy, _whole())[0] + copy = refresh(copy, whole_())[0] if save and copy: - copy = _grow(copy, out) + copy = grow(copy, stamp(out)) return {**out, KEY: copy} if copy else out @@ -259,7 +270,7 @@ def mark(ans: dict, doc: dict) -> dict: return ans copy = doc[KEY] head = {"시점": copy.get("시점"), "단가시점": copy.get("단가시점")} - return {**ans, KEY: {**head, **state(copy, _whole())}} + return {**ans, KEY: {**head, **state(copy, whole_())}} def same_part(doc: Any) -> str | None: @@ -267,7 +278,7 @@ def same_part(doc: Any) -> str | None: if not has(doc): return None copy = doc[KEY] - seen = state(copy, _whole()) + seen = state(copy, whole_()) fresh = not (seen["M01새판"] or seen["지운키"] or seen["단가새값"]) return None if fresh else f"{copy.get('판')}:{copy.get('단가판')}" @@ -296,8 +307,8 @@ def preview(doc: Any, job: str, given: dict | None = None) -> dict[str, Any]: after = calc.calc_doc(after_doc, given, False) out: dict[str, Any] = {"문서": after_doc, "전": _money(before), "후": _money(after)} if job == PRICE: - copy = _stored(doc.get("키")) or _taken(doc.get(KEY)) - out["바뀐자원"] = refresh(copy, _whole())[1] if copy else [] + copy = _stored(doc.get("키")) or taken(doc.get(KEY)) + out["바뀐자원"] = refresh(copy, whole_())[1] if copy else [] else: was = _lost(before) out["못얹은"] = [ diff --git a/resources/tester/test_795_2_sc_copy.py b/resources/tester/test_795_2_sc_copy.py new file mode 100644 index 000000000..09c544ad4 --- /dev/null +++ b/resources/tester/test_795_2_sc_copy.py @@ -0,0 +1,186 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""795-2 일위대가 · 사전조합(SC) 로직 사본 — 임시 storage · M01 은 메모리 덮개(정본 안 고침). + +① 저장 때 사본 · M01 로직 바꿔도 일위대가 · SC 금액 그대로 · 배지 ② 노임 바꿔도 [단가 갱신] 전엔 +그대로 · 누르면 따라감 ③ [재생성] 뒤 줄 덮개 다시 얹힘 · 못 얹은 칸 알림 ④ 층 복사 · 열 복사 = +사본 그대로 ⑤ TS 짝(Labor_Calc.ts settle) 같은 금액 ⑥ 사본 없는 옛 문서 = 옛 길. +""" + +from __future__ import annotations + +import copy +import json + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Copy as lc +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from resources.tester.test_795_1_sk_copy import _changed, _fake, _live +from resources.tester.test_m02_labor_calc_mirror import _same, _ts +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」 +LB = "LB000036" +COVER = {"덮개": {f"labor:{LB}#0": {"qty": 0.5}}} +COL = "sub2임시" + + +def _sc(folder, lines): + with store.at(folder): + body = {"이름": "sub2임시 조합", "단위": "㎡", "담은로직": lines} + return refs.create("precombo", body)["이름"] + + +def _read(folder, kind, key): + with store.at(folder): + return store.read(kind, key)["문서"] + + +def _write(folder, kind, key, doc): + with store.at(folder): + have = ( + store.read(kind, key)["판"] if (store.root() / kind / f"{key}.json").is_file() else "" + ) + store.write(kind, key, have, doc) + + +def _labor(folder, lines): + _write(folder, "labor", COL, {"열": COL, "담은로직": lines, "자재": []}) + return _read(folder, "labor", COL) + + +def _calc(folder, key, doc=None): + """화면 셈 길(`Labor_Router._calc`) — 열 = 일위대가 열 · SC 키.""" + with store.at(folder): + kind = "precombo" if key.startswith("SC-") else "labor" + got = labor.clean({**(doc or store.read(kind, key)["문서"]), "열": key}) + return lc.mark(labor.calc(got, {}), lc.settle(got)) + + +def _unit(folder, key): + """SC 1 단위 값(쓰는 쪽 `Expand` · 안 줄 수량 1).""" + ref = {"층": "personal", "주인": "7/42", "키": key} + with store.at(folder): + lines = refs.read_ref(ref)["담은로직"] + given = {f"{refs.ref_id(ref)}#{i}": 1 for i in range(len(lines))} + return refs.Expand(m01.loaded()[1], given=given)(ref)["기준"]["계"] + + +def _double(key): + row = copy.deepcopy(_live().get(key)) + row["값"] = row["값"] * 2 + return row + + +def test_copy_stamped_logic_change_kept(world, monkeypatch): + """① 일위대가 · SC 저장 때 사본 · M01 로직 바꿔도 금액 그대로 · M01 새 판 배지.""" + me = world["personal"] + sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}]) + doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}]) + for got in (doc, _read(me, "precombo", sc)): + assert PCT in {r["키"] for r in got["사본"]["로직"]} + assert LB in {r["키"] for r in got["사본"]["단가"]} + was = (_calc(me, COL)["반영합"]["계"], _calc(me, sc)["기준합"]["계"], _unit(me, sc)) + assert _calc(me, COL)["사본"]["M01새판"] is False + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + assert labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] != was[2] # M01 셈은 바뀜 + now = (_calc(me, COL)["반영합"]["계"], _calc(me, sc)["기준합"]["계"], _unit(me, sc)) + assert now == was + assert _calc(me, COL)["사본"]["M01새판"] is True + assert labor.server(_read(me, "labor", COL))["계산값"]["반영합"]["계"] == was[0] # 다시 저장 + + +def test_price_refresh_only_on_press(world, monkeypatch): + """② 노임 바꿈 → 그대로 · [단가 갱신] 미리 셈 = 새로 찍은 문서 값 · [저장] 때 적용.""" + me = world["personal"] + sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}]) + was = _calc(me, sc)["기준합"]["계"] + _fake(monkeypatch, [_double(LB)]) + assert _calc(me, sc)["기준합"]["계"] == was and _unit(me, sc) == was + fresh = _calc(me, _sc(me, [{"로직": PCT, "수량": {"손": 1}}]))["기준합"]["계"] + assert fresh != was + with store.at(me): + got = lc.preview(labor.clean({**_read(me, "precombo", sc), "열": sc}), lc.PRICE) + assert got["전"]["기준합"]["계"] == was and got["후"]["기준합"]["계"] == fresh + assert [x["키"] for x in got["바뀐자원"]] == [LB] + _write(me, "precombo", sc, {**_read(me, "precombo", sc), lc.JOB: got["doc"][lc.JOB]}) + saved = _read(me, "precombo", sc) + assert lc.JOB not in saved and _unit(me, sc) == fresh + assert _calc(me, sc)["사본"]["단가새값"] is False + + +def test_regen_reapplies_line_cover(world, monkeypatch): + """③ [재생성] — 줄 덮개(cid) 새 사본에 다시 얹힘 · 없어진 줄 덮개 = 못얹은 · 배지 꺼짐.""" + me = world["personal"] + two = {"덮개": {f"labor:{LB}#0": {"qty": 0.5}, "labor:LB000002#0": {"qty": 0.7}}} + _labor(me, [{"로직": PCT, "수량": {"손": 1}, "패치": two}]) + row = copy.deepcopy(_live().logic(PCT)) + row["호표"] = [x for n, x in enumerate(row["호표"]) if n != 1] # 둘째 줄 없앤 새 판 + _fake(monkeypatch, [row]) + assert not _calc(me, COL)["줄"][0].get("짝잃음") # 사본 = 옛 판 그대로 + with store.at(me): + got = lc.preview(labor.clean(_read(me, "labor", COL)), lc.REGEN) + assert [x["cid"] for x in got["못얹은"]] == ["labor:LB000002#0"] + assert got["전"]["기준합"]["계"] != got["후"]["기준합"]["계"] + _write(me, "labor", COL, got["doc"]) + doc = _read(me, "labor", COL) + assert doc["담은로직"][0]["패치"]["덮개"][f"labor:{LB}#0"] == {"qty": 0.5} + now = _calc(me, COL) + assert now["사본"]["M01새판"] is False and now["기준합"]["계"] == got["후"]["기준합"]["계"] + + +def test_layer_and_column_copy_keep_copy(world, monkeypatch): + """④ 층 사이 복사(회사 → 개인) · 열 복사 = 원본 사본 그대로(지금 M01 로 다시 안 찍음).""" + co, me = world["company"], world["personal"] + src = _sc(co, [{"로직": PCT, "수량": {"손": 1}, "패치": COVER}]) + first = _read(co, "precombo", src)["사본"] + old = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])["사본"] # 받는 열 = 옛 M01 사본 + _fake(monkeypatch, [_double(LB), _changed(_live().logic(PCT))]) + ref = {"층": "company", "주인": "7", "키": src} + with store.at(me): + got = refs.create( + "precombo", refs.carry(refs.origin_mark(refs.read_ref(ref), ref), ("company", "7")) + ) + mine = _read(me, "precombo", got["이름"])["사본"] + assert (mine["판"], mine["단가판"]) == (first["판"], first["단가판"]) + _write(me, "labor", "sub2임시2", {"열": "sub2임시2", "담은로직": [{"로직": PCT}], "자재": []}) + other = _read(me, "labor", "sub2임시2")["사본"] # 원본 열 = 바뀐 M01 사본 + assert other["판"] != old["판"] + with store.at(me): # 열 복사(copy_from) = 받은 사본 먼저 + store.copy_from("labor", COL, "sub2임시2") + assert _read(me, "labor", COL)["사본"]["판"] == other["판"] + + +def test_ts_mirror_same_amount(world, monkeypatch): + """⑤ 사본 셈 답 → TS 짝 settle 같은 답 · M01 바뀐 뒤에도 같은 금액.""" + me = world["personal"] + doc = _labor(me, [{"로직": PCT, "수량": None}]) + with store.at(me): + before = labor.calc(labor.clean(doc), {}) + _fake(monkeypatch, [_double(LB), _changed(_live().logic(PCT))]) + with store.at(me): + clean = labor.clean(_read(me, "labor", COL)) + answer = labor.calc(clean, {}) + assert answer["기준합"] == before["기준합"] + key = answer["줄"][0]["알약"] + for values in ({}, {key: 3}): + _same(labor.settle(answer, clean, values), _ts(answer, clean, values)) + + +def test_old_doc_without_copy_old_path(world, monkeypatch): + """⑥ 사본 없는 옛 일위대가 = 셈은 지금 M01(옛 길) · 다시 저장하면 그때 사본.""" + me = world["personal"] + _labor(me, [{"로직": PCT, "수량": {"손": 1}}]) + path = me / "labor" / f"{COL}.json" + raw = {k: v for k, v in json.loads(path.read_bytes()).items() if k != "사본"} + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + assert "사본" not in _calc(me, COL) + _fake(monkeypatch, [_double(LB)]) + moved = labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] + assert _calc(me, COL)["기준합"]["계"] == pytest.approx(moved, abs=1) + with store.at(me): + assert skc.has(labor.server(raw))