Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
|
||||
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
|
||||
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
@@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [c for c in doc.get("열") or [] if tree.is_item(c)]
|
||||
|
||||
|
||||
def _unique(path: list[str], taken: list[list[str]]) -> list[str]:
|
||||
n, out = 2, path
|
||||
while out in taken:
|
||||
out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def paste(
|
||||
source: Path,
|
||||
target: Path,
|
||||
@@ -67,6 +60,7 @@ def paste(
|
||||
taken_folders = tree.folders_of(dst)
|
||||
plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로)
|
||||
new_folders: list[list[str]] = []
|
||||
renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름
|
||||
|
||||
def busy(path: list[str]) -> bool:
|
||||
paths = [*taken, *(p for _, p in plan)]
|
||||
@@ -78,16 +72,18 @@ def paste(
|
||||
found = next((p for p, k in src_items if k == key), None)
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)])))
|
||||
used = [*taken, *(p for _, p in plan)]
|
||||
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
|
||||
renamed += [path[-1]] if path[-1] != found[-1] else []
|
||||
plan.append((key, path))
|
||||
continue
|
||||
folder = list(one.get("경로") or [])
|
||||
if not folder or (
|
||||
folder not in src_folders and not any(_under(p, folder) for p, _ in src_items)
|
||||
):
|
||||
raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」")
|
||||
top, n = [*dest, folder[-1]], 2
|
||||
while busy(top):
|
||||
top, n = [*dest, f"{folder[-1]} ({n})"], n + 1
|
||||
top = tree.unique_path([*dest, folder[-1]], busy)
|
||||
renamed += [top[-1]] if top[-1] != folder[-1] else []
|
||||
new_folders.append(top)
|
||||
new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)]
|
||||
plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k]
|
||||
@@ -102,4 +98,7 @@ def paste(
|
||||
doc = got["문서"]
|
||||
doc["폴더"] = [*(doc.get("폴더") or []), *new_folders]
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
return {"op": "paste", "경로": dest, "열": made}
|
||||
note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made}
|
||||
if renamed:
|
||||
note["바뀐이름"] = renamed
|
||||
return note
|
||||
|
||||
@@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]:
|
||||
|
||||
|
||||
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
|
||||
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로
|
||||
(나무는 같은 경로 두 항목을 안 둠)."""
|
||||
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
|
||||
out, n = list(full), 2
|
||||
while out in taken:
|
||||
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
|
||||
return out
|
||||
return tree.unique_path(list(full), lambda p: p in taken)
|
||||
|
||||
|
||||
def create_item(
|
||||
|
||||
@@ -186,6 +186,9 @@ export interface StructureNumbers {
|
||||
면적번: string;
|
||||
/** 일위대가 `SU-######`(39장) */
|
||||
일위대가번: string;
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
@@ -367,6 +370,8 @@ export interface TreeNote {
|
||||
/** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */
|
||||
열?: string | string[];
|
||||
지운항목?: string[];
|
||||
/** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */
|
||||
바뀐이름?: string[];
|
||||
}
|
||||
export interface TreeView {
|
||||
판: string;
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
cardShell,
|
||||
@@ -48,7 +47,7 @@ export type EditKind = ItemPart;
|
||||
/** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */
|
||||
export type CardKind = EditKind | "labor";
|
||||
export type SheetKind = Exclude<EditKind, "structure">;
|
||||
export const PART_FIELD: Record<CardKind, keyof StructureNumbers> = {
|
||||
export const PART_FIELD: Record<CardKind, "도번" | "면적번" | "일위대가번"> = {
|
||||
structure: "도번",
|
||||
labor: "일위대가번",
|
||||
area: "면적번",
|
||||
@@ -100,6 +99,8 @@ export interface ItemCardsOptions {
|
||||
extraCards?: HTMLElement[];
|
||||
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
|
||||
onDirty?: (dirty: boolean) => void;
|
||||
/** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */
|
||||
onSaved?: () => void;
|
||||
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
|
||||
sheetEdit?: SheetEditOptions;
|
||||
/** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */
|
||||
@@ -195,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const no = el("span", { className: "m02-struct__no" });
|
||||
const name = el("span", { className: "m02-struct__title" });
|
||||
const shell = cardShell({ title, body, sub: [no, name] });
|
||||
const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty()));
|
||||
const saver = saveControl(
|
||||
shell,
|
||||
async () => {
|
||||
await save();
|
||||
opts.onSaved?.();
|
||||
},
|
||||
() => opts.onDirty?.(anyDirty()),
|
||||
);
|
||||
const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1)
|
||||
const edit = createButton({
|
||||
label: TEXT.edit,
|
||||
|
||||
@@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀)
|
||||
if body.get("op") not in ("move", "delete") or not targets:
|
||||
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": []}
|
||||
doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []}
|
||||
for one in paste_store.outer(targets):
|
||||
step = {**body, "경로": one.get("경로"), "키": one.get("키")}
|
||||
doc, done = tree.apply(doc, step, ids)
|
||||
note["지운항목"] += done.get("지운항목", [])
|
||||
note["바뀐이름"] += done.get("바뀐이름", [])
|
||||
except tree.TreeError as e:
|
||||
raise store.StoreError(e.status, e.detail) from e
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
|
||||
@@ -267,7 +268,7 @@ def post_paste(
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 ·
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
|
||||
target = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.from_layer, body.from_owner)
|
||||
who = access.grant(session, body.from_layer, body.from_owner).owner
|
||||
|
||||
@@ -226,13 +226,39 @@ def _codes() -> dict[str, str]:
|
||||
return codes
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, str]]:
|
||||
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
def _total(sums: Any) -> float | None:
|
||||
"""`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None."""
|
||||
total = sums.get("계") if isinstance(sums, dict) else None
|
||||
if total is None or isinstance(total, bool):
|
||||
return None
|
||||
try:
|
||||
return float(total)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
for name, doc in _docs(kind):
|
||||
row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS})
|
||||
row[key] = str(doc.get(key) or "")
|
||||
if kind == "labor":
|
||||
row.update(_labor_sum(doc))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -73,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
export function laborSumText(numbers: StructureNumbers | undefined): string {
|
||||
if (typeof numbers?.일위대가합계 !== "number") return "";
|
||||
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
export interface StructureColumn {
|
||||
id: string;
|
||||
@@ -183,6 +191,8 @@ export interface StructurePanelOptions {
|
||||
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
|
||||
* (페이지의 `open`) · label 은 화면 제목에 씀 */
|
||||
onEdit: (column: string, label: string, kind: CardKind) => void;
|
||||
/** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */
|
||||
onNumbers?: () => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
|
||||
@@ -221,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
editable: () => opts.isAdmin,
|
||||
onEdit: (kind) => void openEdit(kind),
|
||||
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
|
||||
// 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3)
|
||||
onSaved: () =>
|
||||
void fetchStructureNumbers().then(
|
||||
(got) => {
|
||||
numbers = got;
|
||||
opts.onNumbers?.();
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
|
||||
@@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
|
||||
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
|
||||
|
||||
|
||||
def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]:
|
||||
"""겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀."""
|
||||
out, n = path, 1
|
||||
while busy(out):
|
||||
out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1
|
||||
return out
|
||||
|
||||
|
||||
def _set_path(col: dict[str, Any], path: list[str]) -> None:
|
||||
col["경로"] = path
|
||||
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
|
||||
@@ -272,21 +281,28 @@ def apply(
|
||||
to = _path(body.get("새경로"), "새경로")
|
||||
if key:
|
||||
col = _find(out, key, ids)
|
||||
moved = [*to, path_of(col)[-1]]
|
||||
_taken_item(out, moved, [col])
|
||||
want = [*to, path_of(col)[-1]]
|
||||
taken = _item_paths(out, [col])
|
||||
moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」
|
||||
out["열"] = [c for c in out["열"] if c is not col]
|
||||
_set_path(col, moved)
|
||||
_place(out, col)
|
||||
note["경로"] = moved
|
||||
if moved != want:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
else:
|
||||
_folder(out, where, "옮길")
|
||||
if _under(to, where):
|
||||
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
|
||||
moved = [*to, where[-1]]
|
||||
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
|
||||
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
|
||||
folders_now, paths_now = folders_of(out), _item_paths(out)
|
||||
moved = unique_path(
|
||||
[*to, where[-1]],
|
||||
lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)),
|
||||
)
|
||||
_move_folder(out, where, moved, replace=True)
|
||||
note["경로"] = moved
|
||||
if moved[-1] != where[-1]:
|
||||
note["바뀐이름"] = [moved[-1]]
|
||||
else: # delete
|
||||
if key:
|
||||
gone = [_find(out, key, ids)]
|
||||
|
||||
@@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
}
|
||||
picked = new Set();
|
||||
paint();
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
const renamed = note?.바뀐이름 ?? [];
|
||||
showToast(
|
||||
renamed.length
|
||||
? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「"))
|
||||
: L("M02_TreeDone"),
|
||||
"success",
|
||||
);
|
||||
await opt.onChanged(col);
|
||||
} catch (error) {
|
||||
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
type StructureColumn,
|
||||
@@ -262,6 +263,7 @@ export function createMain(
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
|
||||
onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3)
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
@@ -357,6 +359,7 @@ export function createMain(
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
Reference in New Issue
Block a user