From 354e6cd029ab53c81f90c703377f14de9eb8f639 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:15:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=B8=B5=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EC=B8=B5=20=EA=B8=B8=20=E2=80=94=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=C2=B7=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=C2=B7=20=EA=B0=9C=EC=9D=B8(PLAN=2027-2a=20=C2=B7=2027-2c=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_Combo_Store · Router_Combos — /api/m02/layers/{layer}/combos 목록 · 하나 · 새로 · 고침 · 지움 · 나무 · 알약 · 미리 보기 · /api/m02/combos/all(세 층) - 키 장부 UC{회사}-###### · UP{사용자}-######(파일 머리 다음 · 지운 번호 안 씀) · 마스터 UA 는 M01 저장 길 그대로 - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 빈 폴더는 머리 폴더 · 쓰는 조합 든 폴더 지우기 409 - 옛 /api/m01/combo* 길 뺌(logic/combos 만 남김) · 조합 화면 Api 는 M02 마스터 층 길로 - use-combo · 갈라 내기 · 항목 이름 · 면적 알약 · 스냅샷 원본이 세 층 조합을 읽음 · 남의 층 조합 403 · 갈라 낸 조합은 그 층에(시스템 관리자 한정 뺌) - 시험 옮김(test_m01_combo · test_m01_combo_inputs) · 새 test_m02_combo_layers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/M01_MasterData_Router.py | 65 +-- M01_MasterData/M01_MasterData_Store_Combo.py | 6 +- M01_MasterData/M01_MasterData_UI_Combo_Api.ts | 34 +- M02_MasterTemplete/M02_Item_Source.py | 7 +- .../M02_MasterTemplete_Branch.py | 36 +- .../M02_MasterTemplete_Combo_Store.py | 385 ++++++++++++++++++ .../M02_MasterTemplete_Inputs.py | 10 +- .../M02_MasterTemplete_Items.py | 14 +- .../M02_MasterTemplete_Router_Combos.py | 140 +++++++ .../M02_MasterTemplete_Router_Items.py | 8 +- main.py | 2 + resources/tester/test_m01_combo.py | 110 ++--- resources/tester/test_m01_combo_inputs.py | 57 +-- resources/tester/test_m02_combo_layers.py | 280 +++++++++++++ 14 files changed, 965 insertions(+), 189 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py create mode 100644 resources/tester/test_m02_combo_layers.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index a411a2cb..31501f7f 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -88,32 +88,6 @@ class FormulaPreview(BaseModel): 별칭: list[dict[str, Any]] = [] -class NewCombo(BaseModel): - """일위대가 조합 — 담은 단가산출 로직 목록만(수량·비율·계산은 안 둠).""" - - combo: dict[str, Any] - owner: str = combo_store.OWNERS[0] - - -class EditCombo(BaseModel): - key: str - version: str - combo: dict[str, Any] - owner: str = "" - - -class DeleteCombo(BaseModel): - key: str - version: str - - -class PreviewCombo(BaseModel): - """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 저장된 키 · `inputs` 는 로직마다.""" - - combo: dict[str, Any] | str - inputs: dict[str, dict[str, Any]] = {} - - class DraftLogic(BaseModel): text: str # 줄로 적은 식 — 줄마다 「이름 = 식」 decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}} @@ -307,45 +281,8 @@ def post_logic_draft(body: DraftLogic) -> dict: return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner) -# ── 일위대가 조합(계약 9장) ──────────────────────────────────────────── -@router.get("/combos") -def get_combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> dict: - return {"combos": _call(combo_store.combos, sub, detail, q, owner)} - - -@router.get("/combo") -def get_combo(key: str) -> dict: - return _call(combo_store.combo, key) - - -@router.get("/combo/{key}/inputs") -def get_combo_inputs(key: str) -> dict: - """조합 알약 — 담은 로직 결과단위 분모마다 물량 하나(같은 이름·단위는 합침).""" - return _call(combo_store.combo_inputs, key) - - +# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ── @router.get("/logic/combos") def get_logic_combos(key: str) -> dict: """이 단가산출 로직을 담은 조합 — 로직 쪽에서 거꾸로 봄.""" return _call(combo_store.combos_of_logic, key) - - -@router.post("/combo/new") -def post_combo_new(body: NewCombo) -> dict: - return _call(combo_store.combo_new, body.combo, body.owner) - - -@router.post("/combo/edit") -def post_combo_edit(body: EditCombo) -> dict: - return _call(combo_store.combo_edit, body.key, body.version, body.combo, body.owner) - - -@router.post("/combo/delete") -def post_combo_delete(body: DeleteCombo) -> dict: - return _call(combo_store.combo_delete, body.key, body.version) - - -@router.post("/combo/preview") -def post_combo_preview(body: PreviewCombo) -> dict: - """담은 로직마다 값을 뽑아 비목별로 갈라 합계 — 저장하지 않음.""" - return _call(combo_store.combo_preview, body.combo, body.inputs) diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 74fba826..75ecedb5 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -162,7 +162,11 @@ def combo_inputs(key: str) -> dict: `배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐. `조건` 은 자리만(설계 조건 고르기 · 범위는 알약에 안 넣음). """ - row = _find(_read()[0], key) + return combo_pills(_find(_read()[0], key)) + + +def combo_pills(row: dict) -> dict: + """조합 줄 하나(어느 층이든 · M02 조합 층 저장소가 부름)의 알약 — 규칙은 `combo_inputs`.""" whole = store.loaded()[1] pills: dict[tuple[str, str], dict] = {} for one in mcb.items(row): diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts index 9a9bd455..f21bf7c1 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Combo_Api.ts * 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA) - * 길 = `_화면_계약.md` 9장 + * 길 = M02 층 길(`/api/m02/layers/system/combos` · PLAN 27-2a) · 모양 = `_화면_계약.md` 9장 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -71,9 +71,10 @@ export interface PreviewAnswer { 멈춤: string[]; } -async function request(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { - method: body === undefined ? "GET" : "POST", +/** 조합 길 = M02 층 길 마스터(`/api/m02/layers/system/combos` · PLAN 27-2a) */ +async function request(path: string, method = "GET", body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m02/layers/system/combos${path}`, { + method, credentials: "include", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), @@ -83,26 +84,39 @@ async function request(path: string, body?: unknown): Promise { return data; } +const FILE = "일위대가조합.json"; +const q = (key: string): string => `/${encodeURIComponent(key)}`; + export const fetchCombos = (): Promise => - request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos); + request<{ 줄: ComboSummary[] }>("").then((d) => d.줄); export const fetchCombo = (key: string): Promise => - request(`/combo?key=${encodeURIComponent(key)}`); + request & { 판: string }>(q(key)).then((d) => ({ + ...d, + file: FILE, + version: d.판, + })); export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> => - request("/combo/new", { combo, owner: "현장" }); + request<{ 키: string; 판: string }>("", "POST", { combo: { ...combo, 소유: "현장" } }).then( + (d) => ({ file: FILE, version: d.판, key: d.키 }), + ); export const editCombo = ( key: string, version: string, combo: Combo, -): Promise<{ file: string; version: string }> => request("/combo/edit", { key, version, combo }); +): Promise<{ file: string; version: string }> => + request<{ 판: string }>(q(key), "PUT", { combo, 판: version }).then((d) => ({ + file: FILE, + version: d.판, + })); export const deleteCombo = (key: string, version: string): Promise => - request("/combo/delete", { key, version }); + request(`${q(key)}?판=${encodeURIComponent(version)}`, "DELETE"); /** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ export const previewCombo = ( combo: Combo | string, inputs: Record>, -): Promise => request("/combo/preview", { combo, inputs }); +): Promise => request("/preview", "POST", { combo, inputs }); diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 66b4dca9..fbda9ce7 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -21,7 +21,6 @@ from M02_MasterTemplete import M02_Template_Layers as layers TABLE_NAME = "구조물집계표" UM_FILE = "자재조합.json" -UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`) #: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출) PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json")) LINK_FILE = "입력연결.json" @@ -81,10 +80,12 @@ def version_of(content: Any) -> str: def labor_rows(ua_key: str) -> dict[str, Any] | None: - """UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`).""" + """조합 줄(마스터 UA · 회사 UC · 개인 UP) + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 + `빠진로직`).""" from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos - ua = row_of(read_json(Path(m01.FOLDER) / UA_FILE), ua_key) + ua = combos.find(ua_key) if ua is None: return None wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Branch.py b/M02_MasterTemplete/M02_MasterTemplete_Branch.py index 802c013e..8d67f340 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Branch.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Branch.py @@ -1,6 +1,7 @@ """조합 갈라 내기 — 구조물 열 하나가 가리키는 조합을 고친 내용으로 새로 만들어 그 열만 새 키를 가리키게 함(PLAN 15장 · 검토 `14_조합편집검토.md` 1장). - · 일위대가 조합(UA · `일위대가조합.json`) — 새 줄은 M01 조합 저장소가 만듦(부르기만) + · 일위대가 조합(UA · UC · UP) — 새 줄은 지금 층 조합 저장소가 만듦(`Combo_Store` · PLAN 27-2c: + 마스터 층 = UA · 회사 층 = UC · 개인 층 = UP · 고르기는 세 층 중 이 층이 쓸 수 있는 것) · 자재 조합(UM · 마스터 템플릿 `자재조합.json`) — M02 자재 조합 저장소가 만듦(표 「다음」 번호) 조합은 여러 열 · 모든 프로젝트가 같이 씀 — 제자리 고치지 않음. 담은 것(차례 · 메모까지)이 @@ -11,8 +12,7 @@ from __future__ import annotations from typing import Any, Callable -from M01_MasterData import M01_MasterData_Store as m01 -from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree @@ -20,14 +20,6 @@ from M02_MasterTemplete import M02_MasterTemplete_Tree as tree _OWNER = "현장" -def _m01(fn, *args): - """M01 막힘은 M02 막힘으로 — 상태 · 까닭 그대로.""" - try: - return fn(*args) - except m01.StoreError as e: - raise store.StoreError(e.status, e.detail) from e - - def _free_name(name: str, rows: list[dict]) -> str: taken = {str(r.get("이름") or "") for r in rows} n, out = 2, name @@ -94,7 +86,7 @@ def _point(table: dict, column: str, field: str, key: str) -> str: def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, Any]: """있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 자재조합(UM) · 조합은 그대로(고르기만).""" if field == "일위대가": - name = _m01(combo_store.combo, key)["combo"].get("이름") + name = _combo(key).get("이름") elif field == "자재조합": name = material_combo(key).get("이름") else: @@ -103,11 +95,23 @@ def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, return {"키": key, "이름": str(name or ""), "새로만듦": False, "집계표판": 판} +def _combo(key: str) -> dict[str, Any]: + """이 층이 쓸 수 있는 조합(마스터 · 같은 회사 · 내 것) — 없으면 404 · 남의 층 것은 403.""" + row = combos.find(key) + if row is None: + raise store.StoreError(404, f"없는 조합 「{key}」") + if not key.startswith(combos.heads()): + raise store.StoreError( + 403, f"이 층에서 못 쓰는 조합 「{key}」 — 마스터 · 같은 회사 · 내 것만" + ) + return row + + def branch_combo( column: str, source: str, items: list[dict], name: str, version: str = "" ) -> dict[str, Any]: - """일위대가 조합 갈라 내기 — 구분 · 상세구분 · 단위 · 출처 = 본뜬 것.""" - was = _m01(combo_store.combo, source)["combo"] + """일위대가 조합 갈라 내기 — 구분 · 상세구분 · 단위 · 출처 = 본뜬 것 · 새 줄은 지금 층에.""" + was = _combo(source) items = [ {"로직": str(x.get("로직") or ""), "메모": x.get("메모")} for x in items or [] @@ -122,10 +126,10 @@ def branch_combo( "본뜬키": source, "출처": was.get("출처"), } - return str(_m01(combo_store.combo_new, row, _OWNER)["key"]) + return str(combos.create({**row, "소유": _OWNER})["키"]) name = name.strip() or str(was.get("이름") or source) - rows = _m01(m01.read, combo_store.FILE)[0].get("줄") or [] + rows = combos.rows() return _branch( column, version, "일위대가", rows, "담은로직", ("로직", "메모"), items, source, make, name ) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py new file mode 100644 index 00000000..d328a891 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -0,0 +1,385 @@ +"""M02 일위대가 조합 층 저장소(PLAN 27-2a) — 마스터 · 회사 · 개인 세 층 한 길. + +층 자리 · 키 + 마스터 `resources/master_data/일위대가조합.json` · 키 UA(`_키대장.json`) + 쓰기는 M01 조합 저장소(`M01_MasterData_Store_Combo` 의 검사 · 대장 그대로) + 회사 `storage/{회사}/templates/일위대가조합.json` · 키 `UC{회사}-######` + 개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######` +회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀). +지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫. +나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함). +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import date +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +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 + +mcb = combo_store.mcb +FILE = combo_store.FILE +OWNERS = combo_store.OWNERS +MASTER = mcb.TID # UA +_HEAD = re.compile(r"^U([CP])(\d+)-") +DEPTH = 2 # 구분 · 상세구분 + + +def _m01(fn, *args): + """M01 막힘은 M02 막힘으로 — 상태 · 까닭 그대로.""" + try: + return fn(*args) + except m01.StoreError as e: + raise store.StoreError(e.status, e.detail) from e + + +# ── 자리 ────────────────────────────────────────────────────────────── +def heads() -> tuple[str, ...]: + """지금 층이 쓸 수 있는 키 머리 — 마지막이 제 층 것(마스터 UA · 회사 UC · 개인 UP).""" + root = store.root().resolve() + if root == Path(store.FOLDER).resolve(): + return (MASTER,) + try: + parts = root.relative_to(layers.storage_root()).parts + except ValueError: + parts = () + if len(parts) == 2: + return (MASTER, f"UC{parts[0]}-") + if len(parts) == 3: + return (MASTER, f"UC{parts[0]}-", f"UP{parts[1]}-") + raise store.StoreError(400, "일위대가 조합 층 자리를 모름") + + +def _file(head: str) -> Path | None: + """키 머리의 조합 파일 — 개인은 사용자 id 로 찾음(회사 폴더를 모름).""" + if head.startswith(MASTER): + return Path(m01.FOLDER) / FILE + got = _HEAD.match(head) + if not got: + return None + if got[1] == "C": + return layers.company_dir(got[2]) / FILE + pattern = f"*/{got[2]}/{layers.TEMPLATES_DIRNAME}/{FILE}" + return next(layers.storage_root().glob(pattern), None) + + +def _rows_at(path: Path | None) -> list[dict]: + if path is None or not path.is_file(): + return [] + return json.loads(path.read_bytes()).get("줄") or [] + + +def find(key: str) -> dict[str, Any] | None: + """어느 층 조합이든 키로 한 줄 — 없으면 None.""" + key = str(key or "") + return next((r for r in _rows_at(_file(key)) if str(r.get("키")) == key), None) + + +def names() -> dict[str, str]: + """지금 층이 쓸 수 있는 조합 키 → 이름(마스터 · 회사 · 개인).""" + out: dict[str, str] = {} + for head in heads(): + for r in _rows_at(_file(head)): + out[str(r.get("키"))] = str(r.get("이름") or "") + return out + + +def _load() -> tuple[dict[str, Any], str]: + """(지금 층 조합 표, 판) — 회사 · 개인 파일이 없으면 빈 표 · 판 빈 글.""" + if heads()[-1] == MASTER: + return _m01(combo_store._read) + path = store.root() / FILE + if not path.is_file(): + head = {"그룹": mcb.GROUP, "판": str(date.today().year), "폴더": [], "다음": 1} + return {**head, "줄": []}, "" + raw = path.read_bytes() + return json.loads(raw), store.version_of(raw) + + +def rows() -> list[dict[str, Any]]: + return _load()[0].get("줄") or [] + + +def _find(doc: dict, key: str) -> dict[str, Any]: + row = next((r for r in doc.get("줄") or [] if str(r.get("키")) == key), None) + if row is None: + raise store.StoreError(404, f"없는 조합 「{key}」") + return row + + +# ── 나무 폴더 ───────────────────────────────────────────────────────── +def path_of(row: dict) -> list[str]: + """조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리.""" + a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분")) + return [a, b] if a and b else [a] if a else [] + + +def _set_path(row: dict, path: list[str]) -> None: + row["구분"] = path[0] if path else None + row["상세구분"] = path[1] if len(path) > 1 else None + + +def folders_of(doc: dict) -> list[list[str]]: + """폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지.""" + out: list[list[str]] = [] + written = [p for p in doc.get("폴더") or [] if isinstance(p, list)] + for path in [*written, *(path_of(r) for r in doc.get("줄") or [])]: + path = [str(x) for x in path][:DEPTH] + for at in range(1, len(path) + 1): + if path[:at] not in out: + out.append(path[:at]) + return out + + +# ── 읽기 ────────────────────────────────────────────────────────────── +def _brief(whole, row: dict) -> dict[str, Any]: + problems = mcb.check_row(whole, row) + return { + **{k: row.get(k) for k in mcb.COLS if k != "담은로직"}, + "count": len(mcb.items(row)), + "blocked": bool(problems), + "reasons": problems, + } + + +def listing() -> dict[str, Any]: + """지금 층 — `{판, 폴더, 줄}` · 줄 = 담은 로직 수 · 막힘 까닭(담은 로직 목록은 빼고).""" + doc, version = _load() + whole = m01.loaded()[1] + 줄 = [_brief(whole, r) for r in doc.get("줄") or []] + return {"판": version, "폴더": folders_of(doc), "줄": 줄} + + +def one(key: str) -> dict[str, Any]: + """조합 하나 — 담은 로직에 이름 · 단위를 붙여 줌.""" + doc, version = _load() + row = _find(doc, key) + whole = m01.loaded()[1] + problems = mcb.check_row(whole, row) + brief = combo_store._brief(whole, row) + return { + "판": version, + "combo": row, + "줄": brief, + "blocked": bool(problems), + "reasons": problems, + } + + +def pills(key: str) -> dict[str, Any]: + return combo_store.combo_pills(_find(_load()[0], key)) + + +def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]: + """저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키.""" + row = _find(_load()[0], combo) if isinstance(combo, str) else combo + return _m01(combo_store.combo_preview, row, inputs) + + +# ── 쓰기(회사 · 개인) ────────────────────────────────────────────────── +def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: + """서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든).""" + if not isinstance(row, dict): + raise store.StoreError(400, "조합 줄이 묶음 아님") + name = row.get("이름") + if not isinstance(name, str) or not name.strip(): + raise store.StoreError(400, "조합 줄에 「이름」 이 없거나 글자 아님") + owner = row.get("소유") or (was or {}).get("소유") or OWNERS[0] + if owner not in OWNERS: + raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") + origin = row.get("본뜬키", (was or {}).get("본뜬키")) + if origin is not None and (origin == key or find(origin) is None): + raise store.StoreError(400, f"본뜬키 「{origin}」 — 비었거나 있는 다른 조합 키여야 함") + one = {k: row.get(k) for k in mcb.COLS} + one.update({"키": key, "이름": name.strip(), "소유": owner, "본뜬키": origin}) + one["출처"] = one["출처"] or "자체" + return one + + +def _check(doc: dict, one: dict) -> None: + """이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422.""" + name = one["이름"] + errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else [] + if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]): + errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로") + errors += mcb.check_row(m01.loaded()[1], one) + if errors: + raise store.StoreError(422, {"errors": errors}) + + +def _save(doc: dict, version: str) -> str: + path = store.root() / FILE + with store._LOCK: + have = store.version_of(path.read_bytes()) if path.is_file() else "" + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, doc) + return store.version_of(path.read_bytes()) + + +def _users(key: str) -> list[str]: + """이 층 구조물 조합에서 이 조합을 쓰는 항목 이름.""" + if heads()[-1] == MASTER: + return combo_store.combo_users(key) + return [ + tree.name_of(tree.path_of(c)) or c["id"] + for c in store._structure_columns() + if c.get("일위대가") == key + ] + + +def _refuse_used(keys: list[str]) -> None: + used = [u for key in keys for u in _users(key)] + if used: + msg = f"구조물 {len(used)} 곳이 쓰는 조합이라 못 지움 — {' · '.join(used)}" + raise store.StoreError(409, {"errors": [msg], "users": used}) + + +def create(row: Any, version: str | None = None) -> dict[str, Any]: + """새 조합 — 키 = 층 머리 + 「다음」 번호. `version` None = 지금 판(갈라 내기).""" + if heads()[-1] == MASTER: + owner = row.get("소유") if isinstance(row, dict) else None + done = _m01(combo_store.combo_new, row, owner or OWNERS[0]) + return {"키": done["key"], "판": done["version"], "combo": done["combo"]} + doc, have = _load() + number = int(doc.get("다음") or 1) + fixed = _clean(row, f"{heads()[-1]}{number:06d}") + _check(doc, fixed) + doc = {**doc, "다음": number + 1, "줄": [*doc["줄"], fixed]} + return { + "키": fixed["키"], + "판": _save(doc, have if version is None else version), + "combo": fixed, + } + + +def edit(key: str, row: Any, version: str) -> dict[str, Any]: + if heads()[-1] == MASTER: + owner = row.get("소유") if isinstance(row, dict) else "" + done = _m01(combo_store.combo_edit, key, version, row, owner or "") + return {"키": key, "판": done["version"], "combo": done["combo"]} + doc, _ = _load() + fixed = _clean(row, key, _find(doc, key)) + _check(doc, fixed) + doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]} + return {"키": key, "판": _save(doc, version), "combo": fixed} + + +def delete(key: str, version: str) -> dict[str, Any]: + """지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음).""" + if heads()[-1] == MASTER: + return {"판": _m01(combo_store.combo_delete, key, version)["version"]} + doc, _ = _load() + _find(doc, key) + _refuse_used([key]) + return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)} + + +# ── 나무 고침 ───────────────────────────────────────────────────────── +def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]: + return new + path[len(old) :] if path[: len(old)] == old else path + + +def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]: + path = [str(x) for x in path or []] + if not path or path not in folders: + raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음") + return path + + +def _fresh(name: Any) -> str: + text = str(name or "").strip() + if not text or "›" in text or len(text) > mcb.MAX_이름: + raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀") + return text + + +def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: + """나무 고침 한 번 → (새 표, 지운 조합 키). 조합 더하기 · 고치기는 `create` · `edit` 몫.""" + op, key = body.get("op"), body.get("키") + folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []] + gone: list[str] = [] + target = [str(x) for x in body.get("새경로") or []] + if op in ("move", "add-folder") and target and target not in folders: + raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」") + if op == "add-folder": + made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))] + if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders): + raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만") + if made in folders: + raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」") + folders.append(made) + elif op == "move" and key: + _set_path(_find({"줄": rows}, str(key)), target) + elif op == "delete" and key: + _find({"줄": rows}, str(key)) + gone = [str(key)] + elif op in ("rename", "move", "delete"): + old = _folder(folders, body.get("경로"), "고칠") + if op == "delete": + gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old] + folders = [f for f in folders if f[: len(old)] != old] + else: + new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]] + deepest = max(len(f) for f in folders if f[: len(old)] == old) + if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH: + raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김") + if new in folders: + raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」") + folders = [_moved(f, old, new) for f in folders] + for r in rows: + if path_of(r)[: len(old)] == old: + _set_path(r, _moved(path_of(r), old, new)) + else: + raise store.StoreError( + 422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete" + ) + rows = [r for r in rows if str(r.get("키")) not in gone] + return {**doc, "폴더": folders, "줄": rows}, gone + + +def edit_tree(body: dict) -> dict[str, Any]: + """폴더 더하기 · 이름 바꾸기 · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합) + → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409.""" + doc, have = _load() + if have != (body.get("판") or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + new, gone = _apply(doc, body) + _refuse_used(gone) + if heads()[-1] != MASTER: + _save(new, have) + return {**listing(), "지운조합": gone} + was = {str(r["키"]): r for r in doc["줄"]} + changes = [{"op": "delete", "key": k} for k in gone] + [ + {"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r + ] + version = _m01(combo_store._write, changes, have)["version"] if changes else have + _write_master_folders(new["폴더"], version) + return {**listing(), "지운조합": gone} + + +def _write_master_folders(folders: list[list[str]], version: str) -> None: + """마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길).""" + path = Path(m01.FOLDER) / FILE + with m01._LOCK: + doc, have = m01.read(FILE) + if have != version: + raise store.StoreError(409, {"stale": [FILE], "판": have}) + if doc.get("폴더") == folders: + return + head = {k: v for k, v in doc.items() if k != "줄"} + text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []}) + temporary = path.with_name(f".{FILE}.tmp") + temporary.write_text(text, encoding="utf-8", newline="\n") + os.replace(temporary, path) + m01.cache.stale([FILE]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 348278ef..91bc32d8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -21,6 +21,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store PLACES = ("면적", "재료", "선택", None) @@ -52,8 +53,7 @@ def _item(key: str) -> dict[str, Any]: def _combo_row(ua: str) -> dict[str, Any] | None: - rows = m01.read(combo_store.FILE)[0].get("줄") or [] - return next((r for r in rows if str(r.get("키")) == ua), None) + return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c) def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: @@ -188,15 +188,15 @@ def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]: def pills(key: str, place: str) -> dict[str, Any]: - """그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를 + """그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_pills` · 권한 무관 읽기) + 자리를 그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` — 이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침).""" if place not in ("면적", "재료"): raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료") ua = _item(key).get("일위대가") quantity: list[dict[str, Any]] = [] - if place == "면적" and ua and _combo_row(str(ua)): - quantity = combo_store.combo_inputs(str(ua))["물량"] + if place == "면적" and ua and (combo := _combo_row(str(ua))): + quantity = combo_store.combo_pills(combo)["물량"] _, logics, _ = combo_logics(ua) by_key = {str(lg.get("키")): lg for lg in logics} linked = [] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 5fbcc4b8..c782deeb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -13,8 +13,7 @@ from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -from M01_MasterData import M01_MasterData_Store as m01 -from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -66,23 +65,14 @@ def ensure(folder: Path) -> bool: return True -def _combo_names() -> dict[str, str]: - """일위대가 조합 키 → 이름(M01 시스템 마스터 · 층 무관) — 못 읽으면 빈 표.""" - try: - rows = m01.read(combo_store.FILE)[0].get("줄") or [] - except m01.StoreError: - return {} - return {str(r.get("키")): str(r.get("이름") or "") for r in rows} - - def items(folder: Path) -> list[dict[str, Any]]: """그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 · 분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) — 일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 · 도번 · 면적번 · 재료번 = 항목(열) 이름.""" - ua = _combo_names() with store.at(folder): + ua = combos.names() # 마스터 · 이 층이 쓸 수 있는 회사 · 개인 조합(27-2c) rows = store.read_combos().get("줄", []) um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]} out = [] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py new file mode 100644 index 00000000..f5d4c287 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -0,0 +1,140 @@ +"""M02 일위대가 조합 층 API(PLAN 27-2a) — `/api/m02/layers/{layer}/combos`(옛 `/api/m01/combo*`). + +⚠ 등록은 `main.py`(로그인만) · 층 권한은 `Router_Items._root` 한 규칙: + system 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자(마스터 조합 · 키 UA) + company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) + personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) +저장소 = `M02_MasterTemplete_Combo_Store`. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + Layer, + Session, + _call, + _owner, + _root, +) + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"]) + + +class ComboBody(BaseModel): + combo: dict[str, Any] + 판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409 + + +class ComboTreeBody(BaseModel): + """`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2.""" + + op: str + 경로: list[str] | None = None + 이름: str | None = None + 새경로: list[str] | None = None + 키: str | None = None + 판: str + + +class PreviewBody(BaseModel): + """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" + + combo: dict[str, Any] | str + inputs: dict[str, dict[str, Any]] = {} + + +@router.get("/combos/all") +def get_all(session: Session = Depends(verify_session)) -> list[dict]: + """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" + out = [] + for layer in ("system", "company", "personal"): + who = None if layer == "system" else _owner(session, layer, None) + if layer != "system" and who is None: + out.append({"layer": layer, "owner": None, "판": "", "폴더": [], "줄": []}) + continue + got = _call(_root(session, layer, who), combos.listing) + out.append({"layer": layer, "owner": who, **got}) + return out + + +@router.get("/layers/{layer}/combos") +def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + return _call(_root(session, layer, owner), combos.listing) + + +@router.get("/layers/{layer}/combos/{key}") +def get_combo( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +): + return _call(_root(session, layer, owner), combos.one, key) + + +@router.get("/layers/{layer}/combos/{key}/inputs") +def get_combo_inputs( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +): + """조합 알약 — 담은 로직 결과단위 분모마다 물량 하나(M02 면적산출이 받음).""" + return _call(_root(session, layer, owner), combos.pills, key) + + +@router.post("/layers/{layer}/combos") +def post_combo( + layer: Layer, + body: ComboBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + return _call(folder, combos.create, body.combo, body.판 or None) + + +@router.put("/layers/{layer}/combos/{key}") +def put_combo( + layer: Layer, + key: str, + body: ComboBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + return _call(folder, combos.edit, key, body.combo, body.판 or "") + + +@router.delete("/layers/{layer}/combos/{key}") +def delete_combo( + layer: Layer, + key: str, + 판: str = "", + owner: str | None = None, + session: Session = Depends(verify_session), +): + return _call(_root(session, layer, owner, write=True), combos.delete, key, 판) + + +@router.post("/layers/{layer}/combos/tree") +def post_tree( + layer: Layer, + body: ComboTreeBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + folder = _root(session, layer, owner, write=True) + return _call(folder, combos.edit_tree, body.model_dump()) + + +@router.post("/layers/{layer}/combos/preview") +def post_preview( + layer: Layer, + body: PreviewBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" + return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4399ecce..350b4941 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -6,8 +6,8 @@ personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」 옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로. 회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`). -결정(PLAN 21-9): 일위대가(UA) 갈라 내기는 새 UA 가 M01 시스템 마스터에 생기므로 층 길에서도 -SYSTEM_ADMIN 만 · 첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 409 그대로. +일위대가 갈라 내기(PLAN 27-2c): 새 조합은 그 층에(마스터 UA · 회사 UC · 개인 UP) · 층 쓰기 권한 · +첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 409 그대로. """ from __future__ import annotations @@ -283,10 +283,8 @@ def post_combo( owner: str | None = None, session: Session = Depends(verify_session), ): - """일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만.""" + """일위대가 조합 갈라 내기 — 새 조합은 이 층 조합 표에(`Combo_Store`).""" folder = _root(session, layer, owner, write=True) - if not _admin(session): - raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.") return _call( folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판 ) diff --git a/main.py b/main.py index bee37211..6813531f 100644 --- a/main.py +++ b/main.py @@ -62,6 +62,7 @@ from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router +from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router @@ -641,6 +642,7 @@ app.include_router(m01_master_data_router, dependencies=system_admin_only) app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) app.include_router(m02_items_router, dependencies=protected) +app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) # 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서. app.include_router(m02_project_items_router, dependencies=protected) diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 36349715..f309acf7 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -1,4 +1,6 @@ -"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계. +"""일위대가 조합(PLAN 4-2 · 27-2a) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계. + +길 = M02 층 길 마스터(`/api/m02/layers/system/combos` · 옛 `/api/m01/combo*` 를 옮김). 계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`. 못박는 것 @@ -20,8 +22,10 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) @@ -32,6 +36,8 @@ STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력) LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비) GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력) SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋 +SYS = "/api/m02/layers/system/combos" # 마스터 층 조합 길 +ADMIN = {"user_id": 1, "company_id": 1, "role": "SYSTEM_ADMIN", "is_master": False} @pytest.fixture @@ -46,6 +52,8 @@ def client(tmp_path: Path, monkeypatch) -> TestClient: monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) + app.include_router(combos_router) + app.dependency_overrides[verify_session] = lambda: ADMIN return TestClient(app) @@ -61,6 +69,28 @@ def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: return res.json() +def _new(client: TestClient, body: dict, want: int = 200) -> dict: + """옛 `/combo/new` 본문 `{combo, owner?}` → 층 길(소유는 줄 칸) · 옛 이름 `key` 도 붙임.""" + combo = {**body["combo"], **({"소유": body["owner"]} if "owner" in body else {})} + got = _post(client, SYS, {"combo": combo}, want) + return {"key": got.get("키"), **got} + + +def _one(client: TestClient, key: str) -> dict: + got = _get(client, f"{SYS}/{key}") + return {"version": got["판"], **got} + + +def _list(client: TestClient) -> list[dict]: + return _get(client, SYS)["줄"] + + +def _edit(client: TestClient, key: str, version: str, row: dict, want: int = 200) -> dict: + res = client.put(f"{SYS}/{key}", json={"combo": row, "판": version}) + assert res.status_code == want, res.text + return res.json() + + def _inputs(key: str) -> dict: """그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`).""" row = store.cm.master(REAL).logic(key) @@ -81,7 +111,7 @@ def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: st "단위": "㎡", "담은로직": [{"로직": k, "메모": None} for k in keys], } - return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner}) + return _new(client, {"combo": body, "owner": owner}) # ── 정본 파일 ───────────────────────────────────────────────────────── @@ -119,7 +149,7 @@ def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_ assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지 assert "원문번호" not in made["combo"] - got = _get(client, "/api/m01/combo", key=key) + got = _one(client, key) assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로 assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)" assert got["줄"][0]["결과단위"] == "원/㎡" @@ -128,13 +158,16 @@ def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_ assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"} -def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None: +def test_목록은_소유_담은_수_폴더를_준다(client: TestClient) -> None: + """거르기(구분 · 소유 · 찾기)는 화면 몫 — 층 목록은 통째 · 폴더 = [구분, 상세구분].""" _make(client, 이름="돌쌓기 한 벌") _make(client, keys=(GATHER,), 이름="채집만", owner="공용") - assert len(_get(client, "/api/m01/combos")["combos"]) == 2 - assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"] - only = _get(client, "/api/m01/combos", owner="공용")["combos"] + got = _get(client, SYS) + assert len(got["줄"]) == 2 and got["판"] + only = [c for c in got["줄"] if c["소유"] == "공용"] assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1 + assert "담은로직" not in only[0] + assert got["폴더"] == [["자체"], ["자체", "13장 구조물"]] def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None: @@ -149,38 +182,30 @@ def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> # ── 고치기 · 지우기 ─────────────────────────────────────────────────── def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None: key = _make(client)["key"] - got = _get(client, "/api/m01/combo", key=key) + got = _one(client, key) row = got["combo"] row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}] - done = _post( - client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row} - ) + done = _edit(client, key, got["version"], row) assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER] assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김" assert done["combo"]["키"] == key # 키는 안 바뀜 - now = _get(client, "/api/m01/combo", key=key) - _post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]}) - assert _get(client, "/api/m01/combos")["combos"] == [] + now = _one(client, key) + assert client.delete(f"{SYS}/{key}", params={"판": now["version"]}).status_code == 200 + assert _list(client) == [] def test_낡은_판본으로_고치면_409(client: TestClient) -> None: key = _make(client)["key"] - row = _get(client, "/api/m01/combo", key=key)["combo"] - got = _post( - client, - "/api/m01/combo/edit", - {"key": key, "version": "낡은판본", "combo": row}, - want=409, - ) + row = _one(client, key)["combo"] + got = _edit(client, key, "낡은판본", row, want=409) assert got["detail"] == {"stale": [mcb.FILE]} # ── 검사 ────────────────────────────────────────────────────────────── def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None: - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}}, want=422, ) @@ -188,9 +213,8 @@ def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None: def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None: - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}}, want=422, ) @@ -198,15 +222,14 @@ def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> Non def test_빈_조합은_막는다(client: TestClient) -> None: - got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422) + got = _new(client, {"combo": {"이름": "빈 것", "담은로직": []}}, 422) assert any("빈 조합" in x for x in got["detail"]["errors"]), got def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None: key = _make(client)["key"] - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}}, want=422, ) @@ -214,10 +237,9 @@ def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> Non def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None: - _post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400) - _post( + _new(client, {"combo": {"담은로직": [{"로직": STACK}]}}, want=400) + _new( client, - "/api/m01/combo/new", {"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"}, want=400, ) @@ -225,18 +247,16 @@ def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> def test_이름이_글자_아니면_400(client: TestClient) -> None: """재현: 이름 ["x"] 가 200 으로 저장됨(일감 24 어긋난 것 2) — 이제 400.""" - _post( + _new( client, - "/api/m01/combo/new", {"combo": {"이름": ["x"], "담은로직": [{"로직": STACK}]}}, want=400, ) def test_이름이_너무_길면_막는다(client: TestClient) -> None: - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "가" * 61, "담은로직": [{"로직": STACK}]}}, want=422, ) @@ -245,9 +265,8 @@ def test_이름이_너무_길면_막는다(client: TestClient) -> None: def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None: _make(client, keys=(STACK,), 이름="돌쌓기 한 벌") - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "돌쌓기 한 벌", "담은로직": [{"로직": LAYER}]}}, want=422, ) @@ -256,9 +275,8 @@ def test_같은_이름_두_번_담으면_막는다(client: TestClient) -> None: def test_메모가_글자_아니면_막는다(client: TestClient) -> None: """재현: 메모 {"수량": 3} 이 200 으로 저장됨(일감 24 어긋난 것 1) — 이제 422.""" - got = _post( + got = _new( client, - "/api/m01/combo/new", {"combo": {"이름": "메모 흠", "담은로직": [{"로직": STACK, "메모": {"수량": 3}}]}}, want=422, ) @@ -274,7 +292,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 for name in ("노무비", "재료비", "경비", "계") } - got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each}) + got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): @@ -287,17 +305,17 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: - before = _get(client, "/api/m01/combos")["combos"] + before = _list(client) row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} - got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 - assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김 + assert _list(client) == before # 아무것도 안 생김 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 - got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only}) + got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 @@ -308,8 +326,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] - _post( - client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}} - ) + _post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"] diff --git a/resources/tester/test_m01_combo_inputs.py b/resources/tester/test_m01_combo_inputs.py index d4f174f9..4b0bdedd 100644 --- a/resources/tester/test_m01_combo_inputs.py +++ b/resources/tester/test_m01_combo_inputs.py @@ -1,4 +1,4 @@ -"""M01 조합 알약(`GET /api/m01/combo/{키}/inputs`) — 담은 로직 결과단위 분모가 물량 하나. +"""조합 알약(`GET /api/m02/layers/system/combos/{키}/inputs`) — 결과단위 분모가 물량 하나. 못박는 것 - 물량 = 로직 결과단위 분모(원/㎥ → ㎥) · 설계 조건(고르기 · 범위)은 안 넣음(`조건` 은 빈 자리). @@ -19,20 +19,29 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from M01_MasterData import M01_MasterData_Router as router_module +from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router + +SYS = "/api/m02/layers/system/combos" +ADMIN = {"user_id": 1, "company_id": 1, "role": "SYSTEM_ADMIN", "is_master": False} + + +def _app() -> TestClient: + app = FastAPI() + app.include_router(combos_router) + app.dependency_overrides[verify_session] = lambda: ADMIN + return TestClient(app) @pytest.fixture(scope="module") def client() -> TestClient: - app = FastAPI() - app.include_router(router_module.router) - return TestClient(app) + return _app() def _pills(client: TestClient, key: str) -> dict: - res = client.get(f"/api/m01/combo/{key}/inputs") + res = client.get(f"{SYS}/{key}/inputs") assert res.status_code == 200, res.text return res.json() @@ -61,7 +70,7 @@ def test_준공표지석_물량_여섯(client) -> None: def test_없는_조합은_404(client) -> None: - assert client.get("/api/m01/combo/UA999999/inputs").status_code == 404 + assert client.get(f"{SYS}/UA999999/inputs").status_code == 404 @pytest.fixture @@ -74,16 +83,14 @@ def own(tmp_path: Path, monkeypatch) -> TestClient: sheet = tmp_path / "집계표.json" sheet.write_text(json.dumps({"열": []}), encoding="utf-8") monkeypatch.setattr(combo_store, "USERS", (sheet,)) - app = FastAPI() - app.include_router(router_module.router) - return TestClient(app) + return _app() def _new(client: TestClient, name: str, codes: list[str]) -> str: combo = {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes]} - res = client.post("/api/m01/combo/new", json={"combo": combo}) + res = client.post(SYS, json={"combo": combo}) assert res.status_code == 200, res.text - return res.json()["key"] + return res.json()["키"] def test_알약_키는_로직을_빼고_더하고_차례를_바꿔도_같다(own) -> None: @@ -109,35 +116,33 @@ def _use(sheet: Path, key: str) -> None: def test_쓰는_열이_있으면_조합을_못_지우고_구조물_이름을_알린다(own) -> None: key = _new(own, "쓰는 조합", ["GF000222"]) _use(combo_store.USERS[0], key) - version = own.get(f"/api/m01/combo?key={key}").json()["version"] - res = own.post("/api/m01/combo/delete", json={"key": key, "version": version}) + version = own.get(f"{SYS}/{key}").json()["판"] + res = own.delete(f"{SYS}/{key}", params={"판": version}) assert res.status_code == 409 detail = res.json()["detail"] assert detail["users"] == ["돌기슭막이 찰쌓기"] assert "돌기슭막이 찰쌓기" in detail["errors"][0] - assert own.get(f"/api/m01/combo?key={key}").status_code == 200 # 그대로 남음 + assert own.get(f"{SYS}/{key}").status_code == 200 # 그대로 남음 def test_안_쓰는_조합은_지워진다(own) -> None: key = _new(own, "안 쓰는 조합", ["GF000222"]) _use(combo_store.USERS[0], "UA000009") # 다른 조합을 씀 - version = own.get(f"/api/m01/combo?key={key}").json()["version"] - assert ( - own.post("/api/m01/combo/delete", json={"key": key, "version": version}).status_code == 200 - ) - assert own.get(f"/api/m01/combo?key={key}").status_code == 404 + version = own.get(f"{SYS}/{key}").json()["판"] + assert own.delete(f"{SYS}/{key}", params={"판": version}).status_code == 200 + assert own.get(f"{SYS}/{key}").status_code == 404 def test_본뜬키는_목록에_나오고_없는_키_자기_키는_400(own) -> None: base = _new(own, "원본", ["GF000222"]) combo = {"이름": "본뜬 것", "본뜬키": base, "담은로직": [{"로직": "GF000222", "메모": None}]} - got = own.post("/api/m01/combo/new", json={"combo": combo}).json() + got = own.post(SYS, json={"combo": combo}).json() assert got["combo"]["본뜬키"] == base - rows = {r["키"]: r for r in own.get("/api/m01/combos").json()["combos"]} - assert rows[got["key"]]["본뜬키"] == base and rows[base]["본뜬키"] is None + rows = {r["키"]: r for r in own.get(SYS).json()["줄"]} + assert rows[got["키"]]["본뜬키"] == base and rows[base]["본뜬키"] is None bad = {**combo, "이름": "없는 원본", "본뜬키": "UA999999"} - assert own.post("/api/m01/combo/new", json={"combo": bad}).status_code == 400 - version = own.get(f"/api/m01/combo?key={base}").json()["version"] + assert own.post(SYS, json={"combo": bad}).status_code == 400 + version = own.get(f"{SYS}/{base}").json()["판"] me = {"이름": "원본", "본뜬키": base, "담은로직": combo["담은로직"]} - res = own.post("/api/m01/combo/edit", json={"key": base, "version": version, "combo": me}) + res = own.put(f"{SYS}/{base}", json={"combo": me, "판": version}) assert res.status_code == 400 diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py new file mode 100644 index 00000000..6d5219e0 --- /dev/null +++ b/resources/tester/test_m02_combo_layers.py @@ -0,0 +1,280 @@ +"""일위대가 조합 층(PLAN 27-2a · 27-2c) — 세 층 한 길 `/api/m02/layers/{layer}/combos`. + +못박는 것 + - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. + - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). + - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. + - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). + - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. + - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. + - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. +정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 +URL = "/api/m02/layers/{}/combos" + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.include_router(combos_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "storage": storage} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _combo(name: str, codes=(A,), **more) -> dict: + return {"이름": name, "담은로직": [{"로직": c, "메모": None} for c in codes], **more} + + +def _new(client: TestClient, layer: str, combo: dict, want: int = 200, **params) -> dict: + res = client.post(URL.format(layer), json={"combo": combo}, params=params) + assert res.status_code == want, res.text + return res.json() + + +def _list(client: TestClient, layer: str, **params) -> dict: + res = client.get(URL.format(layer), params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict: + body.setdefault("판", _list(client, layer)["판"]) + res = client.post(URL.format(layer) + "/tree", json=body) + assert res.status_code == want, res.text + return res.json() + + +# ── 키 · 자리 ───────────────────────────────────────────────────────── +def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world): + boss = _as(world, "ADMIN") + one = _new(boss, "company", _combo("회사 하나")) + assert one["키"] == "UC7-000001" and one["combo"]["소유"] == "현장" + assert tuple(one["combo"]) == combo_store.mcb.COLS # 열 한 벌 · 마스터와 같음 + file = world["storage"] / "7" / "templates" / combos.FILE + assert json.loads(file.read_text(encoding="utf-8"))["다음"] == 2 + me = _as(world) + mine = _new(me, "personal", _combo("내 것", (A, B))) + assert mine["키"] == "UP42-000001" + assert (world["storage"] / "7" / "42" / "templates" / combos.FILE).is_file() + gone = me.delete(f"{URL.format('personal')}/{mine['키']}", params={"판": mine["판"]}) + assert gone.status_code == 200 + assert _new(me, "personal", _combo("다음 것"))["키"] == "UP42-000002" + got = me.get(f"{URL.format('personal')}/UP42-000002").json() + assert [x["로직"] for x in got["줄"]] == [A] and got["blocked"] is False + + +def test_권한_층_규칙(world): + user = _as(world) + _new(user, "system", _combo("마스터"), 403) + _new(user, "company", _combo("회사"), 403) + _new(user, "personal", _combo("남의 것"), 403, owner="7/43") + assert user.get(URL.format("system")).status_code == 200 # 마스터 읽기 = 누구나 + assert user.get(URL.format("company")).status_code == 200 # 같은 회사 읽기 + assert user.get(URL.format("personal"), params={"owner": "8/9"}).status_code == 403 + _as(world, "ADMIN") + _new(user, "company", _combo("회사")) + world["session"]["company_id"] = None + assert user.post(URL.format("personal"), json={"combo": _combo("x")}).status_code == 409 + + +def test_검사_빈_조합_이름_겹침_본뜬키_판(world): + me = _as(world) + got = _new(me, "personal", _combo("빈 것", ()), 422) + assert any("빈 조합" in x for x in got["detail"]["errors"]) + first = _new(me, "personal", _combo("원본")) + assert "이미 있음" in _new(me, "personal", _combo("원본"), 422)["detail"]["errors"][0] + _new(me, "personal", _combo("엉터리 계보", 본뜬키="UP42-999999"), 400) + master_key = m01.read(combo_store.FILE)[0]["줄"][0]["키"] + branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key)) + assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보 + url = f"{URL.format('personal')}/{first['키']}" + assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409 + now = _list(me, "personal")["판"] + res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now}) + assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B + + +# ── 나무 ───────────────────────────────────────────────────────────── +def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): + me = _as(world) + key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"] + _tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기") + _tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기") + _tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째") + _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기") + _tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"]) + got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"]) + row = next(r for r in got["줄"] if r["키"] == key) + assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기") + got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축") + assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]] + assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축" + _tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"]) + got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[]) + assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음 + row = next(r for r in got["줄"] if r["키"] == key) + assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) + _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") + got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) + assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] + + +def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world): + me = _as(world) + key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"] + tree = me.post( + "/api/m02/layers/personal/tree", + json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)}, + ).json() + column = tree["알림"]["열"] + use = me.post( + f"/api/m02/layers/personal/structures/{column}/use-combo", + json={"종류": "일위대가", "키": key}, + ) + assert use.status_code == 200, use.text + got = _tree(me, "personal", 409, op="delete", 경로=["석축"]) + assert got["detail"]["users"] == ["옹벽 찰쌓기"] + res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]}) + assert res.status_code == 409 + assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음 + + +def _판(client: TestClient) -> str: + return client.get("/api/m02/layers/personal/tree").json()["판"] + + +@pytest.fixture() +def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + folder = tmp_path / "master" + folder.mkdir() + for path in m01.FOLDER.glob("*.json"): + if not path.name.startswith("_") or path.name == m01.mk.BOOK.name: + shutil.copy(path, folder / path.name) + monkeypatch.setattr(m01, "FOLDER", folder) + return folder + + +def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): + admin = _as(world, "SYSTEM_ADMIN") + before = _list(admin, "system") + assert ["실무 일위대가"] in before["폴더"] + _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") + got = _tree(admin, "system", op="rename", 경로=["실무 일위대가"], 이름="실무") + assert ["실무"] in got["폴더"] and ["빈 폴더"] in got["폴더"] + assert ["실무 일위대가"] not in got["폴더"] + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert ["빈 폴더"] in doc["폴더"] + assert sum(r["구분"] == "실무" for r in doc["줄"]) == len( + [r for r in before["줄"] if r["구분"] == "실무 일위대가"] + ) + assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 + _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") + + +# ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── +def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): + boss = _as(world, "ADMIN") + uc = _new(boss, "company", _combo("회사 조합", (A, B)))["키"] + me = _as(world) + up = _new(me, "personal", _combo("내 조합"))["키"] + all_ = me.get("/api/m02/combos/all").json() + assert [g["layer"] for g in all_] == ["system", "company", "personal"] + assert [r["키"] for r in all_[1]["줄"]] == [uc] and [r["키"] for r in all_[2]["줄"]] == [up] + assert combos.find(uc)["이름"] == "회사 조합" and combos.find(up)["이름"] == "내 조합" + assert combos.find("UP42-999999") is None and combos.find("엉터리") is None + + column = me.post( + "/api/m02/layers/personal/tree", + json={"op": "add-item", "경로": [], "이름": "돌붙임", "단위": "m", "판": _판(me)}, + ).json()["알림"]["열"] + base = f"/api/m02/layers/personal/structures/{column}" + for key in (uc, up): + res = me.post(f"{base}/use-combo", json={"종류": "일위대가", "키": key}) + assert res.status_code == 200 and res.json()["키"] == key + lines = [{"로직": A}, {"로직": B, "메모": "장비"}] # 이 층에 같은 내용 없음 = 새로 + made = me.post(f"{base}/combo", json={"본뜬키": uc, "담은로직": lines, "이름": "회사 것 고침"}) + assert made.status_code == 200, made.text + new = made.json()["키"] + assert new == "UP42-000002" and combos.find(new)["본뜬키"] == uc + folder = layers.personal_dir(7, 42) + item = next(i for i in items_store.items(folder) if i["열"] == column) + assert item["일위대가"] == new and item["names"]["일위대가"] == "회사 것 고침" + assert source.labor_rows(new)["줄"]["키"] == new # 프로젝트 스냅샷 원본도 층 조합 + + _as(world, "ADMIN") + company_col = boss.post( + "/api/m02/layers/company/tree", + json={ + "op": "add-item", + "경로": [], + "이름": "회사 항목", + "단위": "m", + "판": boss.get("/api/m02/layers/company/tree").json()["판"], + }, + ).json()["알림"]["열"] + res = boss.post( + f"/api/m02/layers/company/structures/{company_col}/use-combo", + json={"종류": "일위대가", "키": up}, + ) + assert res.status_code == 403 # 회사 항목은 개인 조합을 못 씀 + + +def test_면적_알약이_개인_조합을_읽음(world): + me = _as(world) + up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"] + got = me.get(f"{URL.format('personal')}/{up}/inputs").json() + assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"] + tree = me.post( + "/api/m02/layers/personal/tree", + json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)}, + ).json() + column = tree["알림"]["열"] + me.post( + f"/api/m02/layers/personal/structures/{column}/use-combo", + json={"종류": "일위대가", "키": up}, + ) + key = next(i["key"] for i in tree["items"] if i["열"] == column) + pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"}) + assert pills.status_code == 200, pills.text + assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"] From ae51bed23c0fa11a27c5e4c2d178269658b4e3a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:17:57 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(M02):=20=EB=8B=B4=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9D=BD=EA=B8=B0=20=EA=B8=B8=20/api/m02/logic-lib?= =?UTF-8?q?=20=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=ED=95=98=EB=82=98=20=C2=B7=20=EA=B2=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=9E=85=EB=A0=A5(PLAN=2027-2b=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회사 · 개인 조합 편집이 M01 관리자 전용 길 없이 로직을 고르고 미리 보기 - 시험 test_m02_combo_layers 한 벌 더함(9) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- .../M02_MasterTemplete_Router_Combos.py | 34 ++++++++++++++++++- resources/tester/test_m02_combo_layers.py | 10 ++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index f5d4c287..fb4ea592 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -5,16 +5,19 @@ company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) 저장소 = `M02_MasterTemplete_Combo_Store`. +담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 +`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. """ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( Layer, @@ -138,3 +141,32 @@ def post_preview( ): """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) + + +# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── +def _read(fn, *args): + try: + return fn(*args) + except m01.StoreError as e: + raise HTTPException(status_code=e.status, detail=e.detail) from e + + +@router.get("/logic-lib/logics", dependencies=[Depends(verify_session)]) +def get_logics() -> dict: + return {"logics": _read(m01.logics, "", "", "", None)} + + +@router.get("/logic-lib/subs", dependencies=[Depends(verify_session)]) +def get_logic_subs() -> dict: + return _read(m01.subs, "", "로직") + + +@router.get("/logic-lib/logic", dependencies=[Depends(verify_session)]) +def get_logic(key: str) -> dict: + return _read(m01.logic, key) + + +@router.get("/logic-lib/auto", dependencies=[Depends(verify_session)]) +def get_logic_auto(key: str) -> dict: + """견본 입력 — 미리 보기용 자동값.""" + return _read(m01.auto, key) diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 6d5219e0..e16f7a39 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -278,3 +278,13 @@ def test_면적_알약이_개인_조합을_읽음(world): pills = me.get(f"/api/m02/layers/personal/items/{key}/pills", params={"place": "면적"}) assert pills.status_code == 200, pills.text assert [p["키"] for p in pills.json()["물량"]] == ["돌쌓기 찰쌓기|㎡"] + + +def test_담을_로직_읽기는_로그인이면_됨(world): + user = _as(world) + logics = user.get("/api/m02/logic-lib/logics").json()["logics"] + assert A in {x["키"] for x in logics} + assert user.get("/api/m02/logic-lib/logic", params={"key": A}).json()["logic"]["키"] == A + assert user.get("/api/m02/logic-lib/logic", params={"key": "GF999999"}).status_code == 404 + assert user.get("/api/m02/logic-lib/subs").json()["subs"] + assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 From 6c0210ebc5cd426c4ea06bb3072c316dd51db104 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:20:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(M02):=20=EC=A1=B0=ED=95=A9=20=ED=82=A4?= =?UTF-8?q?=20=ED=95=98=EB=82=98=20=EC=9D=BD=EA=B8=B0=20/api/m02/combos/ke?= =?UTF-8?q?y/{=ED=82=A4}=20=E2=80=94=20=EC=96=B4=EB=8A=90=20=EC=B8=B5?= =?UTF-8?q?=EC=9D=B4=EB=93=A0=20=C2=B7=20=EA=B7=B8=20=EC=B8=B5=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EA=B6=8C=ED=95=9C(PLAN=2027-2c=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py | 11 +++++++++++ .../M02_MasterTemplete_Router_Combos.py | 10 ++++++++++ resources/tester/test_m02_combo_layers.py | 7 +++++++ 3 files changed, 28 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index d328a891..acd75edf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -84,6 +84,17 @@ def find(key: str) -> dict[str, Any] | None: return next((r for r in _rows_at(_file(key)) if str(r.get("키")) == key), None) +def where(key: str) -> tuple[str, str | None] | None: + """키의 층 · 주인 — ("system", None) · ("company", "7") · ("personal", "7/42") · 모르면 None.""" + key = str(key or "") + got, path = _HEAD.match(key), _file(key) + if key.startswith(MASTER) or path is None or not got: + return ("system", None) if key.startswith(MASTER) else None + if got[1] == "C": + return ("company", got[2]) + return ("personal", f"{path.parent.parent.parent.name}/{got[2]}") # {회사}/{사용자}/templates + + def names() -> dict[str, str]: """지금 층이 쓸 수 있는 조합 키 → 이름(마스터 · 회사 · 개인).""" out: dict[str, str] = {} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index fb4ea592..078e58ba 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -67,6 +67,16 @@ def get_all(session: Session = Depends(verify_session)) -> list[dict]: return out +@router.get("/combos/key/{key}") +def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict: + """키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`.""" + got = combos.where(key) + if got is None: + raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」") + layer, owner = got + return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)} + + @router.get("/layers/{layer}/combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), combos.listing) diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index e16f7a39..6a71f801 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -223,6 +223,13 @@ def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(wo assert [r["키"] for r in all_[1]["줄"]] == [uc] and [r["키"] for r in all_[2]["줄"]] == [up] assert combos.find(uc)["이름"] == "회사 조합" and combos.find(up)["이름"] == "내 조합" assert combos.find("UP42-999999") is None and combos.find("엉터리") is None + by_key = me.get(f"/api/m02/combos/key/{up}").json() + assert (by_key["layer"], by_key["owner"], by_key["combo"]["키"]) == ("personal", "7/42", up) + assert me.get(f"/api/m02/combos/key/{uc}").json()["owner"] == "7" + assert me.get("/api/m02/combos/key/UP42-999999").status_code == 404 + world["session"]["company_id"] = 8 # 다른 회사 사람 — 남의 회사 조합은 못 봄 + assert me.get(f"/api/m02/combos/key/{uc}").status_code == 403 + world["session"]["company_id"] = 7 column = me.post( "/api/m02/layers/personal/tree", From f8302149a91c974a438e2d3771e0a5bb2be1be35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:22:13 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=20M01=20=E2=86=92=20M02=20=EC=98=AE=EA=B9=80?= =?UTF-8?q?=20=E2=80=94=20Labor=5FApi(=EC=B8=B5=20=C2=B7=20=ED=82=A4=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20=EC=84=B8=20=EC=B8=B5=20=C2=B7=20?= =?UTF-8?q?=EB=82=98=EB=AC=B4=20=C2=B7=20=EB=8B=B4=EC=9D=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81)=20=C2=B7=20Pick=20=C2=B7=20Preview=20=C2=B7=20Text?= =?UTF-8?q?=20=C2=B7=20css(PLAN=2027-2b=20=EC=95=9E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조합 Api 가 층 길(/api/m02/layers/{layer}/combos) · 키 하나는 /api/m02/combos/key 로 — 회사 · 개인 조합도 읽음 - 로직 고르기 · 미리 보기는 /api/m02/logic-lib(로그인) 로 읽음 - M01 조합 화면 · M02 구조물 조합 쪽 부름을 새 자리로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/M01_MasterData_UI_Combo.ts | 10 +- M01_MasterData/M01_MasterData_UI_Combo_Api.ts | 122 ----------- M01_MasterData/M01_MasterData_UI_Side.ts | 2 +- .../M02_MasterTemplete_Combo.ts | 4 +- .../M02_MasterTemplete_ComboPanel.ts | 2 +- .../M02_MasterTemplete_Labor.css | 0 .../M02_MasterTemplete_Labor_Api.ts | 194 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Pick.ts | 17 +- .../M02_MasterTemplete_Labor_Preview.ts | 17 +- .../M02_MasterTemplete_Labor_Text.ts | 2 +- .../M02_MasterTemplete_Structure.ts | 2 +- 11 files changed, 224 insertions(+), 148 deletions(-) delete mode 100644 M01_MasterData/M01_MasterData_UI_Combo_Api.ts rename M01_MasterData/M01_MasterData_UI_Combo.css => M02_MasterTemplete/M02_MasterTemplete_Labor.css (100%) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts rename M01_MasterData/M01_MasterData_UI_Combo_Pick.ts => M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts (90%) rename M01_MasterData/M01_MasterData_UI_Combo_Preview.ts => M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts (92%) rename M01_MasterData/M01_MasterData_UI_Combo_Text.ts => M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts (99%) diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M01_MasterData/M01_MasterData_UI_Combo.ts index 777d569b..2a41d4fc 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo.ts @@ -21,14 +21,14 @@ import { newCombo, type Combo, type ComboSummary, -} from "./M01_MasterData_UI_Combo_Api"; -import { openLogicPicker } from "./M01_MasterData_UI_Combo_Pick"; -import { buildPreview } from "./M01_MasterData_UI_Combo_Preview"; -import { tc } from "./M01_MasterData_UI_Combo_Text"; +} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; +import { buildPreview } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Preview"; +import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import "./M01_MasterData_UI_Logic_Style.css"; import "./M01_MasterData_UI_LogicLab_Style.css"; -import "./M01_MasterData_UI_Combo.css"; +import "../M02_MasterTemplete/M02_MasterTemplete_Labor.css"; const FILTER_ID = "조합|"; const blankCombo = (): Combo => ({ diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts deleted file mode 100644 index f21bf7c1..00000000 --- a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Combo_Api.ts - * 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA) - * 길 = M02 층 길(`/api/m02/layers/system/combos` · PLAN 27-2a) · 모양 = `_화면_계약.md` 9장 - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { ApiError } from "./M01_MasterData_UI_Logic_Api"; - -/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ -export interface ComboLogic { - 로직: string; - 메모: string | null; -} -/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */ -export interface Combo { - 키: string; - 이름: string; - 구분: string | null; - 상세구분: string | null; - 단위: string | null; - 담은로직: ComboLogic[]; - 출처: string | null; - 소유: string | null; - /** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */ - 본뜬키: string | null; - 비고: string | null; -} -export interface ComboSummary extends Combo { - count: number; - blocked: boolean; - reasons: string[]; -} -/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */ -export interface ComboLine { - 차례: number; - 로직: string; - 메모: string | null; - ok: boolean; - 까닭?: string; - 이름?: string; - 구분?: string; - 상세구분?: string; - 결과단위?: string; -} -export interface ComboOne { - file: string; - version: string; - combo: Combo; - 줄: ComboLine[]; -} -export interface PreviewLine { - 차례: number; - 로직: string; - 이름: string; - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} -export interface PreviewAnswer { - 줄: PreviewLine[]; - 노무비: number; - 재료비: number; - 경비: number; - 계: number; - 멈춤: string[]; -} - -/** 조합 길 = M02 층 길 마스터(`/api/m02/layers/system/combos` · PLAN 27-2a) */ -async function request(path: string, method = "GET", body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m02/layers/system/combos${path}`, { - method, - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); - return data; -} - -const FILE = "일위대가조합.json"; -const q = (key: string): string => `/${encodeURIComponent(key)}`; - -export const fetchCombos = (): Promise => - request<{ 줄: ComboSummary[] }>("").then((d) => d.줄); - -export const fetchCombo = (key: string): Promise => - request & { 판: string }>(q(key)).then((d) => ({ - ...d, - file: FILE, - version: d.판, - })); - -export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> => - request<{ 키: string; 판: string }>("", "POST", { combo: { ...combo, 소유: "현장" } }).then( - (d) => ({ file: FILE, version: d.판, key: d.키 }), - ); - -export const editCombo = ( - key: string, - version: string, - combo: Combo, -): Promise<{ file: string; version: string }> => - request<{ 판: string }>(q(key), "PUT", { combo, 판: version }).then((d) => ({ - file: FILE, - version: d.판, - })); - -export const deleteCombo = (key: string, version: string): Promise => - request(`${q(key)}?판=${encodeURIComponent(version)}`, "DELETE"); - -/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ -export const previewCombo = ( - combo: Combo | string, - inputs: Record>, -): Promise => request("/preview", "POST", { combo, inputs }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 3b132e30..39288ea1 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -10,7 +10,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { tc } from "./M01_MasterData_UI_Combo_Text"; +import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index ca2112ad..39262ef5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -14,8 +14,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { fetchCombo, type ComboLine } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; -import { openLogicPicker } from "../M01_MasterData/M01_MasterData_UI_Combo_Pick"; +import { fetchCombo, type ComboLine } from "./M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_Combo.css"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts index 6311a601..4d2d418b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -19,7 +19,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; -import { fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { fetchCombos } from "./M02_MasterTemplete_Labor_Api"; import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_ComboPanel.css"; diff --git a/M01_MasterData/M01_MasterData_UI_Combo.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css similarity index 100% rename from M01_MasterData/M01_MasterData_UI_Combo.css rename to M02_MasterTemplete/M02_MasterTemplete_Labor.css diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts new file mode 100644 index 00000000..93da186e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -0,0 +1,194 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Api.ts + * 일위대가 조합 서버 길 — 층(마스터 UA · 회사 UC · 개인 UP) 조합 표 `일위대가조합.json` + * 길 = `/api/m02/layers/{layer}/combos`(PLAN 27-2a) · 키 하나 = `/api/m02/combos/key/{키}` · + * 세 층 = `/api/m02/combos/all` · 담을 로직 읽기 = `/api/m02/logic-lib/…` · 모양 = `_화면_계약.md` 9장 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { + ApiError, + type LogicOne, + type LogicSummary, + type SubBrief, +} from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; + +/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ +export interface ComboLogic { + 로직: string; + 메모: string | null; +} +/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */ +export interface Combo { + 키: string; + 이름: string; + 구분: string | null; + 상세구분: string | null; + 단위: string | null; + 담은로직: ComboLogic[]; + 출처: string | null; + 소유: string | null; + /** 이 조합을 본떠 만든 원본 키 — 비면 원본(M02 가 갈라 낸 조합에 채워짐) */ + 본뜬키: string | null; + 비고: string | null; +} +export interface ComboSummary extends Combo { + count: number; + blocked: boolean; + reasons: string[]; +} +/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */ +export interface ComboLine { + 차례: number; + 로직: string; + 메모: string | null; + ok: boolean; + 까닭?: string; + 이름?: string; + 구분?: string; + 상세구분?: string; + 결과단위?: string; +} +export interface ComboOne { + file: string; + version: string; + combo: Combo; + 줄: ComboLine[]; +} +export interface PreviewLine { + 차례: number; + 로직: string; + 이름: string; + ok: boolean; + 까닭?: string; + 갈래?: string; + 결과?: number; + 노무비?: number; + 재료비?: number; + 경비?: number; + 계?: number; +} +export interface PreviewAnswer { + 줄: PreviewLine[]; + 노무비: number; + 재료비: number; + 경비: number; + 계: number; + 멈춤: string[]; +} + +/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */ +export interface LaborScope { + layer: Exclude; + owner: string | null; +} +export const SYSTEM_SCOPE: LaborScope = { layer: "system", owner: null }; + +/** 폴더 = [구분] · [구분, 상세구분] — 깊이 2(PLAN 27-2a) */ +export interface ComboList { + 판: string; + 폴더: string[][]; + 줄: ComboSummary[]; +} +export interface ComboLayerList extends ComboList { + layer: LaborScope["layer"]; + owner: string | null; +} +export interface ComboTreeEdit { + op: "add-folder" | "rename" | "move" | "delete"; + 경로?: string[]; + 이름?: string; + 새경로?: string[]; + 키?: string; + 판: string; +} + +async function request(url: string, method = "GET", body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m02${url}`, { + method, + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +const enc = encodeURIComponent; +/** 층 길 — `path` 뒤에 주인 · 더 붙일 물음 */ +const at = (scope: LaborScope, path = "", more: Record = {}): string => { + const params = new URLSearchParams(more); + if (scope.owner) params.set("owner", scope.owner); + const q = params.toString(); + return `/layers/${scope.layer}/combos${path}${q ? `?${q}` : ""}`; +}; + +export const fetchComboList = (scope: LaborScope = SYSTEM_SCOPE): Promise => + request(at(scope)); + +export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise => + fetchComboList(scope).then((d) => d.줄); + +/** 세 층 목록 — 고르기(use-combo · 갈라 내기) */ +export const fetchAllCombos = (): Promise => request("/combos/all"); + +/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */ +export const fetchCombo = (key: string): Promise => + request & LaborScope & { 판: string }>( + `/combos/key/${enc(key)}`, + ).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 })); + +export const newCombo = ( + combo: Combo, + scope: LaborScope = SYSTEM_SCOPE, +): Promise<{ version: string; key: string }> => + request<{ 키: string; 판: string }>(at(scope), "POST", { combo }).then((d) => ({ + version: d.판, + key: d.키, + })); + +export const editCombo = ( + key: string, + version: string, + combo: Combo, + scope: LaborScope = SYSTEM_SCOPE, +): Promise<{ version: string }> => + request<{ 판: string }>(at(scope, `/${enc(key)}`), "PUT", { combo, 판: version }).then((d) => ({ + version: d.판, + })); + +export const deleteCombo = ( + key: string, + version: string, + scope: LaborScope = SYSTEM_SCOPE, +): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); + +/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */ +export const editComboTree = ( + body: ComboTreeEdit, + scope: LaborScope = SYSTEM_SCOPE, +): Promise => request(at(scope, "/tree"), "POST", body); + +/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ +export const previewCombo = ( + combo: Combo | string, + inputs: Record>, + scope: LaborScope = SYSTEM_SCOPE, +): Promise => request(at(scope, "/preview"), "POST", { combo, inputs }); + +/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ +export const fetchLibLogics = (): Promise => + request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); + +export const fetchLibLogicSubs = (): Promise => + request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs); + +export const fetchLibLogic = (key: string): Promise => + request(`/logic-lib/logic?key=${enc(key)}`); + +export const fetchLibAuto = (key: string): Promise> => + request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) + .then((d) => d.값 ?? {}) + .catch(() => ({})); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts similarity index 90% rename from M01_MasterData/M01_MasterData_UI_Combo_Pick.ts rename to M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 7567a4d7..ee9acecd 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * M01_MasterData_UI_Combo_Pick.ts + * M02_MasterTemplete_Labor_Pick.ts * [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) * ========================================================================== */ @@ -11,15 +11,14 @@ import { showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; +import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { - fetchLogics, - fetchLogicSubs, - type LogicSummary, - type SubBrief, -} from "./M01_MasterData_UI_Logic_Api"; -import { tc } from "./M01_MasterData_UI_Combo_Text"; -import "./M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 — M02 에서 열 때도 이 모듈이 불러옴 -import "./M01_MasterData_UI_Combo.css"; + fetchLibLogics as fetchLogics, + fetchLibLogicSubs as fetchLogicSubs, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 +import "./M02_MasterTemplete_Labor.css"; const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts similarity index 92% rename from M01_MasterData/M01_MasterData_UI_Combo_Preview.ts rename to M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts index c5df4e17..aeff66e0 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts @@ -1,14 +1,19 @@ /* ============================================================================= - * M01_MasterData_UI_Combo_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST /combo/preview`)에 실어 비목별 합계를 받음 + * M02_MasterTemplete_Labor_Preview.ts + * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음 * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) * ========================================================================== */ import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { fetchAuto, fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; -import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api"; -import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text"; -import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; +import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { + fetchLibAuto as fetchAuto, + fetchLibLogic as fetchLogic, + previewCombo, + type Combo, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; const COSTS = ["노무비", "재료비", "경비"] as const; const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts similarity index 99% rename from M01_MasterData/M01_MasterData_UI_Combo_Text.ts rename to M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 9a284d3b..8d6b1a1d 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -1,5 +1,5 @@ /* ============================================================================= - * M01_MasterData_UI_Combo_Text.ts + * M02_MasterTemplete_Labor_Text.ts * 일위대가 조합 화면 글자 — [한국어, 영어] * ========================================================================== */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 8163f1f6..3c75064e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -24,7 +24,7 @@ import { import { openModal } from "@ui/ui_template_modal"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; -import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { type ComboSummary, fetchCombos } from "./M02_MasterTemplete_Labor_Api"; import { copyArea, copyMaterial, From c8686ee3ad4376acfcdcd3585cf8b5da4eac0e27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:35:38 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=C2=B7=20?= =?UTF-8?q?=EB=BF=8C=EB=A6=AC=20=EC=85=8B=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=ED=8E=B8=EC=A7=91=20=C2=B7=20M01=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=BA=8C(PLAN=2027-2b?= =?UTF-8?q?=20=C2=B7=2027-2c=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit) - 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함 - 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로 - 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음 - TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로) - 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs - M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌 - 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 14 - M01_MasterData/M01_MasterData_UI_Page.ts | 34 +- M01_MasterData/M01_MasterData_UI_Side.ts | 24 +- .../M02_MasterTemplete_ComboPanel.ts | 9 +- .../M02_MasterTemplete_Combo_Store.py | 23 +- .../M02_MasterTemplete_Labor.ts | 156 ++++++++++ .../M02_MasterTemplete_Labor_Api.ts | 32 ++ .../M02_MasterTemplete_Labor_Edit.ts | 290 ++++++++---------- .../M02_MasterTemplete_Labor_Text.ts | 4 + .../M02_MasterTemplete_Labor_Tree.ts | 45 +++ .../M02_MasterTemplete_Router_Combos.py | 19 +- .../M02_MasterTemplete_Structure.ts | 23 +- .../M02_MasterTemplete_TreePanel.ts | 19 +- .../M02_MasterTemplete_UI_Page.ts | 35 ++- .../M02_MasterTemplete_UI_Side.ts | 23 +- resources/tester/test_m02_combo_layers.py | 14 + resources/tester/test_m02_labor_tree.py | 76 +++++ 17 files changed, 572 insertions(+), 268 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor.ts rename M01_MasterData/M01_MasterData_UI_Combo.ts => M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts (56%) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts create mode 100644 resources/tester/test_m02_labor_tree.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index f9105aff..c2eaeb33 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -176,20 +176,6 @@ export const fetchPick = ( q: string, ): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); -/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ -export interface ComboQuantity { - 키: string; - 이름: string; - 단위: string; - 로직: string[]; - 배?: number; -} - -export const fetchComboInputs = ( - combo: string, -): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> => - get(`/combo/${encodeURIComponent(combo)}/inputs`, {}); - /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( q: string, diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 00882927..05e519d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts - * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직·일위대가 조합) + * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직) + * · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — @@ -38,7 +39,6 @@ import { loadTable } from "./M01_MasterData_UI_Logic_Note"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { renderTables } from "./M01_MasterData_UI_Tables"; import type { LogicHandle } from "./M01_MasterData_UI_LogicLab"; -import type { ComboHandle } from "./M01_MasterData_UI_Combo"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; @@ -59,7 +59,6 @@ function buildPage(): HTMLElement { let query = ""; let dispose = (): void => {}; let logicMounted = false; - let comboHandle: ComboHandle | null = null; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -75,7 +74,6 @@ function buildPage(): HTMLElement { children: [title, searchField.root, summary, drop, save], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); - const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [ @@ -98,9 +96,8 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic" | "combo"): void => { + const showMode = (mode: "elements" | "logic"): void => { logicHost.hidden = mode !== "logic"; - comboHost.hidden = mode !== "combo"; elementView.hidden = mode !== "elements"; }; @@ -206,27 +203,6 @@ function buildPage(): HTMLElement { }); }; - /** 「일위대가 조합」 — 목록을 새로 받아 그림 · 로직 줄을 누르면 단가산출 로직 탭으로 */ - const openComboTab = (): void => { - dispose(); - dispose = (): void => {}; - current = null; - showMode("combo"); - if (comboHandle) { - void comboHandle.show(); - return; - } - void import("./M01_MasterData_UI_Combo").then((m) => { - comboHandle = m.mountM01Combo( - comboHost, - side, - (key) => openLogicTab(key), - () => comboHost.hidden && openComboTab(), - ); - void comboHandle.show(); - }); - }; - /** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「단가산출 로직으로 돌아가기」를 둠 */ const jump = async (target: MasterJump): Promise => { let group = target.kind === "element" ? target.group : ""; @@ -267,7 +243,7 @@ function buildPage(): HTMLElement { setMasterJump((target) => void jump(target)); /* --- 좌측: 컨테이너 --- */ - const side = buildSide(openFile, () => openLogicTab(), openComboTab); + const side = buildSide(openFile, () => openLogicTab()); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -275,7 +251,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost, comboHost], + children: [elementView, logicHost], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 39288ea1..1ac821ea 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Side.ts * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개 - * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직 · 일위대가 조합 + * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b) * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 * ========================================================================== */ @@ -10,7 +10,6 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ @@ -86,8 +85,6 @@ export interface SideHandle { root: HTMLElement; /** 단가산출 로직 컨테이너 안(로직 목록이 들어갈 자리) */ logicHost: HTMLElement; - /** 일위대가 조합 컨테이너 안(구분 · 상세구분 거름이 들어갈 자리) */ - comboHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; /** 그룹의 파일 목록(이미 받은 것) */ @@ -145,13 +142,8 @@ const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; -const COMBO_ID = "조합|"; -export function buildSide( - onOpen: (pick: Pick) => void, - onLogic: () => void, - onCombo: () => void = () => {}, -): SideHandle { +export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); @@ -452,17 +444,9 @@ ${first.file}` onLogic(); }); - const comboHost = el("div", { className: "m01-side__items ui-sidebar-list" }); - const combo = section(tc("Title"), comboHost); - combo.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { - if (!combo.classList.contains("is-collapsed")) return; - setActive(buttons.has(COMBO_ID) ? COMBO_ID : null); - onCombo(); - }); - const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic, combo], + children: [...groups, logic], }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => @@ -472,5 +456,5 @@ ${first.file}` onPicked = fn; }; const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; - return { root, logicHost, comboHost, refresh, files, setActive, setOnPicked, filter }; + return { root, logicHost, refresh, files, setActive, setOnPicked, filter }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts index 4d2d418b..77b43e1d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -4,7 +4,8 @@ * 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`). * · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) · * 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움 - * · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · + * · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) · + * 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · * 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로) * ========================================================================== */ @@ -19,8 +20,8 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; -import { fetchCombos } from "./M02_MasterTemplete_Labor_Api"; -import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; +import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_ComboPanel.css"; @@ -177,7 +178,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel { const [base, got] = await Promise.all([ readColumnBase(opts.column), area - ? fetchCombos().then((cs) => + ? fetchUsableCombos(getActiveLayer()).then((cs) => cs.map((c) => ({ 키: c.키, 이름: c.이름, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index acd75edf..042c76e5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -216,14 +216,17 @@ def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: return one -def _check(doc: dict, one: dict) -> None: - """이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422.""" +def _name_errors(doc: dict, one: dict) -> list[str]: name = one["이름"] errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else [] if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]): errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로") - errors += mcb.check_row(m01.loaded()[1], one) - if errors: + return errors + + +def _check(doc: dict, one: dict) -> None: + """이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422.""" + if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one): raise store.StoreError(422, {"errors": errors}) @@ -316,7 +319,7 @@ def _fresh(name: Any) -> str: def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: - """나무 고침 한 번 → (새 표, 지운 조합 키). 조합 더하기 · 고치기는 `create` · `edit` 몫.""" + """나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫.""" op, key = body.get("op"), body.get("키") folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []] gone: list[str] = [] @@ -330,6 +333,8 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: if made in folders: raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」") folders.append(made) + elif op == "rename" and key: + _find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름")) elif op == "move" and key: _set_path(_find({"줄": rows}, str(key)), target) elif op == "delete" and key: @@ -360,14 +365,18 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: def edit_tree(body: dict) -> dict[str, Any]: - """폴더 더하기 · 이름 바꾸기 · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합) - → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409.""" + """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · + 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" doc, have = _load() if have != (body.get("판") or ""): raise store.StoreError(409, {"stale": [FILE], "판": have}) new, gone = _apply(doc, body) _refuse_used(gone) if heads()[-1] != MASTER: + if body.get("op") == "rename" and body.get("키"): + one = _find(new, str(body["키"])) + if errors := _name_errors(new, one): + raise store.StoreError(422, {"errors": errors}) _save(new, have) return {**listing(), "지운조합": gone} was = {str(r["키"]): r for r in doc["줄"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts new file mode 100644 index 00000000..decf8f54 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor.ts + * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 누르면 메인 칸에 일위대가 라이브러리: + * 왼쪽 뿌리 셋(마스터 · 회사 · 개인 · `TreePanel_Roots`) 아래 조합 나무(폴더 [구분, 상세구분] 깊이 2 · + * 구조물 라이브러리와 같은 `TreePanel`) · 오른쪽 조합 목록 · 상세(`Labor_Edit`). + * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). + * ========================================================================== */ + +import { el, showConfirmDialog } from "@ui/ui_template_elements"; +import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; +import { + editComboTree, + fetchComboList, + type ComboList, + type ComboTreeEdit, + type LaborScope, +} from "./M02_MasterTemplete_Labor_Api"; +import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; +import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; + +/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ +function treeError(error: unknown): Error { + if (!(error instanceof ApiError)) + return error instanceof Error ? error : new Error(String(error)); + const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; + const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; + return error.status === 409 ? new StaleError(text) : new Error(text); +} + +export interface LaborLibraryOptions { + scope: LaborScope; + canEdit: boolean; + /** 뿌리 셋 — 고른 뿌리 아래에 이 나무(`panel`)를 붙인 것 */ + roots: (panel: HTMLElement) => HTMLElement; +} + +export interface LaborLibrary { + root: HTMLElement; + refresh: () => Promise; + /** 메인 칸 「저장 안 한 고침」 손잡이 */ + bind: (setDirty: (dirty: boolean) => void) => void; +} + +export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { + const { scope } = opt; + let setDirty: (dirty: boolean) => void = () => {}; + let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 + const took = (list: ComboList): ComboList => { + keys = new Set(list.줄.map((c) => c.키)); + editor.setList(list.줄); + return list; + }; + const editor = createLaborEditor({ + scope, + canEdit: opt.canEdit, + onDirty: (dirty) => setDirty(dirty), + onSaved: async (key) => { + await tree.refresh(); + tree.select(key); + }, + }); + const tree = createTreePanel({ + layer: scope.layer, + owner: scope.owner, + label: tc("Title"), + canEdit: opt.canEdit, + maxDepth: 2, + item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, + api: { + fetch: async () => { + return toView(took(await fetchComboList(scope))); + }, + edit: async (_layer, edit, 판) => { + const body = { ...edit, 판 } as ComboTreeEdit; + const got = await editComboTree(body, scope).catch((error: unknown) => { + throw treeError(error); + }); + return toView(took(got), noteOf(edit, got.지운조합)); + }, + }, + onPick: (item) => void editor.open(item.key), + // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) + settle: async () => { + if (!editor.isDirty()) return "clean"; + if (!(await showConfirmDialog(tc("BackAsk")))) return false; + editor.showList(); + return "changed"; + }, + // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 + onChanged: async () => { + const open = editor.current(); + if (!open) return; + if (keys.has(open)) await editor.open(open); + else editor.showList(); + }, + }); + const aside = el("aside", { + className: "m02-shell__tree", + children: [ + el("h3", { className: "m02-shell__title", text: tc("Library") }), + opt.roots(tree.root), + ], + }); + return { + root: el("div", { className: "m02-shell m02-labor", children: [aside, editor.root] }), + refresh: () => tree.refresh(), + bind: (fn) => (setDirty = fn), + }; +} + +export interface LaborSectionOptions { + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; + make: () => LaborLibrary; +} + +export interface LaborSection { + title: string; + body: HTMLElement; + open: () => Promise; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +export function buildLaborSection(opt: LaborSectionOptions): LaborSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: tc("Library"), + attrs: { type: "button", "data-labor-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + let library: LaborLibrary | null = null; + let opened = false; + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + library ??= opt.make(); + library.bind(opt.show(tc("Library"), library.root)); + opt.onOpen(); + mark(true); + await library.refresh(); + } + row.addEventListener("click", () => void open()); + return { title: tc("Title"), body, open, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 93da186e..fd3cb672 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -134,6 +134,38 @@ export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise => request("/combos/all"); +/** 조합 고르기(PLAN 27-2c) — 이 층 항목이 쓸 수 있는 조합: 마스터 · 회사(회사 · 개인 층) · 개인(개인 층) · + * 차례 = 마스터 → 회사 → 개인 · `layer` = 그 조합의 층 */ +export async function fetchUsableCombos( + layer: Layer, +): Promise<(ComboSummary & { layer: LaborScope["layer"] })[]> { + const usable: LaborScope["layer"][] = + layer === "personal" || layer === "project" + ? ["system", "company", "personal"] + : layer === "company" + ? ["system", "company"] + : ["system"]; + const all = await fetchAllCombos(); + return all + .filter((group) => usable.includes(group.layer)) + .flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer }))); +} + +/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ +export interface ComboQuantity { + 키: string; + 이름: string; + 단위: string; + 로직: string[]; + 배?: number; +} + +/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */ +export const fetchComboInputs = ( + key: string, +): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> => + request(`/combos/key/${enc(key)}/inputs`); + /** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */ export const fetchCombo = (key: string): Promise => request & LaborScope & { 판: string }>( diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts similarity index 56% rename from M01_MasterData/M01_MasterData_UI_Combo.ts rename to M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index 2a41d4fc..c5178784 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,8 +1,8 @@ /* ============================================================================= - * M01_MasterData_UI_Combo.ts - * 일위대가 조합 화면 — 왼쪽 컨테이너(구분 · 상세구분 거름) / 오른쪽 목록 · 상세 - * 상세 = 이름·구분·단위·비고 + 담은 로직 표(차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기 - * 저장·지우기는 서버 조합 마스터(`일위대가조합.json` · 키 UA) — 길은 `Combo_Api` 한 곳 + * M02_MasterTemplete_Labor_Edit.ts + * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ import { @@ -12,30 +12,29 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { deleteCombo, editCombo, fetchCombo, - fetchCombos, + fetchLibLogics, newCombo, type Combo, type ComboSummary, -} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Preview"; -import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; -import type { SideHandle } from "./M01_MasterData_UI_Side"; -import "./M01_MasterData_UI_Logic_Style.css"; -import "./M01_MasterData_UI_LogicLab_Style.css"; -import "../M02_MasterTemplete/M02_MasterTemplete_Labor.css"; + type LaborScope, +} from "./M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "./M02_MasterTemplete_Labor.css"; -const FILTER_ID = "조합|"; -const blankCombo = (): Combo => ({ +const blankCombo = (path: string[] = []): Combo => ({ 키: "", 이름: "", - 구분: null, - 상세구분: null, + 구분: path[0] ?? null, + 상세구분: path[1] ?? null, 단위: null, 담은로직: [], 출처: "자체", @@ -44,135 +43,75 @@ const blankCombo = (): Combo => ({ 비고: null, }); -/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */ -function reason(error: unknown): string { +/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */ +export function reason(error: unknown): string { if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { const d = error.detail as { errors?: string[]; stale?: string[] }; // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 if (d.errors) - return d.errors.map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} )?/, "")).join(" / "); + return d.errors + .map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) + .join(" / "); if (d.stale) return tc("Stale"); } return error instanceof Error ? error.message : tc("Load_Failed"); } -export interface ComboHandle { - /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ - show: () => Promise; +export interface LaborEditOptions { + scope: LaborScope; + canEdit: boolean; + /** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */ + onSaved: (key: string | null) => Promise; + /** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */ + onDirty: (dirty: boolean) => void; } -export function mountM01Combo( - host: HTMLElement, - side: SideHandle, - onOpenLogic: (key: string) => void, - /** 왼쪽 거름 · 찾기를 만지면 — 다른 화면이 떠 있으면 이 화면으로 */ - activate: () => void = () => {}, -): ComboHandle { +export interface LaborEditor { + root: HTMLElement; + /** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */ + setList: (items: ComboSummary[]) => void; + open: (key: string) => Promise; + /** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */ + startNew: (path: string[]) => void; + showList: () => void; + isDirty: () => boolean; + /** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */ + current: () => string | null; +} + +export function createLaborEditor(opt: LaborEditOptions): LaborEditor { + const edit = opt.canEdit; let items: ComboSummary[] = []; let logics = new Map(); - let pick = { sub: "", detail: "" }; /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ let nav = 0; - /** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */ - /** `saved` = 연 때의 모양 — 다시 그려도 「저장 안 한 고침」 판정이 안 풀리게 */ + /** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */ let current: { combo: Combo; version: string; saved: string } | null = null; - let logicsReady: Promise = Promise.resolve(); let q = ""; - const filterHost = el("div"); + const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" }); const find = createInputField({ type: "search", placeholder: tc("Search") }); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); - activate(); if (!current) drawList(); }); - side.comboHost.replaceChildren(find.root, filterHost); - const view = el("div", { className: "m01-logic__editor m01-combo" }); - host.replaceChildren(view); - /** 목록만 받음 — 로직 이름표(1300여 개)는 따로 받아 목록을 붙잡지 않음 */ - const load = async (redraw = true): Promise => { - const my = nav; - try { - items = await fetchCombos(); - } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); - } - drawFilter(); - if (redraw && my === nav) drawList(); - }; - - const loadLogics = (): void => { - logicsReady = fetchLogics() - .then((all) => { - logics = new Map(all.map((x) => [x.키, x])); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); - }; - - const show = async (): Promise => { - if (!logics.size) loadLogics(); - if (!items.length && !current) { - view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); - } - await load(false); - if (!current) drawList(); - await logicsReady; - if (current) draw(current.combo, current.version, current.saved); - }; - - /* --- 왼쪽 거름 --- */ - const drawFilter = (): void => { - const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map( - (name) => ({ - name, - book: null, - details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( - (v): v is string => !!v, - ), - }), + const logicsReady = fetchLibLogics() + .then((all) => { + logics = new Map(all.map((x) => [x.키, x])); + }) + .catch((error) => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), ); - filterHost.replaceChildren( - side.filter({ - id: FILTER_ID, - store: "m01.filter.조합", - subs, - total: items.length, - subLabel: tc("Sub"), - detailLabel: tc("Detail"), - onPick: (sub, detail) => { - pick = { sub, detail }; - activate(); - drawList(); - }, - }), - ); - }; + + const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved; + const markDirty = (): void => opt.onDirty(dirty()); /* --- 목록 --- */ - const toolbar = (...more: HTMLElement[]): HTMLElement => - el("div", { - className: "m01-logic__section-head", - children: [ - createButton({ - label: tc("New"), - variant: "filled", - onClick: () => draw(blankCombo(), ""), - }), - ...more, - ], - }); - const drawList = (): void => { nav++; current = null; - const shown = items.filter( - (x) => - (!pick.sub || x.구분 === pick.sub) && - (!pick.detail || x.상세구분 === pick.detail) && - (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), - ); + opt.onDirty(false); + const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); const heads = [ tc("Head_Key"), tc("Head_Name"), @@ -200,7 +139,15 @@ export function mountM01Combo( return tr; }); view.replaceChildren( - toolbar(), + el("div", { + className: "m01-logic__section-head", + children: [ + find.root, + ...(edit + ? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })] + : [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]), + ], + }), shown.length ? el("div", { className: "m01-master__grid-wrap", @@ -232,19 +179,24 @@ export function mountM01Combo( } }; + const startNew = (path: string[]): void => draw(blankCombo(path), ""); + /* --- 상세 --- */ const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => { nav++; current = { combo, version, saved }; + opt.onDirty(dirty()); const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = combo[name] ?? ""; + f.input.readOnly = !edit; f.input.setAttribute("data-combo-field", name); if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음 f.input.addEventListener("input", () => { const v = f.input.value; if (name === "이름") combo.이름 = v; else combo[name] = v || null; + markDirty(); }); return f.root; }; @@ -256,53 +208,39 @@ export function mountM01Combo( if (j < 0 || j >= combo.담은로직.length) return; [combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]]; drawTable(); + markDirty(); }; const trs = combo.담은로직.map((row, i) => { const info = logics.get(row.로직); const memo = createInputField({ type: "text" }); memo.input.value = row.메모 ?? ""; - memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null)); - const name = el("button", { - className: "ui-btn ui-btn--ghost ui-btn--sm", - text: info?.이름 ?? row.로직, - attrs: { type: "button", title: tc("Open") }, - }); - name.addEventListener("click", async () => { - if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("LeaveAsk")))) return; - onOpenLogic(row.로직); + memo.input.readOnly = !edit; + memo.input.addEventListener("input", () => { + row.메모 = memo.input.value || null; + markDirty(); }); + const small = (label: string, run: () => void): HTMLElement => + createButton({ label, variant: "ghost", size: "sm", onClick: run }); const btns = el("div", { className: "m01-logic__chips", - children: [ - createButton({ - label: tc("Up"), - variant: "ghost", - size: "sm", - onClick: () => move(i, -1), - }), - createButton({ - label: tc("Down"), - variant: "ghost", - size: "sm", - onClick: () => move(i, 1), - }), - createButton({ - label: tc("Remove"), - variant: "ghost", - size: "sm", - onClick: () => { - combo.담은로직.splice(i, 1); - drawTable(); - }, - }), - ], + children: edit + ? [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => { + combo.담은로직.splice(i, 1); + drawTable(); + markDirty(); + }), + ] + : [], }); return el("tr", { attrs: { "data-combo-logic": row.로직 }, children: [ el("td", { text: String(i + 1) }), el("td", { text: row.로직 }), - el("td", { children: [name] }), + el("td", { text: info?.이름 ?? row.로직 }), el("td", { text: info?.결과단위 ?? "" }), el("td", { children: [memo.root] }), el("td", { children: [btns] }), @@ -340,6 +278,7 @@ export function mountM01Combo( if (!logics.has(x.키)) logics.set(x.키, x); combo.담은로직.push({ 로직: x.키, 메모: null }); drawTable(); + markDirty(); }), }); const save = createButton({ @@ -347,19 +286,20 @@ export function mountM01Combo( variant: "filled", onClick: async () => { if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); - const my = nav; + save.disabled = true; // 연달아 눌러도 한 번만 try { - const key = combo.키 ? combo.키 : (await newCombo(combo)).key; - if (combo.키) await editCombo(combo.키, version, combo); + let key = combo.키; + if (key) await editCombo(key, version, combo, opt.scope); + else key = (await newCombo(combo, opt.scope)).key; showToast(tc("Saved"), "success"); - await load(false); - if (my !== nav) { - if (current?.combo === combo) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음 - return; - } + current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음 + opt.onDirty(false); + await opt.onSaved(key); await open(key); } catch (error) { showToast(reason(error), "error"); + } finally { + save.disabled = false; } }, }); @@ -373,9 +313,11 @@ export function mountM01Combo( ) return; try { - await deleteCombo(combo.키, version); + await deleteCombo(combo.키, version, opt.scope); showToast(tc("Deleted"), "success"); - await load(); + current = null; + await opt.onSaved(null); + drawList(); } catch (error) { showToast(reason(error), "error"); } @@ -385,7 +327,7 @@ export function mountM01Combo( label: "←", variant: "ghost", onClick: async () => { - if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("BackAsk")))) return; + if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return; drawList(); }, }); @@ -396,7 +338,11 @@ export function mountM01Combo( view.replaceChildren( el("div", { className: "m01-logic__section-head", - children: [back, el("h3", { text: combo.키 || tc("New") }), remove, save], + children: [ + back, + el("h3", { text: combo.키 || tc("New") }), + ...(edit ? [remove, save] : []), + ], }), el("section", { className: "m01lab__box m01-logic__section", @@ -419,7 +365,7 @@ export function mountM01Combo( children: [ el("div", { className: "m01-logic__section-head", - children: [el("h3", { text: tc("Logics_Title") }), add], + children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])], }), table, ], @@ -429,5 +375,17 @@ export function mountM01Combo( drawTable(); }; - return { show }; + view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); + return { + root: view, + setList: (next) => { + items = next; + if (!current) drawList(); + }, + open, + startNew, + showList: drawList, + isDirty: dirty, + current: () => current?.combo.키 ?? null, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 8d6b1a1d..d1fd7e12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -7,6 +7,10 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["일위대가 조합", "Unit-price combination"], + Library: ["일위대가 라이브러리", "Unit-price library"], + AddCombo: ["조합 더하기", "Add combination"], + ComboName: ["조합 이름", "Combination name"], + ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"], Sub: ["구분", "Category"], Detail: ["상세구분", "Detail"], All: ["전체", "All"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts new file mode 100644 index 00000000..d18017ca --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Tree.ts + * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. + * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). + * ========================================================================== */ + +import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; + +/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ +export const pathOf = (c: ComboSummary): string[] => + c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; + +/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ +export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ + 판: list.판, + 폴더: list.폴더, + items: list.줄.map((c) => ({ + key: c.키, + 열: c.키, + 경로: [...pathOf(c), c.이름], + 이름: c.이름, + 단위: c.단위, + })), + ...(note ? { 알림: note } : {}), +}); + +/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ +export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { + const at = edit.경로 ?? []; + const last = at[at.length - 1] ?? ""; + if (edit.키 && edit.op !== "delete") { + const where = edit.op === "move" ? (edit.새경로 ?? []) : at; + return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; + } + const path = + edit.op === "add-folder" + ? [...at, edit.이름 ?? ""] + : edit.op === "rename" + ? [...at.slice(0, -1), edit.이름 ?? ""] + : edit.op === "move" + ? [...(edit.새경로 ?? []), last] + : undefined; + return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index f6873a78..05970da7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -62,16 +62,27 @@ def get_all(session: Session = Depends(verify_session)) -> list[dict]: return out -@router.get("/combos/key/{key}") -def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict: - """키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`.""" +def _where(key: str) -> tuple[str, str | None]: got = combos.where(key) if got is None: raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」") - layer, owner = got + return got + + +@router.get("/combos/key/{key}") +def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict: + """키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`.""" + layer, owner = _where(key) return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)} +@router.get("/combos/key/{key}/inputs") +def get_inputs_by_key(key: str, session: Session = Depends(verify_session)) -> dict: + """조합 알약(어느 층이든) — 구조물 조합 면적산출 연결이 부름.""" + layer, owner = _where(key) + return _call(_root(session, layer, owner), combos.pills, key) + + @router.get("/layers/{layer}/combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), combos.listing) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 3c75064e..baf34db0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -23,8 +23,8 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; -import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; -import { type ComboSummary, fetchCombos } from "./M02_MasterTemplete_Labor_Api"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; import { copyArea, copyMaterial, @@ -86,13 +86,9 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; -/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */ -let combos: Promise | null = null; -const loadCombos = (): Promise => - (combos ??= fetchCombos().catch((error: unknown) => { - combos = null; - throw error; - })); +/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */ +const loadCombos = () => fetchUsableCombos(getActiveLayer()); +const LAYER_WORD = { system: "마스터", company: "회사", personal: "개인" } as const; interface Choice { value: string; @@ -309,7 +305,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { - let list: ComboSummary[]; + let list: Awaited>; try { list = await loadCombos(); } catch (error) { @@ -321,7 +317,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane value: c.키, key: c.키, // 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌 - label: [c.이름, c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, c.구분] + label: [ + c.이름, + c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, + c.구분, + `· ${LAYER_WORD[c.layer]}`, + ] .filter(Boolean) .join(" "), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index f6f02951..291297f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -56,6 +56,10 @@ export interface TreePanelOptions { }; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; + /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ + maxDepth?: number; + /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ + item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -106,8 +110,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, - { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, + ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth + ? [] + : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), + { + label: opt.item?.add ?? L("M02_TreeAddItem"), + run: () => void addItem(target.path), + }, ] : []), ...(isTop @@ -272,6 +281,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; + if (opt.item?.onAdd) return opt.item.onAdd(parent); const got = await askText(L("M02_TreeAddItem"), [ { key: "name", label: L("M02_TreeItemName") }, { key: "unit", label: L("M02_TreeUnit") }, @@ -291,7 +301,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"), + label: + target.kind === "item" + ? (opt.item?.name ?? L("M02_TreeItemName")) + : L("M02_TreeFolderName"), value: nameOf(target), }, ]); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index a86599a4..cdb0e2d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -23,8 +23,10 @@ import { entryQuery, layerOfRoot, ownOwner, + type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; +import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots"; @@ -55,11 +57,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - const go = async (next: Root, nextOwner: string | null): Promise => { + const go = async ( + next: Root, + nextOwner: string | null, + open: OpenTarget = "구조물조합", + ): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; - navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner })); + navigateTo( + ROUTES.M02_MASTER_TEMPLATE, + entryQuery({ ...entry, open, root: next, owner: nextOwner }), + ); }; + // 일위대가 라이브러리(PLAN 27-2b) — 같은 뿌리 셋 · 다른 뿌리를 누르면 그 뿌리의 일위대가로 다시 엶 + const laborRoots = (panel: HTMLElement): HTMLElement => + createLibraryRoots({ + user, + active: { root: entry.root, owner }, + panel, + go: (next, nextOwner) => void go(next, nextOwner, "일위대가"), + }); const roots = createLibraryRoots({ user, active: { root: entry.root, owner }, @@ -97,6 +114,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + buildLaborSection({ + show: main.showView, + confirmLeave: main.confirmLeave, + onOpen, + make: () => + buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, roots: laborRoots }), + }), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -116,9 +141,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 2be9f784..77d675d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -39,6 +39,7 @@ import { type StructureColumn, } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; +import type { LaborSection } from "./M02_MasterTemplete_Labor"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; @@ -73,6 +74,8 @@ export interface SideOptions { confirmLeave: () => Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ + labor?: (hooks: { onOpen: () => void }) => LaborSection; } export interface SideHandle { @@ -81,7 +84,7 @@ export interface SideHandle { /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ - reveal: (target: "옵션" | "도면") => void; + reveal: (target: "옵션" | "도면" | "일위대가") => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -131,7 +134,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), labor?.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -143,8 +146,9 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), labor?.clear(), paint()), }); + const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) }); // 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) const summary = section( L("M02_KindTable"), @@ -164,7 +168,11 @@ export function buildSide(opt: SideOptions): SideHandle { : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], + children: [ + ...(labor ? [section(labor.title, labor.body)] : []), + summary, + ...(extras ? [extras.옵션, extras.도면] : []), + ], }); attachCollapsible(listHost); @@ -240,6 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); + if (opt.getOpen()) labor?.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -363,7 +372,11 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면"): void => { + const reveal = (target: "옵션" | "도면" | "일위대가"): void => { + if (target === "일위대가") { + labor?.body.closest("section")?.classList.remove("is-collapsed"); + return void labor?.open(); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 6a71f801..d6fecefd 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -156,6 +156,15 @@ def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): row = next(r for r in got["줄"] if r["키"] == key) assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") + _new(me, "personal", _combo("다른 것")) + got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") + assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" + assert ( + "이미 있음" + in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] + ) + other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") + _tree(me, "personal", op="delete", 키=other) got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] @@ -198,6 +207,10 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): admin = _as(world, "SYSTEM_ADMIN") before = _list(admin, "system") + first = before["줄"][0]["키"] + got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") + assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" + before = _list(admin, "system") assert ["실무 일위대가"] in before["폴더"] _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") got = _tree(admin, "system", op="rename", 경로=["실무 일위대가"], 이름="실무") @@ -272,6 +285,7 @@ def test_면적_알약이_개인_조합을_읽음(world): up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"] got = me.get(f"{URL.format('personal')}/{up}/inputs").json() assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"] + assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got tree = me.post( "/api/m02/layers/personal/tree", json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)}, diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py new file mode 100644 index 00000000..f766acf5 --- /dev/null +++ b/resources/tester/test_m02_labor_tree.py @@ -0,0 +1,76 @@ +"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. + - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · + 지우기는 고름 없음. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; +const src = fs.readFileSync(process.argv[1] + file, "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); +const list = { + 판: "v1", + 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], + 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), + row("UP1-000003", "다", null, "무시")], +}; +const view = m.toView(list); +const notes = [ + { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, + { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, + { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, + { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, + { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, + { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, + { op: "delete", 경로: ["빈"] }, +].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); +console.log(JSON.stringify({ view, notes })); +""" + + +@pytest.fixture(scope="module") +def got(): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + proc = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_조합이_나무_항목으로(got): + items = got["view"]["items"] + assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] + assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] + assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] + assert "알림" not in got["view"] + + +def test_나무_고침_알림(got): + add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] + assert add["경로"] == ["석축", "메쌓기"] and "열" not in add + assert rename["경로"] == ["석축", "찰"] + assert move["경로"] == ["찰쌓기"] + assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] + assert rename_item["경로"] == ["석축", "나나"] + assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] + assert "경로" not in delete_folder From 6d4b309e0dfc7dfbbe4a16c1a43aebb1a7e1e1d5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:49:56 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EB=82=98=EB=AC=B4=EC=97=90=EC=84=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=A1=B0=ED=95=A9=20=ED=91=9C=EC=8B=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B8=B5=20=EC=A1=B0=ED=95=A9=20=EA=B2=80=EC=82=AC=20=EA=B8=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=95=84=EC=A7=81=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=82=A4=20=EB=97=8C(PLAN=2027-2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/M02_MasterTemplete_Labor.ts | 5 ++++- M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index decf8f54..b83df40b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -81,7 +81,10 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { return toView(took(got), noteOf(edit, got.지운조합)); }, }, - onPick: (item) => void editor.open(item.key), + onPick: (item) => { + tree.select(item.key); + void editor.open(item.key); + }, // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) settle: async () => { if (!editor.isDirty()) return "clean"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index c5178784..ba54c932 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -50,7 +50,7 @@ export function reason(error: unknown): string { // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 if (d.errors) return d.errors - .map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) + .map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) .join(" / "); if (d.stale) return tc("Stale"); }