From b9e6f77dab000c9a2dc1839d417cd7d6cf2c1250 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 13:44:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(733-3):=20=EC=84=9C=EA=B3=A0=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20=ED=95=A9=EC=B9=98?= =?UTF-8?q?=EA=B8=B0=20=EC=84=9C=EB=B2=84=20=EB=93=B1=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EB=AC=B8=20=EB=AA=A8=EB=93=88=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- M02_MasterTemplete/M02_Library_Same_Router.py | 26 +-- main.py | 5 + .../tester/test_733_3_library_same_merge.py | 191 ++++++++++++++++++ .../tester/test_733_3_library_same_screen.py | 32 +++ 4 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 resources/tester/test_733_3_library_same_merge.py create mode 100644 resources/tester/test_733_3_library_same_screen.py diff --git a/M02_MasterTemplete/M02_Library_Same_Router.py b/M02_MasterTemplete/M02_Library_Same_Router.py index 0d0012d16..080b63636 100644 --- a/M02_MasterTemplete/M02_Library_Same_Router.py +++ b/M02_MasterTemplete/M02_Library_Same_Router.py @@ -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} diff --git a/main.py b/main.py index 982dfdf54..c03b8c3d1 100644 --- a/main.py +++ b/main.py @@ -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) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/test_733_3_library_same_merge.py b/resources/tester/test_733_3_library_same_merge.py new file mode 100644 index 000000000..e6b20aa28 --- /dev/null +++ b/resources/tester/test_733_3_library_same_merge.py @@ -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 diff --git a/resources/tester/test_733_3_library_same_screen.py b/resources/tester/test_733_3_library_same_screen.py new file mode 100644 index 000000000..c53746bcd --- /dev/null +++ b/resources/tester/test_733_3_library_same_screen.py @@ -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 "