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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user