diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index a5b4ec424..dc113d411 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -162,7 +162,7 @@ def edit_library_tree( """새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) · 여럿 = `대상` → 새 나무 · 판 다르면 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, pre_folders.edit, body.model_dump(), kind) + return _call(folder, pre_folders.edit, body.model_dump(), kind, session) @router.post("/layers/{layer}/{kind}-tree/paste") diff --git a/M02_MasterTemplete/M02_Library_Delete.py b/M02_MasterTemplete/M02_Library_Delete.py new file mode 100644 index 000000000..9ecc46e87 --- /dev/null +++ b/M02_MasterTemplete/M02_Library_Delete.py @@ -0,0 +1,278 @@ +"""M02 서고 나무 지우기 = 경고 뒤 지움 · 쓰는 곳 그 줄도 지움(PLAN 735-1 · 요구 574) — +일위대가(`precombo` SC) · 단가산출(`sangeun` SK) 한 벌. + +미리 보기 `POST /layers/{층}/{kind}-tree/delete-check {대상}` → `{문서, 쓰는곳: [줄]}` — +줄 = {층, 주인, 어디, 종류, 문서, 이름, 키, 줄, 쓰기} · 종류 = 구조물 조합 · 일위대가 · 단가산출 · +줄 = 담은로직 「N째」 · 「더한줄 N째」 · 「바탕」 · 모든 층 훑음(프로젝트 뺌 · 지우는 것끼리 뺌). +지우기(나무 고침 `op: delete` · `M02_Precombo_Folders.edit`) — 권한 밖 쓰는 곳 = 409 +`{까닭, 막힘}` · 쓰는 곳이 있는데 `확인` 없음 = 409 `{까닭, 쓰는곳}` · 확인 = 쓰는 문서의 그 줄 +지움(`store.write` = 서버 정본 다시 셈) → 문서 지움 · 기록 `{층}/_합친것/<첫 키>.json`(733-3 자리 · +`지움` = 지운 문서 · 자리 · `고친곳` = 고친 문서 앞판 · 뒤판) · 못 쓰면 앞판으로 되돌리고 409. +되돌림(`undo` · `M02_Library_Same_Router` undo 길이 부름) = 문서 · 자리 · 고친 문서 앞판(그새 안 +고친 것만). 프로젝트 사본 · B09 공사 = 굳힌 값이라 안 건드림. +바탕 지움(단가산출) = 바탕 없음 + 바탕 줄 덮개 · 뺀줄 · 고름 · 자재표시 · 열린입력 걷음 · +더한줄 지움 = 뒤 `add:#k` 열쇠 당김. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers + +GONE = "_합친것" +USER_KINDS = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")) +_ADD = re.compile(r"^add:#(\d+)(.*)$") +_PATCH = ("덮개", "고름", "자재표시") + + +def can_write(session: dict[str, Any] | None, at: refs.Here) -> bool: + if session is None: + return False + try: + return access.grant(session, at[0], at[1]).write + except HTTPException: + return False + + +def _hits(doc: dict[str, Any], rids: dict[str, str]) -> list[tuple[str, str]]: + """문서 안 지울 참조 줄 — [(줄, 키)].""" + out = [] + for field, head in (("담은로직", ""), ("더한줄", "더한줄 ")): + for at, one in enumerate(doc.get(field) or [], 1): + ref = one.get("참조") if isinstance(one, dict) else None + if isinstance(ref, dict) and refs.ref_id(ref) in rids: + out.append((f"{head}{at}째", rids[refs.ref_id(ref)])) + base = doc.get("바탕") + if isinstance(base, dict) and isinstance(base.get("참조"), dict): + if refs.ref_id(base["참조"]) in rids: + out.append(("바탕", rids[refs.ref_id(base["참조"])])) + return out + + +def _columns(folder: Path, memo: dict[Path, dict[str, str]]) -> dict[str, str]: + """층 구조물 열 id → 구조물 이름(구조물 조합 줄 이름 · 층마다 한 번).""" + if folder not in memo: + try: + with store.at(folder): + cols = store._structure_columns() + memo[folder] = {c["id"]: tree.name_of(tree.path_of(c)) for c in cols} + except (store.StoreError, OSError, ValueError): + memo[folder] = {} + return memo[folder] + + +def users(session: dict[str, Any] | None, kind: str, gone: list[str]) -> list[dict[str, Any]]: + """지금 층(`store.at`) 지울 문서를 쓰는 줄 — 모든 층(프로젝트 뺌 · 지우는 것끼리 뺌).""" + mine = refs.here() + rids = {refs.ref_id({"층": mine[0], "주인": mine[1], "키": k}): k for k in gone} + skip = {(store.root() / kind / f"{k}.json").resolve() for k in gone} + rows, memo = [], {} + for ukind, what in USER_KINDS: + for where, path in layers.layer_files(ukind): + if path.resolve() in skip: + continue + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + hits = _hits(doc, rids) if isinstance(doc, dict) else [] + if not hits: + continue + at = refs.where(path.parent.parent) + ok = can_write(session, at) + name = doc.get("일위대가번") or doc.get("이름") or path.stem + if ukind == "labor" and (title := _columns(path.parent.parent, memo).get(path.stem)): + name = f"{title} · {name}" + for line, key in hits: + rows.append( + { + "층": at[0], + "주인": at[1], + "어디": where, + "종류": what, + "문서종류": ukind, + "문서": path.stem, + "이름": name, + "키": key, + "줄": line, + "쓰기": ok, + } + ) + return rows + + +def _shift(key: str, dropped: list[int]) -> str | None: + """`add:#k…` 열쇠 — 지운 더한줄이면 None · 뒤 줄은 당김 · 그 밖 그대로.""" + hit = _ADD.match(key) + if not hit: + return key + k = int(hit[1]) + if k in dropped: + return None + return f"add:#{k - sum(1 for d in dropped if d < k)}{hit[2]}" + + +def strip(doc: dict[str, Any], rids: set[str]) -> dict[str, Any]: + """문서에서 `rids` 참조 줄을 뺌(순수) — 담은로직 · 더한줄(뒤 `add:#k` 당김) · 바탕.""" + + def hit(one: Any) -> bool: + ref = one.get("참조") if isinstance(one, dict) else None + return isinstance(ref, dict) and refs.ref_id(ref) in rids + + out = {**doc} + if "담은로직" in doc: + out["담은로직"] = [one for one in doc.get("담은로직") or [] if not hit(one)] + added = doc.get("더한줄") or [] + dropped = [at for at, one in enumerate(added) if hit(one)] + base_gone = hit(doc.get("바탕")) + if "더한줄" in doc: + out["더한줄"] = [one for one in added if not hit(one)] + if base_gone: + out["바탕"], out["열린입력"] = None, [] + if not (dropped or base_gone): + return out + + def keep(key: str) -> str | None: + if not key.startswith("add:"): + return None if base_gone else key # 바탕 줄 열쇠 — 바탕이 사라지면 걷음 + return _shift(key, dropped) + + for field in _PATCH: + if isinstance(doc.get(field), dict): + moved = {keep(str(k)): v for k, v in doc[field].items()} + out[field] = {k: v for k, v in moved.items() if k is not None} + if isinstance(doc.get("뺀줄"), list): + out["뺀줄"] = [k for k in (keep(str(x)) for x in doc["뺀줄"]) if k is not None] + return out + + +def _restore(done: list[dict[str, Any]]) -> None: + for one in reversed(done): + Path(one["파일"]).write_bytes(one["앞"]) + + +def remove( + session: dict[str, Any] | None, + kind: str, + gone: list[str], + places: dict[str, Any], + sure: bool, +) -> str: + """지금 층 `gone` 지움 — 막힘 · 확인 없음 409 · 쓰는 줄 지움 → 문서 지움 · 기록 → 기록 키.""" + rows = users(session, kind, gone) + if blocked := [r for r in rows if not r["쓰기"]]: + names = " · ".join(f"{r['어디']} 「{r['이름']}」 ← {r['키']}" for r in blocked) + raise store.StoreError( + 409, {"까닭": f"권한 밖 쓰는 곳이 있어 못 지움 — {names}", "막힘": blocked} + ) + if rows and not sure: + raise store.StoreError(409, {"까닭": f"쓰는 곳 {len(rows)} — 확인 뒤 지움", "쓰는곳": rows}) + mine = refs.here() + rids = {refs.ref_id({"층": mine[0], "주인": mine[1], "키": k}) for k in gone} + files = {} + for r in rows: + at = refs.folder_of(r["층"], r["주인"]) / r["문서종류"] / f"{r['문서']}.json" + files[at] = r + done: list[dict[str, Any]] = [] + # ponytail: 여러 파일을 차례로 씀(잠금 없음) — 실패하면 앞서 고친 파일을 앞판 바이트로 되돌림 + for path, r in files.items(): + raw = path.read_bytes() + try: + with store.at(path.parent.parent): + store.write( + r["문서종류"], path.stem, store.version_of(raw), strip(json.loads(raw), rids) + ) + except store.StoreError as error: + _restore(done) + raise store.StoreError( + 409, {"까닭": f"{r['어디']} 「{r['이름']}」 줄을 못 지움 — {error.detail}"} + ) from error + done.append( + {"파일": str(path), "앞": raw, "r": r, "뒤판": store.version_of(path.read_bytes())} + ) + root = store.root() + docs = [ + {"키": k, "문서": json.loads(p.read_bytes()), "자리": places.get(k)} + for k in gone + if (p := root / kind / f"{k}.json").is_file() + ] + for one in docs: + (root / kind / f"{one['키']}.json").unlink() + name = _free(root / GONE, gone[0]) + atomic_write_json( + root / GONE / f"{name}.json", + { + "키": name, + "종류": kind, + "지움": docs, + "고친곳": [ + { + "층": d["r"]["층"], + "주인": d["r"]["주인"], + "종류": d["r"]["문서종류"], + "이름": d["r"]["문서"], + "문서": json.loads(d["앞"]), + "뒤판": d["뒤판"], + } + for d in done + ], + "때": datetime.now().isoformat(timespec="seconds"), + "누가": (session or {}).get("name"), + }, + ) + return name + + +def _free(folder: Path, key: str) -> str: + """기록 이름 — 그 키 기록이 이미 있으면(지운 키를 다시 씀) 「키~2」 …""" + name, n = key, 1 + while (folder / f"{name}.json").exists(): + n += 1 + name = f"{key}~{n}" + return name + + +def undo(session: dict[str, Any], folder: Path, kind: str, rec: dict[str, Any]) -> dict[str, Any]: + """지움 기록 되돌림 — 문서(키를 그새 남이 쓰면 409) · 나무 자리 · 고친 문서 앞판 + (그새 안 고친 것 · 쓸 수 있는 곳만 · 못한 곳 = 까닭).""" + from M02_MasterTemplete import M02_Precombo_Folders as pre_folders + + docs = rec.get("지움") or [] + if taken := [d["키"] for d in docs if (folder / kind / f"{d['키']}.json").exists()]: + raise store.StoreError( + 409, f"「{' · '.join(taken)}」 키를 그새 다른 문서가 씀 — 되돌릴 수 없음" + ) + for d in docs: + atomic_write_json(folder / kind / f"{d['키']}.json", d["문서"]) + with store.at(folder): + tree_doc, _ = pre_folders._read(kind) + places = {**(tree_doc.get("자리") or {}), **{d["키"]: d["자리"] for d in docs if d.get("자리")}} + atomic_write_json(folder / pre_folders.file_of(kind), {**tree_doc, "자리": places}) + back, miss = 0, [] + for one in rec.get("고친곳") or []: + at = (one.get("층"), one.get("주인")) + path = refs.folder_of(*at) / str(one.get("종류")) / f"{one.get('이름')}.json" + try: + if not can_write(session, at) or not path.is_file(): + raise store.StoreError(403, "권한 밖 · 없음") + raw = path.read_bytes() + if store.version_of(raw) != one.get("뒤판"): + raise store.StoreError(409, "그새 고침 — 그 줄은 손으로 다시 담음") + with store.at(path.parent.parent): + store.write(str(one["종류"]), path.stem, store.version_of(raw), one["문서"]) + back += 1 + except store.StoreError as error: + miss.append(f"{one.get('종류')} {one.get('이름')} — {error.detail}") + return {"되돌린곳수": back, "못한곳": miss} diff --git a/M02_MasterTemplete/M02_Library_Same_Router.py b/M02_MasterTemplete/M02_Library_Same_Router.py index 080b63636..e6ed4575f 100644 --- a/M02_MasterTemplete/M02_Library_Same_Router.py +++ b/M02_MasterTemplete/M02_Library_Same_Router.py @@ -7,7 +7,10 @@ GET /layers/{층}/{kind}-same — 이 층 + 위 층(회사 · 마스터) 같은 POST …/merge {남김, 지움[], 판} — 서버가 지문 다시 셈(다르면 409) · 쓰는 곳 참조를 남김으로 (쓰기 권한 닿는 곳만 · `store.write` 정본 다시 셈) · 남은 쓰는 곳이 있는 문서 = 안 지움 「남김 — 쓰는 곳 N」 · 나머지 = `{층}/_합친것/<키>.json` 으로 옮김 · 나무 자리 걷음 -POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되돌림 +POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되돌림 · 나무 지움 기록(735-1 `지움` 칸)이면 + `M02_Library_Delete.undo`(문서 · 자리 · 고친 문서 앞판) +POST /layers/{층}/{kind}-tree/delete-check {대상} — 나무 지우기 미리 보기(735-1) · 지울 문서 · + 쓰는 줄 지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) · 판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라 안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만. @@ -27,7 +30,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Library_Delete as deleting from M02_MasterTemplete import M02_Library_Same as same from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Precombo_Folders as pre_folders @@ -37,8 +40,8 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _ router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Library Same"]) Kind = Literal["precombo", "sangeun"] -GONE = "_합친것" -USER_KINDS = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")) +GONE = deleting.GONE +USER_KINDS = deleting.USER_KINDS _ORDER = {"system": 0, "company": 1, "personal": 2} @@ -54,6 +57,12 @@ class UndoBody(BaseModel): 키: str +class CheckBody(BaseModel): + """나무 지우기 미리 보기 — 나무 고침 `대상` 꼴 `[{경로, 키?}]`.""" + + 대상: list[dict[str, Any]] + + def _bad(status: int, text: Any) -> store.StoreError: return store.StoreError(status, text) @@ -92,10 +101,7 @@ def _user_index() -> dict[str, list[tuple[Path, str, str]]]: def _can_write(session: Session, at: refs.Here) -> bool: - try: - return access.grant(session, at[0], at[1]).write - except HTTPException: - return False + return deleting.can_write(session, at) def _retarget(doc: dict[str, Any], olds: set[str], new: refs.Ref) -> dict[str, Any]: @@ -144,17 +150,23 @@ def _gone_rows(folder: Path, kind: str) -> list[dict[str, Any]]: rec = json.loads(path.read_bytes()) except (OSError, ValueError): continue - if rec.get("종류") == kind: + if rec.get("종류") != kind: + continue + if "지움" in rec: # 나무 지움(735-1) — 남김 없음 · 이름 = 지운 문서들 + names = [(d.get("문서") or {}).get("이름") or d.get("키") for d in rec["지움"]] + row = {"이름": " · ".join(map(str, names)), "남김": None, "고친": rec.get("고친곳")} + else: doc = rec.get("문서") or {} - out.append( - { - "키": rec.get("키"), - "이름": doc.get("이름") or rec.get("키"), - "남김": rec.get("남김"), - "때": rec.get("때"), - "옮긴곳수": len(rec.get("옮긴곳") or []), - } - ) + row = {"이름": doc.get("이름") or rec.get("키"), "남김": rec.get("남김"), "고친": None} + out.append( + { + "키": path.stem, + "이름": row["이름"], + "남김": row["남김"], + "때": rec.get("때"), + "옮긴곳수": len(row["고친"] or rec.get("옮긴곳") or []), + } + ) return out @@ -275,6 +287,10 @@ def undo(session: Session, folder: Path, kind: str, key: str) -> dict[str, Any]: raise _bad(404, f"합친 것에 없음 「{key}」") from None if rec.get("종류") != kind: raise _bad(404, f"합친 것에 없음 「{key}」") + if "지움" in rec: + got = deleting.undo(session, folder, kind, rec) + rec_path.unlink() + return {"되돌림": key, **got, **find(folder, kind)} path = folder / kind / f"{key}.json" if path.exists(): raise _bad(409, f"「{key}」 키를 그새 다른 문서가 씀 — 되돌릴 수 없음") @@ -328,6 +344,32 @@ def library_same_merge( return _run(merge, session, _root(session, layer, owner, write=True), kind, body) +def check(session: Session, folder: Path, kind: str, picks: list[dict[str, Any]]) -> dict: + """나무 지우기 미리 보기 — 나무 고침을 셈만(저장 안 함) → 지울 문서 · 쓰는 줄.""" + with store.at(folder): + tree_doc, _ = pre_folders._read(kind) + try: + _, note = pre_folders.apply_many( + tree_doc, {"op": "delete", "대상": picks}, pre_folders._keys(kind) + ) + except pre_folders.tree.TreeError as e: + raise _bad(e.status, e.detail) from e + gone = note.get("지운문서") or [] + return {"문서": gone, "쓰는곳": deleting.users(session, kind, gone)} + + +@router.post("/layers/{layer}/{kind}-tree/delete-check") +def library_delete_check( + layer: Layer, + kind: Kind, + body: CheckBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """나무 지우기 미리 보기(735-1) — `{문서, 쓰는곳}`(`M02_Library_Delete.users` 줄).""" + return _run(check, session, _root(session, layer, owner, write=True), kind, body.대상) + + @router.post("/layers/{layer}/{kind}-same/undo") def library_same_undo( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index b2b7cb714..9d1194a9b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -177,7 +177,13 @@ export interface PrecomboTree { 판: string; 폴더: string[][]; 자리: Record; - 알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] }; + 알림?: { + op: string; + 지운문서?: string[]; + 만든문서?: string[]; + 바뀐이름?: string[]; + 기록?: string; + }; } const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string => owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner); @@ -191,12 +197,25 @@ export const editPrecomboTree = ( edit: Pick & { op: "add-folder" | "rename" | "move" | "delete"; 대상?: TreeRef[]; + /** 지우기 — 쓰는 곳 경고를 본 뒤 그 줄까지 지움(735-1) */ + 확인?: boolean; }, 판: string, owner: string | null, kind: LibraryKind = "precombo", ): Promise => call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +/** 지우기 미리 보기(735-1) — 지울 문서 키 · 쓰는 줄(`M02_Library_Delete.users`) */ +export const checkLibraryDelete = ( + kind: LibraryKind, + layer: Layer, + owner: string | null, + picks: TreeRef[], +): Promise => + call(libTree(kind, layer, owner, "/delete-check"), { + method: "POST", + body: JSON.stringify({ 대상: picks }), + }); /** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */ export const pasteLibraryTree = ( kind: LibraryKind, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_Delete.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_Delete.ts new file mode 100644 index 000000000..86368a2e5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_Delete.ts @@ -0,0 +1,109 @@ +/* ============================================================================= + * M02_MasterTemplete_Library_Delete.ts + * 서고 나무 지우기 경고(735-1 · 요구 574) — 좌측 일위대가 · 단가산출 나무 [지우기] 앞에 부름. + * 서버 미리 보기(`{kind}-tree/delete-check`) → 쓰는 곳 없음 = 원래 확인 글 · 있음 = 경고 「이 항목을 쓰는 곳 N — + * 지우면 그 줄도 지워짐」 + 표(종류 · 이름 · 키 · 줄 · 지울 키 · 층) + [삭제] [취소] · 권한 밖이 있으면 + * 못 지움 알림 + 그 목록만. 확인 = 나무 고침 몸 `확인: true`(서버가 그 줄 지움 · 금액 다시 셈 · 되돌림 기록). + * 되돌림 = 나무 맨 위 메뉴 [중복 찾기] › 합친 것 · 지운 것. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { askConfirm } from "@ui/ui_template_modal"; +import { createTable, type Cell } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { + checkLibraryDelete, + type Layer, + type LibraryKind, + type TreeRef, +} from "./M02_MasterTemplete_Api_Fetch"; + +const TEXT = { + Title: ["쓰는 곳이 있음", "In use"], + Warn: [ + "이 항목을 쓰는 곳 {n} — 지우면 그 줄도 지워짐(금액 다시 셈)", + "Used in {n} place(s) — deleting also removes those lines (amounts recalculated)", + ], + Blocked: [ + "권한 밖 쓰는 곳 {n} — 지울 수 없음(그 층 주인이 먼저 줄을 뺌)", + "{n} use(s) outside your permission — cannot delete (their owner must remove the lines first)", + ], + Note: [ + "프로젝트 사본 · B09 공사는 굳힌 값이라 영향 없음 · 되돌림 = 나무 맨 위 메뉴 [중복 찾기] › 되돌림", + "Project copies and B09 estimates keep frozen values · Undo = tree top menu [Find duplicates] › Undo", + ], + Kind: ["종류", "Type"], + Name: ["이름", "Name"], + Key: ["키", "Key"], + Line: ["줄", "Line"], + Gone: ["지울 키", "Deleting"], + Layer: ["층", "Layer"], + Ok: ["확인", "OK"], +} as const; +const tx = (key: keyof typeof TEXT, n = 0): string => + String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n)); + +/** 쓰는 줄 하나(서버 `M02_Library_Delete.users`) */ +export interface DeleteUser { + 층: Layer; + 주인: string | null; + 어디: string; + 종류: string; + 문서: string; + 이름: string; + 키: string; + 줄: string; + 쓰기: boolean; +} +interface DeleteCheck { + 문서: string[]; + 쓰는곳: DeleteUser[]; +} + +const table = (rows: DeleteUser[]): HTMLElement => { + const columns = [ + { label: tx("Kind"), width: "96px" }, + { label: tx("Name") }, + { label: tx("Key"), width: "112px" }, + { label: tx("Line"), width: "88px" }, + { label: tx("Gone"), width: "112px" }, + { label: tx("Layer"), width: "96px" }, + ]; + const cells: Cell[][] = rows.map((r) => [r.종류, r.이름, r.문서, r.줄, r.키, r.어디]); + return el("div", { + attrs: { "data-library-delete": "" }, + children: [createTable(columns, cells, { density: "data" }), createText(tx("Note"), "muted")], + }); +}; + +/** 지우기 물음 — 쓰는 곳 없음 = `ask` 글 · 있음 = 경고 + 표 · 권한 밖 = 알림만(false). + * `{ok, sure}` — ok = 지움 · sure = 쓰는 곳까지 지움(나무 고침 `확인`). */ +export async function askLibraryDelete( + kind: LibraryKind, + layer: Layer, + owner: string | null, + picks: TreeRef[], + ask: string, + okLabel: string, +): Promise<{ ok: boolean; sure: boolean }> { + const got = await checkLibraryDelete(kind, layer, owner, picks); + const used = got.쓰는곳; + if (!used.length) + return { ok: await askConfirm({ message: ask, okLabel, danger: true }), sure: false }; + const blocked = used.filter((r) => !r.쓰기); + if (blocked.length) { + const message = tx("Blocked", blocked.length); + await askConfirm({ title: tx("Title"), message, okLabel: tx("Ok"), detail: table(blocked) }); + return { ok: false, sure: false }; + } + const message = `${ask}\n${tx("Warn", used.length)}`; + const ok = await askConfirm({ + title: tx("Title"), + message, + okLabel, + danger: true, + detail: table(used), + }); + return { ok, sure: ok }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts index ca6551690..d39bf69fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts @@ -3,7 +3,8 @@ * 서고 [중복 찾기](733-3 · 요구서_572 3장 4) — 좌측 일위대가 · 단가산출 나무 메뉴에서 엶. * 창 = 같은 내용 묶음(이 층 + 위 층 · 서버 `GET {kind}-same`) · 묶음마다 꼬리표(내용 같음 · 수량만 다름) · * 문서 줄(층 · 키 · 이름 · 구분 · 쓰는 곳 수) · [남김] 고르기 · [합치기](물음 → 서버 `merge` · 이 층 나머지 문서를 - * 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`). + * 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`) · + * 나무 지우기 기록(735-1 · 남김 없음 = 「지움」 · 고친 쓰는 곳 수)도 같은 목록 · 같은 [되돌림]. * 권한 = 그 층 쓰기(서버가 봄) · 판 다르면 409 → 다시 찾음. * ========================================================================== */ @@ -54,8 +55,9 @@ const TEXT = { "남김 — 「{key}」 쓰는 곳 {n}(권한 밖 · 못 옮김): {list}", "Kept “{key}” — {n} user(s) not moved: {list}", ], - Gone: ["합친 것(되돌림)", "Merged (undo)"], - GoneNone: ["합친 것 없음", "Nothing merged"], + Gone: ["합친 것 · 지운 것(되돌림)", "Merged · deleted (undo)"], + GoneNone: ["합친 것 · 지운 것 없음", "Nothing merged or deleted"], + Deleted: ["지움", "Deleted"], When: ["때", "When"], Into: ["남김", "Kept as"], Undo: ["되돌림", "Undo"], @@ -63,6 +65,10 @@ const TEXT = { "「{key}」 되돌림 — 문서를 되살리고 옮긴 쓰는 곳 {n} 을 다시 이 문서로", "Undo “{key}” — restore it and point its {n} user(s) back", ], + AskUndoDeleted: [ + "「{key}」 되돌림 — 지운 문서를 되살리고 쓰는 곳 {n} 에서 지운 줄을 다시 넣음(그새 고친 곳은 건너뜀)", + "Undo “{key}” — restore deleted doc(s) and put the lines back in {n} user(s) (skips ones edited since)", + ], Undone: ["되돌림 — 쓰는 곳 {n}", "Undone — users {n}"], Stale: ["그새 바뀜 — 다시 찾음", "Changed meanwhile — searching again"], } as const; @@ -96,7 +102,8 @@ export interface SameGroup { export interface GoneRow { 키: string; 이름: string; - 남김: SameRef; + /** null = 나무 지우기 기록(735-1) */ + 남김: SameRef | null; 때: string; 옮긴곳수: number; } @@ -195,7 +202,8 @@ export function openLibrarySame(opt: SameOptions): void { }; const undo = async (row: GoneRow): Promise => { - const message = tx("AskUndo", { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 }); + const ask = row.남김 ? "AskUndo" : "AskUndoDeleted"; + const message = tx(ask, { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 }); if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return; await run( () => post(path(opt.kind, opt.layer, opt.owner, "/undo"), { 키: row.키 }), @@ -273,7 +281,7 @@ export function openLibrarySame(opt: SameOptions): void { const cells: Cell[][] = rows.map((r) => [ r.키, r.이름, - `${tx(r.남김.층)} ${r.남김.키}`, + r.남김 ? `${tx(r.남김.층)} ${r.남김.키}` : tx("Deleted"), r.때.replace("T", " "), createButton({ label: tx("Undo"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 0ec01999b..a4631372b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -75,6 +75,8 @@ class TreeBody(BaseModel): 판: str # 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번 대상: list[dict[str, Any]] | None = None + # 서고 지우기(735-1) — 쓰는 곳 경고를 본 뒤 그 줄까지 지움 + 확인: bool = False class PasteBody(BaseModel): diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts index c355ce634..c35cbd33b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -4,7 +4,8 @@ * 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`). * 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버 * (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] · - * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기. + * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 경고 표 뒤 그 줄째 · `Library_Delete`) · 빈 자리 = 뿌리 메뉴 · + * 끌어 옮기기. * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). @@ -13,7 +14,7 @@ * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { t as L } from "@ui/ui_template_locale"; import { @@ -36,6 +37,7 @@ import { type LibraryKind, type PrecomboTree, } from "./M02_MasterTemplete_Api_Fetch"; +import { askLibraryDelete } from "./M02_MasterTemplete_Library_Delete"; import { nameDialog } from "./M02_MasterTemplete_Library_List"; import { openLibrarySame, sameLabel } from "./M02_MasterTemplete_Library_Same"; import type { Root } from "./M02_MasterTemplete_Layer"; @@ -256,7 +258,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ const pickedTargets = (): Target[] => [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); - /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */ + /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 경고 표 뒤 그 줄째(735-1) */ const remove = async (list: Target[]): Promise => { if (!list.length || !opt.scope().canWrite) return; const [one] = list; @@ -268,8 +270,14 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { : L("M02_TreeDeleteFolder") .replace("{value}", one.path[one.path.length - 1]) .replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0)); - if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; - await edit({ op: "delete", 대상: list.map(refOf) }); + const at = opt.scope(); + const picks = list.map(refOf); + try { + const got = await askLibraryDelete(kind, at.layer, at.owner, picks, ask, L("M02_Delete")); + if (got.ok) await edit({ op: "delete", 대상: picks, ...(got.sure ? { 확인: true } : {}) }); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } }; const copy = (list: Target[]): void => { if (!list.length) return; diff --git a/M02_MasterTemplete/M02_Precombo_Folders.py b/M02_MasterTemplete/M02_Precombo_Folders.py index 9e418fe33..b7bb98c53 100644 --- a/M02_MasterTemplete/M02_Precombo_Folders.py +++ b/M02_MasterTemplete/M02_Precombo_Folders.py @@ -4,7 +4,8 @@ 개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면 409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) — -모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음). +쓰는 곳은 `M02_Library_Delete`(735-1 · 권한 밖 409 · `확인` 뒤 그 줄 지움 · 되돌림 기록 · +지우는 것끼리 참조는 막지 않음). 붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 · 못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`). 이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. @@ -18,12 +19,11 @@ from typing import Any from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Delete as deleting from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree -from M02_MasterTemplete import M02_Template_Layers as layers OPS = ("add-folder", "rename", "move", "delete") -USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴 def file_of(kind: str) -> str: @@ -175,33 +175,13 @@ def apply_many( return doc, note -def _refuse_used(kind: str, gone: list[str]) -> None: - """지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 — - 모든 층 훑음(프로젝트 뺌).""" - layer, owner = refs.here() - rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone} - mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone} - found = [] - for what_kind, what in USERS: - for at, path in layers.layer_files(what_kind): - if path.resolve() in mine: - continue - try: - doc = json.loads(path.read_bytes()) - except (OSError, ValueError): - continue - hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids}) - if hit: - name = doc.get("일위대가번") or doc.get("이름") or path.stem - found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}") - if found: - raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found)) - - # ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 # (키 재사용을 막을 때 함께 고침) -def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: - """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409.""" +def edit( + body: dict[str, Any], kind: str = "precombo", session: dict[str, Any] | None = None +) -> dict[str, Any]: + """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째 · 쓰는 곳 = `deleting.remove` · 몸 `확인`) → + 새 나무 + 알림(지우기 = `기록` 되돌림 키) · 판 다르면 409.""" doc, 판 = _read(kind) if 판 != str(body.get("판") or ""): raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판}) @@ -211,10 +191,10 @@ def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e gone = note.get("지운문서") or [] - if gone: - _refuse_used(kind, gone) # 모두 검사 뒤 한 번에 - for key in gone: - (store.root() / kind / f"{key}.json").unlink(missing_ok=True) + if gone: # 모두 검사 뒤 한 번에 + note["기록"] = deleting.remove( + session, kind, gone, _places(doc, keys), bool(body.get("확인")) + ) atomic_write_json(store.root() / file_of(kind), out) return {**view(kind), "알림": note} diff --git a/resources/tester/test_716_4_library_folders.py b/resources/tester/test_716_4_library_folders.py index 01a361ad0..4e929f248 100644 --- a/resources/tester/test_716_4_library_folders.py +++ b/resources/tester/test_716_4_library_folders.py @@ -3,7 +3,7 @@ 임시 storage. `/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 · -폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · +폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 확인 없이는 409 + 목록 · 지우는 것끼리 참조는 됨) · `대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본. """ @@ -64,15 +64,20 @@ def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨 assert _files("sangeun") == [] and sk.now["폴더"] == [] -def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811 +def test_바깥에서_쓰면_확인_없이는_409_목록_아무것도_안_지움(client: TestClient): # noqa: F811 a = _sk(client, "바탕") - body = {"문서": {"이름": "쓰는 일위대가", "단위": "㎡", "담은로직": [{"참조": {**MINE, "키": a}}]}} + body = { + "문서": {"이름": "쓰는 일위대가", "단위": "㎡", "담은로직": [{"참조": {**MINE, "키": a}}]} + } assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200 sk = _sk_tree(client) sk.do(op="add-folder", 경로=[], 이름="묶음") sk.do(op="move", 키=a, 새경로=["묶음"]) - detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] - assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail + detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] # 735-1 — 확인 뒤 그 줄째 지움 + assert "쓰는 곳" in detail["까닭"] + assert [(r["이름"], r["키"], r["줄"]) for r in detail["쓰는곳"]] == [ + ("쓰는 일위대가", a, "1째") + ] assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]] diff --git a/resources/tester/test_735_1_library_delete.py b/resources/tester/test_735_1_library_delete.py new file mode 100644 index 000000000..b18e95d9f --- /dev/null +++ b/resources/tester/test_735_1_library_delete.py @@ -0,0 +1,178 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""서고 나무 지우기 = 경고 뒤 지움 · 쓰는 곳 그 줄도 지움(PLAN 735-1 · 요구 574) — 임시 storage. + +미리 보기(쓰는 줄 = 종류 · 이름 · 키 · 줄) · 확인 없으면 409 목록 · 확인 = 그 줄 지움 +(금액 다시 셈) + 문서 지움 + `_합친것/` 기록 · 되돌림(문서 · 자리 · 고친 문서 앞판 · +그새 고친 문서는 건너뜀) · +권한 밖 409(아무것도 안 바뀜) · 폴더 통째 = 한 기록 · 단가산출 바탕 · 더한줄 열쇠 당김(`strip`). +""" + +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_Labor_Router as labor_router +from M02_MasterTemplete import M02_Library_Delete as deleting +from M02_MasterTemplete import M02_Library_Same_Router as same_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 + +USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"} +TREE = "/api/m02/layers/personal/precombo-tree" +SAME = "/api/m02/layers/personal/precombo-same" +SK_TREE = "/api/m02/layers/personal/sangeun-tree" +SK_SAME = "/api/m02/layers/personal/sangeun-same" +FIX = {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": {"손": 1}} + + +@pytest.fixture() +def api(world): + app = FastAPI() + app.include_router(labor_router.router) + app.include_router(same_router.router) + app.dependency_overrides[verify_session] = lambda: USER + return TestClient(app) + + +def _sc(folder, name, lines): + with store.at(folder): + return refs.create("precombo", {"이름": name, "단위": "m", "담은로직": lines})["이름"] + + +def _labor(folder, lines): + with store.at(folder): + store.write("labor", "x1", "", {"열": "x1", "담은로직": lines, "자재": []}) + + +def _read(folder, kind, key): + with store.at(folder): + return store.read(kind, key)["문서"] + + +def _sum(folder): + return _read(folder, "labor", "x1")["계산값"]["반영합"]["계"] + + +def _tree(api, url=TREE, **body): + 판 = api.get(url).json()["판"] + return api.post(url, json={"판": 판, **body}) + + +def _world(world): + """단가산출 하나(운반 3000 · 1 단위)를 구조물 조합 x1(2 단위) · 일위대가가 물게.""" + me = world["personal"] + line = {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 3000}}, "수량": 1} + with store.at(me): + doc = {"이름": "임시 단가산출", "단위": "m", "바탕": None, "더한줄": [line]} + sk = refs.create("sangeun", doc)["이름"] + top = _sc(me, "위 일위대가", [{"참조": {**MINE, "키": sk}}, FIX]) + _labor(me, [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}, FIX]) + return me, sk, top + + +def test_미리보기_확인없으면_409_확인하면_그줄_지움_금액_다시셈_되돌림(api, world): + me, sk, top = _world(world) + before = _sum(me) + assert before == pytest.approx(5000 + 2 * 3000) + + got = api.post(SK_TREE + "/delete-check", json={"대상": [{"키": sk}]}) + assert got.status_code == 200, got.text + rows = got.json()["쓰는곳"] + assert got.json()["문서"] == [sk] + assert sorted((r["종류"], r["이름"], r["키"], r["줄"], r["쓰기"]) for r in rows) == [ + ("구조물 조합", "x1", sk, "1째", True), + ("일위대가", "위 일위대가", sk, "1째", True), + ] + + res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}]) + assert res.status_code == 409 and len(res.json()["detail"]["쓰는곳"]) == 2 + assert (me / "sangeun" / f"{sk}.json").is_file() + + res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}], 확인=True) + assert res.status_code == 200, res.text + assert res.json()["알림"]["기록"] == sk + assert not (me / "sangeun" / f"{sk}.json").exists() + assert [x.get("고정", {}).get("이름") for x in _read(me, "labor", "x1")["담은로직"]] == ["운반"] + assert len(_read(me, "precombo", top)["담은로직"]) == 1 + assert _sum(me) == pytest.approx(5000) # 서버가 다시 셈 + + gone = api.get(SK_SAME).json()["합친것"] + assert [(g["키"], g["남김"], g["옮긴곳수"]) for g in gone] == [(sk, None, 2)] + back = api.post(SK_SAME + "/undo", json={"키": sk}) + assert back.status_code == 200, back.text + assert back.json()["되돌린곳수"] == 2 and back.json()["못한곳"] == [] + assert (me / "sangeun" / f"{sk}.json").is_file() + assert _read(me, "labor", "x1")["담은로직"][0]["참조"]["키"] == sk + assert _sum(me) == pytest.approx(before) + assert api.get(SK_SAME).json()["합친것"] == [] + + +def test_쓰는곳_없으면_그냥_지움_기록은_남음(api, world): + me = world["personal"] + sc = _sc(me, "혼자", [FIX]) + assert api.post(TREE + "/delete-check", json={"대상": [{"키": sc}]}).json()["쓰는곳"] == [] + assert _tree(api, op="delete", 대상=[{"키": sc}]).status_code == 200 + assert not (me / "precombo" / f"{sc}.json").exists() + assert api.post(SAME + "/undo", json={"키": sc}).status_code == 200 + assert (me / "precombo" / f"{sc}.json").is_file() + + +def test_권한_밖이면_409_목록_아무것도_안_바뀜(api, world, monkeypatch): + me, sk, _top = _world(world) + monkeypatch.setattr(deleting, "can_write", lambda session, at: False) + res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}], 확인=True) + assert res.status_code == 409 + detail = res.json()["detail"] + assert "권한 밖" in detail["까닭"] and "x1" in detail["까닭"] and sk in detail["까닭"] + assert {r["이름"] for r in detail["막힘"]} == {"x1", "위 일위대가"} + assert (me / "sangeun" / f"{sk}.json").is_file() + assert len(_read(me, "labor", "x1")["담은로직"]) == 2 + + +def test_폴더_통째_한_기록_되돌림_자리까지_그새_고친_문서는_건너뜀(api, world): + me = world["personal"] + a = _sc(me, "가", [FIX]) + b = _sc(me, "나", [FIX]) + _labor(me, [{"참조": {**MINE, "키": a}, "수량": {"손": 1}}, FIX]) + other = _sc(me, "다른", [{"참조": {**MINE, "키": b}}, FIX]) + assert _tree(api, op="add-folder", 경로=[], 이름="묶음").status_code == 200 + for key in (a, b): + assert _tree(api, op="move", 키=key, 새경로=["묶음"]).status_code == 200 + check = api.post(TREE + "/delete-check", json={"대상": [{"경로": ["묶음"]}]}).json() + assert sorted(check["문서"]) == [a, b] + assert sorted(r["키"] for r in check["쓰는곳"]) == [a, b] + res = _tree(api, op="delete", 대상=[{"경로": ["묶음"]}], 확인=True) + assert res.status_code == 200, res.text + name = res.json()["알림"]["기록"] + assert [g["키"] for g in api.get(SAME).json()["합친것"]] == [name] + with store.at(me): # 그새 「다른」 을 고침 → 그 문서는 앞판으로 안 돌림 + have = store.read("precombo", other) + store.write("precombo", other, have["판"], {**have["문서"], "이름": "다른 고침"}) + back = api.post(SAME + "/undo", json={"키": name}).json() + assert back["되돌린곳수"] == 1 and "그새 고침" in back["못한곳"][0] + assert (me / "precombo" / f"{a}.json").is_file() and (me / "precombo" / f"{b}.json").is_file() + assert api.get(TREE).json()["자리"] == {a: ["묶음"], b: ["묶음"]} + assert _read(me, "labor", "x1")["담은로직"][0]["참조"]["키"] == a + + +def test_단가산출_바탕_더한줄_지움_열쇠_당김(): + gone = {**MINE, "키": "SK-000002"} + keep = {**MINE, "키": "SK-000003"} + doc = { + "바탕": {"참조": gone}, + "열린입력": ["높이"], + "더한줄": [{"참조": gone, "수량": 1}, {"참조": keep, "수량": 1}, {"종류": "로직"}], + "덮개": {"labor:A#1": {"qty": 1}, "add:#1": {"배율": 2}, "add:#2/labor:B#1": {"qty": 3}}, + "뺀줄": ["labor:A#2", "add:#0"], + "고름": {"add:#2/material:C#1": "MT1"}, + "자재표시": {}, + } + out = deleting.strip(doc, {refs.ref_id(gone)}) + assert out["바탕"] is None and out["열린입력"] == [] + assert [x.get("참조") for x in out["더한줄"]] == [keep, None] + assert out["덮개"] == {"add:#0": {"배율": 2}, "add:#1/labor:B#1": {"qty": 3}} + assert out["뺀줄"] == [] + assert out["고름"] == {"add:#1/material:C#1": "MT1"} + assert deleting.strip(doc, set()) == doc # 안 걸리면 그대로 diff --git a/ui_template/ui_template_modal.css b/ui_template/ui_template_modal.css index 732afa4b3..09a0f96d7 100644 --- a/ui_template/ui_template_modal.css +++ b/ui_template/ui_template_modal.css @@ -100,6 +100,15 @@ font-weight: 700; color: var(--color-text); } +/* 확인창 덧 내용(표 · `detail`) — 넓은 판 · 넘치면 안에서 스크롤 */ +.ui-confirm__panel.ui-confirm__panel--wide { + max-width: min(720px, 92vw); +} +.ui-confirm__detail { + max-height: 40vh; + overflow: auto; + margin-bottom: var(--spacing-16); +} .ui-confirm__input { box-sizing: border-box; width: 100%; diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index 5f98f87f4..1a7b396b0 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -105,6 +105,8 @@ export interface AskOptions { cancelLabel?: string; /** 위험 확인 — 확인 단추 빨강 */ danger?: boolean; + /** 덧 내용(표 등) — 글 아래 · 있으면 판이 넓어지고 넘치면 안에서 스크롤 */ + detail?: HTMLElement; } /** 갈래 묻기 단추 하나 — 누르면 `value` · 맨 뒤 단추가 주 단추(filled) */ @@ -126,6 +128,10 @@ export function confirmPanel( const panel = el("div", { className: "ui-confirm__panel", attrs: { role: "dialog" } }); if (opts.title) panel.append(el("h3", { className: "ui-confirm__title", text: opts.title })); panel.append(el("p", { className: "ui-confirm__message", text: opts.message })); + if (opts.detail) { + panel.classList.add("ui-confirm__panel--wide"); + panel.append(el("div", { className: "ui-confirm__detail", children: [opts.detail] })); + } let input: HTMLInputElement | null = null; if (opts.field) { const box = el("input", {