From 5112bc14257bd2626f83c34dfd6879fad560b1ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 11:16:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(795-1):=20SK=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=E2=80=94=20=EC=A0=80=EC=9E=A5=20=EB=95=8C?= =?UTF-8?q?=20M01=20=EB=8B=AB=ED=9E=8C=20=EB=AC=B6=EC=9D=8C=20=EC=B0=8D?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20[=EB=8B=A8=EA=B0=80=20=EA=B0=B1=EC=8B=A0]?= =?UTF-8?q?=20=C2=B7=20[=EC=9E=AC=EC=83=9D=EC=84=B1]=20=C2=B7=20M01=20?= =?UTF-8?q?=EC=83=88=20=ED=8C=90=20=EB=B0=B0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Sangeun_Copy — 사본 = 로직 · 하위 로직 · 찾기 표 · 그 밖 요소(로직 갈래) + 자원 단가 LB · MT · EQ · MO · FX(단가 기준 시점 갈래) - 셈 = M01 색인 위 사본 줄 덮개(Copy) · Calc 바탕 줄 · run · 저장 · 셈 길 첫머리만 고리(줄 수 그대로) - 사본은 서버가 정함(저장본 사본 · 없으면 받은 사본 · 그것도 없으면 저장 때 찍음) · 할일 칸 사본할일 = [저장] 때 적용 · 자동 갱신 없음 - 지워진 M01 키 = 사본 마지막 값 · 표지 지운키 · 사본 없는 옛 SK = 옛 길 - 같은 내용 지문에 사본 몫(지금 M01 과 다른 판만) · 길 POST …/sangeun/copy/price|regen - 시험 test_795_1_sk_copy 6 · 기존 SK · 지문 시험 294 통과 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Library_Same.py | 2 + M02_MasterTemplete/M02_Sangeun_Calc.py | 12 +- M02_MasterTemplete/M02_Sangeun_Const.py | 4 +- M02_MasterTemplete/M02_Sangeun_Copy.py | 308 +++++++++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Router.py | 27 ++ resources/tester/test_795_1_sk_copy.py | 213 ++++++++++++++++ 6 files changed, 559 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_Sangeun_Copy.py create mode 100644 resources/tester/test_795_1_sk_copy.py diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index a42819cc2..006e20cc2 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -22,6 +22,7 @@ from typing import Any from common_util.common_util_derived_store import derived_key from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Imported as imp DROP = {"키", "이름", "구분", "상세구분", "본뜬", "메모", "별칭", "바탕판", "원값", "계산값", "열"} @@ -135,6 +136,7 @@ class Same: # 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로 **{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")}, "가져온": norm(imp.same_part(doc)), # 가져온 식(793-2) = 로직 · 단가기준 + "사본": skc.same_part(doc), # 로직 사본(795-1) — 지금 M01 과 다른 판만 } return {k: v for k, v in body.items() if v not in EMPTY} diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 71531c2a8..941e830ae 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -37,7 +37,7 @@ from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Doc as sd -from M02_MasterTemplete import M02_Sangeun_Views as views +from M02_MasterTemplete import M02_Sangeun_Copy as skc, M02_Sangeun_Views as views COSTS = labor.COSTS Ref = dict[str, Any] @@ -124,7 +124,7 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s return {**empty, "줄": sd.empty_row(doc)} if "로직" in base: try: - return {**empty, "줄": whole.logic(base["로직"])} + return {**empty, "줄": skc.over(doc, whole).logic(base["로직"])} # 사본(795-1) except Exception as e: # noqa: BLE001 — 엔진 FormulaError raise sd.DocError(f"바탕 로직 · {e}") from e ref = base["참조"] @@ -350,7 +350,7 @@ def run( `line` = 일위대가 덮개 줄.""" from M02_MasterTemplete.M02_Labor_Expand import Expand - got = resolve(doc, whole, read, trail, given, line) + got = resolve(doc, whole := skc.over(doc, whole), read, trail, given, line) row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} _bundles(row, whole, expand or Expand(whole, read), trail) head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]} @@ -529,7 +529,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: """저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`.""" from M01_MasterData import M01_MasterData_Store as m01 - whole = m01.loaded()[1] + whole = skc.over(doc := skc.settle(doc, True), m01.loaded()[1]) # 사본(795-1) try: rough = sd.clean(doc, None, whole) _check_refs(rough, here_) @@ -579,7 +579,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str from M01_MasterData import M01_MasterData_Store as m01 with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2) - whole = m01.loaded()[1] + whole = skc.over(doc := skc.settle(doc), m01.loaded()[1]) # 사본(795-1) try: clean = sd.clean(doc, None, whole) _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 @@ -588,7 +588,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) except sd.DocError as e: raise _fail(e) from e - return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) + return skc.mark(const.answer(got, clean, doc, given, (me,)), clean) # 790-2 · 795-1 def from_line( diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index 0a97ecbe9..bef0c1854 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -229,6 +229,8 @@ def clean_more(raw: Any, out: dict) -> dict: why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()} if why := {k: v for k, v in why.items() if v}: out["까닭"] = why + if isinstance(raw.get("사본"), dict): # 로직 사본(795-1) — 셈 · 저장 첫머리 settle 이 정함 + out["사본"] = raw["사본"] return imp.clean(raw, fx.clean(raw, out)) # 식 칸(792-1) · 가져온 식(793-2) @@ -371,7 +373,7 @@ def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc - whole = calc.labor.m01.loaded()[1] + whole = calc.skc.over(clean, calc.labor.m01.loaded()[1]) # 사본(795-1) — 중간 · 표값도 try: base = calc._base_of(clean, whole, refs.read_ref, trail)["줄"] bare = calc.per_unit(calc.run(_bare(clean), whole, given, trail=trail), clean) diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py new file mode 100644 index 000000000..068903f2b --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -0,0 +1,308 @@ +"""M02 단가산출(SK) 로직 사본(PLAN 795-1 · 요구 655 · 656 · 요구서_655 0 · 1 장) — 서버 단독. + +사본 = 저장 때 서버가 찍는 M01 닫힌 묶음(`M02_Item_Prices.collect` 다시 씀) — 문서 칸 `사본`: + {판, 시점, 로직 [M01 줄], 단가판, 단가시점, 단가 [M01 줄], 빠진?} + 로직 = 바탕 로직 · 더한줄 로직 · 가져온 식 이음 · 하위 로직 · 찾기 표 · 그 밖 요소(요율 등) + 단가 = 자원 단가 줄(`RES` 노임 · 자재 · 기계 · 유가전력 · 환율) = 단가 기준 시점 묶음. +셈 = M01 색인 위에 사본 줄을 얹은 `Copy`(키 · 원문번호 찾기만 사본 먼저 · M01 색인 안 고침) — + `M02_Sangeun_Calc._base_of`(바탕 줄) · `run`(엔진 · 하위 로직 · 표 · 단가)이 `over` 로 부름 · + M01 에서 지워진 키 = 사본의 마지막 줄로 셈(표지 `지운키`) · + 사본 없는 SK(옛 SK · 바탕 참조) = 옛 길. +사본은 셈 · 저장 첫머리 `settle` 이 정함 — 저장본에 사본이 있으면 그것(화면이 보낸 것 버림) · + 없으면 받은 사본(층 복사 · 새로 · 판 다시 셈) · 그것도 없으면 저장 때 새로 찍음. +할일 칸 `사본할일`(화면 캐시 · [저장] 때 적용): + 단가갱신 = 단가 줄만 지금 M01 값(지워진 키 = 그대로) · + 재생성 = 지금 M01 로 새로 찍음 — 덮개 · 식덮개 · 변수줄 · 상수 · 표값 · 까닭은 cid 열쇠라 + 새 사본에 그대로 얹힘 · 못 얹은 칸 = 짝잃음. + 저장 때 = 사본에 없는 새 줄(더한줄 로직 더함 · 기초 바꿈)만 지금 M01 에서 더함. +길(`M02_Sangeun_Router`): POST …/sangeun/copy/{price|regen} 몸 {문서, 입력값?} → + {문서(사본할일), 전, 후, 바뀐자원 [{키, 이름, 전, 후, 지워짐?}] · 못얹은 [{cid, 까닭}]} + (저장 없음). +셈 답 덧칸 `사본` = {시점, 단가시점, M01새판, 지운키, 단가새값} · 같은 내용 지문 몫 = `same_part`. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Imported as imp + +KEY = "사본" +JOB = "사본할일" +PRICE, REGEN = "단가갱신", "재생성" +RES = ("LB", "MT", "EQ", "MO", "FX") # 자원 단가 = 단가 기준 시점 묶음 +_MASTER = prices.mf.Master + + +def _whole() -> Any: + return prices.m01.loaded()[1] + + +def _fp(rows: Any) -> str: + raw = json.dumps(prices.to_json(rows), ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def _now() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def has(doc: Any) -> bool: + copy = doc.get(KEY) if isinstance(doc, dict) else None + return isinstance(copy, dict) and isinstance(copy.get("로직"), list) + + +# ── 셈 색인 ────────────────────────────────────────────────────────── +class Copy: + """M01 색인 위 사본 줄 — `get` · `logic` · `value` · `label` = 엔진 `Master` 한 벌 + (사본 줄 먼저) · 그 밖 칸(자재 색인 · 찾기 답)은 속 M01 것을 읽음.""" + + get, label, value, logic = _MASTER.get, _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) + for row in rows: + key = str(row.get("키")) + tid = key[:2] + self._own(self.index, whole.index, tid)[key] = row + if row.get("원문번호"): + self._own(self.numbers, whole.numbers, tid)[str(row["원문번호"])] = row + + @staticmethod + def _own(mine: dict, theirs: dict, tid: str) -> dict: + if mine.get(tid) is theirs.get(tid): + mine[tid] = dict(theirs.get(tid) or {}) + return mine[tid] + + def __getattr__(self, name: str) -> Any: + return getattr(self._whole, name) + + +def _rows(copy: dict) -> list[dict]: + raw = json.dumps(prices.to_json([*copy["로직"], *(copy.get("단가") or [])]), ensure_ascii=False) + return json.loads(raw, parse_float=Decimal, parse_int=Decimal) + + +_CACHE: dict[tuple, Copy] = {} +_SEEN: dict[tuple, dict] = {} +_LIVE: list[Any] = [None] # 지금 M01 색인 — 바뀌면(저장 · 다시 읽음) 메모 비움(옛 색인 안 붙잡음) + + +def _forget(whole: Any) -> None: + while isinstance(whole, Copy): + whole = whole._whole + if whole is not _LIVE[0]: + _CACHE.clear() + _SEEN.clear() + _LIVE[0] = whole + + +def over(doc: Any, whole: Any) -> Any: + """사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길).""" + if not has(doc): + return whole + copy = doc[KEY] + tag = (copy.get("판"), copy.get("단가판")) + if isinstance(whole, Copy) and whole._tag == tag: + return whole + _forget(whole) + got = _CACHE.get((id(whole), *tag)) + if got is None or got._whole is not whole: + if len(_CACHE) > 32: # ponytail: 통째 비움 · 쓰는 SK 가 많아 느려지면 LRU 로 + _CACHE.clear() + got = _CACHE[(id(whole), *tag)] = Copy(whole, _rows(copy), tag) + return got + + +# ── 찍기 · 단가 갱신 ────────────────────────────────────────────────── +def _split(rows: list[dict]) -> dict[str, Any]: + logic = [r for r in rows if str(r.get("키"))[:2] not in RES] + cost = [r for r in rows if str(r.get("키"))[:2] in RES] + when = _now() + return { + "판": _fp(logic), + "시점": when, + "로직": logic, + "단가판": _fp(cost), + "단가시점": when, + "단가": cost, + } + + +def stamp(doc: dict) -> dict | None: + """지금 M01 로 새 사본 — 바탕 로직 · 더한줄 로직 · 고정 줄 이음 · 가져온 식 줄이 닿는 + 닫힌 묶음 · 바탕 참조 · 닿을 것 없음 = 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] + extra = [row] if (row := imp.row_of(doc)) else [] + if not (keys or links or extra): + return None + got = prices.collect(keys, [str(v) for v in 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() # 줄 = 셈 색인 것(파일 묶음과 같은 판) + 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 {}) + + +def refresh(copy: dict, whole: Any) -> tuple[dict, list[dict]]: + """[단가 갱신] — 단가 줄만 지금 M01 값 · (새 사본, 바뀐자원) · 지워진 키 = 마지막 값 그대로.""" + rows, changed = [], [] + for row in copy.get("단가") or []: + key = str(row.get("키")) + live = whole.index.get(key[:2], {}).get(key) + if live is None: + rows.append(row) + changed.append({"키": key, "이름": row.get("이름"), "지워짐": True}) + continue + new = prices.to_json(live) + if _fp(new) != _fp(row): + changed.append( + {"키": key, "이름": new.get("이름"), "전": row.get("값"), "후": new.get("값")} + ) + rows.append(new) + return {**copy, "단가": rows, "단가판": _fp(rows), "단가시점": _now()}, changed + + +def _grow(copy: dict, doc: dict) -> dict: + """저장 때 — 사본에 없는 키 줄만 지금 M01 에서 더함(있는 줄 = 사본 그대로).""" + fresh = stamp(doc) + have = {str(r.get("키")) for r in [*copy["로직"], *(copy.get("단가") or [])]} + new = [ + r + for r in [*(fresh or {}).get("로직", []), *(fresh or {}).get("단가", [])] + if str(r.get("키")) not in have + ] + if not new: + return copy + logic = [*copy["로직"], *(r for r in new if str(r.get("키"))[:2] not in RES)] + cost = [*(copy.get("단가") or []), *(r for r in new if str(r.get("키"))[:2] in RES)] + return {**copy, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)} + + +def _taken(raw: Any) -> dict | None: + """받은 사본(층 복사 · 새로) — 줄 꼴만 보고 판은 서버가 다시 셈.""" + if not has({KEY: raw}): + return None + logic = [r for r in raw["로직"] if isinstance(r, dict) and str(r.get("키"))[:2] not in RES] + cost = [r for r in raw.get("단가") or [] if isinstance(r, dict) and str(r.get("키"))[:2] in RES] + keep = {k: str(raw.get(k) or "") for k in ("시점", "단가시점")} + return {**keep, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)} + + +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: + got = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return None + return got[KEY] if has(got) else None + + +def settle(doc: Any, save: bool = False) -> Any: + """셈 · 저장 첫머리 — 서버가 정한 사본으로 갈음(할일 적용 · 저장 때 새 줄 더함 · 할일 칸 뗌).""" + if not isinstance(doc, dict): + return doc + job = doc.get(JOB) # 모르는 할일 = 없음 + 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)) + if job == REGEN or (copy is None and save): + copy = stamp(out) + elif job == PRICE and copy: + copy = refresh(copy, _whole())[0] + if save and copy: + copy = _grow(copy, out) + return {**out, KEY: copy} if copy else out + + +# ── 표지 ────────────────────────────────────────────────────────────── +def state(copy: dict, whole: Any) -> dict[str, Any]: + """{M01새판, 지운키, 단가새값} — 사본 줄과 지금 M01 줄 대조(같은 M01 · 같은 판 = 메모).""" + _forget(whole) + tag = (id(whole), copy.get("판"), copy.get("단가판")) + if tag in _SEEN and _SEEN[tag]["whole"] is whole: + return _SEEN[tag]["답"] + + def live(row: dict) -> Any: + key = str(row.get("키")) + return whole.index.get(key[:2], {}).get(key) + + gone = [str(r.get("키")) for r in [*copy["로직"], *(copy.get("단가") or [])] if live(r) is None] + logic = any(live(r) is None or _fp(live(r)) != _fp(r) for r in copy["로직"]) + cost = any(live(r) is not None and _fp(live(r)) != _fp(r) for r in copy.get("단가") or []) + got = {"M01새판": logic, "지운키": gone, "단가새값": cost} + _SEEN[tag] = {"whole": whole, "답": got} + return got + + +def mark(ans: dict, doc: dict) -> dict: + """셈 답 덧칸 `사본` — 사본 없는 문서 = 칸 없음.""" + if not has(doc): + return ans + copy = doc[KEY] + head = {"시점": copy.get("시점"), "단가시점": copy.get("단가시점")} + return {**ans, KEY: {**head, **state(copy, _whole())}} + + +def same_part(doc: Any) -> str | None: + """같은 내용 지문 몫 — 지금 M01 과 같은 사본 = None(새로 찍을 문서와 같음) · 다르면 판.""" + if not has(doc): + return None + copy = doc[KEY] + seen = state(copy, _whole()) + fresh = not (seen["M01새판"] or seen["지운키"] or seen["단가새값"]) + return None if fresh else f"{copy.get('판')}:{copy.get('단가판')}" + + +# ── [단가 갱신] · [재생성] 미리 셈(저장 없음) ────────────────────────── +def _money(ans: dict) -> dict | None: + base = ans.get("기준") + return {k: base.get(k) for k in ("노무비", "재료비", "경비", "계")} if base else None + + +def _lost(ans: dict) -> dict[str, dict]: + rows = [*(ans.get("짝잃음") or []), *((ans.get("시트") or {}).get("짝잃음") or [])] + return {str(x.get("cid")): x for x in rows if isinstance(x, dict)} + + +def preview(doc: Any, job: str, given: dict | None = None) -> dict[str, Any]: + """{문서(할일 얹음), 전, 후, 바뀐자원 · 못얹은} — 재생성은 단가갱신을 품음.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not isinstance(doc, dict): + raise sd.DocError("단가산출 문서가 묶음 아님") + old = doc.get(JOB) + new = REGEN if REGEN in (old, job) else job + after_doc = {**doc, JOB: new} + before = calc.calc_doc(doc, given, False) + 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 [] + else: + was = _lost(before) + out["못얹은"] = [ + {"cid": c, "까닭": x.get("까닭") or "새 사본에 그 줄 없음"} + for c, x in _lost(after).items() + if c not in was + ] + return out diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 18546747e..9f6b78f1f 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -5,6 +5,8 @@ `/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기) · 불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo` (만들 층 쓰기). +로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성] +(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) · @@ -23,6 +25,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session 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 M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Formula as fx from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo @@ -188,3 +191,27 @@ def sangeun_imported_edit( raise HTTPException(status_code=404, detail="price · link · to-base") fn, *args = _EDIT[how](body) return _call(_root(session, layer, owner), _sd(fn), *args) + + +class CopyBody(BaseModel): + 문서: dict[str, Any] + 입력값: dict[str, Any] | None = None + + +_JOBS = {"price": skc.PRICE, "regen": skc.REGEN} + + +@router.post("/layers/{layer}/sangeun/copy/{how}") +def sangeun_copy( + layer: Layer, + how: str, + body: CopyBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """로직 사본(795-1) — price [단가 갱신] · regen [재생성] · 답 = 할일 얹은 문서(화면 캐시 · + [저장] 때 서버가 적용) + 전 · 후 금액 + 바뀐자원 | 못얹은.""" + if how not in _JOBS: + raise HTTPException(status_code=404, detail="price · regen") + args = (body.문서, _JOBS[how], body.입력값) + return _call(_root(session, layer, owner), _sd(skc.preview), *args) diff --git a/resources/tester/test_795_1_sk_copy.py b/resources/tester/test_795_1_sk_copy.py new file mode 100644 index 000000000..7e403df2f --- /dev/null +++ b/resources/tester/test_795_1_sk_copy.py @@ -0,0 +1,213 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""795-1 SK 로직 사본 — 저장 때 M01 닫힌 묶음을 찍고 셈은 사본으로(임시 storage · M01 은 메모리 덮개). + +① M01 로직 줄 바꿔도 사본 SK 금액 그대로 ② 노임 바꿔도 [단가 갱신] 전엔 그대로 · 누르면 따라감 +③ [재생성] 뒤 고친 칸 다시 얹힘 · 못 얹은 칸 알림 ④ M01 새 판 배지 ⑤ 지워진 키 = 마지막 값 +⑥ 옛 SK(사본 없음) = 옛 길 · 지문 그대로 ⑦ 가져온 식 SK 같은 규칙. +""" + +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_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Same as same +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 M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +MAN = "LB000002" # 보통인부 + + +def _live(): + return m01.loaded()[1] + + +def _fake(monkeypatch, rows=(), drop=()): + """M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침).""" + files, live = m01.loaded() + fake = skc.Copy(live, [copy.deepcopy(r) for r in rows]) + for key in drop: + skc.Copy._own(fake.index, live.index, key[:2]).pop(key, None) + monkeypatch.setattr(m01, "loaded", lambda: (files, fake)) + return fake + + +def _sk(folder, **doc): + with store.at(folder): + body = {"이름": "sub2임시 사본", "단위": "㎡", "더한줄": [], **doc} + return refs.create("sangeun", body)["이름"] + + +def _doc(folder, key): + with store.at(folder): + return store.read("sangeun", key)["문서"] + + +def _save(folder, key, doc): + with store.at(folder): + store.write("sangeun", key, store.read("sangeun", key)["판"], doc) + + +def _calc(folder, doc): + with store.at(folder): + return calc.calc_doc(doc, None, False) + + +def _sum(folder, key): + return _calc(folder, _doc(folder, key))["기준"]["계"] + + +def _cids(folder, key): + return [x["cid"] for x in _calc(folder, _doc(folder, key))["줄"]] + + +def _changed(row, at=0): + row = copy.deepcopy(row) + row["호표"][at]["수량"] = f"({row['호표'][at]['수량']}) * 2" + return row + + +def test_copy_stamped_and_logic_change_kept(world, monkeypatch): + """① · ④ 저장 때 사본(로직 · 단가 갈래) · M01 로직 바꿔도 금액 그대로 · 배지만.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + got = _doc(me, key)["사본"] + assert PCT in {r["키"] for r in got["로직"]} + assert got["단가"] and all(r["키"][:2] in skc.RES for r in got["단가"]) + assert not any(r["키"][:2] in skc.RES for r in got["로직"]) + first = _calc(me, _doc(me, key)) + assert first["사본"]["M01새판"] is False and first["사본"]["지운키"] == [] + was = first["기준"]["계"] + plain = labor.run_line(_live(), PCT, {})["기준"]["계"] + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + assert labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] != plain # M01 셈은 바뀜 + now = _calc(me, _doc(me, key)) + assert now["기준"]["계"] == was + assert now["사본"]["M01새판"] is True + + +def test_price_refresh_only_on_press(world, monkeypatch): + """② 노임 바꿈 → 그대로 · [단가 갱신] 미리 셈 · 저장 = 새 SK 와 같은 값 · 바뀐자원.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + was = _sum(me, key) + lb = _cids(me, key)[0].split(":")[1].split("#")[0] + row = copy.deepcopy(_live().get(lb)) + row["값"] = row["값"] * 2 + _fake(monkeypatch, [row]) + assert _sum(me, key) == was # 누르기 전 = 단가 기준 시점 값 + fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) # 지금 M01 로 새로 찍은 SK + assert fresh != was + with store.at(me): + got = skc.preview(_doc(me, key), skc.PRICE) + assert got["전"]["계"] == was and got["후"]["계"] == fresh + assert [x["키"] for x in got["바뀐자원"]] == [lb] + assert got["문서"][skc.JOB] == skc.PRICE + _save(me, key, got["문서"]) # [저장] = 서버가 할일 적용 + saved = _doc(me, key) + assert skc.JOB not in saved and _sum(me, key) == fresh + assert saved["사본"]["단가시점"] >= saved["사본"]["시점"] + + +def test_regen_reapplies_covers(world, monkeypatch): + """③ [재생성] — 덮개 · 까닭 새 사본에 다시 얹힘 · 없어진 줄 덮개 = 못얹은 · 배지 꺼짐.""" + me = world["personal"] + probe = _sk(me, 바탕={"로직": PCT}) + one, two = _cids(me, probe)[:2] + covers = {one: {"qty": 0.5}, two: {"qty": 0.7}} + key = _sk(me, 바탕={"로직": PCT}, 덮개=covers, 까닭={one: "현장 실측"}) + row = copy.deepcopy(_live().logic(PCT)) + row["호표"] = [x for n, x in enumerate(row["호표"]) if n != 1] # 둘째 줄 없앤 새 판 + _fake(monkeypatch, [row]) + with store.at(me): + got = skc.preview(_doc(me, key), skc.REGEN) + assert [x["cid"] for x in got["못얹은"]] == [two] + assert got["전"]["계"] != got["후"]["계"] + _save(me, key, got["문서"]) + doc = _doc(me, key) + assert doc["덮개"][one]["qty"] == 0.5 and doc["까닭"][one] == "현장 실측" + now = _calc(me, doc) + assert now["줄"][0]["덮개"] == ["qty"] + assert now["사본"]["M01새판"] is False + assert now["기준"]["계"] == got["후"]["계"] + + +def test_deleted_keys_keep_last_value(world, monkeypatch): + """⑤ 사본이 부르는 M01 키(바탕 로직 · 노임)가 지워져도 마지막 값 · 지운키 표지.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + was = _sum(me, key) + lb = _cids(me, key)[0].split(":")[1].split("#")[0] + _fake(monkeypatch, drop=[PCT, lb]) + with pytest.raises(Exception): + m01.loaded()[1].logic(PCT) + now = _calc(me, _doc(me, key)) + assert now["기준"]["계"] == was + assert {PCT, lb} <= set(now["사본"]["지운키"]) + with store.at(me): + got = skc.preview(_doc(me, key), skc.PRICE) + assert {"키": lb, "이름": got["바뀐자원"][0]["이름"], "지워짐": True} in got["바뀐자원"] + assert got["후"]["계"] == was + + +def test_old_sk_without_copy_old_path(world, monkeypatch): + """⑥ 사본 없는 옛 SK = M01 지금 값(옛 길) · 같은 내용 지문 = 새 사본 SK 와 같음.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + path = me / "sangeun" / f"{key}.json" + raw = json.loads(path.read_bytes()) + old = {k: v for k, v in raw.items() if k != "사본"} + path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8") + fresh = _sk(me, 바탕={"로직": PCT}) + assert _sum(me, key) == _sum(me, fresh) + assert "사본" not in _calc(me, _doc(me, key)) + with store.at(me): + assert same.Same().finger("sangeun", old) == same.Same().finger("sangeun", _doc(me, fresh)) + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + moved = labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] + assert _sum(me, key) == pytest.approx(moved, abs=1) # 옛 길 = 따라감 + assert _sum(me, fresh) != _sum(me, key) # 사본 SK = 그대로 + with store.at(me): # 판 다른 두 SK = 지문 다름(오판 막음) + assert same.Same().finger("sangeun", _doc(me, key)) != same.Same().finger( + "sangeun", _doc(me, fresh) + ) + + +def _imported_row(): + man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"} + mat = {"종류": "재료", "이름": "각재", "단위": "㎥", "수량": "0.01", "비목": "재료비"} + return { + "이름": "sub2임시 가져온", + "결과단위": "원/㎡", + "호표": [{**man, "단가": 165545, "이음": MAN}, {**mat, "단가": 500661}], + } + + +def test_imported_same_rule(world, monkeypatch): + """⑦ 가져온 식 SK(단가기준 우리) — 이음 노임 바꿔도 그대로 · [단가 갱신] 뒤 따라감.""" + me = world["personal"] + with store.at(me): + key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"] + doc = _doc(me, key) + assert MAN in {r["키"] for r in doc["사본"]["단가"]} + with store.at(me): + ours = imp.to_price(doc, "우리")["문서"] + _save(me, key, ours) + was = _sum(me, key) + row = copy.deepcopy(_live().get(MAN)) + row["값"] = row["값"] * 2 + _fake(monkeypatch, [row]) + assert _sum(me, key) == was + with store.at(me): + got = skc.preview(_doc(me, key), skc.PRICE) + assert [x["키"] for x in got["바뀐자원"]] == [MAN] + assert got["후"]["노무비"] == pytest.approx(got["전"]["노무비"] * 2, abs=1)