Merge remote-tracking branch 'origin/dev' into sub_laptop_4
충돌 49개 구조물 json + Store.py + test_m02_structure.py — dev 쪽 산출근거 분리(basis/) 구조를 따르고 내 쪽 도면.layers 기본 도면층을 얹어 합침. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
@@ -7,8 +7,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */
|
||||
export type Kind = "table" | "drawing" | "structure";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「상세 산출근거」 = basis(구조물집계표 열마다 한 통합문서) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "basis";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -95,7 +96,7 @@ export const deleteLayerTemplate = (
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 산출근거는 따로(`basis` · 같은 열 id) */
|
||||
export interface StructureDoc {
|
||||
양식: "구조물도면";
|
||||
종류: "structure";
|
||||
@@ -103,14 +104,13 @@ export interface StructureDoc {
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
산출근거: { 열: unknown[]; 줄: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
|
||||
/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */
|
||||
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
call(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
@@ -118,6 +118,11 @@ export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/* --- ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움) · 저장 자리 = 서버 tmp 견본 --- */
|
||||
export const readSpreadsheetTrial = (): Promise<{ 문서: unknown }> => call("/spreadsheet-trial");
|
||||
export const saveSpreadsheetTrial = (문서: unknown): Promise<{ 문서: unknown }> =>
|
||||
call("/spreadsheet-trial", { method: "PUT", body: JSON.stringify({ 문서 }) });
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -57,3 +61,34 @@ def put_template(kind: str, name: str, body: SaveBody) -> dict:
|
||||
def delete_template(kind: str, name: str, 판: str | None = None) -> dict:
|
||||
_call(store.delete, kind, name, 판)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── ⚠ 임시(스프레드시트 시험 단추 · 사용자 지시 · 1단계 검증 뒤 지움) ──────────
|
||||
# 저장 자리 = `tmp/spreadsheet_trial.json`(git 밖) · 없으면 시트 셋 빈 통합문서.
|
||||
TRIAL_PATH = Path(__file__).resolve().parent.parent / "tmp" / "spreadsheet_trial.json"
|
||||
|
||||
|
||||
def _trial_doc() -> dict[str, Any]:
|
||||
sheets = [{"id": f"s{i}", "이름": f"시트{i}", "칸": {}} for i in (1, 2, 3)]
|
||||
return {"종류": "통합문서", "판": 1, "열": "시험", "서식": [{}], "시트": sheets, "활성": "s1"}
|
||||
|
||||
|
||||
@router.get("/spreadsheet-trial")
|
||||
def get_spreadsheet_trial() -> dict:
|
||||
if not TRIAL_PATH.is_file():
|
||||
return {"문서": _trial_doc()}
|
||||
return {"문서": json.loads(TRIAL_PATH.read_text(encoding="utf-8"))}
|
||||
|
||||
|
||||
class TrialBody(BaseModel):
|
||||
문서: dict[str, Any]
|
||||
|
||||
|
||||
@router.put("/spreadsheet-trial")
|
||||
def put_spreadsheet_trial(body: TrialBody) -> dict:
|
||||
try:
|
||||
doc = spreadsheet.with_server_values(body.문서)
|
||||
except spreadsheet.RecalcError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
atomic_write_json(TRIAL_PATH, doc)
|
||||
return {"문서": doc}
|
||||
|
||||
@@ -22,6 +22,7 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
@@ -260,6 +261,12 @@ async def save_layer_template(
|
||||
if kind == "table" and fill.is_fillable(document):
|
||||
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
|
||||
document = fill.strip_design(document)
|
||||
if kind == "basis":
|
||||
# 산출근거 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음(브라우저 값 버림 · 5장 ②)
|
||||
try:
|
||||
document = await asyncio.to_thread(spreadsheet.with_server_values, document)
|
||||
except spreadsheet.RecalcError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
try:
|
||||
version = await asyncio.to_thread(
|
||||
layers.write_template,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* ⚠ 임시 — [스프레드시트 시험] 화면(1단계 검증 뒤 지움) */
|
||||
.m02-trial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-trial__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.m02-trial__note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.m02-trial__sheet {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
min-height: 420px;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Spreadsheet_Trial.ts
|
||||
* ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 스프레드시트 1단계 검증 뒤 지움:
|
||||
* 이 파일 · `M02_MasterTemplete_UI_Main.ts` 의 시험 단추 · Api_Fetch `*SpreadsheetTrial` ·
|
||||
* Router `/spreadsheet-trial` · 저장 자리 `tmp/spreadsheet_trial.json`).
|
||||
* 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · [시험 저장] = 서버가 같은 엔진으로 다시 풀어 tmp 에 적음.
|
||||
* 글자는 이 파일에 둠.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { readSpreadsheetTrial, saveSpreadsheetTrial } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import "./M02_MasterTemplete_Spreadsheet_Trial.css";
|
||||
|
||||
const TEXT = {
|
||||
save: "시험 저장",
|
||||
note: "임시 시험 화면 — 저장은 서버 tmp 견본 자리(작업 양식과 무관)",
|
||||
saved: "시험 통합문서를 저장했음(서버 재계산)",
|
||||
failed: "스프레드시트 시험 실패 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
export interface TrialHandle {
|
||||
getDoc: () => unknown;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** `onDirty` = 고침 있음 · 저장 뒤 없음(페이지의 떠날 때 물음과 이음) */
|
||||
export function mountSpreadsheetTrial(
|
||||
host: HTMLElement,
|
||||
onDirty: (dirty: boolean) => void,
|
||||
): TrialHandle {
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let alive = true;
|
||||
const save = createButton({ label: TEXT.save, variant: "filled" });
|
||||
const area = el("div", { className: "m02-trial__sheet" });
|
||||
const root = el("div", {
|
||||
className: "m02-trial",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-trial__bar",
|
||||
children: [el("span", { className: "m02-trial__note", text: TEXT.note }), save],
|
||||
}),
|
||||
area,
|
||||
],
|
||||
});
|
||||
host.replaceChildren(root);
|
||||
const fail = (error: unknown): void =>
|
||||
showToast(TEXT.failed.replace("{value}", why(error)), "error");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createSpreadsheet }, got] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
readSpreadsheetTrial(),
|
||||
]);
|
||||
if (!alive) return;
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) });
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) }));
|
||||
fail(error);
|
||||
}
|
||||
})();
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
if (!sheet) return;
|
||||
save.disabled = true;
|
||||
try {
|
||||
await saveSpreadsheetTrial(sheet.getDoc());
|
||||
onDirty(false);
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => sheet?.getDoc() ?? null,
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
sheet?.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,14 +14,22 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
|
||||
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면(그린 만큼이 설계 영역)
|
||||
# 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서)
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위"))
|
||||
_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)")
|
||||
_BASIS_HEAD_STYLE = {
|
||||
"굵게": True,
|
||||
"가로": "center",
|
||||
"세로": "center",
|
||||
"테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")},
|
||||
}
|
||||
_NUMBER = re.compile(r"^구-(\d+)$")
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
|
||||
@@ -84,10 +92,14 @@ def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
|
||||
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면도 같이 만듦(잠금 밖에서 · `create_structure`
|
||||
가 다시 잠금을 잡으므로 안에서 부르면 죽음)."""
|
||||
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면 · 산출근거도 같이 만듦(잠금 밖에서 ·
|
||||
`create_structure` 가 다시 잠금을 잡으므로 안에서 부르면 죽음).
|
||||
산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
||||
(브라우저 값 버림 · 5장 ②)."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
if kind == "basis":
|
||||
doc = _server_values(doc)
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
@@ -129,16 +141,17 @@ def _number(doc: Any) -> int:
|
||||
return int(found.group(1)) if found else 0
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409."""
|
||||
path = _path("structure", column)
|
||||
def _column_ids() -> list[str]:
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}:
|
||||
return [col["id"] for col in table.get("열", []) if isinstance(col, dict) and col.get("id")]
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.
|
||||
산출근거 파일이 없으면 빈 통합문서도 같이 만듦."""
|
||||
path = _path("structure", column)
|
||||
if column not in _column_ids():
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
|
||||
basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD]
|
||||
basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"})
|
||||
# 빈 줄 몇 개를 미리 둠 — 「줄 더하기」 안 눌러도 바로 입력하게
|
||||
basis_rows = [{"id": str(i), "값": {}} for i in range(1, 4)]
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
|
||||
@@ -150,21 +163,107 @@ def create_structure(column: str) -> dict[str, Any]:
|
||||
"열": column,
|
||||
"도번": f"구-{number:02d}",
|
||||
"도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]},
|
||||
"산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": basis_rows},
|
||||
}
|
||||
atomic_write_json(path, doc)
|
||||
ensure_basis(column)
|
||||
return read("structure", column)
|
||||
|
||||
|
||||
# ── 산출근거 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _col_letters(index: int) -> str:
|
||||
letters = ""
|
||||
index += 1
|
||||
while index:
|
||||
index, rest = divmod(index - 1, 26)
|
||||
letters = chr(65 + rest) + letters
|
||||
return letters
|
||||
|
||||
|
||||
def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
|
||||
"""빈 산출근거 통합문서 — 1 행 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)).
|
||||
`old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김
|
||||
(옛 열 식은 없어 안 옮김)."""
|
||||
columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None
|
||||
if columns:
|
||||
heads = [
|
||||
" ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns
|
||||
]
|
||||
else:
|
||||
heads, columns = list(_BASIS_HEAD), []
|
||||
cells: dict[str, Any] = {
|
||||
f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head
|
||||
}
|
||||
for r, row in enumerate(old.get("줄", []) if columns else [], start=2):
|
||||
values = row.get("값", {}) if isinstance(row, dict) else {}
|
||||
for i, col in enumerate(columns):
|
||||
value = values.get(col.get("id"))
|
||||
if value not in (None, ""):
|
||||
cells[f"{_col_letters(i)}{r}"] = {"값": value}
|
||||
widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {}
|
||||
# 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림)
|
||||
info = {
|
||||
_col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)}
|
||||
for i, c in enumerate(columns)
|
||||
if isinstance(widths.get(c.get("id")), (int, float))
|
||||
}
|
||||
sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells}
|
||||
if info:
|
||||
sheet["열"] = info
|
||||
return {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": column,
|
||||
"서식": [{}, _BASIS_HEAD_STYLE],
|
||||
"시트": [sheet],
|
||||
"활성": "s1",
|
||||
}
|
||||
|
||||
|
||||
def ensure_basis(column: str, old: Any = None) -> bool:
|
||||
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
|
||||
path = _path("basis", column)
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
return False
|
||||
atomic_write_json(path, basis_doc(column, old))
|
||||
return True
|
||||
|
||||
|
||||
def _server_values(doc: Any) -> dict[str, Any]:
|
||||
try:
|
||||
return spreadsheet.with_server_values(doc)
|
||||
except spreadsheet.RecalcError as e:
|
||||
raise StoreError(503, str(e)) from e
|
||||
|
||||
|
||||
def migrate_basis() -> dict[str, list[str]]:
|
||||
"""한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고
|
||||
구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서.
|
||||
두 번 돌려도 같음(있는 파일은 안 덮음)."""
|
||||
moved: list[str] = []
|
||||
for name, doc in _structures():
|
||||
if not isinstance(doc, dict) or "산출근거" not in doc:
|
||||
continue
|
||||
ensure_basis(name, doc["산출근거"])
|
||||
rest = {key: value for key, value in doc.items() if key != "산출근거"}
|
||||
with _LOCK:
|
||||
atomic_write_json(_path("structure", name), rest)
|
||||
moved.append(name)
|
||||
made = [column for column in _column_ids() if ensure_basis(column)]
|
||||
return {"옮김": moved, "새로": made}
|
||||
|
||||
|
||||
def backfill_missing_structures() -> list[str]:
|
||||
"""구조물집계표 열마다 빈 구조물 도면이 있게 — 없는 열만 열 차례로 새로 만듦(도번 이어서).
|
||||
"""구조물집계표 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로
|
||||
만듦(도번 이어서).
|
||||
그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀."""
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
have = {name for name, _ in _structures()}
|
||||
made: list[str] = []
|
||||
for col in table.get("열", []):
|
||||
cid = col.get("id") if isinstance(col, dict) else None
|
||||
if not cid or cid in have:
|
||||
for cid in _column_ids():
|
||||
if cid in have:
|
||||
ensure_basis(cid)
|
||||
continue
|
||||
try:
|
||||
create_structure(cid)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Structure.ts
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표.
|
||||
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거.
|
||||
* 카드 둘(도면 정보 · 상세 산출근거)은 좌측 패널과 같은 공용 ui-sidebar 틀.
|
||||
* 상세 산출근거 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`basis/<열 id>.json`) ·
|
||||
* 부품은 이 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임) · [산출근거 저장] 때 서버가 다시 풂.
|
||||
*
|
||||
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
|
||||
* 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
|
||||
@@ -11,7 +13,7 @@
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
fetchStructureNumbers,
|
||||
readTemplate,
|
||||
@@ -36,17 +38,20 @@ const TEXT = {
|
||||
basis: "상세 산출근거",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
noBasis: "표 없음 — 도면이 아직 없음",
|
||||
noBasis: "산출근거 없음",
|
||||
noSheet: "스프레드시트를 못 띄움 — {value}",
|
||||
saved: "산출근거를 저장했음",
|
||||
dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?",
|
||||
kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것",
|
||||
stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것",
|
||||
stale: "그 사이 산출근거가 바뀜 — 열을 다시 고를 것",
|
||||
readFailed: "구조물 도면을 못 읽음 — {value}",
|
||||
saveFailed: "저장 못 함 — {value}",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
/** 스프레드시트는 산출근거 카드를 열 때만 받음 */
|
||||
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
/** 집계표 열 — 머리 글만 씀 */
|
||||
export interface StructureColumn {
|
||||
@@ -83,9 +88,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
let column: string | null = null;
|
||||
let columnLabel = "";
|
||||
let doc: StructureDoc | null = null;
|
||||
let version = "";
|
||||
let basisDoc: Workbook | null = null;
|
||||
let basisVersion = "";
|
||||
let preview: DrawingTemplateHandle | null = null;
|
||||
let basis: SheetHandle | null = null;
|
||||
let basis: SpreadsheetHandle | null = null;
|
||||
let basisDirty = false;
|
||||
let busy = false;
|
||||
let seq = 0;
|
||||
@@ -146,19 +152,34 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
apply();
|
||||
};
|
||||
|
||||
/** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (): void => {
|
||||
/** 산출근거 스프레드시트 — 부품을 늦게 받음 · 그 사이 열이 바뀌면 버림 */
|
||||
const paintBasis = async (mine: number): Promise<void> => {
|
||||
if (!basisDoc) return void basisHost.replaceChildren(note(TEXT.noBasis));
|
||||
try {
|
||||
const { createSpreadsheet } = await loadSpreadsheet();
|
||||
if (mine !== seq || !basisDoc) return;
|
||||
basis = createSpreadsheet(basisHost, basisDoc, {
|
||||
readOnly: !opts.isAdmin,
|
||||
onChange: () => (basisDirty = true),
|
||||
});
|
||||
} catch (error) {
|
||||
if (mine === seq) basisHost.replaceChildren(note(fill(TEXT.noSheet, why(error))));
|
||||
}
|
||||
};
|
||||
|
||||
/** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
|
||||
const paintBody = (mine: number): void => {
|
||||
drop();
|
||||
number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber;
|
||||
edit.hidden = !opts.isAdmin;
|
||||
saveBasis.hidden = !opts.isAdmin || !doc;
|
||||
saveBasis.hidden = !opts.isAdmin || !basisDoc;
|
||||
basisHost.replaceChildren();
|
||||
void paintBasis(mine);
|
||||
if (!doc) {
|
||||
previewHost.replaceChildren(note(TEXT.noDrawing));
|
||||
basisHost.replaceChildren(note(TEXT.noBasis));
|
||||
return;
|
||||
}
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
|
||||
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
|
||||
readOnly: true,
|
||||
@@ -167,10 +188,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, {
|
||||
mode: "master",
|
||||
onChange: () => (basisDirty = true),
|
||||
});
|
||||
};
|
||||
|
||||
async function load(col: StructureColumn | null): Promise<void> {
|
||||
@@ -188,20 +205,23 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
columnName.textContent = columnLabel;
|
||||
number.textContent = numbers[col.id] ?? TEXT.noNumber;
|
||||
doc = null;
|
||||
version = "";
|
||||
basisDoc = null;
|
||||
basisVersion = "";
|
||||
previewHost.replaceChildren();
|
||||
basisHost.replaceChildren();
|
||||
try {
|
||||
const got = await readTemplate("system", "structure", col.id, null);
|
||||
if (mine !== seq) return;
|
||||
doc = got.문서 as StructureDoc;
|
||||
version = got.판 ?? "";
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error");
|
||||
const [drawing, sheet] = await Promise.allSettled([
|
||||
readTemplate("system", "structure", col.id, null),
|
||||
readTemplate("system", "basis", col.id, null),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error");
|
||||
if (sheet.status === "fulfilled") {
|
||||
basisDoc = sheet.value.문서 as Workbook;
|
||||
basisVersion = sheet.value.판 ?? "";
|
||||
}
|
||||
paintBody();
|
||||
paintBody(mine);
|
||||
}
|
||||
|
||||
const show = (colId: string | null, columns: StructureColumn[]): void => {
|
||||
@@ -217,17 +237,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
|
||||
saveBasis.addEventListener("click", async () => {
|
||||
const at = column;
|
||||
if (!at || !doc || !basis || busy) return;
|
||||
if (!at || !basis || busy) return;
|
||||
busy = true;
|
||||
saveBasis.disabled = true;
|
||||
try {
|
||||
const next: StructureDoc = {
|
||||
...doc,
|
||||
산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"],
|
||||
};
|
||||
const info = await saveTemplate("system", "structure", at, null, version, next);
|
||||
version = info.판;
|
||||
doc = next;
|
||||
// 계산값은 서버가 같은 엔진으로 다시 풀어 적음(보낸 값은 버림)
|
||||
const next = basis.getDoc();
|
||||
const info = await saveTemplate("system", "basis", at, null, basisVersion, next);
|
||||
basisVersion = info.판;
|
||||
basisDoc = next;
|
||||
basisDirty = false;
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
// ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움)
|
||||
import { mountSpreadsheetTrial } from "./M02_MasterTemplete_Spreadsheet_Trial";
|
||||
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
@@ -77,6 +79,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 1단계 검증 뒤 지움)
|
||||
const trial = createButton({ label: "스프레드시트 시험", variant: "ghost" });
|
||||
trial.hidden = !isAdmin;
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
@@ -84,7 +89,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
const root = el("div", {
|
||||
className: "m02-main",
|
||||
children: [
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
|
||||
el("div", { className: "m02-main__head", children: [title, slot, reload, save, trial] }),
|
||||
notice,
|
||||
host,
|
||||
extra,
|
||||
@@ -269,6 +274,18 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
||||
}
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
// ⚠ 임시 — [스프레드시트 시험] 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · 저장은 서버 tmp 견본 자리
|
||||
trial.addEventListener("click", async () => {
|
||||
if (!(await confirmLeave())) return;
|
||||
seq += 1;
|
||||
dirty = false;
|
||||
drop();
|
||||
showNotice([]);
|
||||
sel = null;
|
||||
bar();
|
||||
title.textContent = "스프레드시트 시험";
|
||||
editor = mountSpreadsheetTrial(host, (d) => (dirty = d));
|
||||
});
|
||||
reload.addEventListener("click", async () => {
|
||||
if (sel && (await confirmLeave())) void open(sel);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing,structure}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/`
|
||||
system `resources/master_template/{table,drawing,structure,basis}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure,basis}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,basis}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure,basis}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
|
||||
@@ -26,7 +26,8 @@ from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
KINDS = ("table", "drawing", "structure")
|
||||
# basis = 산출근거 통합문서(구조물집계표 열마다 · 스프레드시트 `A00_Common/spreadsheet/`)
|
||||
KINDS = ("table", "drawing", "structure", "basis")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -165,13 +166,14 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any]
|
||||
_SKELETON = {
|
||||
"table": ("열",),
|
||||
"drawing": ("entities",),
|
||||
"structure": ("도면.entities", "산출근거.열"),
|
||||
"structure": ("도면.entities",),
|
||||
"basis": ("시트",),
|
||||
}
|
||||
|
||||
|
||||
def check_skeleton(kind: str, document: Any) -> None:
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 ·
|
||||
구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기)."""
|
||||
구조물 도면 = `도면.entities` 목록 · 산출근거 = `시트` 목록(빈 `{}` 저장 막기)."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
for key in _SKELETON.get(kind, ()):
|
||||
|
||||
Reference in New Issue
Block a user