feat(733-3): 서고 중복 찾기 · 합치기 서버 등록 · 지문 모듈 연결 · 시험

- M02_Library_Same_Router — 733-1a 지문(Same 한 벌 · above) 으로 묶음 · 합칠 때 지문 다시 셈(다르면 409)
- main.py 에 라우터 등록(로그인만 · 층 쓰기 권한은 길 안에서)
- 새 시험 test_733_3_library_same_merge(합치기 참조 옮김 · 금액 같음 · 되돌림 · 지문 · 판 409 · 권한 밖 남김 · 위 층 키 · 일반 사용자 회사 층 403 · SC 수량만 다름)
- 새 시험 test_733_3_library_same_screen(부품만 · 날 색 없음 · 나무 메뉴 · 서버 길 짝)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:44:00 +09:00
co-authored by Claude Opus 5.5
parent 16c7b2c782
commit b9e6f77dab
4 changed files with 235 additions and 19 deletions
+7 -19
View File
@@ -62,14 +62,6 @@ def _ref(at: refs.Here, key: str) -> refs.Ref:
return {"층": at[0], "주인": at[1], "키": key}
def _uppers(at: refs.Here) -> list[refs.Here]:
"""이 층이 읽는 위 층 — 개인 → 그 회사 · 마스터 / 회사 → 마스터 / 마스터 → 없음."""
layer, owner = at
if layer == "personal":
return [("company", str(owner).split("/")[0]), ("system", None)]
return [("system", None)] if layer == "company" else []
def _docs(at: refs.Here, kind: str) -> list[tuple[str, dict[str, Any]]]:
folder = refs.folder_of(*at) / kind
out = []
@@ -83,12 +75,6 @@ def _docs(at: refs.Here, kind: str) -> list[tuple[str, dict[str, Any]]]:
return out
def _finger(at: refs.Here, kind: str, doc: dict[str, Any]) -> str | None:
"""문서 내용 지문(733-1 · 빈 문서 None) — 참조는 가리킨 문서 지문(층 넘어 사본도 같음)."""
with store.at(refs.folder_of(*at)):
return same.finger(kind, doc)
# ── 쓰는 곳 ───────────────────────────────────────────────────────────
def _user_index() -> dict[str, list[tuple[Path, str, str]]]:
"""모든 층(프로젝트 뺌) 훑기 한 번 — {ref_id: [(파일, 종류, 「어디 이름」)]}."""
@@ -177,9 +163,10 @@ def find(folder: Path, kind: str) -> dict[str, Any]:
mine = refs.where(folder)
index = _user_index()
groups: dict[str, list[tuple[refs.Here, str, dict[str, Any]]]] = {}
for at in [mine, *_uppers(mine)]:
marks = same.Same() # 한 요청 안 참조 지문 메모
for at in [mine, *same.above(mine)]:
for key, doc in _docs(at, kind):
if (mark := _finger(at, kind, doc)) is not None:
if (mark := marks.finger(kind, doc)) is not None:
groups.setdefault(mark, []).append((at, key, doc))
out = []
for mark, docs in groups.items():
@@ -204,7 +191,7 @@ def merge(session: Session, folder: Path, kind: str, body: MergeBody) -> dict[st
raw = body.남김 or {}
keep_at: refs.Here = (str(raw.get("층") or ""), raw.get("주인") or None)
keep = _ref(keep_at, str(raw.get("키") or ""))
if keep_at not in [mine, *_uppers(mine)]:
if keep_at not in [mine, *same.above(mine)]:
raise _bad(400, "남김은 이 층 또는 위 층 문서")
if refs.kind_of(keep["키"]) != kind or (keep_doc := refs.read_ref(keep)) is None:
raise _bad(404, f"없는 {refs.TITLE[kind]} 「{keep['키']}」")
@@ -218,8 +205,9 @@ def merge(session: Session, folder: Path, kind: str, body: MergeBody) -> dict[st
tree_doc, 판 = pre_folders._read(kind)
if 판 != body.판:
raise _bad(409, {"stale": [pre_folders.file_of(kind)], "판": 판})
mark = _finger(keep_at, kind, keep_doc)
if mark is None or any(_finger(mine, kind, have[k]) != mark for k in gone):
marks = same.Same()
mark = marks.finger(kind, keep_doc)
if mark is None or any(marks.finger(kind, have[k]) != mark for k in gone):
raise _bad(409, "내용이 그새 달라짐 — [중복 찾기]를 다시 누름")
rids = {refs.ref_id(_ref(mine, k)): k for k in gone}
+5
View File
@@ -578,6 +578,11 @@ app.include_router(
__import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router,
dependencies=protected,
)
# 서고 [중복 찾기] · 합치기 · 되돌림(PLAN 733-3) — 로그인만 · 층 쓰기 권한은 길 안에서
app.include_router(
__import__("M02_MasterTemplete.M02_Library_Same_Router", fromlist=["router"]).router,
dependencies=protected,
)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
@@ -0,0 +1,191 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""서고 [중복 찾기] · 합치기 · 되돌림(PLAN 733-3 · 요구서_572 3장 4) — 임시 storage.
묶음 = 이 층 + 위 층 같은 지문 · 합치기 = 쓰는 곳 참조 → 남김(금액 같음) · 지운 문서 `_합친것/` ·
되돌림 = 문서 · 참조 되살림 · 지문 · 판 다르면 409 · 권한 밖 · 같은 참조 두 번 = 그 문서 남김 ·
위 층과 같은 아래 층 문서 = 남김 = 위 층 키 · 일반 사용자 회사 층 = 403.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Same_Router as same_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"}
ADMIN = {**USER, "role": "ADMIN", "name": "관리"}
URL = "/api/m02/layers/{}/sangeun-same"
@pytest.fixture()
def api(world):
app = FastAPI()
app.include_router(same_router.router)
who = dict(USER)
app.dependency_overrides[verify_session] = lambda: who
got = TestClient(app)
got.who = who
return got
def _make(folder, **doc):
with store.at(folder):
return refs.create("sangeun", {"단위": "㎡", "더한줄": [], **doc})["이름"]
def _read(folder, key):
with store.at(folder):
return store.read("sangeun", key)["문서"]
def _total(folder, key):
with store.at(folder):
return calc.calc_doc(store.read("sangeun", key)["문서"])["기준"]["계"]
def _top(ref_keys, at=MINE):
return [{"참조": {**at, "키": k}, "수량": 2} for k in ref_keys] + [
{"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}
]
def _merge(api, keep, gone, 판, layer="personal"):
body = {"남김": keep, "지움": gone, "판": 판}
return api.post(URL.format(layer) + "/merge", json=body)
def test_권한_밖이면_안_지움(api, world, monkeypatch):
me = world["personal"]
a = _make(me, 이름="가", 바탕={"로직": PCT})
c = _make(me, 이름="다", 바탕={"로직": PCT})
top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([c]))
monkeypatch.setattr(same_router, "_can_write", lambda session, at: False)
판 = api.get(URL.format("personal")).json()["판"]
res = _merge(api, {**MINE, "키": a}, [c], 판)
left = res.json()["남김"]
assert res.status_code == 200 and left[0]["키"] == c and "권한 밖" in left[0]["쓰는곳"][0]
assert res.json()["합침"] == [] and (me / "sangeun" / f"{c}.json").is_file()
assert _read(me, top)["더한줄"][0]["참조"]["키"] == c
def test_합치기_참조_옮김_금액_같음_되돌림(api, world):
me = world["personal"]
a = _make(me, 이름="밑", 바탕={"로직": PCT})
b = _make(me, 이름="다른 이름 같은 내용", 바탕={"로직": PCT})
_make(me, 이름="다른 내용", 바탕={"로직": PCT}, 더한줄=_top([])[-1:])
top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([b]))
before = _total(me, top)
got = api.get(URL.format("personal"))
assert got.status_code == 200, got.text
view = got.json()
assert len(view["묶음"]) == 1
rows = view["묶음"][0]["문서"]
assert [r["키"] for r in rows] == [a, b] and view["묶음"][0]["꼬리표"] == "내용 같음"
assert [r["쓰는곳수"] for r in rows] == [0, 1] and not any(r["위층"] for r in rows)
res = _merge(api, {**MINE, "키": a}, [b], view["판"])
assert res.status_code == 200, res.text
out = res.json()
assert out["합침"] == [{"키": b, "옮긴곳수": 1}] and out["남김"] == []
assert out["묶음"] == [] and [g["키"] for g in out["합친것"]] == [b]
assert not (me / "sangeun" / f"{b}.json").exists()
assert (me / same_router.GONE / f"{b}.json").is_file()
assert _read(me, top)["더한줄"][0]["참조"]["키"] == a
assert _total(me, top) == pytest.approx(before)
back = api.post(URL.format("personal") + "/undo", json={"키": b})
assert back.status_code == 200, back.text
assert back.json()["되돌린곳수"] == 1 and back.json()["합친것"] == []
assert (me / "sangeun" / f"{b}.json").is_file()
assert not (me / same_router.GONE / f"{b}.json").exists()
assert _read(me, top)["더한줄"][0]["참조"]["키"] == b
assert _total(me, top) == pytest.approx(before)
assert api.post(URL.format("personal") + "/undo", json={"키": b}).status_code == 404
def test_지문_판_다르면_409(api, world):
me = world["personal"]
a = _make(me, 이름="가", 바탕={"로직": PCT})
b = _make(me, 이름="나", 바탕={"로직": PCT})
판 = api.get(URL.format("personal")).json()["판"]
assert _merge(api, {**MINE, "키": a}, [b], "옛판").status_code == 409
with store.at(me): # 그새 b 내용이 바뀜
doc = store.read("sangeun", b)
store.write("sangeun", b, doc["판"], {**doc["문서"], "더한줄": _top([])[-1:]})
res = _merge(api, {**MINE, "키": a}, [b], 판)
assert res.status_code == 409 and "달라짐" in res.text
assert (me / "sangeun" / f"{b}.json").is_file()
assert _merge(api, {**MINE, "키": a}, [a], 판).status_code == 400 # 남김 = 지움
assert _merge(api, {**MINE, "키": a}, ["SK-000099"], 판).status_code == 404
def test_권한_밖_남김_둘다_담은_문서(api, world, monkeypatch):
me = world["personal"]
a = _make(me, 이름="가", 바탕={"로직": PCT})
b = _make(me, 이름="나", 바탕={"로직": PCT})
c = _make(me, 이름="다", 바탕={"로직": PCT})
both = _make(me, 이름="둘 다", 단위="식", 바탕=None, 더한줄=_top([a, b]))
before = _total(me, both)
판 = api.get(URL.format("personal")).json()["판"]
res = _merge(api, {**MINE, "키": a}, [b, c], 판) # 둘 다 담은 문서 = 두 줄 모두 a · 금액 같음
assert res.status_code == 200, res.text
assert [r["키"] for r in res.json()["합침"]] == [b, c]
assert [x["참조"]["키"] for x in _read(me, both)["더한줄"][:2]] == [a, a]
assert _total(me, both) == pytest.approx(before)
for key in (b, c):
assert api.post(URL.format("personal") + "/undo", json={"키": key}).status_code == 200
def test_위층과_같으면_남김_위층_키(api, world):
me, firm = world["personal"], world["company"]
up = _make(firm, 이름="회사 것", 바탕={"로직": PCT})
mine = _make(me, 이름="내 것", 바탕={"로직": PCT})
top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([mine]))
before = _total(me, top)
view = api.get(URL.format("personal")).json()
rows = view["묶음"][0]["문서"]
assert [(r["층"], r["키"], r["위층"]) for r in rows] == [
("company", up, True),
("personal", mine, False),
]
res = _merge(api, {**COMPANY, "키": up}, [mine], view["판"])
assert res.status_code == 200, res.text
assert _read(me, top)["더한줄"][0]["참조"] == {**COMPANY, "키": up}
assert _total(me, top) == pytest.approx(before)
assert (firm / "sangeun" / f"{up}.json").is_file()
# 위 층 문서는 지울 수 없음 · 일반 사용자는 회사 층 합치기 403
assert _merge(api, {**MINE, "키": mine}, [up], view["판"]).status_code in (403, 404)
assert api.get(URL.format("company")).status_code == 403
api.who.update(ADMIN)
assert api.get(URL.format("company")).status_code == 200
def test_일위대가_수량만_다름_꼬리표(api, world):
me = world["personal"]
line = {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": None}
with store.at(me):
keys = [
refs.create("precombo", {"이름": n, "단위": "m", "담은로직": [line], "본뜬": src})[
"이름"
]
for n, src in (
(
"수로BOX 4.0×2.0",
{"층": "실무자료", "키": "HP-1 제1호표", "실무수량": {"줄": {"0": 3}}},
),
(
"수로BOX 2.5×2.5",
{"층": "실무자료", "키": "HP-1 제2호표", "실무수량": {"줄": {"0": 7}}},
),
)
]
got = api.get("/api/m02/layers/personal/precombo-same")
assert got.status_code == 200, got.text
group = got.json()["묶음"][0]
assert group["꼬리표"] == "수량만 다름" and [r["키"] for r in group["문서"]] == keys
@@ -0,0 +1,32 @@
"""서고 [중복 찾기] 화면 지킴(PLAN 733-3) — 부품 상자만 · 날 색 없음 · 나무 메뉴 · 서버 길 짝."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
TS = (M02 / "M02_MasterTemplete_Library_Same.ts").read_text(encoding="utf-8")
CSS = (M02 / "M02_MasterTemplete_Library_Same.css").read_text(encoding="utf-8")
RAW_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(")
def test_부품만_씀():
for part in ("openModal", "askConfirm", "createTable", "createButton", "createBadge"):
assert part in TS, f"부품 {part} 를 안 씀"
assert "<button" not in TS and "<input" not in TS
assert not re.search(r"""createElement\(["'](button|input|table)""", TS)
assert not RAW_COLOR.search(CSS)
for sel in (".ui-btn", ".ui-table", ".ui-badge", "button", "table", "th", "td"):
assert not re.search(rf"(^|[\s,]){re.escape(sel)}\b", CSS), f"CSS 가 {sel} 꼴을 만짐"
def test_나무_메뉴_서버_길_짝():
side = (M02 / "M02_MasterTemplete_UI_Side_Library.ts").read_text(encoding="utf-8")
assert "openLibrarySame" in side and "sameLabel()" in side
router = (M02 / "M02_Library_Same_Router.py").read_text(encoding="utf-8")
for tail in ("", "/merge", "/undo"):
assert f'"/layers/{{layer}}/{{kind}}-same{tail}"' in router
assert "`/layers/${layer}/${kind}-same${tail}" in TS
assert '"/merge"' in TS and '"/undo"' in TS
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "M02_MasterTemplete.M02_Library_Same_Router" in main