diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 534e01d30..ac1649609 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -8,7 +8,8 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · - * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */ + * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) · + * 「일위대가」 = labor(열마다 문서 · 39장) */ export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor"; export interface TemplateInfo { @@ -153,6 +154,8 @@ export interface StructureNumbers { 도번: string; 면적번: string; 재료번: string; + /** 일위대가 `SU-######`(39장) */ + 일위대가번: string; } /** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 9bd76f293..3f1df113f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -8,7 +8,7 @@ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; const TEXT = { title: "칸 지정 — {value}", @@ -39,6 +39,10 @@ export interface AreaLinkOptions { readOnly: boolean; } +/** 칸 값 글 — 수 · 글 · 참거짓 · 오류 */ +export const cellText = (v: CalcValue | undefined): string => + !v ? "" : "수" in v ? v.수 : "글" in v ? v.글 : "참" in v ? String(v.참) : v.오류; + /** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ function linkedText(opts: AreaLinkOptions): string | null { const link = opts.sheet @@ -47,7 +51,7 @@ function linkedText(opts: AreaLinkOptions): string | null { .find((l) => l.키 === opts.key); if (!link) return null; const value = opts.sheet.getLinkedValues()[opts.key]; - return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); + return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim(); } export function openAreaLink(opts: AreaLinkOptions): void { diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css new file mode 100644 index 000000000..9f7c0e18d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -0,0 +1,64 @@ +/* M02 면적산출 상세 오른쪽 패널(일위대가 담은 로직 · 자재목록) · 칸 지정 모달 — 틀은 공용 사이드바 카드 · + 접기 · 폭 손잡이 · 접기 손잡이 */ + +/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-apanel-width` 로 줌 */ +.m02-apanel { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + width: var(--m02-apanel-width, 36rem); /* 금액 칸 넷이 가로 굴림 없이 보이게 */ + min-width: 280px; + max-width: 60%; + min-height: 0; + border-left: 1px solid var(--color-border); +} + +.m02-apanel.is-resizing { + transition: none; +} + +.m02-apanel.is-collapsed { + width: 0; + min-width: 0; +} + +.m02-apanel.is-collapsed > .m02-apanel__body, +.m02-apanel.is-collapsed > .ui-resizer { + display: none; +} + +/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */ +.m02-apanel > .ui-workflow-overlay__handle--side { + z-index: 6; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +/* 안쪽만 굴림 — 페이지 스크롤 안 생김 */ +.m02-apanel__body { + flex: 1 1 0; + min-height: 0; + overflow: auto; + padding: var(--spacing-8); +} + +/* 칸 지정 모달 — 지금 연결 한 줄 · [지금 고른 칸으로] [풀기] · 주소 칸 [잇기] */ +.m02-area-link__now { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); +} + +.m02-area-link__row { + display: flex; + align-items: flex-end; + gap: var(--spacing-8); + margin-bottom: var(--spacing-12); +} + +.m02-area-link__row > .ui-field { + flex: 1 1 auto; + margin: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts new file mode 100644 index 000000000..89e6ff7ef --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -0,0 +1,125 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaPanel.ts + * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6) — 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영). + * 몸 = 일위대가 보기 갈래(`Labor_View` · 고치기 없음) · 틀 = 공용 사이드바 카드 · 접기 손잡이 · 폭 손잡이 + * (폭 · 접힘 기억은 옛 조합 패널 키 그대로). + * · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기) + * · 시트 칸 값이 바뀌면 `refresh` — 받은 기준에 바로 곱함(서버 안 부름 · `Labor_View.setQty`) + * ========================================================================== */ + +import { el, showToast } from "@ui/ui_template_elements"; +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 type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { openAreaLink } from "./M02_MasterTemplete_AreaLink"; +import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View"; +import "./M02_MasterTemplete_AreaPanel.css"; + +const TEXT = { + handle: "일위대가", + title: "이 열의 일위대가", + noPill: "이 줄은 면적산출 알약이 없음 — 돈 아닌 로직이거나 계산 전", +}; + +/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ +const MIN_WIDTH = 280; +const MAX_RATIO = 0.6; + +export interface AreaPanelOptions { + layer: Layer; + /** 집계표 열 id */ + column: string; + sheet: () => SpreadsheetHandle | null; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + /** 알약 막대 항목 — 칸 지정 때 연결에 같이 적을 이름 · 단위 · 자재 */ + pills: () => LinkItem[]; + readOnly: boolean; +} + +export interface AreaPanel { + root: HTMLElement; + /** 시트 칸 값 · 연결이 바뀜 — 반영을 바로 다시 곱함 */ + refresh: () => void; + destroy: () => void; +} + +/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */ +function quantities(sheet: SpreadsheetHandle | null): Record { + const out: Record = {}; + for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) { + const n = "수" in v ? Number(v.수) : NaN; + if (Number.isFinite(n)) out[key] = n; + } + return out; +} + +export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { + const openLink = (row: LaborViewRow): void => { + const sheet = opts.sheet(); + if (!sheet) return; + if (!row.pill) return showToast(TEXT.noPill, "info"); + const item = opts.pills().find((p) => p.키 === row.pill); + openAreaLink({ + label: row.name, + key: row.pill, + info: { + 이름: item?.이름 ?? row.name, + ...((item?.단위 ?? row.unit) ? { 단위: item?.단위 ?? row.unit } : {}), + ...(item?.자재 ? { 자재: item.자재 } : {}), + }, + sheet, + picked: opts.picked, + readOnly: opts.readOnly, + }); + }; + const view = createLaborView(openLink); + const body = el("div", { + className: "m02-apanel__body ui-sidebar-panel", + children: [ + el("section", { + className: "ui-collapsible ui-sidebar-section ui-sidebar-card", + children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), view.root], + }), + ], + }); + attachCollapsible(body); + + const root = el("aside", { className: "m02-apanel" }); + const handle = createWorkflowPanelHandle("side", "right", TEXT.handle); + // 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭 + const resizer = createPanelResizer({ + axis: "horizontal", + target: root, + cssVar: "--m02-apanel-width", + direction: -1, + min: MIN_WIDTH, + max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO, + storageKey: stateKey("m02-combo-panel-width") ?? undefined, + }); + root.append(handle.root, resizer.root, body); + const setCollapsed = (collapsed: boolean): void => { + root.classList.toggle("is-collapsed", collapsed); + handle.setOpen(!collapsed); + writeStateRaw("m02-combo-panel-collapsed", String(collapsed)); + }; + handle.root.addEventListener("click", () => + setCollapsed(!root.classList.contains("is-collapsed")), + ); + setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true"); + + // 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번) + void view.show(opts.layer, opts.column, quantities(opts.sheet())); + return { + root, + refresh: () => view.setQty(quantities(opts.sheet())), + destroy: () => { + view.destroy(); + resizer.dispose(); + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py deleted file mode 100644 index 85b5d499f..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py +++ /dev/null @@ -1,98 +0,0 @@ -"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기. - -정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 · -같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지. -붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로. -마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움. -권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`. -""" - -from __future__ import annotations - -from typing import Any - -from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos -from M02_MasterTemplete import M02_MasterTemplete_Store as store - - -def _free_name(name: str, taken: set[str]) -> str: - out, n = name, 2 - while out in taken: - out, n = f"{name} ({n})", n + 1 - taken.add(out) - return out - - -def _paths(value: Any, what: str) -> list[list[str]]: - if not isinstance(value, list) or not all(isinstance(p, list) for p in value): - raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님") - return [[str(x) for x in p] for p in value] - - -def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]): - """(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404.""" - have = combos.folders_of(source) - rows = {str(r.get("키")): r for r in source.get("줄") or []} - out: dict[str, tuple[dict, list[str]]] = {} - made: list[list[str]] = [] - for folder in folders: - if folder not in have: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」") - cut = len(folder) - 1 # 고른 폴더부터 대상 아래로 - made += [where + f[cut:] for f in have if f[: len(folder)] == folder] - for key, row in rows.items(): - path = combos.path_of(row) - if path[: len(folder)] == folder: - out[key] = (row, where + path[cut:]) - for key in keys: - if key not in rows: - raise store.StoreError(404, f"없는 조합 「{key}」") - out.setdefault(key, (rows[key], list(where))) - if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]): - raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음") - return list(out.values()), made - - -def paste(source: dict, body: dict) -> dict[str, Any]: - """`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` → - `{…대상 층 목록, 새키: [원본 → 새 키]}`.""" - keys = [str(k) for k in body.get("키") or []] - folders = _paths(body.get("폴더") or [], "폴더") - where = [str(x) for x in body.get("경로") or []] - if not keys and not folders: - raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음") - doc, have = combos._load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [combos.FILE], "판": have}) - if where and where not in combos.folders_of(doc): - raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」") - items, made = picked(source, keys, folders, where) - taken = {str(r.get("이름") or "").strip() for r in doc["줄"]} - master = combos.heads()[-1] == combos.MASTER - rows = [] - for row, path in items: - one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"} - one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken) - one["본뜬키"] = str(row.get("키")) - if master and not one["본뜬키"].startswith(combos.MASTER): - one["본뜬키"] = None # 마스터 틀은 UA 계보만 - combos._set_path(one, path) - rows.append(one) - folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]}) - if master: - new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩 - _, version = combos._load() - combos._write_master_folders(folders_after, version) - else: - number = int(doc.get("다음") or 1) - fixed = [] - for at, one in enumerate(rows): - fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}")) - new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after} - new_doc["줄"] = [*doc["줄"], *fixed] - for one in fixed: - combos._check(new_doc, one) - combos._save(new_doc, have) - new = [one["키"] for one in fixed] - pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)] - return {**combos.listing(), "새키": pairs} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 07d519954..935336213 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -7,13 +7,12 @@ 개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######` 회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀). 지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫. -나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함). +목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움). """ from __future__ import annotations import json -import os import re from datetime import date from pathlib import Path @@ -127,18 +126,13 @@ def _find(doc: dict, key: str) -> dict[str, Any]: 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]] = [] @@ -306,128 +300,3 @@ def delete(key: str, version: str) -> dict[str, Any]: _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 == "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: - _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 · 조합 이름 겹침 422.""" - return edit_tree_many({"판": body.get("판"), "ops": [body]}) - - -def edit_tree_many(body: dict) -> dict[str, Any]: - """나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` · - 차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번.""" - doc, have = _load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": have}) - ops = body.get("ops") - if not isinstance(ops, list) or not ops: - raise store.StoreError(422, "나무 고침 목록(ops)이 비었음") - new, gone = doc, [] - for op in ops: - new, got = _apply(new, op if isinstance(op, dict) else {}) - gone += got - _refuse_used(gone) - renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")] - commit(doc, have, new, gone, renamed) - return {**listing(), "지운조합": gone} - - -def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None: - """고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) · - 마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로.""" - if heads()[-1] != MASTER: - for key in checked: - if key in {str(r.get("키")) for r in new["줄"]}: - if errors := _name_errors(new, _find(new, key)): - raise store.StoreError(422, {"errors": errors}) - _save(new, have) - return - 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) - - -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_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 3b3a24b3f..d00f83160 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -65,6 +65,11 @@ const TEXT = { sheetHelp: "단축키 — 칸을 고르고 바로 침", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", + take: "복사해 가져오기", + takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", + blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -81,6 +86,11 @@ const SHEET_KEYS: [string, string][] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */ +const emptyBook = (book: Workbook): boolean => + (book.시트 ?? []).every( + (s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식), + ); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 스프레드시트는 면적산출 · 재료산출 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); @@ -102,6 +112,8 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 · 재료산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; + /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + onImport?: (kind: EditKind) => void; } /** 카드 스프레드시트를 다루는 손잡이 — 알약 · 자재 조합 판이 씀 */ @@ -154,6 +166,8 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; + /** 「복사해 가져오기」 — 찬 카드만 */ + take: HTMLButtonElement; no: HTMLElement; name: HTMLElement; } @@ -165,6 +179,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let docVersion: string | null = null; let preview: DrawingCardHandle | null = null; let seq = 0; + /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ + const started = new Set(); const numberOf = (kind: EditKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -192,8 +208,44 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { size: "sm", }); edit.addEventListener("click", () => opts.onEdit(kind)); - shell.tools.append(help, saver.button, edit); - return { kind, shell, saver, help, edit, no, name }; + const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); + take.title = fill(TEXT.takeTip, PART_WORD[kind]); + take.addEventListener("click", () => opts.onImport?.(kind)); + shell.tools.append(help, saver.button, take, edit); + return { kind, shell, saver, help, edit, take, no, name }; + }; + + /** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */ + const blank = (c: Card, empty: boolean): boolean => { + const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind); + c.take.hidden = show || !opts.editable() || !opts.onImport; + return show; + }; + /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ + const startBox = (c: Card, paint: () => void): HTMLElement => { + const fresh = createButton({ + label: TEXT.makeNew, + variant: "ghost", + size: "sm", + onClick: () => { + started.add(c.kind); + paint(); + }, + }); + const copy = createButton({ + label: TEXT.makeCopy, + variant: "ghost", + size: "sm", + onClick: () => opts.onImport?.(c.kind), + }); + copy.hidden = !opts.onImport; + return el("div", { + className: "m02-struct__start", + children: [ + el("p", { className: "m02-struct__empty", text: TEXT.blank }), + el("div", { className: "m02-struct__start-row", children: [fresh, copy] }), + ], + }); }; const previewHost = el("div", { className: "m02-struct__preview" }); @@ -328,6 +380,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { if (!c.doc) return; + if (blank(c, emptyBook(c.doc))) + return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq))); + c.host.replaceChildren(); try { const { createSpreadsheet } = await loadSpreadsheet(); if (mine !== seq || !c.doc) return; @@ -348,14 +403,21 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.number || numberOf(c.kind, at) || fill(TEXT.noFieldNumber, PART_FIELD[c.kind]); void paintSheet(c, mine); } + paintDrawing(); + }; + + /** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */ + const paintDrawing = (): void => { if (!doc) return; previewHost.replaceChildren(); + if (blank(info, !doc.도면?.entities?.length)) + return void previewHost.append(startBox(info, paintDrawing)); // 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`) preview = mountDrawingCard(previewHost, { doc, version: docVersion, source: opts.source(), - column: at, + column: column ?? "", editable: opts.editable(), saver: info.saver, }); @@ -384,6 +446,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + if (col.id !== column) started.clear(); column = col.id; label = col.label; for (const c of cards) c.name.textContent = label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 3ee87e241..4fd1b1215 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -9,7 +9,7 @@ import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { buildItemList, @@ -20,7 +20,13 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { + entryFromHash, + ownOwner, + readableSources, + viewCompany, + type Source, +} from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -236,3 +242,14 @@ export function pickFrom( }); }); } + +/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */ +export async function pickHere( + opt: Omit & { + title: string; + ask?: (picked: Picked) => string; + }, +): Promise { + const user = await fetchSessionUser().catch(() => null); + return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 36301dd77..121ce2776 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -141,8 +141,9 @@ def copy_item( origin: dict[str, Any], path: list[str] | None = None, ) -> dict[str, Any]: - """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · - 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 · + """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 바인딩 그대로 · + 도면 · 일위대가(새 SU · 조합 키는 안 받음 · 39-1b) · 면적산출 · 재료산출 내용(알약 연결 포함) · + 항목 연결 · 자재 조합 줄(새 UM) 복사 · 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키) · `path` = 새 자리(폴더 + 이름 · 없으면 원래 경로 · 붙여넣기 PLAN 30-2). 돌려줌 `{key, 열, 이름}`.""" @@ -179,7 +180,13 @@ def copy_item( table = store.read("table", store.STRUCTURE_TABLE) doc = table["문서"] new_id = _fresh_id(col_id, {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)}) - made = {**json.loads(json.dumps(col)), "id": new_id, "자재조합": new_um, "본뜬": origin} + made = { + **json.loads(json.dumps(col)), + "id": new_id, + "일위대가": None, # 정본 = 일위대가 문서(아래 복사) + "자재조합": new_um, + "본뜬": origin, + } if path: made.update({"경로": list(path), "머리": list(path)}) # 머리 줄 수는 저장 맞춤이 채움 doc["열"] = [*doc.get("열", []), made] @@ -192,6 +199,7 @@ def copy_item( body = store._server(kind, body) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) + store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) return {"key": ss["키"], "열": new_id, "이름": ss["이름"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6a49e2f5d..447e53967 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -79,15 +79,6 @@ 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, @@ -181,36 +172,6 @@ export const deleteCombo = ( 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); - -/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */ -export const editComboTreeMany = ( - ops: Omit[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/tree-many"), "POST", { 판, ops }); - -/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */ -export const pasteCombos = ( - from: LaborScope, - pick: { 키?: string[]; 폴더?: string[][] }, - 경로: string[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/paste"), "POST", { - from_layer: from.layer, - from_owner: from.owner, - ...pick, - 경로, - 판, - }); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index ef603b5eb..55965704d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -21,7 +21,6 @@ from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access -from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -42,36 +41,6 @@ class ComboBody(BaseModel): 판: 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 TreeManyBody(BaseModel): - """여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번.""" - - 판: str - ops: list[dict[str, Any]] - - -class PasteBody(BaseModel): - """층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` → - 이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판.""" - - from_layer: Layer - from_owner: str | None = None - 키: list[str] = [] - 폴더: list[list[str]] = [] - 경로: list[str] = [] - 판: str = "" - - @router.get("/combos/all") def get_all(session: Session = Depends(verify_session)) -> list[dict]: """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" @@ -158,41 +127,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) - return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) -@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/tree-many") -def post_tree_many( - layer: Layer, - body: TreeManyBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree_many, body.model_dump()) - - -@router.post("/layers/{layer}/combos/paste") -def post_paste( - layer: Layer, - body: PasteBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자.""" - source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0]) - folder = _root(session, layer, owner, write=True) - return _call(folder, combo_copy.paste, source, body.model_dump()) - - # ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── def _read(fn, *args): try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index cef56af78..3151acf2e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -400,18 +400,36 @@ def post_use_combo( return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) +def _copy_practice(column: str, key: str) -> dict[str, Any]: + """실무문서 조합(마스터 UA) → 이 열 일위대가 문서(입력값 빈 값 · 39-1b).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + if not key.startswith("UA"): + raise store.StoreError(400, f"실무문서 조합 키가 아님 「{key}」") + return store.copy_from("labor", column, key, body=labor.from_combo(key)) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, - kind: Literal["structures", "area", "material"], + kind: Literal["structures", "labor", "area", "material"], column: str, source: str = Query(alias="from"), owner: str | None = None, + from_layer: Literal["system", "company", "personal", "practice"] | None = None, + from_owner: str | None = None, session: Session = Depends(verify_session), ): + """항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) · + 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만).""" folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind - return _call(folder, store.copy_from, part, column, source) + if from_layer == "practice": + if part != "labor" or not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만") + return _call(folder, _copy_practice, column, source) + origin = _root(session, from_layer, from_owner) if from_layer else None + return _call(folder, store.copy_from, part, column, source, origin) @router.get("/layers/{layer}/material-combos") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0ff11389e..e8bedb6c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -186,6 +186,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) + _refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() @@ -335,7 +336,8 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool: def _take_origins(doc: Any) -> dict[str, str]: """표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) · - 그 열에 일위대가 · 자재조합 키가 비었으면 원본 열 것을 받음.""" + 그 열에 자재조합 키가 비었으면 원본 열 것을 받음 · 일위대가는 원본 열 문서를 새 번호로 복사 + (`_ensure` · 39-1b — 조합 키는 안 받음).""" cols = ( [c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else [] ) @@ -348,8 +350,7 @@ def _take_origins(doc: Any) -> dict[str, str]: if (root() / "structure" / f"{col['id']}.json").is_file(): continue out[col["id"]] = source - for field in ("일위대가", "자재조합"): - col[field] = col.get(field) or by_id[source].get(field) + col["자재조합"] = col.get("자재조합") or by_id[source].get("자재조합") return out @@ -365,17 +366,32 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) -def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: +def copy_from( + kind: str, + column: str, + source: str, + source_root: Path | None = None, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: """다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은 - 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂.""" + 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 · + `source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌 + (실무문서 조합 → 일위대가 · 39-1b).""" if kind not in _KEY: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] - doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} + if body is None and source_root is not None: + with at(source_root): + body = read(kind, source)["문서"] + body = read(kind, source)["문서"] if body is None else body + doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}} doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) - return _info(kind, _path(kind, column)) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + return info def _copy_inputs(source: str, column: str) -> None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 6b54347fd..fb4c86d4a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -196,6 +196,22 @@ color: var(--color-text-secondary); } +/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */ +.m02-struct__start { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + padding-bottom: var(--spacing-16); +} + +.m02-struct__start-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding: 0 var(--spacing-16); +} + /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ .m02-pick__list { display: flex; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 6576d077c..77bf194ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -32,6 +32,7 @@ import { fetchLaborPills, fetchStructureNumbers, getActiveLayer, + getActiveOwner, saveTemplate, type StructureNumbers, type TreeEdit, @@ -47,6 +48,7 @@ import { } from "./M02_MasterTemplete_ItemCards"; import { buildMaterialPanel, loadMaterialState, toPills } from "./M02_MasterTemplete_Materials"; import { layerSource } from "./M02_MasterTemplete_ItemSource"; +import { pickHere, pickedName, type From } from "./M02_MasterTemplete_ItemPick"; import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs"; import "./M02_MasterTemplete_Structure.css"; @@ -66,6 +68,7 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + acrossLater: "다른 곳(층 · 사람)에서 가져오기는 서버 길이 선 뒤 — 지금은 같은 곳만", copyFailed: "복사 실패 — {value}", noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", @@ -413,6 +416,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], extraCards: [inputs.root], + // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 + onImport: (kind) => void pickCopy(kind, shown ?? undefined), // 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { can: () => opts.isAdmin, @@ -456,37 +461,31 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return got ? got.key : null; }; - /** 다른 열의 도면 · 면적산출 · 재료산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */ - const pickCopy = async ( - kind: EditKind, - colId: string, - columns: StructureColumn[], - ): Promise => { - const field = PART_FIELD[kind]; + /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise => { const word = PART_WORD[kind]; - const copy = COPY[kind]; - const to = columns.find((c) => c.id === colId); - const toNo = numbers[colId]?.[field]; + const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); - const items = columns - .filter((c) => c.id !== colId && numbers[c.id]?.[field]) - .map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) })); - const from = await choose( - `${fill(TEXT.pickCopy, word)} — ${toNo} ${columnHead(to)}`, - items, - false, - ); - const src = columns.find((c) => c.id === from); - if (!from || !src) return; - const ask = TEXT.overwrite - .replace("{from}", `${numbers[from][field]} ${columnHead(src)}`) - .replace("{what}", word) - .replace("{to}", `${toNo} ${columnHead(to)}`); - if (!(await showConfirmDialog(ask))) return; + const target = `${toNo} ${columnHead(to)}`; + const here = { layer: getActiveLayer(), owner: getActiveOwner() }; + const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; + const picked = await pickHere({ + title: `${fill(TEXT.pickCopy, word)} — ${target}`, + kind: kind === "material" ? "item" : kind, + skip: (from, row) => same(from) && row.열 === to.id, + ask: (p) => + TEXT.overwrite + .replace("{from}", `${p.from.label} ${pickedName(p)}`) + .replace("{what}", word) + .replace("{to}", target), + }); + if (!picked || !("row" in picked)) return; try { - await copy(colId, from); + if (!same(picked.from)) throw new Error(TEXT.acrossLater); // 층 넘는 복사 길(39-1b) 뒤에 이음 + await COPY[kind](to.id, picked.row.열); showToast(TEXT.copied, "success"); - if (column === colId) void load(shown); + if (column === to.id) void load(shown); } catch (error) { showToast(fill(TEXT.copyFailed, why(error)), "error"); } @@ -499,7 +498,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane ): Promise => { if (!opts.isAdmin || !colId) return null; if (row === "s:price") return pickPrice(colId, columns); - if (ROW_KIND[row]) await pickCopy(ROW_KIND[row], colId, columns); + if (ROW_KIND[row]) + await pickCopy( + ROW_KIND[row], + columns.find((c) => c.id === colId), + ); return null; }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 4675ae01c..821a1f8db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -28,7 +28,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { inputPills } from "./M02_MasterTemplete_Inputs"; import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; -import { buildComboBar } from "./M02_MasterTemplete_Combo"; +import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { buildMaterialPanel, @@ -193,7 +193,9 @@ export function createMain( let sheetHandle: SpreadsheetHandle | null = null; let matPanel: MaterialPanel | null = null; let matState: MaterialState | null = null; - let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님) + let cpanel: ComboPanel | null = null; // 재료산출 오른쪽 조합 패널(프로젝트 층 아님) + let apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4) + let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로] let seq = 0; let dirty = false; /** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */ @@ -242,6 +244,8 @@ export function createMain( matState = null; cpanel?.destroy(); cpanel = null; + apanel?.destroy(); + apanel = null; panel?.destroy(); panel = null; extra.replaceChildren(); @@ -264,8 +268,8 @@ export function createMain( return made; } - /** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 · - * 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] · + /** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) · + * 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) · * 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판 * (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */ async function mountSheet( @@ -305,13 +309,29 @@ export function createMain( onChange: () => { dirty = !readOnly; matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신 + apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함 }, + onSelect: (s) => (picked = s.칸), links, }, ); editor = handle; sheetHandle = handle; if (at.layer === "project") return; + if (kind === "area") { + // 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달 + apanel?.destroy(); + apanel = buildAreaPanel({ + layer: at.layer, + column: at.name, + sheet: () => sheetHandle, + picked: () => picked, + pills: () => links.항목, + readOnly, + }); + host.append(apanel.root); + return; + } // 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤) cpanel ??= buildComboPanel({ kind, @@ -320,20 +340,6 @@ export function createMain( onUsed: () => remount(kind, at, readOnly, true), }); host.append(cpanel.root); - if (kind === "area") { - const bar = await buildComboBar({ - column: at.name, - label: at.label ?? at.name, - isAdmin, - beforeEdit: async () => true, - onApplied: async () => { - await remount(kind, at, readOnly, false); - await cpanel?.refresh(); - }, - }); - if (mine === seq) cpanel?.setMine(bar); - return; - } if (matPanel || !matState) return; matPanel = buildMaterialPanel({ column: at.name, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7d10e1875..51c9400c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -48,17 +48,17 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · - * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */ type ColumnKind = "structure" | "labor" | "area" | "material"; const COLUMN_KINDS: { kind: ColumnKind; - key: keyof StructureNumbers | null; + key: keyof StructureNumbers; label: () => string; all: boolean; }[] = [ { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, - { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true }, { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; @@ -262,10 +262,8 @@ export function buildSide(opt: SideOptions): SideHandle { /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { - const { key } = k; - const head = k.all ? [allRow(k.kind as AllKind)] : []; - if (key === null) return body.replaceChildren(...head); - const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 + const { kind, key } = k; + const head = k.all ? [allRow(kind as AllKind)] : []; body.replaceChildren( ...head, ...(columns.length @@ -318,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle { opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: Kind): Promise { + async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py new file mode 100644 index 000000000..61276d60d --- /dev/null +++ b/resources/tester/test_m02_area_panel.py @@ -0,0 +1,48 @@ +"""면적산출 상세 오른쪽 일위대가 패널 · 칸 지정 모달(PLAN 39-4 D2 · D5 · D6) — 화면 조작은 ORCA. + +· 패널 = 그 열 일위대가 보기 갈래(`Labor_View`) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 없음 +· 줄 누름 = 칸 지정 모달 → 스프레드시트 `setLink`(면적 문서 `연결` 한 곳 · 알약과 같은 길) +· 시트 칸 값 · 연결이 바뀌면 `setQty` 로 바로 곱함(서버 안 부름) +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def test_면적_길은_일위대가_패널(): + main = _text("M02_MasterTemplete_UI_Main.ts") + body = main.split("async function mountSheet")[1].split("async function remount")[0] + area = body.split('if (kind === "area") {')[1].split("\n }\n")[0] + assert "apanel = buildAreaPanel({" in area and "host.append(apanel.root);" in area + assert "picked: () => picked," in area and "pills: () => links.항목," in area + assert "apanel?.refresh();" in body and "onSelect: (s) => (picked = s.칸)," in body + assert "buildComboBar" not in main # 조합 줄 · [조합 고치기] 지움 + assert body.index('if (at.layer === "project") return;') < body.index("buildAreaPanel") + assert "apanel?.destroy();\n apanel = null;" in main # drop + + +def test_패널_몸은_보기_갈래_줄_누름은_칸_지정(): + panel = _text("M02_MasterTemplete_AreaPanel.ts") + assert "createLaborView(openLink)" in panel + assert "void view.show(opts.layer, opts.column, quantities(opts.sheet()));" in panel + assert "refresh: () => view.setQty(quantities(opts.sheet()))," in panel + assert "openAreaLink({" in panel and "if (!row.pill) return showToast(TEXT.noPill" in panel + for gone in ("fetchUsableCombos", "setColumnCombo", "branchCombo"): + assert gone not in panel + + +def test_칸_지정_모달은_setLink_만(): + link = _text("M02_MasterTemplete_AreaLink.ts") + assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link + assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link + assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 + assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link + assert "saveTemplate" not in link # 저장은 [저장] diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index f0070d654..4972c8711 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, edit);" in cards + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index d219dd1a4..f9bdf8e14 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -4,8 +4,7 @@ - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. - - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). - - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. + - 나무 고침 · 층 사이 붙여넣기 길은 없음(PLAN 39-2 · 일위대가는 구조물 열마다). - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. 정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. @@ -98,13 +97,6 @@ def _list(client: TestClient, layer: str, **params) -> dict: 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") @@ -154,60 +146,7 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): ) # 조합 고치기 은퇴(39-1d) -# ── 나무 ───────────────────────────────────────────────────────────── -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", 경로=[], 이름="석축", 판="낡은판") - _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["폴더"] == [["석축"]] - - -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()["판"] @@ -227,28 +166,6 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return folder -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") - old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음 - assert [old] in before["폴더"] - _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") - got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더") - assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"] - assert [old] 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["구분"] == old] - ) - assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 - _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") - - # ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): boss = _as(world, "ADMIN") @@ -335,81 +252,10 @@ def test_담을_로직_읽기는_로그인이면_됨(world): assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 -# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── -def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: - body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") - res = client.post(URL.format(layer) + "/paste", json=body, params=params) - assert res.status_code == want, res.text - return res.json() - - -def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): - user = _as(world) - master = _list(user, "system")["줄"][:3] - keys = [r["키"] for r in master] - got = _paste(user, "personal", {"from_layer": "system", "키": keys}) - assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] - rows = {r["키"]: r for r in got["줄"]} - for pair, src in zip(got["새키"], master): - one = rows[pair["새키"]] - assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) - assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 - again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) - assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") - _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 - _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) - _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) - stale = {"from_layer": "system", "키": keys, "판": "낡은판"} - assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 - - -def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): - admin = _as(world, "ADMIN") - for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): - _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) - _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") - body = {"from_layer": "personal", "폴더": [["석축"]]} - got = _paste(admin, "company", body) - assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] - assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] - by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} - assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") - _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") - deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} - assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] - one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} - got = _paste(admin, "company", one) # 한 단 폴더는 들어감 - assert ["다른 곳", "찰쌓기"] in got["폴더"] - - -def test_여러_개_옮기기_지우기_한_번에(world): - me = _as(world) - keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] - _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") - 판 = _list(me, "personal")["판"] - ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] - res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) - assert res.status_code == 200, res.text - assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] - bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} - assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 - assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 - 판 = _list(me, "personal")["판"] - ops = [{"op": "delete", "키": k} for k in keys[1:]] - got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() - assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] - stale = {"판": "낡은판", "ops": ops} - assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 - - -def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): +# ── 나무 길 없음(PLAN 39-2) ──────────────────────────────────────────── +def test_나무_고침_붙여넣기_길은_없음(world): + """PLAN 39-2 — 일위대가 조합 나무 길(`/tree` · `/tree-many` · `/paste`)을 지움.""" admin = _as(world, "SYSTEM_ADMIN") - up = _new(admin, "personal", _combo("개인 것"))["키"] - before = len(_list(admin, "system")["줄"]) - got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) - new = got["새키"][0]["새키"] - assert new.startswith("UA") and len(got["줄"]) == before + 1 - row = next(r for r in got["줄"] if r["키"] == new) - assert row["본뜬키"] is None and row["이름"] == "개인 것" - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert m01.cm.check_form(combos.FILE, doc) == [] + for tail in ("/tree", "/tree-many", "/paste"): + res = admin.post(URL.format("personal") + tail, json={"판": ""}) + assert res.status_code in (404, 405), tail diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 63b809bd7..ccdfa1f68 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -75,7 +75,9 @@ def test_서버_목록_칸_수정일_labor(world): ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) assert set(ditch["수정일"]) >= {"structure", "area"} assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO - assert ditch["labor"] is None # 일위대가 파일 없음 + assert ( + ditch["labor"]["로직수"] == 0 and ditch["labor"]["합계"] is None + ) # 새 열 = 빈 일위대가(39-1b) (root / "labor").mkdir(exist_ok=True) doc = { "일위대가번": "SU-000001", diff --git a/resources/tester/test_m02_labor_copy.py b/resources/tester/test_m02_labor_copy.py new file mode 100644 index 000000000..56ca1b727 --- /dev/null +++ b/resources/tester/test_m02_labor_copy.py @@ -0,0 +1,134 @@ +"""일위대가 복사(PLAN 39-1b B5 · B6) — 항목 하나 복사(같은 층 · 다른 층 · 실무문서 조합) · +구조물 조합 복사 · 분류 더하기 = 새 일위대가번(조합 키는 안 받음). + +시스템 층 = 견본 표(임시 폴더) · 조합 · 로직은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +from __future__ import annotations + +import json +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 M02_MasterTemplete import M02_MasterTemplete_Items as items_store +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 + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/copy" + + +@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) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + for cid in ("rv", "c1"): + store.create_structure(cid) + got = store.read("labor", "rv") + logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}] + store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER") -> TestClient: + world["session"]["role"] = role + return world["client"] + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world): + system = world["system"] + before = _labor(system, "c1") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", "labor", "c1") + "?from=rv") + assert got.status_code == 200 + c1 = _labor(system, "c1") + assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"]) + assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN) + assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈 + + +def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world): + client, personal = _as(world), world["personal"] + url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system" + for _ in range(2): # 저장된 데이터도 다시 바꿈 + assert client.post(url).status_code == 200 + assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN + area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system" + assert client.post(area).status_code == 200 + + +def test_권한_없는_원본_층_403_없는_원본_404(world): + client = _as(world) + other = URL.format("personal", "labor", "a") + "?from=x&from_layer=personal&from_owner=7/99" + assert client.post(other).status_code == 403 + missing = URL.format("personal", "labor", "a") + "?from=없는열&from_layer=system" + assert client.post(missing).status_code == 404 + # 시스템 층 쓰기는 시스템 관리자만 + assert client.post(URL.format("system", "labor", "c1") + "?from=rv").status_code == 403 + + +def test_실무문서_조합에서_일위대가로_시스템_관리자만(world): + url = URL.format("system", "labor", "c1") + "?from=UA000008&from_layer=practice" + assert _as(world).post(url).status_code == 403 + admin = _as(world, "SYSTEM_ADMIN") + assert admin.post(url).status_code == 200 + c1 = _labor(world["system"], "c1") + assert c1["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert len(c1["담은로직"]) == 14 and all(x["입력값"] == {} for x in c1["담은로직"]) + area = URL.format("system", "area", "c1") + "?from=UA000008&from_layer=practice" + assert admin.post(area).status_code == 403 # 실무문서는 일위대가로만 + bad = URL.format("system", "labor", "c1") + "?from=UP1-000001&from_layer=practice" + assert admin.post(bad).status_code == 400 + + +def test_구조물_조합_복사는_새_일위대가번_조합_키는_안_받음(world): + system, personal = world["system"], world["personal"] + with store.at(system): + key = next(r["키"] for r in store.read_combos()["줄"] if r["열"] == "rv") + body = {"from_layer": "system", "key": key} + made = _as(world).post("/api/m02/layers/personal/items/copy", json=body).json() + got = _labor(personal, made["열"]) + src = _labor(system, "rv") + assert got["담은로직"] == src["담은로직"] and got["일위대가번"] != src["일위대가번"] + assert got["일위대가번"].startswith("SU-") + table = json.loads((personal / "table" / f"{store.STRUCTURE_TABLE}.json").read_bytes()) + assert next(c for c in table["열"] if c["id"] == made["열"]).get("일위대가") is None + + +def test_분류_더하기_복사도_새_번호로_일위대가_문서(world): + system = world["system"] + with store.at(system): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + rv = next(c for c in doc["열"] if c["id"] == "rv") + doc["열"].append({**rv, "id": "rv2", "본뜬열": "rv", "일위대가": None}) + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + new, src = _labor(system, "rv2"), _labor(system, "rv") + assert new["담은로직"] == src["담은로직"] and new["일위대가번"] != src["일위대가번"] diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index d4ae1e3f8..9f2cd52b1 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -211,3 +211,32 @@ def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client): assert client.post(url.format("system", "없는열"), json={}).status_code == 404 other = client.post(url.format("personal", "rv") + "?owner=7/99", json={}) assert other.status_code == 403 + + +def test_로직_지우기_검사는_모든_층_일위대가를_봄(tmp_path, monkeypatch): + from config import config_system + from M01_MasterData import M01_MasterData_Store_Cache as cache + + system, storage = tmp_path / "system", tmp_path / "storage" + for folder, code in ( + (system / "labor", STONE), + (storage / "7" / "templates" / "labor", TEN), # 회사 층 + (storage / "7" / "42" / "templates" / "labor", STONE), # 개인 층 + (storage / "7" / "42" / "p1" / "templates" / "labor", STONE), # 프로젝트 — 안 봄 + ): + folder.mkdir(parents=True) + body = {"담은로직": [{"로직": code}]} + (folder / "a.json").write_text(json.dumps(body), encoding="utf-8") + from M02_MasterTemplete import M02_Template_Layers as layers + + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) # 검사는 39-1d 한 벌(층 자리 = layers) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + + class Gone: # STONE 을 지운 마스터 + def logic(self, code): + if code == STONE: + raise cache.cm.mf.FormulaError("없음") + + got = cache.labor_errors(Gone()) + assert len(got) == 2 and all(STONE in x for x in got) + assert any("마스터" in x for x in got) and any("개인 7/42" in x for x in got) diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 0776b560f..e77d15a6d 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -34,7 +34,11 @@ def test_하위_차례_전체_항목_이름(): kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 - assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds + # 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길 + assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side + api = _text("M02_MasterTemplete_Api_Fetch.ts") + assert '| "labor";' in api and "일위대가번: string;" in api assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side assert 'return void selectAll("labor");' in side # 해시 open=일위대가 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 721a75faa..ce9c04253 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -301,17 +301,15 @@ def test_구조물_조합_제목_줄_새_항목(): main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main assert ( - "children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" + "children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]" in main ) assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert ( - "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {})" - in page - ) + assert "...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {})" in page panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") - assert 'void addItem(at?.kind === "folder" ? at.path : []);' in panel + assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder()) + assert "const newItem = (): void => void addItem(folder());" in panel assert ".m02-main__new {\n margin-left: auto;" in _text( M02 / "M02_MasterTemplete_UI_Style.css" ) diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 9398233f1..9c954b3c0 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -175,7 +175,8 @@ def test_회사_항목을_개인으로_복사(world): made = got.json() assert made["열"] == "cw" and made["key"] == "SP42-000001" # 응답 = 개인 층 새 키(바로 고름) mine_rows = user.get("/api/m02/items?layer=personal").json() - assert mine_rows[0]["key"] == made["key"] and mine_rows[0]["names"]["일위대가"] == ua_name + assert mine_rows[0]["key"] == made["key"] + assert mine_rows[0]["names"]["일위대가"] is None # 조합 키는 안 받음 — 일위대가 문서로(39-1b) mine = "/api/m02/layers/personal" copy = user.get(f"{mine}/templates/structure/cw").json()["문서"] assert copy["도면"]["entities"] == [{"type": "line"}] and copy["열"] == "cw" diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index b10a492d8..1df781508 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -46,7 +46,7 @@ def test_소스가_700줄을_넘지_않음(): [ ( "M02_MasterTemplete_Api_Fetch.ts", - ["createStructure", "fetchStructureNumbers", "readTemplate"], + ["createStructure", "fetchStructureNumbers"], # readTemplate 은 39-4 에서 뺌 PANEL, ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 @@ -224,7 +224,7 @@ def test_표_도구_막대에_줄_더하기_줄_지우기_손_입력_열_없음( def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): main = _text(MAIN) - assert "children: [title, back, slot, reload, save, " in main # 제목 바로 오른쪽 + assert "children: [back, title, slot, reload, save, " in main # M01 꼴 — 제목 앞(39-5) assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 면적산출 · 재료산출만 body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index b3f46bb7a..ef9a68c3e 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -301,8 +301,8 @@ export async function laborOf( 번호: null, 로직: got.줄.map((line) => line.로직), 자재: got.자재.length, - base: got.기준합.계 ?? null, - reflected: got.반영합.계 ?? null, + base: got.기준합?.계 ?? null, + reflected: got.반영합?.계 ?? null, gaps: 0, stuck: got.줄.filter((line) => line.까닭).length, };