diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 8ae8b8bfe..e185ae1ea 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -188,7 +188,7 @@ export function createStackScreen(opt: StackScreenOptions) { ctl?.abort(); ctl = new AbortController(); const mine = ++seq; - void call<{ 연결: DocRecord }>( + void call<{ 연결: DocRecord; 사본?: CopyMark }>( "/preview", cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() }, ctl.signal, @@ -197,6 +197,7 @@ export function createStackScreen(opt: StackScreenOptions) { if (mine !== seq) return; cache.merge(got.연결); cache.merge({ 문서: cut }); + if (got.사본) mark = got.사본; // 새로 읽은 줄 「사본 없는 줄 n」(826-3) recalc(); draw(); }) diff --git a/M02_MasterTemplete/M02_Copy_Slim.py b/M02_MasterTemplete/M02_Copy_Slim.py new file mode 100644 index 000000000..1aa3dde11 --- /dev/null +++ b/M02_MasterTemplete/M02_Copy_Slim.py @@ -0,0 +1,107 @@ +"""사본 = 셈이 실제로 읽은 줄만(PLAN 826-3 · 요구 806 · 808 · 조사 804 5 절) — 서버 단독. + +[저장] 관문(`M02_MasterTemplete_Store._server` — SK · 일위대가 · 사전조합 · 층 복사 · 열 복사)에서: + `saved` 동안 사본 셈 색인(`skc.Copy.get`)이 내준 줄 키를 그 색인의 사본 줄 묶음별로 적음 + (`skc.READS`) → 문서 사본(`사본` · `수식` 값 몫)을 이 문서 셈이 읽은 줄만 남김 · 읽었는데 + 사본에 없는 줄(옵션 · 기계 · 지역을 바꿔 새로 읽은 줄) = 지금 M01(+ 층 내 기계) 값으로 더함. +이 문서 셈 = 사본 줄 묶음이 이 문서 사본 안에 드는 색인(남의 SK · SC 참조 셈 = 제 사본 → 안 셈). +파일 꼴 그대로 — 옛 코드는 사본에 없는 줄을 지금 M01 로 읽음(지금 옵션에서 안 읽는 줄 = 금액 같음). +읽은 줄 없음(셈 안 돎) · 프로젝트 층 = 그대로 · 이미 저장된 파일은 다음 [저장] 때 줄어듦. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_Sangeun_Copy as skc + + +def _keys(rows: list[dict]) -> set[str]: + return {str(r.get("키")) for r in rows} + + +def _rows(copy: dict) -> list[dict]: + return [*copy["로직"], *(copy.get("단가") or [])] + + +def cut(copy: Any, keys: set[str], pool: dict | None = None) -> Any: + """값 사본(`skc` 꼴) → `keys` 줄만 · 사본 없는 줄 = `pool`(다른 칸 사본) › 지금 M01(+ 내 기계) · + 판은 바뀐 몫만 다시.""" + if not skc.has({skc.KEY: copy}) or not keys: + return copy + rows = _rows(copy) + have = _keys(rows) + whole = skc.whole_() + add = [ + (pool or {}).get(k) or prices.to_json(whole.index[k[:2]][k]) + for k in sorted(keys - have) + if k in (pool or {}) or k in whole.index.get(k[:2], {}) + ] + keep = [r for r in rows if str(r.get("키")) in keys] + add + logic = [r for r in keep if str(r.get("키"))[:2] not in skc.RES] + cost = [r for r in keep if str(r.get("키"))[:2] in skc.RES] + out = {**copy, "로직": logic, "단가": cost} + if logic != copy["로직"]: + out["판"] = skc._fp(logic) + if cost != (copy.get("단가") or []): + out["단가판"] = skc._fp(cost) + return out + + +def cut_doc(doc: Any, reads: dict) -> Any: + """문서의 로직 사본(`사본`) · 수식 사본 값 몫(`수식`)을 두 칸 같은 줄로 줄임 — + 줄 = 사본 줄 묶음이 두 칸 안에 드는 색인이 읽은 것 + 문서 안 내 기계 키(822-1) · + 읽은 줄 없음 = 그대로.""" + if not isinstance(doc, dict): + return doc + old = doc[skc.KEY] if skc.has(doc) else None + form = doc[dc.KEY] if dc.has(doc) and isinstance(doc[dc.KEY].get("로직"), list) else None + mine = {str(r.get("키")): r for c in (form, old) if c for r in _rows(c)} + keys = {k for box, got in reads.items() if box <= mine.keys() for k in got} + if not keys: + return doc # 셈이 이 사본을 안 읽음 — 그대로 + keys |= set(mym.keys_in({k: v for k, v in doc.items() if k not in (skc.KEY, dc.KEY)})) + out = dict(doc) + if form: + out[dc.KEY] = {**form, **cut(dc.values_of(form), keys)} + if old: + pool = {str(r.get("키")): r for r in _rows(out[dc.KEY])} if form else None + out[skc.KEY] = cut(old, keys, pool) + return out + + +def _project() -> bool: + from M02_MasterTemplete import M02_Labor_Ref as refs + + return refs.here()[0] == "project" + + +def _record(kind: str, out: Any) -> None: + """저장 길이 이 문서 셈을 안 돌린 몫만 한 번 셈(읽은 줄 적기) — 사전조합 · 수식 길로 고친 SK.""" + if not isinstance(out, dict): + return + if kind == "precombo" and skc.has(out): + from M02_MasterTemplete import M02_Labor_Doc as labor + + labor.calc(out, {}) + elif kind == "sangeun" and dc.has(out) and out[dc.KEY].get("따로"): + from M02_MasterTemplete import M02_Doc_Sk as dsk + + dsk.run(out, out[dc.KEY]) + + +def saved(kind: str, make: Callable[[], Any]) -> Any: + """[저장] 관문 — `make()`(정리 · 셈 · 사본 정함) 동안 읽은 줄을 적고 사본을 그 줄로 줄임.""" + if _project(): + return make() + reads: dict = {} + token = skc.READS.set(reads) + try: + out = make() + _record(kind, out) + finally: + skc.READS.reset(token) + return cut_doc(out, reads) diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py index fa3c5ed75..e790a0a1c 100644 --- a/M02_MasterTemplete/M02_Doc_Sk.py +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -165,14 +165,7 @@ def run(sk: dict, copy: dict, master: Any = None) -> dict: # ── 저장 첫머리(옛 길 `clean_doc` 끝) ────────────────────────────────── def stored(key: Any) -> dict | None: - from M02_MasterTemplete import M02_MasterTemplete_Store as store - - if not sd.KEY.match(str(key or "")): - return None - try: - return json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes()) - except (OSError, ValueError): - return None + return skc.stored_doc(key) # 한 저장 안 한 번 읽음(826-3) def norm(copy: dict) -> dict: @@ -204,7 +197,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: if dim.is_imported(out): return {**out, KEY: norm(have)} copy = dc.refresh(have)[0] if job == skc.PRICE else have - return {**out, KEY: norm(gap.fill(copy))} # 사본 빈틈 = 지금 값으로 박음(822-1) + slim = skc.READS.get() is not None # 저장 관문이 셈 한 번으로 박음(826-3) + return {**out, KEY: norm(copy if slim else gap.fill(copy))} # 빈틈 = 지금 값(822-1) # 빈 SK(바탕 · 줄 없음) = 빈 문서(요구서_664 0 장) · 수식 길로 고친 빈 SK = 위 따로(804-4) if not skc.has(out) and not dim.is_imported(out): empty = seed(out) @@ -214,7 +208,9 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls} copy = copy_of({**out, KEY: first}, box[-1] if box else None) - if copy and skc.has(out) and (keys := gap.gaps(copy)): # 파생 — 옛 로직 사본에 박음(822-1) + if ( + copy and skc.has(out) and skc.READS.get() is None and (keys := gap.gaps(copy)) + ): # 파생 — 옛 로직 사본에 박음(822-1) out = {**out, skc.KEY: skc.grow(out[skc.KEY], skc.snap([], keys))} copy = {**copy, **dc.values_of(out[skc.KEY])} return {**out, KEY: norm({k: v for k, v in (copy or first).items() if k != "따로"})} diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py index 6d4f3303d..b0dba178b 100644 --- a/M02_MasterTemplete/M02_Doc_Sk_Path.py +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -145,8 +145,11 @@ def preview(key: str, body: dict) -> dict[str, Any]: mine = _basis(doc, body) before = current(doc) job = body.get("할일") or None - after = edited(current(doc, job) if job else before, body, key, doc) + was = current(doc, job) if job else before + after = edited(was, body, key, doc) out = answer(after, mine) + if not dim.is_imported(doc): # 고침으로 새로 읽은 줄 = 지금 값으로 셈 · 저장하면 굳음(826-3) + out["사본"]["사본없음"] += len(_rows(after) - _rows(was)) out |= {"전": dc.money(dsk.run(doc, before)), "후": dc.money(out["셈"])} if job == skc.PRICE and not dim.is_imported(doc): out["바뀐자원"] = dc.refresh(before)[1] @@ -158,6 +161,10 @@ def preview(key: str, body: dict) -> dict[str, Any]: return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str)) +def _rows(copy: dict) -> set[str]: + return {str(r.get("키")) for r in [*(copy.get("로직") or []), *(copy.get("단가") or [])]} + + def _basis(doc: dict, body: dict) -> dict: """가져온 식 단가기준 바꿈(807-1 · 몸 `단가기준` 엑셀 | 우리) — 그 밖 SK · 빈 값 = 그대로.""" want = body.get("단가기준") diff --git a/M02_MasterTemplete/M02_Labor_Copy.py b/M02_MasterTemplete/M02_Labor_Copy.py index fe2a8704e..9d391004f 100644 --- a/M02_MasterTemplete/M02_Labor_Copy.py +++ b/M02_MasterTemplete/M02_Labor_Copy.py @@ -99,7 +99,7 @@ def settle(doc: Any, save: bool = False) -> Any: elif job == PRICE and copy: copy = skc.refresh(copy, skc.whole_())[0] if save and copy: - copy = skc.grow(copy, stamp(out)) + copy = skc.grow(copy, stamp(out)) if skc.READS.get() is None else copy # 826-3 if not copy: return out got = {**out, KEY: copy} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 561310534..aa1f42a59 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -577,9 +577,11 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any: if source is not None and isinstance(doc, dict): doc = refs.carry(doc, refs.where(source)) own.carry(doc, source) # 개인 자재(148-3) — 받는 개인 층 목록에 같은 키로 - if kind == "labor": - return labor.server(doc) - return refs.clean_doc(kind, doc) + from M02_MasterTemplete import M02_Copy_Slim as slim # 사본 = 읽은 줄만(826-3) + + return slim.saved( + kind, lambda: labor.server(doc) if kind == "labor" else refs.clean_doc(kind, doc) + ) return doc diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 950b41bac..65e3038c6 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -25,6 +25,7 @@ from __future__ import annotations import hashlib import json +from contextvars import ContextVar from datetime import datetime from decimal import Decimal from typing import Any @@ -39,6 +40,8 @@ JOB = "사본할일" PRICE, REGEN = "단가갱신", "재생성" RES = ("LB", "MT", "EQ", "MO", "FX") # 자원 단가 = 단가 기준 시점 묶음 _MASTER = prices.mf.Master +#: 읽은 줄 적기(826-3 · `M02_Copy_Slim`) — {사본 줄 키 묶음: 그 색인이 내준 줄 키} · 꺼짐 = None +READS: ContextVar[dict | None] = ContextVar("읽은줄", default=None) def whole_() -> Any: @@ -67,11 +70,13 @@ class Copy: """M01 색인 위 사본 줄 — `get` · `logic` · `value` · `label` = 엔진 `Master` 한 벌 (사본 줄 먼저) · 그 밖 칸(자재 색인 · 찾기 답)은 속 M01 것을 읽음.""" - get, label, value, logic = _MASTER.get, _MASTER.label, _MASTER.value, _MASTER.logic + label, value, logic = _MASTER.label, _MASTER.value, _MASTER.logic def __init__(self, whole: Any, rows: list[dict], tag: Any = None) -> None: self._whole, self._tag, self.twice = whole, tag, whole.twice self.index, self.numbers = dict(whole.index), dict(whole.numbers) + mine = isinstance(tag, tuple) and tag[:1] == ("내기계",) # 층 내 기계 바탕 = 안 적음 + self._box = None if mine else frozenset(str(r.get("키")) for r in rows) for row in rows: key = str(row.get("키")) tid = key[:2] @@ -79,6 +84,13 @@ class Copy: if row.get("원문번호"): self._own(self.numbers, whole.numbers, tid)[str(row["원문번호"])] = row + def get(self, ref: str) -> dict: + row = _MASTER.get(self, ref) + seen = READS.get() + if seen is not None and self._box is not None: + seen.setdefault(self._box, set()).add(str(row.get("키"))) + return row + @staticmethod def _own(mine: dict, theirs: dict, tid: str) -> dict: if mine.get(tid) is theirs.get(tid): @@ -237,15 +249,29 @@ def taken(raw: Any) -> dict | None: return {**keep, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)} -def _stored(key: Any) -> dict | None: +_FILE: list = [(None, None)] # (자국, SK 파일) — 한 저장 안 같은 파일 되풀이 읽기 막음(826-3) + + +def stored_doc(key: Any) -> dict | None: + """저장된 SK 파일(읽기만 — 고치지 말 것) · 파일 자국(경로 · 고친 때 · 크기)이 같으면 앞 것.""" from M02_MasterTemplete import M02_MasterTemplete_Store as store if not sd.KEY.match(str(key or "")): return None + file = store.root() / sd.KIND / f"{key}.json" try: - got = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes()) + st = file.stat() + mark = (str(file), st.st_mtime_ns, st.st_size) + have = _FILE[0] + if have[0] != mark: + have = _FILE[0] = (mark, json.loads(file.read_bytes())) except (OSError, ValueError): return None + return have[1] + + +def _stored(key: Any) -> dict | None: + got = stored_doc(key) return got[KEY] if has(got) else None @@ -264,7 +290,7 @@ 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 save and copy: + if save and copy and READS.get() is None: # 읽은 줄만(826-3) = 저장 관문이 더함 copy = grow(copy, stamp(out)) out = _formula_job(out, job) return {**out, KEY: copy} if copy else out diff --git a/resources/tester/test_826_3_copy_slim.py b/resources/tester/test_826_3_copy_slim.py new file mode 100644 index 000000000..0961ef5cc --- /dev/null +++ b/resources/tester/test_826_3_copy_slim.py @@ -0,0 +1,247 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""826-3 사본 = 셈이 실제로 읽은 줄만(요구 806 · 808) · SK 저장 길 한 번씩. + +지킴: 사본 줄 = 읽은 줄(닫힌 묶음 수백 줄 → 열 줄 안팎) · 금액 = 닫힌 묶음 사본과 같음 · +M01 값 바뀌어도 그대로 · 옵션(기계) 바꿈 = 미리 셈 「사본없음 n」 + 지금 M01 값 → [저장] 때 박음 +(남은 줄 = 사본 값 그대로) · [단가 갱신] · [재생성] · 층 복사 · 일위대가 · 사전조합 사본도 · +옛 큰 사본 SK 그대로 읽힘 · 다음 [저장] 때 줄어듦 · 저장 한 번에 M01 모으기 0 · SK 파일 읽기 한 번. +M01 바꿈 = 메모리 색인 사본(정본 파일 안 고침) · 층 = 임시 storage. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +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 +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 M02_MasterTemplete import M02_Sangeun_Copy as skc +from resources.tester.test_795_2_sc_copy import _labor, _read, _sc, _unit +from resources.tester.test_822_1_copy_gap import ( + DIG, + DIG2_NO, + MACHINE, + _at, + _fuel_and_crew, + _move, + _price, + _sk, + _stack, + _sum, +) +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +DIG_BASE = "GC000088" # 터파기(기계) 보통토사 — 「기계」 고르기 후보를 닫힌 묶음이 다 담음 + + +def _file(folder, key) -> dict: + return json.loads((folder / "sangeun" / f"{key}.json").read_bytes()) + + +def _keys(copy_: dict) -> list[str]: + return [str(r.get("키")) for r in [*copy_["로직"], *(copy_.get("단가") or [])]] + + +def _closed(doc: dict) -> int: + """옛 길(후보 전부 닫힌 묶음) 줄 수.""" + got = prices.collect([str(doc["바탕"]["로직"])]) + return sum(len(d[m01.items_key(d)]) for k, d in got.items() if not k.startswith("_")) + + +def test_base_logic_read_rows_only(world): + """견본(터파기 기계) — 닫힌 묶음 → 읽은 줄만 · 자원 줄 한 자리 · + 금액 = 닫힌 묶음 사본.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + doc = _file(me, key) + have = doc[skc.KEY] + assert _closed(doc) >= 3 * len(_keys(have)) + assert len(_keys(have)) <= 12 and len(have["단가"]) < 10 + assert _keys(have) == _keys(doc["수식"]) # 두 자리 같은 줄(꼴 그대로) + full = {**doc, skc.KEY: skc.stamp(doc)} # 옛 길 사본(후보 전부) + assert len(_keys(full[skc.KEY])) >= 3 * len(_keys(have)) + with store.at(me): + old = calc.calc_doc(full, None, False)["기준"] + new = calc.calc_doc(doc, None, False)["기준"] + assert new == old and new["계"] > 0 + assert _sum(me, key) == new["계"] + + +def test_m01_change_kept_then_price(world, monkeypatch): + """만든 뒤 M01(기계 · 유가 · 노임) 바뀜 → 금액 그대로 + 단가새값 → + [단가 갱신] → 반영 · 그래도 작음.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + was = _sum(me, key) + read = set(_keys(_file(me, key)[skc.KEY])) + moved = [k for k in read if k[:2] in ("EQ", "LB", "MO")] + assert moved + _move(monkeypatch, *moved) + assert _sum(me, key) == was + assert _at(me, path.read, key)["사본"]["단가새값"] is True + got = _price(me, key) + assert set(got["바뀐"]) == set(moved) and got["후"] > was + assert set(_keys(_file(me, key)[skc.KEY])) == read + + +def test_machine_swap_preview_then_save(world, monkeypatch): + """기계 바꿈(옵션) — 미리 셈 = 새 기계 지금 M01 값 + 「사본없음 n」 · [저장] = 새 줄만 박음 · + 남은 줄(유가 · 노임) = 사본 값 그대로 · 안 읽는 옛 기계 줄은 빠짐.""" + me = world["personal"] + key = _sk(me) + _stack(me, key, [("기계", MACHINE, "묶음")]) + first = _file(me, key) # 줄 쌓기(수식 길) = 수식 사본 값 몫 + before = set(_keys(first["수식"])) + assert DIG in before + fuel, crew = _fuel_and_crew() + w = m01.loaded()[1] + dig2 = w.numbers["EQ"][DIG2_NO]["키"] + assert dig2 not in before + files, live = m01.loaded() + _move(monkeypatch, dig2) # 바꾸는 때의 M01 = 새 기계 값 두 배 + fake_dig2 = m01.loaded()[1] + _move(monkeypatch, fuel, crew) # 사본에 든 유가 · 노임도 바뀜(사본 값이 이겨야 함) + got = _at(me, path.read, key) + doc = copy.deepcopy(got["문서"]) + for var in doc["변수"]: + if (var.get("연결") or {}).get("갈래") == "단가산출": + var["연결"]["입력"] = {"기계": f"'{DIG2_NO}'"} + look = _at(me, path.preview, key, {"문서": doc}) + assert look["사본"]["사본없음"] > 0 # 저장하면 지금 값으로 굳음 + _at(me, path.save, key, {"문서": doc, "판": got["판"]}) + after = _file(me, key) + now = set(_keys(after["수식"])) + assert dig2 in now and DIG not in now and {fuel, crew} <= now + old_rows = {str(r["키"]): r for r in first["수식"]["단가"]} + new_rows = {str(r["키"]): r for r in after["수식"]["단가"]} + assert new_rows[fuel] == old_rows[fuel] and new_rows[crew] == old_rows[crew] + amount = _sum(me, key) + assert amount == look["셈"]["계"] + # 기준 = 새 기계만 두 배인 M01 로 처음 만든 같은 SK(유가 · 노임 = 처음 값) + monkeypatch.setattr(m01, "loaded", lambda: (files, fake_dig2)) + ref = _sk(me) + _stack(me, ref, [("기계", {**MACHINE, "입력": {"기계": f"'{DIG2_NO}'"}}, "묶음")]) + assert _sum(me, ref) == amount + monkeypatch.setattr(m01, "loaded", lambda: (files, live)) + assert _sum(me, key) == amount # M01 되돌아가도 그대로 + + +def test_regen_slim(world, monkeypatch): + """[재생성] — 지금 M01 로 다시 찍어도 읽은 줄만 · 그 뒤 M01 바뀌어도 그대로.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + read = [k for k in _keys(_file(me, key)[skc.KEY]) if k[:2] in ("EQ", "MO")] + _move(monkeypatch, *read) + 판 = _at(me, path.read, key)["판"] + _at(me, path.save, key, {"할일": skc.REGEN, "판": 판}) + after = _file(me, key)[skc.KEY] + assert len(_keys(after)) <= 12 + was = _sum(me, key) + _move(monkeypatch, *read) + assert _sum(me, key) == was + + +def test_layer_copy_slim(world, monkeypatch): + """층 복사(개인 → 회사) — 받은 사본째 · 읽은 줄만 · 원본 M01 바뀐 뒤 복사해도 그 값.""" + me, co = world["personal"], world["company"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + was = _sum(me, key) + _move(monkeypatch, DIG, *[k for k in _keys(_file(me, key)[skc.KEY]) if k[:2] == "EQ"]) + ref = {"층": "personal", "주인": "7/42", "키": key} + with store.at(co): + doc = refs.read_ref(ref) + new = refs.create("sangeun", refs.carry(refs.origin_mark(doc, ref), ("personal", "7/42"))) + assert _sum(co, new["이름"]) == was + assert _keys(_file(co, new["이름"])[skc.KEY]) == _keys(_file(me, key)[skc.KEY]) + + +def test_labor_and_combo_slim(world, monkeypatch): + """일위대가 · 사전조합 사본 — 닫힌 묶음보다 작음 · 금액 그대로 · M01 바뀌어도 그대로.""" + me = world["personal"] + lines = [{"로직": DIG_BASE, "수량": {"손": 1}}] + lab = _labor(me, lines) + sc = _sc(me, lines) + full = prices.collect([DIG_BASE]) + closed = sum(len(d[m01.items_key(d)]) for k, d in full.items() if not k.startswith("_")) + for doc in (lab, _read(me, "precombo", sc)): + assert len(_keys(doc[skc.KEY])) * 3 <= closed + assert _keys(doc[skc.KEY]) == _keys(doc["수식"]) + unit = _unit(me, sc) + assert unit > 0 + _move(monkeypatch, *[k for k in _keys(lab[skc.KEY]) if k[:2] in ("EQ", "LB", "MO")]) + assert _unit(me, sc) == unit + + +def test_old_big_copy_reads_then_slims(world): + """옛 큰 사본(후보 전부) SK — 그대로 읽힘(같은 금액 · 열기 = 안 고쳐 씀) · + 다음 [저장] 때 줄어듦.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + was = _sum(me, key) + file = me / "sangeun" / f"{key}.json" + raw = json.loads(file.read_bytes()) + big = skc.stamp(raw) + raw[skc.KEY] = big + raw["수식"] = {**raw["수식"], **{k: big[k] for k in ("판", "로직", "단가판", "단가")}} + file.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + before = file.read_bytes() + assert _sum(me, key) == was + assert file.read_bytes() == before + with store.at(me): + got = store.read("sangeun", key) + store.write("sangeun", key, got["판"], got["문서"]) + assert len(_keys(_file(me, key)[skc.KEY])) * 3 <= len(big["로직"]) + len(big["단가"]) + assert _sum(me, key) == was + + +def test_save_path_once(world, monkeypatch): + """SK 다시 저장 한 번 — M01 모으기 0 · SK 파일 읽기(셈 쪽) 한 번 · 옛 셈 한 번.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + count = {"모으기": 0, "읽기": 0, "셈": 0} + collect, run, read_bytes = prices.collect, calc.run, Path.read_bytes + + def counted(name, fn): + def wrap(*a, **k): + count[name] += 1 + return fn(*a, **k) + + return wrap + + def reading(self): + if self.name == f"{key}.json": + count["읽기"] += 1 + return read_bytes(self) + + with store.at(me): + got = store.read("sangeun", key) + monkeypatch.setattr(prices, "collect", counted("모으기", collect)) + monkeypatch.setattr(calc, "run", counted("셈", run)) + monkeypatch.setattr(Path, "read_bytes", reading) + store.write("sangeun", key, got["판"], got["문서"]) + # 읽기 = 사본 정함 한 번 + 판 맞춤(쓰기 전) + 새 판(쓰기 뒤) + assert count == {"모으기": 0, "읽기": 3, "셈": 1} + + +def test_doc_path_save_keeps_both_slots(world): + """수식 길 저장(옵션 Type · 지역 바꿈) — 사본 · 수식 두 칸 같은 줄 · 새로 읽은 줄 더함 · + 금액 = 미리 셈 = 같은 옵션으로 처음 만든 것.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": DIG_BASE}, 단위="㎥") + got = _at(me, path.read, key) + doc = copy.deepcopy(got["문서"]) + for var in doc["변수"]: + if var.get("열쇠") in ("Type", "지역"): + var["연결"]["값"] = {"Type": "TypeⅢ", "지역": "경북"}[var["열쇠"]] + look = _at(me, path.preview, key, {"문서": doc}) + assert look["사본"]["사본없음"] > 0 + _at(me, path.save, key, {"문서": doc, "판": got["판"]}) + after = _file(me, key) + assert set(_keys(after[skc.KEY])) == set(_keys(after["수식"])) + assert len(_keys(after[skc.KEY])) >= 6 + assert _sum(me, key) == look["셈"]["계"] != got["셈"]["계"]