diff --git a/M01_MasterData/M01_MasterData_Alias.py b/M01_MasterData/M01_MasterData_Alias.py index 58d95a9e1..f474c0db4 100644 --- a/M01_MasterData/M01_MasterData_Alias.py +++ b/M01_MasterData/M01_MasterData_Alias.py @@ -1,17 +1,22 @@ -"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6). +"""M01 별칭 모아 보기 · 제안 → 승인(PLAN 815-2 · 요구 764 · 765) — 서버 단독. -모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤 -기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀). -후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만 -(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름. -승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) · -반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음. +별칭 = 요소 · 로직 줄 칸 `별칭` 하나(콤마 여러 개 · 풀이 정본 `master_alias`). + GET /api/m01/alias → {줄: 별칭이 적힌 줄 전부(갈래 · 키 · 이름 · 규격 · 별칭 · 조각 · + 들어옴 수기/승인), 대기: 승인 대기 제안} + POST /api/m01/alias/save {키, 별칭} → 그 줄 `별칭` 만 고침(M01 저장 길 `make.save`) + POST /api/m01/alias/approve {id} → 그 요소 `별칭` 에 콤마로 더함 · 제안 = 「승인」 기록 + POST /api/m01/alias/reject {id} → 제안 지움 + POST /api/m01/alias/suggest {키, 이름, 규격?, 단위?(갈래는 서버가 키로 정함)} → 제안 하나(로그인만 · 일반 계정도) +제안 파일 = `storage/_shared/별칭제안.json` `{제안: {id: {…, 상태 대기|승인}}}`. +권한 = `router` 시스템 관리자 · `suggest_router` 로그인만(`main.py`). """ from __future__ import annotations import hashlib import json +import os +import threading from pathlib import Path from typing import Any @@ -20,160 +25,161 @@ from pydantic import BaseModel, Field from common_util.common_util_auth import verify_session from common_util.common_util_time import utc_now_text -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger -from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store -from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Template_Layers as layers -ALIAS, BILL = "맞춤", "내역맞춤" -KINDS = (ALIAS, BILL) -REJECT = "별칭반려" +import master_alias as ma # noqa: E402 (`store` 가 scripts 를 길에 둠) -router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"]) +ALIAS = "별칭" +FUEL = "재료_유가전력.json" +ELEMENTS = ("인력.json", "기계.json", "재료_자재품목.json", FUEL) # 제안을 받는 요소 파일 +_LOCK = threading.Lock() + +router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"]) +suggest_router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"]) -# ── 읽기 ───────────────────────────────────────────────────────────── -def layer_folders() -> list[tuple[str, str | None, Path]]: - """(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음).""" - root = layers.storage_root() - out: list[tuple[str, str | None, Path]] = [] - for c in sorted(root.iterdir() if root.is_dir() else []): - if not (c.is_dir() and c.name.isdigit()): +def kind_of(file: str, group: str) -> str: + if file == FUEL: + return "유가" + return "단가산출 기초" if group == "로직" else group + + +# ── 제안 파일 ───────────────────────────────────────────────────────── +def suggest_path() -> Path: + return layers.storage_root() / "_shared" / "별칭제안.json" + + +def _book() -> dict[str, dict[str, Any]]: + try: + got = json.loads(suggest_path().read_text(encoding="utf-8")).get("제안") + except (OSError, ValueError, AttributeError): + return {} + return got if isinstance(got, dict) else {} + + +def _keep(book: dict[str, dict[str, Any]]) -> None: + path = suggest_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps({"제안": book}, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, path) + + +# ── 줄 찾기 · 고치기 ─────────────────────────────────────────────────── +def _find(key: str, files: tuple[str, ...] | None = None) -> tuple[str, dict[str, Any]]: + loaded, _ = store.loaded() + for name, data in loaded.items(): + if files and name not in files: continue - out.append((c.name, None, layers.company_dir(c.name))) - for u in sorted(c.iterdir()): - if u.is_dir() and u.name.isdigit(): - out.append((c.name, u.name, layers.personal_dir(c.name, u.name))) - return out + for row in data.get("줄") or []: + if str(row.get("키")) == key: + return name, row + raise store.StoreError(404, f"없는 키 「{key}」") -def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]: - """서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름.""" - if kind == ALIAS: - return {"갈래": row.get("갈래"), "짝": row.get("짝") or []} - return {"확정": row.get("확정"), "이름": row.get("이름")} +def set_alias(key: str, text: str) -> dict[str, Any]: + """그 줄 `별칭` 만 바꿔 M01 저장 길로(빈 글 = 칸 없음 · 별칭만이라 정본 개정 확인 안 물음).""" + file, _ = _find(key) + data, version = store.read(file) + row = dict(next(r for r in data["줄"] if str(r.get("키")) == key)) + row[ALIAS] = text + change = {"op": "edit", "key": key, "row": row} + make.save([{"file": file, "version": version, "changes": [change]}], True) + return board() -def sign(kind: str, row: dict[str, Any]) -> str: - raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True) - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] +def _quoted(piece: str) -> str: + return f'"{piece}"' if any(s in piece for s in (",", ",")) else piece -def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]: - if kind == ALIAS: - return ledger.read(folder) - got = hopyo_store.memory(folder, BILL) - return { - k: v - for k, v in got.items() - if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2 - } # 잊음 · 깨진 열쇠 뺌 +def _joined(text: Any, piece: str) -> str: + """별칭 글 끝에 한 조각 — 이미 있으면(정규화 같음) 그대로.""" + raw = str(text or "").strip() + if ma.norm(piece) in {ma.norm(x) for x in ma.names(raw)}: + return raw + return f"{raw}, {_quoted(piece)}" if raw else _quoted(piece) -def _origin(full: str, row: dict[str, Any]) -> dict[str, str]: - if isinstance(row.get("원래"), dict): - return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")} - return ledger._plain_of(full) +# ── 모아 보기 ───────────────────────────────────────────────────────── +def board() -> dict[str, Any]: + book = _book() + approved = { + (str(v.get("키")), ma.norm(v.get(ALIAS))) for v in book.values() if v.get("상태") == "승인" + } + rows = [] + loaded, _ = store.loaded() + for file, data in loaded.items(): + kind = kind_of(file, str(data.get("그룹") or "")) + for row in data.get("줄") or []: + if not str(row.get(ALIAS) or "").strip(): + continue + key = str(row.get("키")) + pieces = [ + {"글": p, "승인": (key, ma.norm(p)) in approved} for p in ma.names(row[ALIAS]) + ] + rows.append( + { + "갈래": kind, + "파일": file, + "키": key, + "이름": str(row.get("이름") or ""), + "규격": str(row.get("규격") or ""), + ALIAS: str(row[ALIAS]), + "조각": pieces, + "들어옴": "승인" if any(p["승인"] for p in pieces) else "수기", + } + ) + waiting = [{"id": k, **v} for k, v in book.items() if v.get("상태") == "대기"] + waiting.sort(key=lambda x: str(x.get("때") or "")) + return {"줄": rows, "대기": waiting} -def rejected() -> dict[str, dict[str, Any]]: - return hopyo_store.memory(store.FOLDER, REJECT) +# ── 제안 · 승인 · 반려 ──────────────────────────────────────────────── +def suggest(key: str, name: str, spec: str, unit: str, who: Any) -> dict[str, Any]: + file, row = _find(key, ELEMENTS) + piece = " ".join(x for x in (name.strip(), spec.strip()) if x) + if not piece: + raise store.StoreError(400, "가져온 이름이 비었음") + if _joined(row.get(ALIAS), piece) == str(row.get(ALIAS) or "").strip(): + return {"이미": True} # 이미 그 별칭이 있음 + sid = hashlib.sha256(f"{key}|{ma.norm(piece)}".encode()).hexdigest()[:16] + with _LOCK: + book = _book() + if book.get(sid, {}).get("상태") != "승인": + book[sid] = { + "갈래": kind_of(file, str(store.loaded()[0][file].get("그룹") or "")), + "키": key, + "이름": name.strip(), + "규격": spec.strip(), + "단위": unit.strip(), + ALIAS: piece, + "상태": "대기", + "누가": who, + "때": utc_now_text(), + } + _keep(book) + return {"id": sid} -def _reject_id(kind: str, full: str, sig: str) -> str: - return f"{kind}|{sig}|{full}" +def decide(sid: str, ok: bool, who: Any) -> dict[str, Any]: + with _LOCK: + book = _book() + one = book.get(sid) + if not one or one.get("상태") != "대기": + raise store.StoreError(404, "대기 중인 제안이 없음(이미 승인 · 반려)") + if ok: + _, row = _find(str(one["키"]), ELEMENTS) + set_alias(str(one["키"]), _joined(row.get(ALIAS), str(one[ALIAS]))) + one.update(상태="승인", 승인={"누가": who, "때": utc_now_text()}) + else: + del book[sid] + _keep(book) + return board() -def gather() -> dict[str, Any]: - """후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌.""" - seen: dict[tuple[str, str, str], dict[str, Any]] = {} - firms, people = set(), set() - for company, user, folder in layer_folders(): - for kind in KINDS: - for full, row in _book(kind, folder).items(): - sig = sign(kind, row) - who = ( - (row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None - ) - person = f"{company}/{user}" if user else f"{company}/{who or '-'}" - one = seen.setdefault( - (kind, full, sig), - {"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row), - **_body(kind, row), "회사": set(), "사람": set(), "때": None}, - ) # fmt: skip - one["회사"].add(company) - one["사람"].add(person) - firms.add(company) - people.add(person) - stamp = row.get("때") - if stamp and (one["때"] is None or str(stamp) > str(one["때"])): - one["때"] = stamp - master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS} - gone = rejected() - live = [] - for (kind, full, sig), one in seen.items(): - mine = master[kind].get(full) - if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone: - continue - one["마스터"] = "다름" if mine else None - live.append(one) - many: dict[tuple[str, str], int] = {} - for one in live: - many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1 - out = [] - for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])): - clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름" - out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash}) - return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)} - - -def _find(kind: str, full: str, sig: str) -> dict[str, Any]: - if kind not in KINDS: - raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}") - got = next( - (x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)), - None, - ) - if got is None: - raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」") - return got - - -# ── 쓰기(마스터 층만) ───────────────────────────────────────────────── -def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]: - """후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사).""" - one = _find(kind, full, sig) - if kind == ALIAS: - ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who) - else: - row = {**_body(kind, one), "누가": who, "때": utc_now_text()} - hopyo_store.remember(store.FOLDER, {full: row}, BILL) - return gather() - - -def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]: - _find(kind, full, sig) - row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()} - hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT) - return gather() - - -def master_bill() -> list[dict[str, Any]]: - """마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`.""" - book = _book(BILL, Path(store.FOLDER)) - return [ - {"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items()) - ] - - -def forget_bill(full: str) -> list[dict[str, Any]]: - if full not in _book(BILL, Path(store.FOLDER)): - raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」") - hopyo_store.remember(store.FOLDER, {full: None}, BILL) - return master_bill() - - -# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ─────────────── +# ── 길 ─────────────────────────────────────────────────────────────── def _who(session: dict[str, Any]) -> dict[str, Any]: return {"id": session.get("user_id"), "이름": session.get("name")} @@ -185,31 +191,44 @@ def _call(fn, *args): raise HTTPException(error.status, error.detail) from error -class Pick(BaseModel): - 종류: str = Field(max_length=10) - 열쇠: str = Field(min_length=1, max_length=400) - 서명: str = Field(min_length=16, max_length=16) +class AliasBody(BaseModel): + 키: str = Field(min_length=1, max_length=20) + 별칭: str = Field(max_length=2000) + + +class Decide(BaseModel): + id: str = Field(min_length=16, max_length=16) + + +class Suggest(BaseModel): + 갈래: str = Field(default="", max_length=20) + 키: str = Field(min_length=1, max_length=20) + 이름: str = Field(min_length=1, max_length=200) + 규격: str = Field(default="", max_length=200) + 단위: str = Field(default="", max_length=40) @router.get("") -def candidates() -> dict[str, Any]: - return {**gather(), "마스터내역": master_bill()} +def get_board() -> dict[str, Any]: + return board() + + +@router.post("/save") +def post_save(body: AliasBody) -> dict[str, Any]: + return _call(set_alias, body.키, body.별칭) @router.post("/approve") -def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]: - return _call(approve, body.종류, body.열쇠, body.서명, _who(session)) +def post_approve(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]: + return _call(decide, body.id, True, _who(session)) @router.post("/reject") -def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]: - return _call(reject, body.종류, body.열쇠, body.서명, _who(session)) +def post_reject(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]: + return _call(decide, body.id, False, _who(session)) -class Forget(BaseModel): - 열쇠: str = Field(min_length=1, max_length=400) - - -@router.post("/master-bill/forget") -def forget_one(body: Forget) -> dict[str, Any]: - return {"마스터내역": _call(forget_bill, body.열쇠)} +@suggest_router.post("/suggest") +def post_suggest(body: Suggest, session: dict = Depends(verify_session)) -> dict[str, Any]: + args = (body.키, body.이름, body.규격, body.단위, _who(session)) + return _call(suggest, *args) diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index aac015d41..142a9bae1 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -283,12 +283,15 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: """`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) · 자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}.""" revise.guard(batch, confirmed) - for part in batch: # 별칭(815-2) = 글 그대로 · 앞뒤 빈칸만 뗌 · 빈 글이면 칸 없음 + for part in batch: for ch in part.get("changes") or []: row = ch.get("row") - if isinstance(row, dict) and "별칭" in row: + if ( + isinstance(row, dict) and "별칭" in row + ): # 별칭(815-2) = 앞뒤 빈칸만 뗌 · 빈 글 = 칸 없음 text = str(row["별칭"] or "").strip() row.update(별칭=text) if text else row.pop("별칭") + _keep_same(part) fresh = False for part in batch: if part.get("file") != OWN: @@ -314,6 +317,20 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: return {"files": files, "added": added, "warnings": []} +def _keep_same(part: dict) -> None: + """고친 줄의 값이 같은 칸 = 파일 값 그대로(화면 왕복 「9.0」 → 「9」 막음 · 그 칸만 바뀜).""" + try: + data = store.read(str(part.get("file") or ""))[0] + except store.StoreError: + return + rows = {str(r.get("키")): r for r in data.get(store.items_key(data)) or []} + for ch in part.get("changes") or []: + old, row = rows.get(str(ch.get("key"))), ch.get("row") + if ch.get("op") == "edit" and isinstance(old, dict) and isinstance(row, dict): + same = {k for k, v in row.items() if k in old and store._dec(v) == old[k]} + ch["row"] = {k: old[k] if k in same else v for k, v in row.items()} + + def _file_keys(file: str) -> list[str]: try: data = store.read(file)[0] diff --git a/M01_MasterData/M01_MasterData_UI_Alias.ts b/M01_MasterData/M01_MasterData_UI_Alias.ts index 3262b4bb2..92b7ee75a 100644 --- a/M01_MasterData/M01_MasterData_UI_Alias.ts +++ b/M01_MasterData/M01_MasterData_UI_Alias.ts @@ -1,67 +1,55 @@ /* ============================================================================= * M01_MasterData_UI_Alias.ts - * M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만. - * · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음 - * (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 · - * [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창 - * · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움] - * · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤) + * M01 별칭 모아 보기(PLAN 815-2 · 요구 764 · 765) — 본 칸 · 시스템 관리자만. + * · 위 = 「승인 대기 n」 — 일반 사용자 제안(가져온 이름 · 규격 → 우리 요소) · [승인] = 그 요소 별칭에 + * 콤마로 더함 · [반려] = 지움(둘 다 확인 창 뒤 바로 서버) + * · 아래 = 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초 — 수기로 넣은 것도 바로) · + * 갈래 거름 · 찾기 · 별칭 칸 눌러 고침(콤마로 여러 개) → [저장](그 줄 별칭만 · `/api/m01/alias/save`) · + * 줄을 누르면 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭 + * · 서버 = `M01_MasterData_Alias.py` · 조각 나누기 = 서버(`master_alias.names`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createBadge } from "@ui/ui_template_badge"; import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { createPanelHead } from "@ui/ui_template_panel_head"; +import { createSegment } from "@ui/ui_template_segment"; import { createTable, type Column } from "@ui/ui_template_table"; -import { createTabs } from "@ui/ui_template_tabs"; -import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음 -import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api"; -import { - buildHopyoLedger, - KIND_TEXT, - LEDGER_HINT, - pairText, -} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger"; +import { startEdit } from "./M01_MasterData_UI_Cells"; -export const ALIAS_TITLE = "별칭 장부"; -const KIND_NAME: Record = { 맞춤: "일위대가", 내역맞춤: "공사 내역" }; +export const ALIAS_TITLE = "별칭 모아 보기"; +const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"]; -interface Origin { - 품명: string; +export interface AliasRow { + 갈래: string; + 파일: string; + 키: string; + 이름: string; + 규격: string; + 별칭: string; + 조각: { 글: string; 승인: boolean }[]; + 들어옴: "수기" | "승인"; +} +interface Waiting { + id: string; + 갈래: string; + 키: string; + 이름: string; 규격: string; 단위: string; + 별칭: string; + 누가?: { 이름?: string } | null; + 때?: string; } -export interface AliasCandidate { - 종류: "맞춤" | "내역맞춤"; - 열쇠: string; - 서명: string; - 원래: Origin; - 갈래?: string; - 짝?: LedgerPair[]; - 확정?: string; - 이름?: string; - 회사: number; - 사람: number; - 때: string | null; - 충돌: boolean; - 마스터: "다름" | null; -} -interface MasterBill { - 열쇠: string; - 원래: Origin; - 확정: string; - 이름?: string; -} -export interface AliasBoard { - 후보: AliasCandidate[]; - 모은곳: { 회사: number; 사람: number }; - 반려: number; - 마스터내역?: MasterBill[]; +interface Board { + 줄: AliasRow[]; + 대기: Waiting[]; } async function call(path: string, body?: unknown): Promise { - const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, { + const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -72,72 +60,75 @@ async function call(path: string, body?: unknown): Promise { return got; } -/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */ -export const candidateText = (c: AliasCandidate): string => - c.종류 === "내역맞춤" - ? `${c.이름 ?? ""} (${c.확정 ?? ""})` - : c.갈래 === "짝" - ? (c.짝 ?? []).map(pairText).join(" + ") - : (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? ""); - const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 }); -const CANDIDATE_COLS: Column[] = [ - { label: "종류", width: "84px" }, - { label: "엑셀 품명" }, - { label: "규격" }, +const WAIT_COLS: Column[] = [ + { label: "갈래", width: "96px" }, + { label: "가져온 이름 · 규격" }, { label: "단위", width: "64px" }, - { label: "짝" }, - { label: "회사", width: "56px", align: "end" }, - { label: "사람", width: "56px", align: "end" }, - { label: "충돌", width: "96px" }, + { label: "우리 요소" }, + { label: "누가 · 때", width: "180px" }, { label: "", width: "136px" }, ]; -const BILL_COLS: Column[] = [ - { label: "엑셀 품명" }, - { label: "규격" }, - { label: "단위", width: "64px" }, - { label: "우리 공종" }, - { label: "", width: "72px" }, +const ROW_COLS: Column[] = [ + { label: "갈래", width: "96px" }, + { label: "키", width: "96px" }, + { label: "이름 · 규격" }, + { label: "별칭" }, + { label: "들어옴", width: "72px" }, ]; -export function buildAliasLedger(): HTMLElement { - const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } }); - const body = el("div", { className: "m01-alias__body" }); - let view = "candidates"; +/** `open` = 줄을 눌렀을 때(왼쪽 판이 그 요소 화면 · 로직 탭으로) · `setDirty` = 저장 안 한 고침 표시 */ +export function buildAliasBoard(opts: { + open: (row: AliasRow) => void; + setDirty?: (dirty: boolean) => void; +}): HTMLElement { + const root = el("div", { className: "m01-alias", attrs: { "data-alias-board": "" } }); + const waitHost = el("div"); + const listHost = el("div"); + const edits = new Map(); + let board: Board = { 줄: [], 대기: [] }; + let kind = ""; + let q = ""; - async function act(c: AliasCandidate, ok: boolean): Promise { - const name = `${c.원래.품명} ${c.원래.규격}`.trim(); + const save = createButton({ label: "저장", variant: "filled", disabled: true }); + const undo = createButton({ label: "되돌림", variant: "ghost", disabled: true }); + const mark = (): void => { + save.disabled = undo.disabled = edits.size === 0; + opts.setDirty?.(edits.size > 0); + }; + + async function decide(w: Waiting, ok: boolean): Promise { const yes = await askConfirm({ - title: ok ? "마스터 장부에 승인" : "후보 반려", + title: ok ? "별칭 승인" : "제안 반려", message: ok - ? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).` - : `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`, + ? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).` + : `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`, okLabel: ok ? "승인" : "반려", danger: !ok, }); if (!yes) return; try { - const got = await call(ok ? "/approve" : "/reject", pick(c)); - showToast(ok ? "마스터 장부에 넣음" : "반려함", "success"); - paintCandidates(got); + board = await call(ok ? "/approve" : "/reject", { id: w.id }); + showToast(ok ? "별칭에 더함" : "반려함", "success"); + paint(); } catch (error) { showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error"); } } - function candidateRow(c: AliasCandidate): (string | Node)[] { - const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : ""; - return [ - KIND_NAME[c.종류] ?? c.종류, - c.원래.품명, - c.원래.규격, - c.원래.단위, - candidateText(c), - String(c.회사), - String(c.사람), - clash ? createBadge(clash, "warning") : "", + const target = (key: string): string => { + const one = board.줄.find((r) => r.키 === key); + return one ? `${key} · ${one.이름} ${one.규격}`.trim() : key; + }; + + function paintWaiting(): void { + const rows = board.대기.map((w) => [ + w.갈래, + `${w.이름} ${w.규격}`.trim(), + w.단위, + target(w.키), + `${w.누가?.이름 ?? ""} · ${(w.때 ?? "").slice(0, 16).replace("T", " ")}`, el("div", { className: "ui-toolbar", children: [ @@ -145,93 +136,137 @@ export function buildAliasLedger(): HTMLElement { label: "승인", variant: "filled", size: "sm", - onClick: () => void act(c, true), + onClick: () => void decide(w, true), }), createButton({ label: "반려", variant: "ghost", size: "sm", - onClick: () => void act(c, false), + onClick: () => void decide(w, false), }), ], }), - ]; - } - - function paintCandidates(board: AliasBoard): void { - const head = createPanelHead({ - title: "모인 후보", - sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`, - }); - body.replaceChildren( - head.root, - createTable(CANDIDATE_COLS, board.후보.map(candidateRow), { - density: "data", - empty: "모인 후보 없음", - }), - ); - } - - async function forget(b: MasterBill): Promise { - const yes = await askConfirm({ - title: "마스터 공사 내역 맞춤 지움", - message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`, - okLabel: "지움", - danger: true, - }); - if (!yes) return; - try { - const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 }); - showToast("지움", "success"); - paintMaster(got.마스터내역); - } catch (error) { - showToast(`지우지 못 함 — ${why(error)}`, "error"); - } - } - - function paintMaster(bill: MasterBill[]): void { - const rows = bill.map((b) => [ - b.원래.품명, - b.원래.규격, - b.원래.단위, - `${b.이름 ?? ""} (${b.확정})`, - createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }), ]); - body.replaceChildren( - buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }), + waitHost.replaceChildren( createPanelHead({ - title: "마스터 공사 내역 맞춤", - sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)", + title: `승인 대기 ${board.대기.length}`, + sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함", }).root, - createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }), + createTable(WAIT_COLS, rows, { density: "data", empty: "승인 대기 없음" }), ); } + function aliasCell(r: AliasRow): HTMLElement { + const box = el("div", { className: "ui-toolbar", attrs: { "data-alias-cell": r.키 } }); + const edited = edits.get(r.키); + if (edited !== undefined) box.append(createBadge(edited || "(지움)", "warning")); + else for (const p of r.조각) box.append(createBadge(p.글, p.승인 ? "success" : "neutral")); + box.title = "눌러 고침 — 콤마로 여러 개"; + box.addEventListener("click", (e) => { + e.stopPropagation(); + startEdit(box, edited ?? r.별칭, (text) => { + if (text.trim() === r.별칭.trim()) edits.delete(r.키); + else edits.set(r.키, text.trim()); + mark(); + paintList(); + }); + }); + return box; + } + + function paintList(): void { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const shown = board.줄.filter( + (r) => + (!kind || r.갈래 === kind) && + words.every((w) => `${r.키} ${r.이름} ${r.규격} ${r.별칭}`.toLowerCase().includes(w)), + ); + const table = createTable( + ROW_COLS, + shown.map((r) => [ + r.갈래, + r.키, + `${r.이름} ${r.규격}`.trim(), + aliasCell(r), + createBadge(r.들어옴, r.들어옴 === "승인" ? "success" : "neutral"), + ]), + { density: "data", empty: "별칭이 적힌 줄 없음" }, + ); + table.querySelectorAll("tbody tr").forEach((tr, i) => { + const r = shown[i]; + if (!r) return; + tr.setAttribute("data-key", r.키); + tr.addEventListener("click", () => opts.open(r)); + }); + listHost.replaceChildren(table); + } + + function paint(): void { + paintWaiting(); + paintList(); + } + async function load(): Promise { - const stop = showLoading(body); + const stop = showLoading(listHost); try { - const got = await call(""); - if (view === "candidates") paintCandidates(got); - else paintMaster(got.마스터내역 ?? []); + board = await call(""); + paint(); } catch (error) { - body.replaceChildren(loadingError(why(error), () => void load())); + listHost.replaceChildren(loadingError(why(error), () => void load())); } finally { stop(); } } - const tabs = createTabs({ - tabs: [ - { key: "candidates", label: "모인 후보" }, - { key: "master", label: "마스터 장부" }, - ], - value: view, - onChange: (key) => { - view = key; - void load(); + save.addEventListener("click", async () => { + save.disabled = true; + try { + for (const [key, text] of [...edits]) { + board = await call("/save", { 키: key, 별칭: text }); + edits.delete(key); + } + showToast("별칭 저장함", "success"); + } catch (error) { + showToast(`저장 못 함 — ${why(error)}`, "error"); + } finally { + mark(); + paint(); + } + }); + undo.addEventListener("click", () => { + edits.clear(); + mark(); + paintList(); + }); + + const kinds = createSegment({ + options: [{ value: "", label: "전체" }, ...KINDS.map((k) => ({ value: k, label: k }))], + value: kind, + onChange: (v) => { + kind = v; + paintList(); + }, + ariaLabel: "갈래", + }); + const find = createInputField({ + type: "search", + placeholder: "키 · 이름 · 별칭 찾기", + compact: true, + onInput: (v) => { + q = v.trim(); + paintList(); }, }); - root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김) + const listHead = createPanelHead({ + title: "별칭이 적힌 줄", + sub: "수기로 넣은 것 · 승인한 것 모두 · 줄을 누르면 그 요소 화면 · 별칭 칸을 눌러 고친 뒤 [저장]", + }); + root.append( + waitHost, + listHead.root, + el("div", { className: "ui-toolbar", children: [kinds.root, find.root, undo, save] }), + listHost, + ); void load(); return root; } diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index decbba1ce..c46d06ab6 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -2,7 +2,7 @@ * M01_MasterData_UI_Page.ts * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직) * · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`) - * / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) · + * / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 모아 보기(815-2) · * 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2) * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 7a83aa253..c44e878fe 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -3,7 +3,7 @@ * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b) * ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기) - * · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`) + * · 별칭 모아 보기(815-2 · 누르면 본 칸에 별칭이 적힌 줄 · 승인 대기 · `M01_MasterData_UI_Alias`) * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 * ========================================================================== */ @@ -15,7 +15,7 @@ import { buildOptionsSection, type OptionsSectionOptions, } from "../M02_MasterTemplete/StructOptions/M02_SO_Options"; -import { ALIAS_TITLE, buildAliasLedger } from "./M01_MasterData_UI_Alias"; +import { ALIAS_TITLE, buildAliasBoard, type AliasRow } from "./M01_MasterData_UI_Alias"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; @@ -504,13 +504,24 @@ ${first.file}` // 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능) const options = buildOptionsSection({ ...optionsOpt, isAdmin: true }); const optionsSec = section(options.title, options.body); - // 별칭 장부(718-3) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리) + // 별칭 모아 보기(815-2) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리) const aliasSec = section(ALIAS_TITLE, null); aliasSec.classList.add("m01-side__leaf"); buttons.set(ALIAS_ID, aliasSec); + /** 모아 보기 줄 → 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭 */ + const openAliasRow = (row: AliasRow): void => { + if (row.갈래 === "단가산출 기초") { + setPendingLogicKey(row.키); + return reveal("로직"); + } + const group = row.갈래 === "유가" ? FUEL_GROUP : row.갈래; + jumpToMaster({ kind: "element", group, ref: row.키, file: row.파일 }); + }; const openAlias = async (): Promise => { if (!(await optionsOpt.confirmLeave())) return; - optionsOpt.show(ALIAS_TITLE, buildAliasLedger()); + let setDirty: (dirty: boolean) => void = () => {}; + const board = buildAliasBoard({ open: openAliasRow, setDirty: (d) => setDirty(d) }); + setDirty = optionsOpt.show(ALIAS_TITLE, board); options.clear(); setActive(ALIAS_ID); }; diff --git a/main.py b/main.py index 1983be204..ec057c6bb 100644 --- a/main.py +++ b/main.py @@ -549,10 +549,9 @@ app.include_router(b09_estimation_router, dependencies=protected_with_company) # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(m01_master_data_router, dependencies=system_admin_only) -app.include_router( - __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router, - dependencies=system_admin_only, -) # 별칭 장부 모으기(718-2) +_m01_alias = __import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]) +app.include_router(_m01_alias.router, dependencies=system_admin_only) # 별칭 모아 보기(815-2) +app.include_router(_m01_alias.suggest_router, dependencies=protected) # 별칭 제안 — 로그인만 # 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`) — 읽기만 · 로그인만(M02 줄 쌓기 단위) app.include_router( __import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router, diff --git a/resources/tester/test_815_2_alias_column.py b/resources/tester/test_815_2_alias_column.py index f622bb4ae..4907feef4 100644 --- a/resources/tester/test_815_2_alias_column.py +++ b/resources/tester/test_815_2_alias_column.py @@ -8,7 +8,9 @@ from __future__ import annotations import json +import re import shutil +import subprocess from pathlib import Path import pytest @@ -84,10 +86,12 @@ def test_별칭_저장_그_줄만_빈칸이면_칸_없음(copy: Path) -> None: old, new = _around(before.decode("utf-8")), _around(after) assert old[0] == new[0] and old[2] == new[2] # 그 줄 밖 글 · 서식 그대로 assert json.loads(new[1]) == {**json.loads(old[1]), "별칭": "시험별칭A, 시험별칭B"} + flat = lambda s: re.sub(r"\s", "", s) # noqa: E731 + assert flat(new[1]) == flat(old[1])[:-1] + ',"별칭":"시험별칭A,시험별칭B"}' # 9.0 그대로 _save_alias(client, " ") last = _around((copy / MACHINE).read_bytes().decode("utf-8")) assert last[0] == old[0] and last[2] == old[2] - assert json.loads(last[1]) == json.loads(old[1]) # 빈 글 = 칸 없음 = 처음 값 + assert last[1] == old[1] # 빈 글 = 칸 없음 = 처음 글 그대로 def test_M01_길은_시스템_관리자만() -> None: @@ -102,3 +106,130 @@ def test_마스터_기계_별칭_다섯_옮김_장부_걷음() -> None: rows = {r["키"]: r for r in json.loads((REAL / MACHINE).read_text(encoding="utf-8"))["줄"]} assert {k: r.get("별칭") for k, r in rows.items() if r.get("별칭")} == FIVE assert not (ROOT / "resources" / "master_template" / "실무자료" / "맞춤.json").exists() + + +# ── 모아 보기 · 제안 → 승인 ─────────────────────────────────────────── +@pytest.fixture +def suggest_file(tmp_path: Path, monkeypatch) -> Path: + from M01_MasterData import M01_MasterData_Alias as alias + + path = tmp_path / "_shared" / "별칭제안.json" + monkeypatch.setattr(alias, "suggest_path", lambda: path) + return path + + +def _alias_app(role: str = "SYSTEM_ADMIN") -> TestClient: + from M01_MasterData import M01_MasterData_Alias as alias + + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: { + "user_id": 7, + "name": "시험", + "role": role, + "company_id": 1, + } + admin = [Depends(auth.verify_session), Depends(auth.require_system_admin)] + app.include_router(alias.router, dependencies=admin) + app.include_router(alias.suggest_router, dependencies=[Depends(auth.verify_session)]) + return TestClient(app) + + +def test_모아_보기_다섯_수기_넣은_것_바로(copy: Path, suggest_file: Path) -> None: + client = _alias_app() + rows = client.get("/api/m01/alias").json()["줄"] + assert {r["키"]: r["별칭"] for r in rows if r["갈래"] == "기계"} == FIVE + assert all(r["들어옴"] == "수기" for r in rows) + one = next(r for r in rows if r["키"] == "EQ000248") + assert [p["글"] for p in one["조각"]] == ["바이브레이트 봉상후렉시블"] + res = client.post("/api/m01/alias/save", json={"키": "LB000001", "별칭": "시험A,시험B"}) + assert res.status_code == 200, res.text + got = next(r for r in res.json()["줄"] if r["키"] == "LB000001") + assert got["갈래"] == "인력" and [p["글"] for p in got["조각"]] == ["시험A", "시험B"] + res = client.post("/api/m01/alias/save", json={"키": "LB000001", "별칭": " "}) + assert "LB000001" not in [r["키"] for r in res.json()["줄"]] + assert ( + client.post("/api/m01/alias/save", json={"키": "ZZ999999", "별칭": "x"}).status_code == 404 + ) + + +def test_제안_승인_별칭에_더함_반려_지움(copy: Path, suggest_file: Path) -> None: + user, admin = _alias_app("USER"), _alias_app() + assert user.get("/api/m01/alias").status_code == 403 # 모아 보기 · 승인 = 시스템 관리자만 + body = {"키": "EQ000400", "이름": "워터 젯", "규격": "96kW, 고압", "단위": "hr"} + sid = user.post("/api/m01/alias/suggest", json=body).json()["id"] + again = user.post("/api/m01/alias/suggest", json={**body, "이름": "워터제트", "규격": ""}) + assert again.json() == {"이미": True} # 이미 있는 별칭은 제안 안 남음 + assert user.post("/api/m01/alias/approve", json={"id": sid}).status_code == 403 + wait = admin.get("/api/m01/alias").json()["대기"] + assert [(w["id"], w["갈래"], w["별칭"], w["누가"]["id"]) for w in wait] == [ + (sid, "기계", "워터 젯 96kW, 고압", 7) + ] + got = admin.post("/api/m01/alias/approve", json={"id": sid}).json() + row = next(r for r in got["줄"] if r["키"] == "EQ000400") + assert row["별칭"] == '워터제트, "워터 젯 96kW, 고압"' # 속 콤마 = 큰따옴표 조각 + assert [(p["글"], p["승인"]) for p in row["조각"]] == [ + ("워터제트", False), + ("워터 젯 96kW, 고압", True), + ] + assert row["들어옴"] == "승인" and got["대기"] == [] + data = json.loads((copy / MACHINE).read_text(encoding="utf-8")) + assert next(r for r in data["줄"] if r["키"] == "EQ000400")["별칭"] == row["별칭"] + assert admin.post("/api/m01/alias/approve", json={"id": sid}).status_code == 404 + + sid2 = user.post("/api/m01/alias/suggest", json={**body, "키": "EQ000425"}).json()["id"] + got = admin.post("/api/m01/alias/reject", json={"id": sid2}).json() + assert got["대기"] == [] and sid2 not in json.loads(suggest_file.read_text("utf-8"))["제안"] + assert user.post("/api/m01/alias/suggest", json={**body, "키": "LT000001"}).status_code == 404 + + +# ── 옛 별칭 장부 · 연결 찾기 걷음 ────────────────────────────────────── +def test_옛_장부_길_연결_찾기_길_404() -> None: + from M02_MasterTemplete import M02_Doc_Router as doc_router + from M02_MasterTemplete import M02_Hopyo_Router as hopyo_router + + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: {"user_id": 1, "role": "SYSTEM_ADMIN"} + for r in (hopyo_router.router, doc_router.router, _alias_router()): + app.include_router(r) + client = TestClient(app) + for method, url in ( + ("get", "/api/m02/layers/personal/hopyo-ledger"), + ("put", "/api/m02/layers/personal/hopyo-ledger"), + ("post", "/api/m02/layers/personal/sangeun/SK-000001/match"), + ("get", "/api/m01/alias-ledger"), + ("post", "/api/m01/alias-ledger/approve"), + ): + assert getattr(client, method)(url).status_code == 404, url + + +def _alias_router(): + from M01_MasterData import M01_MasterData_Alias as alias + + return alias.router + + +def test_걷은_파일_쓰는_곳_0() -> None: + gone = ( + "M02_MasterTemplete/M02_Doc_Match.py", + "M02_MasterTemplete/M02_Doc_Match_Note.py", + "M02_MasterTemplete/M02_Doc_Match_Value.py", + "M02_MasterTemplete/M02_Doc_MatchSk.py", + "M02_MasterTemplete/M02_MasterTemplete_Sangeun_MatchBand.ts", + "M02_MasterTemplete/logic/M02_Sangeun_Match.ts", + "M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts", + ) + assert not [g for g in gone if (ROOT / g).exists()] + names = [Path(g).stem for g in gone] + listed = subprocess.run( + ["git", "ls-files", "*.py", "*.ts"], cwd=ROOT, capture_output=True, text=True + ).stdout.split() + for one in listed: + path = ROOT / one + if "old_code" in path.parts or "tester" in path.parts or not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="ignore") + assert not [n for n in names if f"import {n}" in text or f'/{n}"' in text], path + build = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Build.py").read_text(encoding="utf-8") + assert "ledger.write(" not in build # 일위대가 불러오기 확인 = 장부에 안 적음 + doc = (ROOT / "M02_MasterTemplete" / "M02_Doc_Router.py").read_text(encoding="utf-8") + assert "mfind.learn" not in doc # 가져온 식 저장 = 장부에 안 적음 diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 28651f922..5b0f36726 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -173,7 +173,7 @@ export const ui_locales_m1 = { B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"], B01_Dashboard_CardLogic: ["단가산출 기초", "Cost-logic library"], B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"], - B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"], + B01_Dashboard_CardAliasLedger: ["별칭 모아 보기", "Aliases"], M01_RateSheetTitle: [ "조달청 제비율표 — 읽기만 · 오른쪽 클릭으로 시트 ↔ 리스트 이동", "PPS cost-rate table — read only · right-click to jump between sheet and list",