feat(836-2): M02 단가산출 M01 자동 따라옴 — 서버(사본 줄 · 부름 문서 · 고친 항목 다시 복사 · 알림)

- 새 M02_Sk_AutoRenew — 프로젝트 층 밖 SK 사본 줄 = 지금 M01(+ 내 기계) · 부름 문서 = 지금 M01 수식 문서
- 고침 판정 = 내용 대조(끊음 대 부름 · 따로 문서 대 처음) · 바꿨다 되돌림 = 안 고침
- M01 에서 바뀐 항목의 고침 = 지움 + 다시 복사 + 열기 답 사본.갱신 알림 · 사본.자동 표지
- 파일은 [저장] · 알림 확인(저장) 때만 씀 · 읽기 길 안 씀 · 프로젝트 층 그대로
- 옛 시험 8 개 = 손 갱신 길(프로젝트 층 꼴 · 자동 끔) 고정 — 개인 층 「금액 그대로」 단언이 낡음
- 새 시험 test_836_2_sk_auto_renew(㉠ ~ ㉦)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 13:35:01 +09:00
co-authored by Claude Opus 5.5
parent 6f3daf7f3b
commit 7311d911e0
13 changed files with 504 additions and 4 deletions
+6
View File
@@ -27,6 +27,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_MyMachine_Draft as draft
from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
KEY = dc.KEY KEY = dc.KEY
OLD_FIELDS = ( OLD_FIELDS = (
@@ -119,6 +120,7 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None:
got = sk.get(KEY) if dc.has(sk) else seed(sk) got = sk.get(KEY) if dc.has(sk) else seed(sk)
if got is None: if got is None:
return None return None
got = renew.form(sk, got) # M01 자동 따라옴(836-2)
if got.get("따로") or ctx is None: if got.get("따로") or ctx is None:
return got return got
if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎) if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎)
@@ -133,6 +135,7 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None:
except dc._ERR as e: except dc._ERR as e:
root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)} root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)}
values = dc.values_of(sk[skc.KEY]) if skc.has(sk) else dc.values_of(got) # 서버가 정한 값 values = dc.values_of(sk[skc.KEY]) if skc.has(sk) else dc.values_of(got) # 서버가 정한 값
values = renew.rows(values) if renew.is_sk(sk) else values # 836-2
whole = dc.over(values) whole = dc.over(values)
calls = dc.closure(whole, dc.calls(root), got.get("부름")) calls = dc.closure(whole, dc.calls(root), got.get("부름"))
base = str((sk.get("바탕") or {}).get("로직") or "") base = str((sk.get("바탕") or {}).get("로직") or "")
@@ -191,6 +194,7 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
return {**out, KEY: norm(mine["사본"])} return {**out, KEY: norm(mine["사본"])}
file = stored(out.get("키")) or {} file = stored(out.get("키")) or {}
have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None) have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None)
have = renew.form(out, have) if have else have # 836-2 — 바뀐 항목 고침 지움 · 저장 때 굳음
if have and have.get("따로") and job != skc.REGEN: if have and have.get("따로") and job != skc.REGEN:
if file and old_fp(file) != old_fp(out): if file and old_fp(file) != old_fp(out):
raise sd.DocError( raise sd.DocError(
@@ -210,6 +214,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
calc.run(out, whole, catch=box) calc.run(out, whole, catch=box)
calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} calls = {} if job == skc.REGEN else (have or {}).get("부름") or {}
first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls} first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls}
if job != skc.REGEN and (have or {}).get("끊음"): # 안 바뀐 항목 고침 = 그대로(836-2)
first["끊음"] = have["끊음"]
copy = copy_of({**out, KEY: first}, box[-1] if box else None) copy = copy_of({**out, KEY: first}, box[-1] if box else None)
if ( if (
copy and skc.has(out) and skc.READS.get() is None and (keys := gap.gaps(copy)) copy and skc.has(out) and skc.READS.get() is None and (keys := gap.gaps(copy))
+7 -2
View File
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine_Draft as draft from M02_MasterTemplete import M02_MyMachine_Draft as draft
from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
MAX = 2_000_000 # 문서 글 크기(바이트) 상한 MAX = 2_000_000 # 문서 글 크기(바이트) 상한
JOBS = (skc.PRICE, skc.REGEN) JOBS = (skc.PRICE, skc.REGEN)
@@ -77,7 +78,7 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]:
"""{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준.""" """{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준."""
got = dsk.run(sk, copy) got = dsk.run(sk, copy)
head = {k: copy.get(k) for k in ("시점", "단가시점")} head = {k: copy.get(k) for k in ("시점", "단가시점")}
mark = {**head, "따로": bool(copy.get("따로"))} mark = {**head, "따로": bool(copy.get("따로")), "자동": renew.on()} # 836-2 M01 자동 따라옴
mark |= ( mark |= (
{"단가기준": dim.basis_of(sk)} {"단가기준": dim.basis_of(sk)}
if dim.is_imported(sk) if dim.is_imported(sk)
@@ -97,7 +98,9 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]:
def read(key: str) -> dict[str, Any]: def read(key: str) -> dict[str, Any]:
"""읽기 — {문서, 부름, 끊음, 셈, 사본, 판}.""" """읽기 — {문서, 부름, 끊음, 셈, 사본, 판}."""
got = _file(key) got = _file(key)
return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]} out = {**answer(current(got["문서"]), got["문서"]), "판": got["판"]}
out["사본"]["갱신"] = renew.notes(got["문서"]) # 836-2 열 때 알림(지울 고침)
return out
# ── 몸 검사 ─────────────────────────────────────────────────────────── # ── 몸 검사 ───────────────────────────────────────────────────────────
@@ -137,6 +140,8 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름")) out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1) out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1)
out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1) out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1)
if not copy.get("따로"): # 고침 판정 원본(836-2) = 따로가 되기 전 문서
out[renew.FIRST] = copy["문서"]
out["따로"] = True out["따로"] = True
return out return out
+12 -2
View File
@@ -125,11 +125,18 @@ def _forget(whole: Any) -> None:
_LIVE[0] = whole _LIVE[0] = whole
def _auto(doc: Any, copy: dict) -> dict:
"""SK 사본 = 프로젝트 층 밖이면 지금 M01 줄(836-2 · `M02_Sk_AutoRenew`)."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
return renew.rows(copy) if renew.is_sk(doc) else copy
def over(doc: Any, whole: Any) -> Any: def over(doc: Any, whole: Any) -> Any:
"""사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길).""" """사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길)."""
if not has(doc): if not has(doc):
return whole return whole
copy = doc[KEY] copy = _auto(doc, doc[KEY])
tag = (copy.get("판"), copy.get("단가판")) tag = (copy.get("판"), copy.get("단가판"))
if isinstance(whole, Copy) and whole._tag == tag: if isinstance(whole, Copy) and whole._tag == tag:
return whole return whole
@@ -290,6 +297,8 @@ def settle(doc: Any, save: bool = False) -> Any:
copy = refresh(copy, whole_())[0] copy = refresh(copy, whole_())[0]
if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []): if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []):
copy = rejoin(copy, out["더한줄"]) copy = rejoin(copy, out["더한줄"])
if copy:
copy = _auto(doc, copy) # M01 자동 따라옴(836-2) — 저장 때 굳음
if save and copy and READS.get() is None: # 읽은 줄만(826-3) = 저장 관문이 더함 if save and copy and READS.get() is None: # 읽은 줄만(826-3) = 저장 관문이 더함
copy = grow(copy, stamp(out)) copy = grow(copy, stamp(out))
out = _formula_job(out, job) out = _formula_job(out, job)
@@ -305,7 +314,8 @@ def _formula_job(out: dict, job: Any) -> dict:
if got is None or job not in (PRICE, REGEN): if got is None or job not in (PRICE, REGEN):
return out return out
if job == REGEN: if job == REGEN:
got = {k: v for k, v in got.items() if k not in ("문서", "끊음", "따로")} | {"부름": {}} drop = ("문서", "끊음", "따로", "처음")
got = {k: v for k, v in got.items() if k not in drop} | {"부름": {}}
elif got.get("따로"): elif got.get("따로"):
got = dc.refresh(got)[0] got = dc.refresh(got)[0]
return {**out, dc.KEY: got} return {**out, dc.KEY: got}
+190
View File
@@ -0,0 +1,190 @@
"""M02 단가산출(SK) M01 자동 따라옴(PLAN 836-2 · 요구 854) — 서버 단독.
프로젝트 층 밖 SK(마스터 · 회사 · 개인) = 셈 · 열기 · 목록 · 일위대가 펼침이 모두
사본 줄 = 지금 M01(+ 층 내 기계) 줄(`rows` · 지운 키 = 사본 마지막 줄) ·
부름 문서 = 지금 M01 수식 문서(`form`)로 봄 — 묻지 않음 · [단가 갱신] · [재생성] 없음.
파일(작업본)은 [저장] · 알림 [확인](= 저장) 때만 새 것으로 씀 — 읽기 길은 안 씀 ·
저장 파일의 사본 = 마지막 저장 때 받은 M01(= 고침 판정의 「복사 당시 원본」).
고침 판정 = 내용 대조(표지 아님 · 바꿨다 되돌림 = 안 고침) — 셈에 닿는 칸(`LOGIC`)만 견줌:
끊음[키] 대 부름[키](복사 당시) · 따로 문서 대 `처음`(따로가 된 때의 문서 · 없으면 고친 것).
M01 에서 바뀐 항목에 고침이 얹혀 있으면 그 고침을 지우고 다시 복사 + 알림 `갱신` 한 줄
{키, 이름, 갈래, M01 [전 → 후], 내고침 [지워진 값]} · 고침 없는 항목 = 조용히 따라옴 ·
안 바뀐 항목의 고침 = 그대로. 따로 문서를 지우면 파생(옛 칸 + 새 M01)으로 돌아감.
가져온 식 SK = 값 줄만 따라옴(가져온 줄 문서 · 식 안 건드림 · 단가기준 「엑셀」 은 엑셀 값 그대로).
프로젝트 층 · B09 사본 = 그대로(사용자가 고르는 [단가 갱신] · [재생성]).
"""
from __future__ import annotations
import copy as cp
import json
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
FIRST = "처음" # `수식` 칸 — 따로가 된 때의 문서(고침 판정 원본)
LOGIC = (
"변수",
"식",
"금액",
"끝수",
"결과단위",
) # 셈에 닿는 칸 — 쌓기(차림) · 이름 · 판은 안 견줌
MAX_LINES = 8
_MEMO: dict[tuple, dict] = {}
_LIVE: list[Any] = [None]
def on() -> bool:
"""자동 따라옴 — 프로젝트 층 밖."""
from M02_MasterTemplete import M02_Labor_Ref as refs
return refs.here()[0] != "project"
def is_sk(doc: Any) -> bool:
return isinstance(doc, dict) and doc.get("종류") == sd.TITLE
# ── 값 줄 ──────────────────────────────────────────────────────────────
def _fresh(row: dict, live: Any) -> dict:
key = str(row.get("키"))
got = live.index.get(key[:2], {}).get(key)
if got is None:
return row # M01 에서 지운 키 = 마지막 값
new = prices.to_json(got)
return row if skc._fp(new) == skc._fp(row) else new
def rows(copy: Any) -> Any:
"""값 사본(`skc` 꼴) → 줄마다 지금 M01(+ 층 내 기계) · 바뀐 몫만 판 다시 · 프로젝트 층 = 그대로."""
if not (isinstance(copy, dict) and isinstance(copy.get("로직"), list)) or not on():
return copy
live = skc.whole_()
if live is not _LIVE[0]:
_MEMO.clear()
_LIVE[0] = live
tag = (copy.get("판"), copy.get("단가판"), len(copy["로직"]), len(copy.get("단가") or []))
hit = _MEMO.get(tag)
if hit is not None:
return {**copy, **hit}
got: dict = {}
for part, mark in (("로직", "판"), ("단가", "단가판")):
old = copy.get(part) or []
new = [_fresh(r, live) if isinstance(r, dict) else r for r in old]
if any(a is not b for a, b in zip(old, new)):
got |= {part: new, mark: skc._fp(new)}
if len(_MEMO) > 64: # ponytail: 통째 비움 · SK 가 많아 느려지면 LRU 로
_MEMO.clear()
_MEMO[tag] = got
return {**copy, **got}
# ── 수식 사본(부름 · 끊음 · 따로 문서) ──────────────────────────────────
def _logic(doc: Any) -> str:
from M02_MasterTemplete import M02_Doc_Sk as dsk
doc = doc if isinstance(doc, dict) else {}
return dc.fp(dsk._strip({k: doc.get(k) for k in LOGIC}))
def _say(v: dict) -> str:
link = v.get("연결") or {}
for slot in ("값", "식", "키"):
if link.get(slot) not in (None, ""):
return str(link[slot])
return json.dumps(link, ensure_ascii=False, default=str)[:60]
def _name(v: dict) -> str:
return str(v.get("이름") or f"{v.get('기호') or ''}{v.get('아래') or ''}")
def diff(a: Any, b: Any) -> list[str]:
"""두 문서의 셈 칸 다른 곳 — 「이름 전 → 후」(변수 · 금액 줄) · 많으면 앞 몇 줄."""
a, b = (x if isinstance(x, dict) else {} for x in (a, b))
out: list[str] = []
va = {dc.md.var_id(v): v for v in a.get("변수") or []}
vb = {dc.md.var_id(v): v for v in b.get("변수") or []}
for vid in [*va, *(k for k in vb if k not in va)]:
x, y = va.get(vid), vb.get(vid)
if x and y and _say(x) == _say(y):
continue
name = _name(y or x)
out.append(f"{name} {_say(x) if x else '—'} → {_say(y) if y else '—'}")
la, lb = a.get("금액") or [], b.get("금액") or []
for n in range(max(len(la), len(lb))):
x, y = (la[n] if n < len(la) else None), (lb[n] if n < len(lb) else None)
if dc.fp(x) == dc.fp(y):
continue
name = str((y or x or {}).get("이름") or f"금액 {n + 1}")
show = lambda z: "—" if not z else f"{z.get('수량') or ''} × {z.get('단가') or ''}" # noqa: E731
out.append(f"{name} {show(x)} → {show(y)}")
for slot in ("식", "끝수", "결과단위"):
if dc.fp(a.get(slot)) != dc.fp(b.get(slot)):
out.append(slot)
return out[:MAX_LINES] + ([f"… {len(out) - MAX_LINES}"] if len(out) > MAX_LINES else [])
def form(sk: Any, got: Any, notes: list | None = None) -> Any:
"""수식 사본 → 지금 M01 으로 본 것(값 줄 · 부름) · M01 에서 바뀐 항목의 고침(끊음 · 따로 문서)은
지우고 `notes` 에 한 줄 · 안 바뀐 항목 고침 · 프로젝트 층 = 그대로."""
if not isinstance(got, dict) or not on() or not is_sk(sk):
return got
out = rows(got) if "로직" in got else dict(got)
if imp.is_imported(sk):
return out # 가져온 식 = 값만(식 · 가져온 줄 그대로)
live = dc.folder()
calls = dict(out.get("부름") or {})
changed = {k for k, d in calls.items() if k in live and dc.fp(d) != dc.fp(live[k])}
if not changed and not out.get("따로"):
return out
for k in changed:
calls[k] = cp.deepcopy(live[k])
cut = dict(out.get("끊음") or {})
for k in sorted(changed & set(cut)):
mine, was = cut.pop(k), (got.get("부름") or {}).get(k)
if _logic(mine) != _logic(was) and notes is not None:
notes.append(_note(k, was, live[k], diff(was, mine), "부름"))
out |= {"부름": calls, "끊음": cut}
base = out.get("원본") or {}
key = str(base.get("키") or "")
if out.get("따로") and key in live and dc.fp(live[key]) != base.get("판"):
first = out.get(FIRST)
mine = out.get("문서") or {}
if first is None or _logic(mine) != _logic(first):
mark = (
diff(first, mine)
if first is not None
else ["수식 문서 고침(옛 저장본 — 원본 없음)"]
)
if notes is not None:
notes.append(_note(key, first, live[key], mark, "문서", mine.get("이름")))
out = {k: v for k, v in out.items() if k not in ("따로", "문서", FIRST)}
if changed and "문서" in out: # 새 문서가 부르는 키 = 닫힌 묶음
keys = [*dc.calls(out["문서"]), *(c for d in cut.values() for c in dc.calls(d))]
out["부름"] = dc.closure(dc.over(out), keys, calls)
return out
def _note(key: str, was: Any, now: dict, mine: list[str], kind: str, name: Any = None) -> dict:
return {
"키": key,
"이름": str(name or now.get("이름") or key),
"갈래": kind,
"M01": diff(was, now) or ["바뀜"],
"내고침": mine,
}
def notes(sk: Any) -> list[dict]:
"""열 때 알림 — 이 SK 저장본에서 지울 고침(없으면 []) · 가져온 식 · 프로젝트 층 = []."""
if not is_sk(sk) or not dc.has(sk) or imp.is_imported(sk):
return []
out: list[dict] = []
form(sk, sk[dc.KEY], out)
return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str))
+9
View File
@@ -23,6 +23,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Imported as imp from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
MAN = "LB000002" # 보통인부 MAN = "LB000002" # 보통인부
@@ -32,6 +32,15 @@ from resources.tester.test_795_1_sk_copy import (
) )
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공 KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공
DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}] DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}]
+9
View File
@@ -28,6 +28,15 @@ from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor, _sc
from resources.tester.test_795_2_sc_copy import _calc as _sc_calc from resources.tester.test_795_2_sc_copy import _calc as _sc_calc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
FORM = "GC000211" # 합판거푸집(옵션 많음) FORM = "GC000211" # 합판거푸집(옵션 많음)
BASIC = "GF000089" # 토사깍기(기계) — 굴착기 시간당 × 1 / 작업량(813-2 GC001148 지움 뒤) BASIC = "GF000089" # 토사깍기(기계) — 굴착기 시간당 × 1 / 작업량(813-2 GC001148 지움 뒤)
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"} ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"}
@@ -26,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음 DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
PCT = "GC001109" # M01 만 쓰는 SK 바탕 PCT = "GC001109" # M01 만 쓰는 SK 바탕
NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로) NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로)
+9
View File
@@ -22,6 +22,15 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc from M02_MasterTemplete import M02_Sangeun_Copy as skc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
DIG, DIG_NO, DIG_LOGIC = "EQ000022", "0201-0100", "GC000267" # 굴착기(무한궤도) 1.0㎥ DIG, DIG_NO, DIG_LOGIC = "EQ000022", "0201-0100", "GC000267" # 굴착기(무한궤도) 1.0㎥
DIG2_NO = "0201-0070" # 바꿔 끼울 굴착기 DIG2_NO = "0201-0070" # 바꿔 끼울 굴착기
MAN = "LB000002" # 보통인부 MAN = "LB000002" # 보통인부
@@ -14,6 +14,8 @@ import json
import os import os
import subprocess import subprocess
import pytest
from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Import as dim from M02_MasterTemplete import M02_Doc_Import as dim
from M02_MasterTemplete import M02_Doc_Sk as dsk from M02_MasterTemplete import M02_Doc_Sk as dsk
@@ -24,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_822_1_copy_gap import _move from resources.tester.test_822_1_copy_gap import _move
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
HERE = os.path.dirname(__file__) HERE = os.path.dirname(__file__)
DIG = "GC000267" # 굴착기 시간당 경비 DIG = "GC000267" # 굴착기 시간당 경비
NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥ NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥
+11
View File
@@ -14,6 +14,8 @@ import copy
import json import json
from pathlib import Path from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_Sk_Path as path from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Item_Prices as prices
@@ -36,6 +38,15 @@ from resources.tester.test_822_1_copy_gap import (
) )
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
DIG_BASE = "GC000088" # 터파기(기계) 보통토사 — 「기계」 고르기 후보를 닫힌 묶음이 다 담음 DIG_BASE = "GC000088" # 터파기(기계) 보통토사 — 「기계」 고르기 후보를 닫힌 묶음이 다 담음
@@ -25,6 +25,15 @@ from M02_MasterTemplete import M02_MyMachine_Draft as draft
from M02_MasterTemplete import M02_MyMachine_Router as route from M02_MasterTemplete import M02_MyMachine_Router as route
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
@pytest.fixture(autouse=True)
def _manual(monkeypatch):
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
monkeypatch.setattr(renew, "on", lambda: False)
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음 DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
NAME = "sub1임시 기계" NAME = "sub1임시 기계"
USER = {"role": "USER", "company_id": 7, "user_id": 42} USER = {"role": "USER", "company_id": 7, "user_id": 42}
@@ -0,0 +1,213 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""836-2 M01 → M02 단가산출 자동 따라옴(요구 854) — 임시 storage + M01 메모리 사본(정본 파일 안 고침).
㉠ 노임 · 재료 · 기계 값 바뀜 = 누르지 않고 새 금액(수식 길 열기 · 옛 셈 길 목록 · 일위대가 펼침 같은 답)
㉡ 부른 M01 수식 문서 바뀜 = 따라옴 ㉢ 고친 문서 + 그 문서 M01 바뀜 = 다시 복사 + 알림(전 · 후 · 내 고침)
㉣ 고침 + 다른 항목만 바뀜 = 고침 그대로 ㉤ 바꿨다 되돌림 = 안 고침(알림 없음)
㉥ 프로젝트 층 = 그대로 ㉦ 가져온 식(우리) = 값만 · 식 그대로 · 열기 = 파일 안 고침 · 알림 [확인] = 저장.
"""
from __future__ import annotations
import copy
import shutil
import pytest
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_822_1_copy_gap import (
DIG,
MACHINE,
MAN,
PCT,
_at,
_live,
_move,
_sk,
_stack,
_sum,
)
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
TOOL = 2 # PCT 「공구손료 및 경장비」 금액 줄 차례
def _doc_move(monkeypatch, key: str, fn) -> None:
"""M01 수식 문서 하나 고침(메모리 · 폴더 그대로)."""
live = dc.folder()
new = copy.deepcopy(live[key])
fn(new)
fake = {**live, key: new}
monkeypatch.setattr(dc, "folder", lambda: fake)
def _rate(text: str):
def fn(d: dict) -> None:
d["금액"][TOOL]["단가"] = text
return fn
def _edit(me, key: str, text: str) -> None:
got = _at(me, path.read, key)
doc = copy.deepcopy(got["문서"])
doc["금액"][TOOL]["단가"] = text
_at(me, path.save, key, {"문서": doc, "판": got["판"]})
def _base(me) -> str:
return _sk(me, 바탕={"로직": PCT}, 단위="㎡")
def _labor_unit(me, key: str):
"""일위대가 줄이 SK 참조를 펼치는 길(`Expand` → `calc.unit`) 1 단위 계."""
from M02_MasterTemplete.M02_Labor_Expand import Expand
with store.at(me):
ref = {"층": "personal", "주인": "7/42", "키": key}
whole = _live()
got = calc.unit(refs.read_ref(ref), whole, Expand(whole), ())
return got["기준"]["계"] if got["기준"] else None
def _list_unit(me, key: str):
"""목록 · 옛 셈 길(`calc_doc`) 1 단위 계."""
with store.at(me):
got = calc.calc_doc(store.read("sangeun", key)["문서"], None, False)
return got["1단위"]
@pytest.mark.parametrize("what", ["노임", "재료", "기계"])
def test_values_follow(world, monkeypatch, what):
"""㉠ 값 바뀜 = 묻지 않고 새 금액 · 세 길 같은 답 · 열기 = 파일 안 고침 · 알림 없음."""
me = world["personal"]
key = _sk(me)
res = {"노임": MAN, "재료": next(iter(_live().index["MT"])), "기계": DIG}[what]
link = MACHINE if what == "기계" else {"갈래": "요소", "키": res}
part = {"노임": "노무비", "재료": "재료비", "기계": "묶음"}[what]
_stack(me, key, [("자원", link, part)])
was = _sum(me, key)
file = me / "sangeun" / f"{key}.json"
before = file.read_bytes()
_move(monkeypatch, res)
got = _at(me, path.read, key)
assert got["셈"]["계"] > was
assert got["사본"]["자동"] is True and got["사본"]["갱신"] == []
assert not got["사본"]["단가새값"] and not got["사본"]["M01새판"]
unit = _list_unit(me, key)
assert unit["계"] == pytest.approx(got["셈"]["계"])
assert _labor_unit(me, key) == pytest.approx(got["셈"]["계"])
assert file.read_bytes() == before # 읽기 = 작업본 안 씀
def test_logic_follows(world, monkeypatch):
"""㉡ 부른 M01 수식 문서(기계 시간당) 바뀜 = 따라옴 · 알림 없음."""
me = world["personal"]
key = _sk(me)
_stack(me, key, [("기계", MACHINE, "묶음")])
was = _sum(me, key)
def twice(d: dict) -> None:
for line in d["금액"]:
line["수량"] = f"({line.get('수량') or 1}) × 2"
_doc_move(monkeypatch, MACHINE["키"], twice)
got = _at(me, path.read, key)
assert got["셈"]["계"] > was * 1.5 # 율 줄은 누계를 받아 두 배 넘음
assert got["사본"]["갱신"] == [] and not got["사본"]["문서새판"]
def test_edited_item_recopied(world, monkeypatch):
"""㉢ 고친 문서 + 그 M01 문서 바뀜 = 고침 지우고 다시 복사 + 알림(전 · 후 · 내 고침) ·
[확인](저장) 뒤 알림 없음 · 따로 아님."""
me = world["personal"]
key = _base(me)
plain = _sum(me, key)
_edit(me, key, "노무비 × 5%")
assert _sum(me, key) > plain
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
got = _at(me, path.read, key)
labor = got["셈"]["노무비"]
assert got["셈"]["경비"] == pytest.approx(int(labor * 0.04), abs=1)
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%"
[note] = got["사본"]["갱신"]
assert note["키"] == PCT and note["갈래"] == "문서"
assert any("3%" in x and "4%" in x for x in note["M01"])
assert any("3%" in x and "5%" in x for x in note["내고침"])
_at(me, path.save, key, {"할일": "갱신확인", "판": got["판"]})
after = _at(me, path.read, key)
assert after["사본"]["갱신"] == [] and after["사본"]["따로"] is False
assert after["셈"]["계"] == got["셈"]["계"]
def test_edit_kept_when_other_item_moves(world, monkeypatch):
"""㉣ 고침 + 다른 항목(노임 값)만 바뀜 = 고침 그대로 · 값은 따라옴."""
me = world["personal"]
key = _base(me)
_edit(me, key, "노무비 × 5%")
was = _at(me, path.read, key)["셈"]
_move(monkeypatch, MAN)
got = _at(me, path.read, key)
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%"
assert got["사본"]["따로"] is True and got["사본"]["갱신"] == []
assert got["셈"]["노무비"] > was["노무비"]
def test_reverted_is_not_edit(world, monkeypatch):
"""㉤ 바꿨다 되돌려 저장 = 안 고침 — M01 바뀌면 조용히 따라옴(알림 없음)."""
me = world["personal"]
key = _base(me)
_edit(me, key, "노무비 × 5%")
_edit(me, key, "노무비 × 3%")
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
got = _at(me, path.read, key)
assert got["사본"]["갱신"] == []
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%"
def test_project_layer_keeps(world, monkeypatch, tmp_path):
"""㉥ 프로젝트 층 사본 = 그대로(M01 바뀌어도 금액 같음 · 고침 그대로)."""
me = world["personal"]
key = _base(me)
_edit(me, key, "노무비 × 5%")
proj = me.parent / "p836" / "templates"
(proj / "sangeun").mkdir(parents=True)
shutil.copy(me / "sangeun" / f"{key}.json", proj / "sangeun" / f"{key}.json")
with store.at(proj):
assert refs.here()[0] == "project"
was = _at(proj, path.read, key)
_move(monkeypatch, MAN)
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
got = _at(proj, path.read, key)
assert got["셈"]["계"] == was["셈"]["계"] and got["사본"]["자동"] is False
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%" and got["사본"]["갱신"] == []
def test_imported_values_only(world, monkeypatch):
"""㉦ 가져온 식 단가기준 우리 = 이음 노임만 따라옴 · 가져온 줄 식 그대로 · 엑셀 기준 = 엑셀 값."""
from M02_MasterTemplete import M02_Sangeun_Imported as imp
me = world["personal"]
man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"}
row = {
"이름": "sub2임시 가져온",
"결과단위": "원/㎡",
"호표": [{**man, "단가": 165545, "이음": MAN}],
}
key = _at(me, lambda: imp.create(row, None, "sub2임시 가져온")["이름"])
excel = _sum(me, key)
doc = _at(me, lambda: store.read("sangeun", key)["문서"])
ours = _at(me, lambda: imp.to_price(doc, "우리")["문서"])
_at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], ours))
was = _at(me, path.read, key)
_move(monkeypatch, MAN)
got = _at(me, path.read, key)
assert got["셈"]["계"] == pytest.approx(was["셈"]["계"] * 2, abs=2)
assert got["문서"]["금액"] == was["문서"]["금액"] and got["사본"]["갱신"] == []
back = _at(me, lambda: imp.to_price(store.read("sangeun", key)["문서"], "엑셀")["문서"])
_at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], back))
assert _sum(me, key) == excel