Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-09-25 12:16:18 +09:00
18 changed files with 595 additions and 132 deletions
@@ -1,14 +1,23 @@
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
import type { FC } from 'react';
import { type FC, useEffect, useState } from 'react';
import { HtmlEvent } from '../App.types';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
import { getHostTitle } from '../state';
export const QuickAccessBar: FC = () => (
export const QuickAccessBar: FC = () => {
const [hostTitle, setTitle] = useState(getHostTitle());
useEffect(() => {
const refresh = () => setTitle(getHostTitle());
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
}, []);
return (
<header className="cad-titlebar controls">
<div className="cad-brand">
<strong>Aislo CAD</strong>
<span>B07 상세 설계</span>
<span>{hostTitle}</span>
</div>
<div className="cad-quick-access">
{QUICK_ACCESS_COMMANDS.map((id) => {
@@ -32,3 +41,4 @@ export const QuickAccessBar: FC = () => (
</div>
</header>
);
};
@@ -21,6 +21,7 @@ import {
setEntities,
setFrameEditMode,
setHostReadOnly,
setHostTitle,
setLayers,
} from '../state.ts';
import { toast } from 'react-toastify';
@@ -50,6 +51,8 @@ interface DrawingLoadMessage {
recoveryScope?: string;
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
readOnly?: boolean;
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string;
}
interface DrawingSaveRequestMessage {
@@ -193,6 +196,7 @@ export function registerAisloDrawingBridge() {
setDesignMeta(event.data.meta ?? null);
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
+7
View File
@@ -192,6 +192,7 @@ let frameEditMode = false;
let frameFields: Record<string, string> = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false;
let hostTitle = 'B07 상세 설계';
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -496,6 +497,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
};
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
export const getHostTitle = (): string => hostTitle;
export const setHostTitle = (title: string) => {
hostTitle = title;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setHostReadOnly = (readOnly: boolean) => {
hostReadOnly = readOnly;
notifyWindow(HtmlEvent.UPDATE_STATE);
@@ -24,12 +24,42 @@ export interface DrawingTemplateDoc extends CadHostDrawing {
}
export interface DrawingTemplateOptions {
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
/** 쓰지 않음 — 저장은 페이지 머리 [저장] 한 곳(`getDoc()` 으로 편집본을 가져감 · 판 · 409 흐름). */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
/** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */
layer?: string;
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
projectId?: string | null;
}
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
function recoveryScopeOf(options: DrawingTemplateOptions): string {
const name = options.name ?? "drawing";
if (!options.layer) return `m02:${name}`;
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
return `m02:${options.layer}${project}:${name}`;
}
/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */
function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void {
const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`;
if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return;
try {
const value = localStorage.getItem(old);
if (value === null) return;
if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) {
localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value);
}
localStorage.removeItem(old);
} catch {
// 저장소를 못 써도 편집은 됨
}
}
export interface DrawingTemplateHandle {
@@ -79,7 +109,8 @@ export function mountDrawingTemplate(
options: DrawingTemplateOptions = {},
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const recoveryScope = `m02:${options.name ?? "drawing"}`;
const recoveryScope = recoveryScopeOf(options);
migrateOldRecovery(options, recoveryScope);
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
@@ -139,7 +170,13 @@ export function mountDrawingTemplate(
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
cad.load(
drawing,
null,
!readOnly,
{},
{ recoveryScope, readOnly, hostTitle: "도면 양식 편집" },
);
};
const actions = document.createElement("div");
@@ -184,27 +221,6 @@ export function mountDrawingTemplate(
return base;
};
if (options.onSave && !readOnly) {
const onSave = options.onSave;
const saveButton = createButton({
label: "저장",
variant: "filled",
onClick: () => {
saveButton.disabled = true;
getDoc()
.then((next) => onSave(next))
.catch((error) =>
showToast(
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
"error",
),
)
.finally(() => (saveButton.disabled = false));
},
});
actions.append(saveButton);
}
toolbar.append(area, fields, actions);
root.append(toolbar, cad.element);
container.append(root);
@@ -116,6 +116,8 @@ export function createMain(isAdmin: boolean): MainHandle {
onSave: () => void doSave(),
readOnly,
name: at.name,
layer: at.layer,
projectId: at.projectId,
});
return;
}
+89 -46
View File
@@ -6,7 +6,8 @@
B06 횡단 설계 `design.pipe_length_m` 관 연장(부르는 쪽이 DB 에서 읽어 넘김)
- 펼침 열 — 마스터 한 칸 → 설계에 쓰인 값마다 열(choices 차례) · 쓰인 값이 없으면 열을 뺌.
열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로).
열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로 · 빈 값은 `-`).
한 묶음 열은 값마다 한데 모음 · 계산 열은 `펼침틀` 로 묶음을 따라 나뉨(바인딩 없음).
- 줄 — 측점마다 한 줄(같은 측점 구조물은 한 줄) · 점 `NO.x+y` · 구간 `NO.a~NO.b` ·
같은 측점에 관이 둘이면 줄을 나눔 · 줄 id 는 측점에서 지어 손 값이 다시 채워도 붙어 있음.
- 손 열 값 · 전구간 줄 · 사용자가 더한 줄(`손`)은 작업본 것을 그대로 지킴.
@@ -35,7 +36,10 @@ FACILITY_TYPES = {
"ford_bridge": "ford_bridge",
"revetment": "revetment",
}
EMPTY_LABEL = "-"
EMPTY_LABEL = "-" # 열 id 안 빈 값(저장본과 맞춤) — 머리 글은 `빈값`
EMPTY_TEXT = "미입력"
#: 계산 열이 설계값 열 묶음을 따라 나뉠 때 쓰는 틀(바인딩 아님 — 표 부품이 설계값으로 안 봄).
FRAME_KEY = "펼침틀"
ORPHAN_NOTE = "설계에서 측점 없어짐"
#: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀.
RULE_COLUMNS_KEY = "양식열"
@@ -271,12 +275,31 @@ def _blank_vars(formula: str, variables: dict[str, Any]) -> list[str]:
return [name for name in names if variables.get(name) in (None, "")]
def _frame(column: dict[str, Any]) -> dict[str, Any]:
"""펼침 틀 — 설계값 열은 `바인딩` · 따라 나뉘는 계산 열은 `펼침틀`(설계값 딱지 없음)."""
return column.get("바인딩") or column.get(FRAME_KEY) or {}
def _group(column: dict[str, Any]) -> str:
return _frame(column).get("묶음") or column["id"]
def _head_part(frame: dict[str, Any], index: int, value: str) -> str:
"""펼친 값 한 자리 머리 글 — `값꼴`(`H={}`) · 빈 값은 `빈값`(`높이 미입력`)."""
if value == EMPTY_LABEL:
empties = frame.get("빈값") or []
return (empties[index] if index < len(empties) else "") or EMPTY_TEXT
shapes = frame.get("값꼴") or []
shape = (shapes[index] if index < len(shapes) else "") or "{}"
return shape.replace("{}", str((frame.get("이름") or {}).get(value, value)))
def _unexpand(columns: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""채운 표가 작업본으로 저장됐으면 펼친 열을 마스터 칸(`원열`)으로 되돌림 — 두 번 안 펼침."""
out: list[dict[str, Any]] = []
seen: set[str] = set()
for column in columns:
origin = (column.get("바인딩") or {}).get("원열")
origin = _frame(column).get("원열")
if origin is None:
out.append(column)
elif origin["id"] not in seen:
@@ -291,62 +314,82 @@ def _expand_columns(
variables: dict[str, Any],
choices: dict[str, list[str]],
) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]:
"""펼침 열 → 값마다 열. 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`."""
"""펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.
"""
columns = _unexpand(columns)
groups: dict[str, set[tuple[str, ...]]] = {}
orders_of: dict[str, list[list[str]]] = {}
members: dict[str, list[dict[str, Any]]] = {}
for column in columns:
binding = column.get("바인딩") or {}
if not column.get("펼침") or not binding:
if not column.get("펼침") or not _frame(column):
continue
group = binding.get("묶음") or column["id"]
orders_of.setdefault(group, _position_orders(binding, choices))
found = groups.setdefault(group, set())
members.setdefault(_group(column), []).append(column)
binding = column.get("바인딩")
if not binding:
continue # 계산 열 — 설계값 열이 연 값을 따름
orders_of.setdefault(_group(column), _position_orders(binding, choices))
found = groups.setdefault(_group(column), set())
for source in _sources(binding):
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
found.update(_expansion(source, item) for item in items if _matches(source, item))
members: dict[str, set[str]] = {}
for column in columns:
binding = column.get("바인딩") or {}
if column.get("펼침") and binding:
members.setdefault(binding.get("묶음") or column["id"], set()).add(column["id"])
out: list[dict[str, Any]] = []
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
for column in columns:
binding = column.get("바인딩") or {}
if not column.get("펼침") or not binding:
if not column.get("펼침") or not _frame(column):
out.append(copy.deepcopy(column))
continue
group = binding.get("묶음") or column["id"]
ordered = sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders_of[group]))
names = binding.get("이름") or {}
for values in ordered:
shown = [names.get(value, value) for value in values]
suffix = "|".join(values)
new = copy.deepcopy(column)
new["id"] = f"{column['id']}|{suffix}"
new["펼침"] = False
new["머리"] = [
None if part is None else part.format(*shown)
for part in (binding.get("머리틀") or column["머리"])
]
new["바인딩"] = {**binding, "펼친값": list(values), "원열": copy.deepcopy(column)}
formula = binding.get("식틀")
formula = formula.format(*values) if formula else column.get("식")
if formula:
formula = _rewrite(formula, members.get(group, set()), suffix)
if _blank_vars(formula, variables):
new.pop("식", None)
new["설명"] = f"{column.get('설명', '')} · 변수가 빈칸이라 계산 안 함".strip()
else:
new["식"] = formula
out.append(new)
made.setdefault(column["id"], []).append((new["id"], suffix, new))
group = _group(column)
if members[group][0] is not column:
continue # 묶음 첫 열 자리에서 값마다 묶음 열을 한데 냄
orders = orders_of.get(group, [])
sibling_ids = {member["id"] for member in members[group]}
for values in sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders)):
for member in members[group]:
new = _expand_one(member, values, sibling_ids, variables)
out.append(new)
made.setdefault(member["id"], []).append((new["id"], "|".join(values), new))
return out, made
def _expand_one(
column: dict[str, Any],
values: tuple[str, ...],
siblings: set[str],
variables: dict[str, Any],
) -> dict[str, Any]:
frame = _frame(column)
suffix = "|".join(values)
shown = [_head_part(frame, index, value) for index, value in enumerate(values)]
new = copy.deepcopy(column)
new["id"] = f"{column['id']}|{suffix}"
new["펼침"] = False
new["머리"] = [
None if part is None else part.format(*shown)
for part in (frame.get("머리틀") or column["머리"])
]
key = "바인딩" if column.get("바인딩") else FRAME_KEY
new[key] = {**frame, "펼친값": list(values), "원열": copy.deepcopy(column)}
template = frame.get("식틀")
used = [int(i) for i in re.findall(r"\{(\d+)\}", template or "")]
blank = ""
if any(values[i] == EMPTY_LABEL for i in used if i < len(values)):
formula, blank = None, "설계값이"
else:
formula = template.format(*values) if template else column.get("식")
if formula:
formula = _rewrite(formula, siblings, suffix)
blank = "변수가" if _blank_vars(formula, variables) else ""
if blank:
new.pop("식", None)
new["설명"] = f"{column.get('설명', '')} · {blank} 빈칸이라 계산 안 함".strip()
elif formula:
new["식"] = formula
return new
def fill_document(
document: dict[str, Any],
items: list[dict[str, Any]],
@@ -400,8 +443,8 @@ def fill_document(
spacing = _number(item["options"].get("joint_spacing_m"))
if item["type_id"] != "pavement_concrete" or spacing in (None, base_spacing):
continue
for new_id, _, new in made.get("pv_jt", []):
if list(_expansion({"펼침": new["바인딩"]["펼침"]}, item)) == new["바인딩"]["펼친값"]:
for new_id, suffix, new in made.get("pv_jt", []):
if "|".join(_expansion({"펼침": ["thickness_cm"]}, item)) == suffix:
text = new.get("식", "").replace("[$수축줄눈_간격_m]", _label("", spacing))
slots[slot].setdefault("식", {})[new_id] = text
@@ -452,11 +495,11 @@ def fill_document(
def _hand_ids(columns: list[dict[str, Any]]) -> set[str]:
"""사람이 적는 열 — 바인딩 없는 열(손 열 · 비고 · 사용자가 더한 열) · 차례 · 측점 빼고."""
"""사람이 적는 열 — 설계값 · 계산 열 말고(손 열 · 비고 · 더한 열) · 차례 · 측점 빼고."""
return {
column["id"]
for column in columns
if not column.get("바인딩") and column["id"] not in ("no", "sta")
if not _frame(column) and not column.get("식") and column["id"] not in ("no", "sta")
}
@@ -209,6 +209,15 @@ def _write_manifest(layer_dir: str | Path, entries: dict[str, Any]) -> None:
atomic_write_json(Path(layer_dir) / MANIFEST_NAME, {"양식": entries})
def note_source(
layer_dir: str | Path, kind: str, name: str, source_layer: str, version: str
) -> None:
"""manifest 한 줄 — 복사가 아니라 고쳐 쓴 양식의 출처 · 판."""
manifest = read_manifest(layer_dir)
manifest[f"{kind}/{name}"] = _stamp(source_layer, name, version)
_write_manifest(layer_dir, manifest)
def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str, Any]:
entry = {
"층": layer,
+62 -8
View File
@@ -3,14 +3,17 @@
프로젝트마다 `seed_project(only_missing=True)` — 작업본 · `_initial/` 에 **없는 것만 더함**.
이미 있는 양식 · 다른 파일은 안 건드림 · 폴더가 없는 프로젝트는 건너뜀(만들지 않음).
다시 돌려도 됨 — 시스템 양식이 늘었으면 그 몫만 더해짐.
`--refresh-tables` — 옛 표 틀(계산 열에 바인딩)만 새 판으로 · 손 값은 지킴.
./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py [--dry-run] [--report 파일]
./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py \\
[--dry-run] [--refresh-tables] [--report 파일]
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import os
import sys
from datetime import datetime
@@ -21,6 +24,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import config_system # noqa: E402
from config.config_db import close_db_pool, get_db_pool, init_db_pool # noqa: E402
from M02_MasterTemplete import M02_Table_Fill as fill # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
@@ -51,13 +55,62 @@ def project_root(storage_path: str | None) -> Path | None:
return path
def migrate(projects: list[dict[str, Any]], *, dry_run: bool = False) -> list[dict[str, Any]]:
"""프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 상태}]`."""
def _stale_frame(document: Any) -> bool:
"""옛 표 틀 — 계산 열에 바인딩(설계값 딱지)이 붙은 판."""
return isinstance(document, dict) and any(
isinstance(column, dict)
and column.get("바인딩")
and (column.get("식") or column["바인딩"].get("값") == "식")
for column in document.get("열") or []
)
def refresh_tables(root: Path, *, dry_run: bool = False) -> list[str]:
"""옛 표 틀을 시스템 새 판으로 — 작업본 · `_initial/` 둘 다 · 줄(손 값) · 변수 · 보기는 지킴.
손댄 것이 없으면 시스템 파일을 그대로 복사(판이 시스템과 같음).
"""
done: list[str] = []
for row in layers.list_templates(layers.system_dir()):
if row["종류"] != "table":
continue
system = layers.read_template(layers.system_dir(), "table", row["이름"])
for folder in (layers.project_dir(root), layers.initial_dir(root)):
got = layers.read_template(folder, "table", row["이름"])
if not system or not got or not _stale_frame(got["문서"]):
continue
done.append(f"{folder.name}/table/{row['이름']}")
if dry_run:
continue
old = fill.strip_design(got["문서"])
doc = copy.deepcopy(system["문서"])
doc["변수"] = {**(doc.get("변수") or {}), **(old.get("변수") or {})}
doc.update({key: old[key] for key in ("줄", "보기") if key in old})
if doc == system["문서"]:
layers.copy_templates(
layers.system_dir(),
folder,
source_layer="system",
kind="table",
name=row["이름"],
)
else:
layers.write_template(folder, "table", row["이름"], doc)
layers.note_source(folder, "table", row["이름"], "system", system["판"])
return done
def migrate(
projects: list[dict[str, Any]], *, dry_run: bool = False, refresh: bool = False
) -> list[dict[str, Any]]:
"""프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 표갱신, 상태}]`."""
system = {f"{row['종류']}/{row['이름']}" for row in layers.list_templates(layers.system_dir())}
report = []
for project in projects:
root = project_root(project.get("storage_path"))
entry = {**project, "작업본": [], "초기": [], "상태": ""}
entry = {**project, "작업본": [], "초기": [], "표갱신": [], "상태": ""}
if root is not None and refresh:
entry["표갱신"] = refresh_tables(root, dry_run=dry_run)
if root is None:
entry["상태"] = "폴더 없음 — 건너뜀"
elif dry_run:
@@ -85,14 +138,14 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str:
"- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로",
f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개",
"",
"| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 상태 |",
"| --- | --- | --- | --- | --- | --- |",
"| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 표 틀 새 판 | 상태 |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
for row in report:
added = " · ".join(row["작업본"]) or "—"
lines.append(
f"| {row['company_id']} | {row.get('owner') or row['user_id']} | {row['name']} "
f"| `{row['id']}` | {added} | {row['상태']} |"
f"| `{row['id']}` | {added} | {' · '.join(row['표갱신']) or '—'} | {row['상태']} |"
)
return "\n".join(lines) + "\n"
@@ -100,6 +153,7 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str:
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--refresh-tables", action="store_true")
parser.add_argument("--report", type=Path)
args = parser.parse_args()
await init_db_pool()
@@ -107,7 +161,7 @@ async def main() -> None:
projects = await _projects()
finally:
await close_db_pool()
report = migrate(projects, dry_run=args.dry_run)
report = migrate(projects, dry_run=args.dry_run, refresh=args.refresh_tables)
text = render(report, layers.list_templates(layers.system_dir()))
if args.report:
args.report.write_text(text, encoding="utf-8")
@@ -3,7 +3,7 @@
"종류": "표",
"판": 1,
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값)",
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)",
"층": ["종류", "공법", "규격"],
"변수": {
"파형강관_1본_m": 8,
@@ -23,7 +23,7 @@
"일위대가": "UA000004",
"바인딩": {
"종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv",
"머리틀": ["돌기슭막이", "{0}", "H={1}"],
"머리틀": ["돌기슭막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형태 미입력", "높이 미입력"],
"이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" },
"더함": [
{ "종류": "pipe", "펼침": ["inlet_revet_form", "inlet_revet_height_m"], "값": "inlet_revet_length_m", "조건": { "inlet_type": "기슭막이" } },
@@ -35,18 +35,18 @@
{
"id": "pv_b", "머리": ["콘크리트포장", "T=(두께마다)", "B"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 폭",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "B"] }
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "B"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이",
"일위대가": "UA000007",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "L"] }
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "L"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "설계값 — 확폭 면적",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "확폭"] }
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "확폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true,
@@ -54,14 +54,14 @@
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — B × L + 확폭",
"일위대가": "UA000006",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "A"] }
"펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "A"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true,
"식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")",
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "수축줄눈"] }
"펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "수축줄눈"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
@@ -69,14 +69,14 @@
"설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔",
"일위대가": "UA000036",
"일위대가후보": ["UA000036", "UA000027"],
"바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "관매설"] }
"바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} {1}", "관매설"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"] }
},
{
"id": "pp_cp", "머리": ["관공", "(관종·관경마다)", "커플링밴드"], "단위": "개소", "꼴": "수", "펼침": true,
"식": "IF([pp_len]>0,ROUNDUP([pp_len]/[$파형강관_1본_m],0)-1,\"\")",
"설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)",
"바인딩": {
"종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "식", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "커플링밴드"],
"펼침틀": {
"묶음": "pp", "머리틀": ["관공", "{0} {1}", "커플링밴드"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"],
"식틀": "IF([pp_len]>0,ROUNDUP([pp_len]/[${0}_1본_m],0)-1,\"\")"
}
},
@@ -87,7 +87,7 @@
"일위대가": "UA000008",
"일위대가후보": ["UA000008", "UA000025", "UA000029"],
"바인딩": {
"종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"],
"종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"], "빈값": ["형식 미입력"],
"이름": { "A-TYPE": "날개벽 A형", "C-TYPE": "날개벽 C형", "A-TYPE+집수정": "날개벽 A형+집수정" },
"더함": [ { "종류": "pipe", "펼침": ["inlet_basin_form"], "값": "개소", "조건": { "inlet_type": "집수정" } } ]
}
@@ -97,14 +97,14 @@
"id": "bx_len", "머리": ["BOX암거", "(폭×높이마다)", "연장"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — BOX 연장(계류 방향)",
"일위대가": "UA000014",
"바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"] }
"바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"], "빈값": ["폭 미입력", "높이 미입력"] }
},
{
"id": "bx_wing", "머리": ["BOX암거", "(폭×높이마다)", "날개벽"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 날개벽 유입·유출 있음 수",
"일위대가": "UA000025",
"바인딩": {
"종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"],
"종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"], "빈값": ["폭 미입력", "높이 미입력"],
"조건": { "wing_in": "있음" },
"더함": [ { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "조건": { "wing_out": "있음" } } ]
}
@@ -113,32 +113,32 @@
{
"id": "fb", "머리": ["세월교", "(관경 × 련수마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 세월교 개소",
"바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "Φ{0}×{1}련", null] }
"바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "{0}×{1}", null], "값꼴": ["Φ{}", "{}련"], "빈값": ["관경 미입력", "련수 미입력"] }
},
{
"id": "fp_w", "머리": ["물넘이포장", "T=(두께마다)", "폭"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 월류 폭",
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "폭"] }
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이(노폭 방향)",
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "길이"] }
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "길이"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] }
},
{
"id": "od_len", "머리": ["횡단개거", "(규격마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 연장",
"일위대가": "UA000026",
"바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"] }
"바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"], "빈값": ["규격 미입력"] }
},
{
"id": "od_cnt", "머리": ["횡단개거", "(규격마다)", "개소"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 · 노출형 횡단수로 개소",
"일위대가": "UA000003",
"바인딩": {
"종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"],
"종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"], "빈값": ["규격 미입력"],
"더함": [ { "종류": "cross_drain_exposed", "펼침": ["=노출형"], "값": "개소" } ]
}
},
@@ -146,13 +146,13 @@
{
"id": "rw", "머리": ["옹벽", "(형식마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 옹벽 길이",
"바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "H={1}"] }
"바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
},
{
"id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 돌쌓기 길이",
"바인딩": {
"종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "H={1}"],
"종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["", "높이 미입력"],
"더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ]
}
},
@@ -160,19 +160,19 @@
"id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만",
"일위대가": "UA000032",
"바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "H={1}"] }
"바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] }
},
{
"id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 큰돌쌓기 길이",
"바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "길이"] }
"바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "길이"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"] }
},
{
"id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "계산 — L × H (H = 펼친 높이)",
"일위대가": "UA000031",
"바인딩": {
"종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "식", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "면적"],
"펼침틀": {
"묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "면적"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"],
"식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")"
}
},
@@ -180,7 +180,7 @@
{
"id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 골막이 개소",
"바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null] }
"바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null], "빈값": ["형식 미입력"] }
},
{
"id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수",
@@ -200,7 +200,7 @@
{
"id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 기타 구조물 개소",
"바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null] }
"바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null], "빈값": ["이름 미입력"] }
},
{ "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" },
+28 -1
View File
@@ -92,10 +92,37 @@ def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> No
assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음
# 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값
assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids
assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "H=-"]
assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "높이 미입력"]
assert _col(doc, "fb|1000|-")["머리"] == ["세월교", "Φ1000×련수 미입력", None]
assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음
def test_묶음_열은_값마다_한데(project: Path) -> None:
ids = [column["id"] for column in _filled(project)["열"]]
assert [i for i in ids if i.startswith("pp_")] == [
"pp_len|흄관|1000",
"pp_cp|흄관|1000",
"pp_len|파형강관|1000",
"pp_cp|파형강관|1000",
]
assert [i for i in ids if i.startswith("pv_")] == [
f"pv_{k}|20" for k in ("b", "l", "w", "a", "jt")
]
def test_계산_열은_설계값_딱지_없음(project: Path) -> None:
master = _master()
assert not [c["id"] for c in master["열"] if c.get("식") and c.get("바인딩")]
doc = _filled(project)
for col_id in ("pv_a|20", "pv_jt|20", "pp_cp|파형강관|1000", "bm_area|메쌓기|40~60|2.5"):
column = _col(doc, col_id)
assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0]
assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"]
# 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림)
again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0})
assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]]
def test_계산_열_식은_같은_묶음_열로_바뀌고_빈_변수면_안_셈(project: Path) -> None:
doc = _filled(project)
assert _col(doc, "pv_a|20")["식"] == 'IF([pv_l|20]>0,[pv_b|20]*[pv_l|20]+[pv_w|20],"")'
@@ -295,3 +295,44 @@ def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dic
stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"]
assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}}
assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"])
def test_옛_표_틀은_새_판으로_손_값은_지킴(world: dict[str, Any]) -> None:
from M02_MasterTemplete import M02_Template_Migrate as migrate
new = {
"판": 2,
"열": [{"id": "a", "식": "1", "펼침틀": {"묶음": "g"}}],
"줄": [],
"변수": {"k": 1},
}
old = {
"판": 1,
"열": [{"id": "a", "식": "1", "바인딩": {"종류": "pipe", "값": "식"}}, {"id": "memo"}],
"줄": [{"id": "s10.00", "값": {"sta": "NO.0+10", "memo": "손"}}],
"변수": {"k": 5},
}
layers.write_template(layers.system_dir(), "table", "구조물집계표", new)
root2, root1 = _root(world, P2, user=43), _root(world, P1)
for folder in (root1 / "templates", root1 / "templates/_initial", root2 / "templates"):
layers.write_template(folder, "table", "구조물집계표", old)
layers.write_template(
root2 / "templates", "table", "구조물집계표", {**old, "줄": [], "변수": {"k": 1}}
)
assert migrate.refresh_tables(root1, dry_run=True) == [
"templates/table/구조물집계표",
"_initial/table/구조물집계표",
]
migrate.refresh_tables(root1)
work = layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"]
assert work["열"] == new["열"] and work["변수"] == {"k": 5} # 틀만 새 판 · 변수 · 손 값 지킴
assert work["줄"] == old["줄"]
system_version = layers.version_of(layers.system_dir() / "table/구조물집계표.json")
initial = root1 / "templates/_initial"
assert layers.version_of(initial / "table/구조물집계표.json") != system_version # 변수 다름
assert layers.read_manifest(root1 / "templates")["table/구조물집계표"]["판"] == system_version
assert migrate.refresh_tables(root1) == [] # 다시 돌려도 그대로
# 손댄 것 없는 작업본은 시스템 파일 그대로(판이 같음)
migrate.refresh_tables(root2)
assert layers.version_of(root2 / "templates/table/구조물집계표.json") == system_version
+81
View File
@@ -0,0 +1,81 @@
"""표 쪽 나눔 — `ui_template_sheet_ops.ts` `pagePlan` 을 Node 로 바로 돌려 봄.
격자(`_render`)가 쪽을 이 계획대로 그림.
줄 120 문서 → project 쪽 3(50 · 50 · 20) · 쪽마다 머리(격자가 쪽마다 thead) · 합계는 마지막 쪽만 ·
「전구간」 줄은 맨 위 · master 는 한 쪽.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
SCRIPT = """
const { pagePlan } = await import(process.argv[1]);
const doc = JSON.parse(process.argv[2]);
const out = {};
for (const mode of ["project", "master"]) {
out[mode] = pagePlan(doc, mode).map((p) => ({
ids: p.rows.map((r) => r.id), first: p.first, totals: p.totals,
}));
}
console.log(JSON.stringify(out));
"""
def _plan(doc: dict) -> dict:
result = subprocess.run(
[
"node",
"--experimental-strip-types",
"--no-warnings",
"--input-type=module",
"-e",
SCRIPT,
OPS.as_uri(),
json.dumps(doc, ensure_ascii=False),
],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _doc(n: int, **head) -> dict:
rows = [{"id": f"p{i}", "값": {}} for i in range(1, n)]
rows.insert(7, {"id": "all", "값": {}, "고정": "전구간"}) # 문서 가운데 있어도 맨 위로
return {"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": rows, **head}
def test_120_rows_make_three_pages_totals_last():
plan = _plan(_doc(120))["project"]
assert [len(p["ids"]) for p in plan] == [50, 50, 20]
assert [p["first"] for p in plan] == [1, 51, 101]
assert [p["totals"] for p in plan] == [False, False, True]
assert plan[0]["ids"][0] == "all"
assert sum((p["ids"] for p in plan), []).count("all") == 1
def test_page_rows_from_doc_and_master_single_page():
got = _plan(_doc(55, 쪽줄=20))
assert [len(p["ids"]) for p in got["project"]] == [20, 20, 15]
assert [len(p["ids"]) for p in got["master"]] == [55]
assert got["master"][0]["totals"] is True
def test_empty_doc_still_one_page_with_totals():
plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"]
assert plan == [{"ids": [], "first": 1, "totals": True}]
+2
View File
@@ -56,6 +56,8 @@ export interface CadLoadExtra {
recoveryScope?: string;
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
readOnly?: boolean;
/** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
hostTitle?: string;
}
export interface CadHost<D extends CadHostDrawing, M, Q> {
+8
View File
@@ -144,6 +144,14 @@
font-weight: 600;
}
.ui-sheet__clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
}
.ui-sheet__chip {
display: inline-block;
margin-right: 4px;
+2 -2
View File
@@ -23,7 +23,6 @@ import {
} from "./ui_template_sheet_ops";
import { recalcSheet } from "./ui_template_sheet_recalc";
import {
columnWidth,
KEY_UNIT,
keyEditable,
navRows,
@@ -280,7 +279,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
event.preventDefault();
handle.setPointerCapture(event.pointerId);
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total };
const now = scroll.querySelector<HTMLElement>(`col[data-col="${CSS.escape(col.id)}"]`);
drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total };
};
const onPointerMove = (event: PointerEvent): void => {
if (!drag) return;
@@ -0,0 +1,103 @@
/* =============================================================================
* ui_template_sheet_labels.ts
* 사람이 읽는 글 — 식의 열 id 를 열 이름(머리 글)으로 · 머리 글이 안 잘리는 최소 열 폭.
*
* 열 이름 = 맨 위 글(종류) + 맨 아래 글 · 다른 열과 겹치면 가운데 층을 더 붙임(자리 글 「(…마다)」 는 뺌).
* 보이기만 바꿈 — 문서의 식은 그대로 id 식(고칠 때도 id 식).
* ========================================================================== */
import { layoutHead } from "./ui_template_sheet_header";
import type { SheetColumn } from "./ui_template_sheet_types";
const PLACEHOLDER = /[((][^))]*마다[))]/;
const NAME_PAD = 16;
const MAX_MIN_WIDTH = 220;
export interface ColumnName {
/** 맨 위 글(종류) + 아래 글 — 표 전체에서 안 겹침. */
full: string;
/** 같은 종류 안에서 부를 이름 — 겹치면 `full`. */
short: string;
top: string;
}
/** 열 id → 열 이름. */
export function columnNames(cols: SheetColumn[]): Map<string, ColumnName> {
const paths = cols.map((c) => {
const path = c.머리.filter((label): label is string => !!label && !PLACEHOLDER.test(label));
return path.length ? path : [c.id];
});
/** 맨 위 + 아래서 `take` 층 — 모자라면 통째. */
const nameOf = (path: string[], take: number): string =>
(take + 1 >= path.length ? path : [path[0], ...path.slice(-take)]).join(" ");
const names = new Map<string, ColumnName>();
cols.forEach((col, j) => {
const path = paths[j];
let full = path.join(" ");
for (let take = 1; take + 1 < path.length; take += 1) {
const name = nameOf(path, take);
if (!paths.some((other, k) => k !== j && nameOf(other, take) === name)) {
full = name;
break;
}
}
const leaf = path.length > 1 ? path.slice(1).join(" ") : path[0];
const clash = paths.some(
(other, k) =>
k !== j &&
other[0] === path[0] &&
(other.length > 1 ? other.slice(1).join(" ") : other[0]) === leaf,
);
names.set(col.id, { full, short: clash ? full : leaf, top: path[0] });
});
return names;
}
/** 식 → 읽는 글 — `[열id]` → 열 이름 · `[열id@줄]` → 이름@줄 · `[$변수]` → 변수 이름 · `*` `/` → × ÷.
* `host` 를 주면 같은 종류 열은 짧은 이름. */
export function formulaLabel(
formula: string,
names: Map<string, ColumnName>,
host?: string,
): string {
const top = host ? names.get(host)?.top : undefined;
return formula
.replace(/\[([^\]]+)\]/g, (_, ref: string) => {
const text = ref.trim();
if (text.startsWith("$")) return text.slice(1).replace(/_/g, " ");
const [col, row] = text.split("@");
const hit = names.get(col.trim());
const name = !hit ? col.trim() : top !== undefined && hit.top === top ? hit.short : hit.full;
return row ? `${name}@${row.trim()}` : name;
})
.replace(/\s*\*\s*/g, " × ")
.replace(/\s*\/\s*/g, " ÷ ");
}
let ruler: CanvasRenderingContext2D | null = null;
function textWidth(text: string, font: string): number {
ruler ??= document.createElement("canvas").getContext("2d");
if (!ruler) return text.length * 13;
ruler.font = font;
return ruler.measureText(text).width;
}
/** 열마다 머리 글 · 단위가 안 잘리는 최소 폭(px) — 여러 열을 덮는 칸은 모자란 만큼 나눠 얹음. */
export function headMinWidths(
cols: SheetColumn[],
depth: number,
font: string,
): Map<string, number> {
const mins = cols.map((c) => textWidth(c.단위 ?? "", font) + NAME_PAD);
for (const level of layoutHead(cols, depth).reverse()) {
for (const cell of level) {
const need = textWidth(cell.label, font) + NAME_PAD;
const have = mins.slice(cell.col, cell.col + cell.colspan).reduce((a, b) => a + b, 0);
if (need <= have) continue;
const extra = (need - have) / cell.colspan;
for (let k = cell.col; k < cell.col + cell.colspan; k += 1) mins[k] += extra;
}
}
return new Map(cols.map((c, j) => [c.id, Math.min(MAX_MIN_WIDTH, Math.ceil(mins[j]))]));
}
@@ -2,6 +2,7 @@
* ui_template_sheet_ops.ts
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
* ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
* ========================================================================== */
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
@@ -9,6 +10,7 @@ import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_s
export type SheetMode = "master" | "project";
export const ROW_FIXED_ALL = "전구간";
const PAGE_ROWS = 50;
/** 겹치지 않는 새 id — `c1` · `c2` … */
function freshId(prefix: string, used: Iterable<string>): string {
@@ -24,6 +26,26 @@ export function orderedRows(doc: SheetDoc): SheetRow[] {
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
}
export interface SheetPage {
rows: SheetRow[];
/** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */
first: number;
/** 합계 줄을 붙이나 — 마지막 쪽만. */
totals: boolean;
}
/** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */
export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] {
const rows = orderedRows(doc);
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length);
const count = Math.max(1, Math.ceil(rows.length / size));
return Array.from({ length: count }, (_, p) => ({
rows: rows.slice(p * size, (p + 1) * size),
first: p * size + 1,
totals: p === count - 1,
}));
}
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
+56 -22
View File
@@ -9,11 +9,13 @@
import { el } from "@ui/ui_template_elements";
import { headDepth, layoutHead } from "./ui_template_sheet_header";
import { columnNames, formulaLabel, headMinWidths } from "./ui_template_sheet_labels";
import {
cellEditable,
isBoundColumn,
isCalcColumn,
orderedRows,
pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
@@ -24,7 +26,6 @@ export const KEY_UNIT = "s:unit";
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
const GUTTER = 64;
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
const PAGE_ROWS = 50;
export interface RenderState {
doc: SheetDoc;
@@ -33,8 +34,18 @@ export interface RenderState {
sel: { r: string; c: string } | null;
}
export const columnWidth = (doc: SheetDoc, col: SheetColumn): number =>
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"];
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
Math.max(
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"],
mins.get(col.id) ?? 0,
);
/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
function headFont(): string {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
}
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
@@ -57,9 +68,13 @@ export function keyEditable(state: RenderState, key: string, col: SheetColumn):
const shown = (text: string | undefined): string =>
!text || text === "0" ? "" : groupDigits(text);
/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */
function kindOf(col: SheetColumn): { cls: string; label: string } | null {
if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") };
if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") };
if (isBoundColumn(col)) {
const label = st("Kind_Bound");
return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label };
}
if (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
return null;
}
@@ -69,6 +84,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const cols = doc.열;
const depth = headDepth(cols, doc.층);
const head = layoutHead(cols, depth);
const names = columnNames(cols);
const mins = headMinWidths(cols, depth, headFont());
const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭]));
for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭);
const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length);
@@ -76,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement {
let left = GUTTER;
for (const col of cols) {
lefts.push(left);
left += columnWidth(doc, col);
left += columnWidth(doc, col, mins);
}
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
@@ -133,19 +150,34 @@ export function renderSheet(state: RenderState): HTMLElement {
cols.forEach((col, j) => {
const text =
key === "s:formula"
? (col.식 ?? "")
? formulaLabel(col.식 ?? "", names, col.id)
: key === "s:desc"
? (col.설명 ?? "")
? ""
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
if (key === "s:formula" && col.식) {
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
td.textContent = "";
td.append(el("div", { className: "ui-sheet__clamp", text }));
td.title = `${formulaLabel(col.식, names)}\n${col.식}`;
}
const kind = kindOf(col);
if (key === "s:desc" && kind) {
const chip = el("span", {
className: `ui-sheet__chip ${kind.cls}`,
text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label,
});
td.prepend(chip);
if (key === "s:desc") {
// 두세 줄로 자름 — 전문은 마우스를 올리면(title)
const desc = col.설명 ?? "";
td.title = desc;
td.append(
el("div", {
className: "ui-sheet__clamp",
children: [
...(kind
? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })]
: []),
desc,
],
}),
);
}
tr.append(td);
});
@@ -159,6 +191,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const th = el("th", {
text: cell.label,
attrs: {
title: cell.label,
colspan: String(cell.colspan),
rowspan: String(cell.rowspan),
"data-level": String(cell.level),
@@ -189,7 +222,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] });
cols.forEach((col, j) => {
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
if (totalFormula(total, col)) td.title = totalFormula(total, col)!;
const formula = totalFormula(total, col);
if (formula) td.title = formulaLabel(formula, names);
tr.append(td);
});
return tr;
@@ -199,22 +233,22 @@ export function renderSheet(state: RenderState): HTMLElement {
children: [
el("col", { attrs: { style: `width:${GUTTER}px` } }),
...cols.map((col) =>
el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }),
el("col", {
attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id },
}),
),
],
});
const data = orderedRows(doc);
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length);
const pageCount = Math.max(1, Math.ceil(data.length / size));
const plan = pagePlan(doc, mode);
const pageCount = plan.length;
const pages: HTMLElement[] = [];
for (let p = 0; p < pageCount; p += 1) {
const slice = data.slice(p * size, (p + 1) * size);
for (const [p, page] of plan.entries()) {
const body = el("tbody", {
children: [
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
...slice.map((row, i) => dataRow(row, p * size + i + 1)),
...(p === pageCount - 1 ? totalRows() : []),
...page.rows.map((row, i) => dataRow(row, page.first + i)),
...(page.totals ? totalRows() : []),
],
});
const table = el("table", {