feat(802-4a): M02 단가산출 · 일위대가 · SC · B09 사본 = 수식 문서 사본 — 문서 셈 · 수식 길 · 옮기기 도구 · 대조

- 문서 칸 `수식`(옛 `사본` 은 802-5 까지 옆에) = 값 사본(로직 · 자원 단가 줄) + 부름 닫힌 묶음 + SK 문서 · 끊음 · 따로 — `M02_Doc_Copy`(새)
  · 부름 문서 = 값 줄이 지금 M01 줄과 같으면 M01 수식 문서 · 다르면 만든 때 줄 변환
- SK 금액 = 문서 셈(`M02_Doc_Sk.on_run`) — 옛 칸(덮개 · 더한줄 …)은 파생(엔진에 넣던 고친 줄 → 문서 · 덮개 없는 바탕 = M01 문서 사본) · 수식 길 고침 = 따로(옛 칸 고침 저장 409 · 재생성 = 파생으로)
- 일위대가 · SC 로직 줄 = 문서 셈(`M02_Labor_Copy.line`) · 줄 옵션 = 문서 규칙(켜쌓기율 변수 r · 고른 자재 = 요소 키 · 별도계상 = 합밖 + 뒤 「재료비의 %」 밑엔 그 줄 금액 더함)
- [단가 갱신] = 자원 단가 줄만(문서 그대로) · [재생성] = 지금 M01 문서로(고친 문서 · 끊음 버림 알림) · 빈 SK = 빈 문서 「—」
- 수식 길 GET/POST …/sangeun/{키}/doc · /preview · /save(층 쓰기 · 서버 정본 셈) — `M02_Doc_Sk_Path` · `M02_Doc_Router`(새 · main 등록)
- B09 구조물 · 서고 사본 = `수식.json` 함께 굳힘(`M02_Doc_B09` · 원본판 안 듦) · B09 셈 그대로(문서 셈 전환 = 802-6)
- 옮기기 `M02_Doc_Migrate`(보기만 · --apply --backup · --restore) · 목록 `docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx` — 층 SK 15 · 일위대가 21 · SC 4 · B09 서고 사본 3 같음 · 빈 SK 2 = 옛 0 → 「—」 · 가져온 식 1 = 802-4b
- 시험 test_802_4a(12) · test_204_2 서고 사본 파일 목록에 수식.json · test_795_1 `_fake` 가 M01 수식 문서도 같이 바꿈(M01 정본 = 수식 문서 — 옛 줄만 바꾼 흉내는 문서 사본에 안 닿음) · 옛 SK 흉내 = 사본 · 수식 둘 다 뗌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:40:04 +09:00
co-authored by Claude Opus 5.5
parent d51c0b2200
commit 040fc6458e
19 changed files with 1364 additions and 23 deletions
@@ -82,7 +82,8 @@ def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사
folder = made["폴더"]
assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True}
copy = library.library_dir(root) / folder
assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"}
names = {"일위대가.json", "단가.json", "수식.json", "manifest.json"} # 수식 사본(802-4a)
assert {p.name for p in copy.iterdir()} == names
got, block = _block(root, folder)
assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡")
kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]]
+8 -2
View File
@@ -32,12 +32,18 @@ def _live():
def _fake(monkeypatch, rows=(), drop=()):
"""M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침)."""
"""M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침) · M01 수식 문서도 같이
(802-4a — 수식 사본 SK 의 M01 정본 = 수식 문서 · 바뀐 줄은 그 줄 변환 문서로)."""
from M02_MasterTemplete import M02_Doc_Copy as dc
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))
docs = {k: v for k, v in dc.folder().items() if k not in drop}
docs |= {str(r["키"]): dc.cv.convert(fake, copy.deepcopy(r))[0] for r in rows if "호표" in r}
monkeypatch.setattr(dc, "folder", lambda: docs)
return fake
@@ -165,7 +171,7 @@ def test_old_sk_without_copy_old_path(world, monkeypatch):
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 != "사본"}
old = {k: v for k, v in raw.items() if k not in ("사본", "수식")} # 795 앞 SK = 둘 다 없음
path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8")
fresh = _sk(me, 바탕={"로직": PCT})
assert _sum(me, key) == _sum(me, fresh)
+260
View File
@@ -0,0 +1,260 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""802-4a M02 단가산출 · 일위대가 · B09 사본 = 수식 문서 — 임시 storage · M01 은 메모리 덮개.
① 찍기 = M01 문서 사본(금액 = M01 문서 셈) ② 고침 = 사본 문서(M01 안 바뀜 · 옛 칸 고침 409)
③ [단가 갱신] = 자원 단가만(문서 그대로) ④ [재생성] = 지금 M01 문서 · 고친 것 버림
⑤ 단가산출 연결 = [재생성] 때 따라감 · 끊기 = 사본 안에 굳힘 ⑥ SC · 일위대가 = 문서 셈(옛 금액 같음 ·
줄 규칙 켜쌓기율 · 고른 자재 · 별도계상) ⑦ B09 사본 `수식.json` ⑧ 권한 ⑨ 지움 견딤 · 빈 SK 「—」.
"""
from __future__ import annotations
import copy
import pytest
from fastapi import HTTPException
from M02_MasterTemplete import M02_Doc_B09 as doc_b09
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Router as router
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_795_1_sk_copy import _calc, _changed, _doc, _fake, _live, _save, _sk
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_m02_labor_ref import world # noqa: F401 — 같은 임시 층
FORM = "GC000211" # 합판거푸집(옵션 많음)
BASIC = "GC001148" # 굴착기 시간당
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"}
def _sum(folder, key):
return _calc(folder, _doc(folder, key))["기준"]["계"]
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _twice(doc, at=0):
"""금액 줄 하나 수량 × 2 — 사용자 고침 흉내."""
doc = copy.deepcopy(doc)
line = doc["금액"][at]
line["수량"] = f"({line.get('수량') or 1}) × 2"
return doc
def test_stamp_is_m01_doc_copy(world):
"""① 만들 때 M01 문서를 찍음 — 키 = SK · 옵션 값 · 금액 = M01 문서 셈 = 옛 금액."""
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 입력값={"유형": "복잡", "사용횟수": "3회"})
got = _doc(me, key)[dc.KEY]
root, m01 = got["문서"], dc.folder()[FORM]
assert root["키"] == key and [v["기호"] for v in root["변수"]] == [
v["기호"] for v in m01["변수"]
]
assert next(v for v in root["변수"] if v["열쇠"] == "유형")["연결"]["값"] == "복잡"
assert not got.get("따로") and got["로직"] and got["단가"]
m01_calc = dc.mc.calc(root, dc.ml.Links(_live(), dc.folder()))
assert _sum(me, key) == m01_calc["계"]
token = dsk.OLD.set(True)
try:
assert _sum(me, key) == m01_calc["계"] # 옛 엔진 금액과 같음
finally:
dsk.OLD.reset(token)
read = _at(me, path.read, key)
assert read["셈"]["계"] == m01_calc["계"] and read["사본"]["따로"] is False
def test_edit_goes_to_copy_doc(world):
"""② 고침 = 그 사본 문서(따로) · M01 문서 그대로 · 옛 칸 고침 저장 409 · 다시 열어 같음."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was, m01_before = _sum(me, key), dc.fp(dc.folder()[PCT])
read = _at(me, path.read, key)
pre = _at(me, path.preview, key, {"문서": _twice(read["문서"])})
assert pre["전"]["계"] == was and pre["후"]["계"] > was
saved = _at(me, path.save, key, {"문서": _twice(read["문서"]), "판": read["판"]})
assert saved["사본"]["따로"] is True and saved["셈"]["계"] == pre["후"]["계"]
assert _sum(me, key) == pre["후"]["계"] # SK 금액(일위대가가 쓰는 값) = 고친 문서
assert dc.fp(dc.folder()[PCT]) == m01_before
assert _at(me, path.read, key)["문서"] == saved["문서"]
doc = _doc(me, key)
with pytest.raises(store.StoreError) as e:
_save(me, key, {**doc, "입력값": {"x": 1}, "덮개": {"labor:LB000036#0": {"qty": 3}}})
assert e.value.status == 409
_save(me, key, {**doc, "이름": "sub2임시 이름만"}) # 머리 칸 = 그대로 받음
assert _sum(me, key) == pre["후"]["계"]
with pytest.raises(store.StoreError) as e: # 판 다름
_at(me, path.save, key, {"문서": read["문서"], "판": read["판"]})
assert e.value.status == 409
def test_price_refresh_values_only(world, monkeypatch):
"""③ [단가 갱신] = 자원 단가만 — M01 문서 바뀜은 안 따라감 · 바뀐자원 · 누르기 전 그대로."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was = _sum(me, key)
root = _at(me, path.read, key)["문서"]
_fake(monkeypatch, [_changed(_live().logic(PCT)), _double(LB)])
assert _sum(me, key) == was
pre = _at(me, path.preview, key, {"할일": "단가갱신"})
assert [x["키"] for x in pre["바뀐자원"]] == [LB] and pre["문서"] == root
fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) # 새 문서 + 새 단가
assert pre["후"]["계"] != was and pre["후"]["계"] != fresh
read = _at(me, path.read, key)
got = _at(me, path.save, key, {"할일": "단가갱신", "판": read["판"]})
assert got["셈"]["계"] == pre["후"]["계"] == _sum(me, key) and got["문서"] == root
assert got["사본"]["단가새값"] is False and PCT in got["사본"]["문서새판"]
def test_regen_takes_m01_doc_drops_edits(world, monkeypatch):
"""④ [재생성] = 지금 M01 문서로 · 고친 문서 버림(알림) · 파생으로 돌아감."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
read = _at(me, path.read, key)
_at(me, path.save, key, {"문서": _twice(read["문서"], 1), "판": read["판"]})
_fake(monkeypatch, [_changed(_live().logic(PCT))])
fresh = _sum(me, _sk(me, 바탕={"로직": PCT}))
pre = _at(me, path.preview, key, {"할일": "재생성", "문서": {"아무": "버려짐"}})
assert pre["버림"]["문서고침"] is True and pre["후"]["계"] == fresh
read = _at(me, path.read, key)
got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]})
assert got["사본"]["따로"] is False and got["셈"]["계"] == fresh == _sum(me, key)
assert got["사본"]["M01새판"] is False and got["사본"]["문서새판"] == []
def test_link_follows_on_regen_unlink_freezes(world, monkeypatch):
"""⑤ 단가산출 연결 = 부름 사본(M01 바뀌어도 그대로 · [재생성] 때 따라감) · 끊기 = 사본 안 고침."""
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)])
was = _sum(me, key)
read = _at(me, path.read, key)
assert BASIC in read["부름"]
cut = {BASIC: _twice(read["부름"][BASIC])}
pre = _at(me, path.preview, key, {"끊음": cut})
assert pre["후"]["계"] > was
saved = _at(me, path.save, key, {"끊음": cut, "판": read["판"]})
assert saved["끊음"][BASIC] == {**cut[BASIC], "키": BASIC} and _sum(me, key) == pre["후"]["계"]
_fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 원본 바뀜
assert _sum(me, key) == pre["후"]["계"] # 끊은 것 · 연결 모두 그대로(누르기 전)
with pytest.raises(store.StoreError) as e: # 부르지 않는 키 끊기
_at(me, path.preview, key, {"끊음": {"GC999999": read["부름"][BASIC]}})
assert e.value.status == 400
read = _at(me, path.read, key)
pre = _at(me, path.preview, key, {"할일": "재생성"})
assert pre["버림"]["끊음"] == [BASIC]
got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]})
assert got["끊음"] == {} and got["셈"]["계"] > was # 연결 = 새 원본 따라감
def test_sc_and_labor_use_doc(world, monkeypatch):
"""⑥ SC · 일위대가 로직 줄 = 문서 셈(부름 사본) · 옛 엔진 금액과 같음 · M01 바뀌어도 그대로."""
me = world["personal"]
sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}])
_labor(me, [{"로직": FORM, "수량": {"손": 2}, "입력값": {"유형": "보통"}}])
stored = _at(me, store.read, "precombo", sc)["문서"]
assert dc.has(stored) and PCT in stored[dc.KEY]["부름"]
runs = []
real = dc.run
monkeypatch.setattr(dc, "run", lambda *a, **k: runs.append(1) or real(*a, **k))
new = (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"])
assert runs
token = dsk.OLD.set(True)
try:
assert (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"]) == new
finally:
dsk.OLD.reset(token)
_fake(monkeypatch, [_changed(_live().logic(PCT))])
assert _sc_calc(me, sc)["기준합"] == new[0]
@pytest.mark.parametrize(
"code, rate, picks, marks",
[
("GC000261", 90, None, None), # 켜쌓기율 = 변수 r · 수량 × r ÷ 100
("GF000156", None, {"3": "MT004176", "4": "MT003834"}, None), # 고른 자재 = 요소 키
("GC000665", None, None, None), # 별도계상(기본) = 합밖 · 「재료비의 %」 밑엔 듦
],
)
def test_line_rules_in_doc(code, rate, picks, marks):
"""⑥ 일위대가 줄 옵션 = 문서 규칙(브레인 ④) — 옛 `run_line` · `drop_apart` 와 같은 금액."""
whole = _live()
old = labor.drop_apart(labor.run_line(whole, code, {}, rate, picks), marks)
assert old["기준"]
apart = dc.apart_of(marks, old.get("재료"))
doc = dc.line_doc(dc.folder()[code], rate, picks, apart)
if rate:
assert dc.RATE_ID in str(doc["금액"][0]["수량"])
if apart:
assert all(doc["금액"][n].get("합밖") for n in apart)
got = dc.mc.calc(doc, dc.ml.Links(whole, dc.folder()), dc.given_of(whole, code, {}))
assert {k: got[k] for k in dc.COSTS} == {k: old["기준"][k] for k in dc.COSTS}
assert dc.line_doc(dc.folder()[code]) is dc.folder()[code] # 옵션 없음 = 그 문서
def test_b09_copy_has_doc_file(world, monkeypatch):
"""⑦ B09 사본 = 단가 묶음 옆 `수식.json`(만든 때 부름) · 원본판엔 안 듦 · M01 바뀌어도 그대로."""
me = world["personal"]
doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])
rows = source.labor_doc_rows(doc)
files = doc_b09.attach({"일위대가.json": rows, "단가.json": source.price_bundle(rows, me)})
made = files[doc_b09.FILE]
assert made["그룹"] == "수식문서" and PCT in made["문서"]
assert doc_b09.FILE not in snap.source_files(files)
assert doc_b09.attach(files) is files # 이미 있음 = 그대로
_fake(monkeypatch, [_changed(_live().logic(PCT))])
again = doc_b09.build(source.price_bundle(rows, me), rows)
assert again["문서"][PCT] == made["문서"][PCT] # 일위대가 수식 사본 = 만든 때
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_permission_layer(world):
"""⑧ 읽기 = 층 읽기 · 미리 셈 · 저장 = 층 쓰기(마스터 = 시스템 관리자 · 회사 = 회사 관리자)."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
body = router.DocBody(문서=_at(me, path.read, key)["문서"])
for layer in ("system", "company"):
for fn in (router.preview_doc, router.save_doc):
with pytest.raises(HTTPException) as e:
fn(layer, key, body, None, _session())
assert e.value.status_code == 403
assert router.read_doc("personal", key, None, _session())["셈"]["셈됨"]
got = router.preview_doc("personal", key, body, None, _session())
assert got["후"] == got["전"]
def test_delete_tolerant_and_blank(world):
"""⑨ 무엇을 지워도 오류 없음 — 변수 지움 = 정의 필요 · 금액 「—」 · 저장됨 · 빈 SK = 「—」."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
read = _at(me, path.read, key)
gone = copy.deepcopy(read["문서"])
gone["변수"] = [v for v in gone["변수"] if v["열쇠"] != "w_지붕잇기공"]
pre = _at(me, path.preview, key, {"문서": gone})
assert pre["셈"]["셈됨"] is False and pre["후"] is None
assert any(m["갈래"] == "정의 필요" for m in pre["셈"]["표지"])
_at(me, path.save, key, {"문서": gone, "판": read["판"]})
got = _calc(me, _doc(me, key))
assert got["기준"] is None and got["수식셈"]["셈됨"] is False
lost = copy.deepcopy(read["문서"])
lost["변수"].append({"열쇠": "b", "기호": "b", "연결": {"갈래": "단가산출", "키": "GC999999"}})
lost["금액"].append({"이름": "없는 기초", "비목": "묶음", "단가": "b"})
pre = _at(me, path.preview, key, {"문서": lost})
assert any(m["갈래"] == "연결 끊김" for m in pre["셈"]["표지"])
for bad in ({"문서": "글"}, {"문서": {"금액": "x"}}, {"끊음": []}, {"할일": "몰라"}):
with pytest.raises(store.StoreError) as e:
_at(me, path.preview, key, bad)
assert e.value.status == 400
empty = _sk(me)
got = _calc(me, _doc(me, empty))
assert got["기준"] is None and "빈 문서" in got["수식셈"]["까닭"]