feat(795-1): SK 로직 사본 — 저장 때 M01 닫힌 묶음 찍음 · [단가 갱신] · [재생성] · M01 새 판 배지

- 새 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 11:16:39 +09:00
co-authored by Claude Opus 5.5
parent 785f5793a7
commit 5112bc1425
6 changed files with 559 additions and 7 deletions
+2
View File
@@ -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}
+6 -6
View File
@@ -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(
+3 -1
View File
@@ -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)
+308
View File
@@ -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
+27
View File
@@ -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)
+213
View File
@@ -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)