Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-04 20:38:47 +09:00
28 changed files with 656 additions and 587 deletions
@@ -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: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
@@ -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 {
@@ -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;
}
@@ -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<string, number> {
const out: Record<string, number> = {};
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();
},
};
}
@@ -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}
@@ -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])
@@ -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<EditKind>();
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<void> => {
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;
@@ -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<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
const user = await fetchSessionUser().catch(() => null);
return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) });
}
+11 -3
View File
@@ -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["이름"]}
@@ -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<T>(url: string, method = "GET", body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${url}`, {
method,
@@ -181,36 +172,6 @@ export const deleteCombo = (
scope: LaborScope = SYSTEM_SCOPE,
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */
export const editComboTree = (
body: ComboTreeEdit,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> => request(at(scope, "/tree"), "POST", body);
/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */
export const editComboTreeMany = (
ops: Omit<ComboTreeEdit, "판">[],
판: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> =>
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<ComboList & { 새키: { 원본: string; 새키: string }[] }> =>
request(at(scope, "/paste"), "POST", {
from_layer: from.layer,
from_owner: from.owner,
...pick,
경로,
판,
});
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
@@ -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:
@@ -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")
+23 -7
View File
@@ -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:
@@ -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;
@@ -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<void> => {
const field = PART_FIELD[kind];
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */
const pickCopy = async (kind: EditKind, to: StructureColumn | undefined): Promise<void> => {
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<string | null> => {
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;
};
@@ -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,
@@ -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<void> {
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
+48
View File
@@ -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 # 저장은 [저장]
+1 -1
View File
@@ -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
+8 -162
View File
@@ -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
+3 -1
View File
@@ -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",
+134
View File
@@ -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["일위대가번"]
+29
View File
@@ -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)
+5 -1
View File
@@ -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=일위대가
+4 -6
View File
@@ -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"
)
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -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
@@ -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,
};