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

This commit is contained in:
2026-10-04 23:50:09 +09:00
40 changed files with 1674 additions and 27 deletions
@@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불
그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를
건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다.
표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①):
`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` ·
`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`.
"""
from __future__ import annotations
@@ -31,6 +35,14 @@ logger = logging.getLogger(__name__)
_DXF_VERSION = "R2018"
_DWG_SOURCE_VERSION = "R2004"
_DEFAULT_TEXT_MM = 3.0
#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게
_TABLE_FONT = 2.2
_TABLE_PADDING = 1.0
_ALIGN = {
"left": TextEntityAlignment.MIDDLE_LEFT,
"right": TextEntityAlignment.MIDDLE_RIGHT,
"center": TextEntityAlignment.MIDDLE_CENTER,
}
def _xy(point: Any) -> tuple[float, float] | None:
@@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str:
return name[:255] or "0"
def _span(value: Any) -> int:
"""병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림)."""
number = float(value) if isinstance(value, (int, float)) else 1.0
return max(1, math.floor(number + 0.5))
def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]:
owners: list[list[str | None]] = [[None] * columns for _ in range(rows)]
for row in range(rows):
for column in range(columns):
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
if not isinstance(cell, dict):
continue
for r in range(row, min(rows, row + _span(cell.get("rowSpan")))):
for c in range(column, min(columns, column + _span(cell.get("colSpan")))):
owners[r][c] = f"{row}:{column}"
return owners
def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]:
"""표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로.
바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 ·
정렬에 따라 여백만큼 안쪽."""
origin = _xy(shape.get("origin")) or (0.0, 0.0)
widths = [float(w) for w in shape.get("columnWidths") or []]
heights = [float(h) for h in shape.get("rowHeights") or []]
cells = shape.get("cells") or []
style = shape.get("style") or {}
rows, columns = len(heights), len(widths)
if not rows or not columns:
return [], []
xs, ys = [origin[0]], [origin[1]]
for width in widths:
xs.append(xs[-1] + width)
for height in heights:
ys.append(ys[-1] - height)
owners = _owners(cells, rows, columns)
lines = [
(xs[0], ys[0], xs[columns], ys[0]),
(xs[0], ys[rows], xs[columns], ys[rows]),
(xs[0], ys[0], xs[0], ys[rows]),
(xs[columns], ys[0], xs[columns], ys[rows]),
]
for column in range(1, columns):
for row in range(rows):
left, right = owners[row][column - 1], owners[row][column]
if left is None or left != right:
lines.append((xs[column], ys[row], xs[column], ys[row + 1]))
for row in range(1, rows):
for column in range(columns):
above, below = owners[row - 1][column], owners[row][column]
if above is None or above != below:
lines.append((xs[column], ys[row], xs[column + 1], ys[row]))
raw = style.get("padding")
padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING
texts = []
for row in range(rows):
for column in range(columns):
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
if not isinstance(cell, dict) or not cell.get("text"):
continue
right = xs[min(columns, column + _span(cell.get("colSpan")))]
bottom = ys[min(rows, row + _span(cell.get("rowSpan")))]
align = cell.get("align") if cell.get("align") in _ALIGN else "center"
x = {"left": xs[column] + padding, "right": right - padding}.get(
align, (xs[column] + right) / 2
)
height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT
texts.append(
{
"text": str(cell["text"]),
"x": x,
"y": (ys[row] + bottom) / 2,
"align": align,
"height": float(height),
}
)
return lines, texts
def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None:
lines, texts = table_parts(shape)
for x1, y1, x2, y2 in lines:
space.add_line((x1, y1), (x2, y2), dxfattribs=attribs)
for one in texts:
text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]})
text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]])
def _add_entity(
space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int]
) -> None:
@@ -65,6 +167,9 @@ def _add_entity(
# 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다.
skipped["Image"] = skipped.get("Image", 0) + 1
return
if kind == "Table": # 표 = 선 + 글(칸 정렬 반영)
_add_table(space, shape, attribs)
return
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
if start and end:
@@ -29,6 +29,7 @@ import {
revCloudToolStateMachine,
updateDimensions,
} from '../tools/annotate/leader-table-tools';
import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool';
import {
tableColumnToolStateMachine,
tableEditToolStateMachine,
@@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [
tool: Tool.TABLEUNMERGE,
machine: tableUnmergeToolStateMachine,
},
{
id: 'PLACETABLE',
label: '표 놓기',
glyph: '▦',
hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다',
tool: Tool.PLACETABLE,
machine: placeTableToolStateMachine,
},
];
export const MARKUP_COMMANDS: CadCommand[] = [
@@ -34,6 +34,8 @@ export interface TableStyleOptions {
textColor: string;
/** 문자를 칸 좌우 끝에서 띄우는 거리 */
padding: number;
/** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */
연결?: string;
}
export const DEFAULT_TABLE_STYLE: TableStyleOptions = {
@@ -97,6 +99,11 @@ export class TableEntity implements Entity {
}
// ── 쓰기 (표 편집 명령이 쓴다)
/** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */
public replaceCells(cells: TableCells): void {
this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length);
}
public setCell(row: number, column: number, patch: Partial<TableCell>): void {
const current = this.cells[row]?.[column];
if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다
@@ -4,6 +4,9 @@
*
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
*
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
*/
export interface TableCell {
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
import { registerBoxResizeDrag } from './box-resize-drag.ts';
import { handleTableMessage } from './aislo-table-bridge.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
@@ -190,6 +191,7 @@ function applyFramePreview(): void {
export function registerAisloDrawingBridge() {
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
const message = event.data as Partial<DrawingSaveRequestMessage>;
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
try {
@@ -0,0 +1,119 @@
import { Point } from '@flatten-js/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TableEntity } from '../entities/TableEntity.ts';
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
import {
AISLO_PLACE_TABLE_MESSAGE,
AISLO_TABLE_UPDATED_MESSAGE,
AISLO_UPDATE_TABLE_MESSAGE,
handleTableMessage,
updateLinkedTables,
} from './aislo-table-bridge.ts';
const run = vi.hoisted(() => vi.fn());
vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run }));
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
function table(link?: string): TableEntity {
return new TableEntity(
layer.id,
new Point(10, 20),
[30, 15],
[5, 5],
[
[{ text: '항목', colSpan: 2, key: 'S1!A1' }, null],
[
{ text: '면적', key: 'S1!A2' },
{ text: '12.5', align: 'right', key: 'S1!B2' },
],
],
{ fontSize: 2.5, ...(link ? { 연결: link } : {}) }
);
}
beforeEach(() => {
setLayers([layer]);
setEntities([]);
setPendingTable(null);
run.mockClear();
});
describe('표 놓기(PLACETABLE)', () => {
it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => {
const placed = tableAt(table('T1'), new Point(100, 200), layer.id);
expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]);
expect(placed.getColumnWidths()).toEqual([30, 15]);
expect(placed.getCell(1, 1)?.text).toBe('12.5');
expect(placed.getStyle().연결).toBe('T1');
});
it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => {
const notify = vi.fn();
const handled = await handleTableMessage(
{
type: AISLO_PLACE_TABLE_MESSAGE,
entity: { ...(await table().toJson()), lineColor: '#123456' },
link: 'T7',
},
notify
);
expect(handled).toBe(true);
expect(run).toHaveBeenCalledWith('PLACETABLE');
expect(getPendingTable()?.getStyle().연결).toBe('T7');
expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2);
expect(getPendingTable()?.lineColor).toBe('#123456');
expect(notify).not.toHaveBeenCalled();
});
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
const linked = table('T1');
expect(linked.clone().getStyle().연결).toBe('T1');
const json = await linked.toJson();
if (!json) throw new Error('표 JSON 없음');
const back = await TableEntity.fromJson(json);
expect(back.getStyle().연결).toBe('T1');
});
});
describe('표 칸 갈기(update-table)', () => {
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
const linked = table('T1');
const other = table('T2');
setEntities([linked, other]);
const count = updateLinkedTables('T1', [
[{ text: '항목(새)', colSpan: 2 }, null],
[{ text: '면적' }, { text: '99.0', align: 'right' }],
]);
expect(count).toBe(1);
expect(linked.getCell(1, 1)?.text).toBe('99.0');
expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로
expect(other.getCell(1, 1)?.text).toBe('12.5');
expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]);
expect(isDrawingDirty()).toBe(true);
});
it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => {
const linked = table('T1');
setEntities([linked]);
updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]);
expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([
['a', 'b'],
['', ''],
]);
});
it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => {
setEntities([table('T1')]);
const before = getEntities();
const notify = vi.fn();
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify);
expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 });
expect(getEntities()).toBe(before);
});
it('표 메시지가 아니면 false', async () => {
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
});
});
@@ -0,0 +1,80 @@
/**
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
* → 부모에 table-updated{link, count}
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
*/
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import type { JsonEntity } from '../entities/Entity.ts';
import { TableEntity } from '../entities/TableEntity.ts';
import type { TableCells } from '../helpers/table-geometry.ts';
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
interface TableMessage {
type?: string;
entity?: JsonEntity;
link?: string;
cells?: TableCells;
}
/** 이 표가 그 연결의 것인가 */
export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity =>
entity instanceof TableEntity && entity.getStyle().연결 === link;
/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */
export function updateLinkedTables(link: string, cells: TableCells): number {
const tables = getEntities().filter((entity) => isLinkedTable(entity, link));
for (const table of tables) table.replaceCells(cells);
if (tables.length) setEntities([...getEntities()], true);
return tables.length;
}
/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */
export async function startPlaceTable(entity: JsonEntity, link: string): Promise<void> {
const table = await TableEntity.fromJson(entity as Parameters<typeof TableEntity.fromJson>[0]);
const linked = new TableEntity(
table.layerId,
table.getOrigin(),
table.getColumnWidths(),
table.getRowHeights(),
table.getCells(),
{ ...table.getStyle(), 연결: link }
);
linked.lineColor = table.lineColor; // 보낸 선 색 · 굵기 그대로
linked.lineWidth = table.lineWidth;
setPendingTable(linked);
runCommandInput('PLACETABLE');
}
const blocked = (): boolean => {
if (!isDrawingReadOnly() && !isViewOnly()) return false;
toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.');
return true;
};
/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */
export async function handleTableMessage(
data: unknown,
notify: (type: string, payload: Record<string, unknown>) => void
): Promise<boolean> {
const message = data as TableMessage | null;
if (!message || typeof message !== 'object') return false;
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
if (!blocked()) await startPlaceTable(message.entity, message.link);
return true;
}
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
return true;
}
return false;
}
+1
View File
@@ -113,6 +113,7 @@ export enum Tool {
MLEADERSTYLE = 'MLEADERSTYLE',
MLEADERALIGN = 'MLEADERALIGN',
TABLE = 'TABLE',
PLACETABLE = 'PLACETABLE',
TABLESTYLE = 'TABLESTYLE',
TABLEEDIT = 'TABLEEDIT',
TABLEROW = 'TABLEROW',
@@ -0,0 +1,49 @@
/**
* 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게
* 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음.
* 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다.
*/
import type { Point } from '@flatten-js/core';
import { TableEntity } from '../../entities/TableEntity';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import { createSequenceTool } from '../factories/sequence-tool';
let pending: TableEntity | null = null;
/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */
export function setPendingTable(table: TableEntity | null): void {
pending = table;
}
export function getPendingTable(): TableEntity | null {
return pending;
}
/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */
export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity {
const placed = new TableEntity(
layerId,
at.clone(),
table.getColumnWidths(),
table.getRowHeights(),
table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))),
table.getStyle()
);
placed.lineColor = table.lineColor;
placed.lineWidth = table.lineWidth;
return placed;
}
export const placeTableToolStateMachine = createSequenceTool({
tool: Tool.PLACETABLE,
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
helpers: true,
restart: false,
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
commit: (input) => {
if (!pending) return;
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
pending = null;
},
});
+84
View File
@@ -0,0 +1,84 @@
"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환은 Node 번들 한 벌(`common_util_spreadsheet_node.ts`
`areaTable` · 화면 표시 글과 같은 코드) · 여기는 부르기 · 도면 표 맞춤만(파이썬 이식 없음).
도면 문서(`structure/<열>.json`) 맨 위 `연결표` = `[{연결, 시트, 범위, 글자크기, 값}]` — 도면 표
`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 ·
칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용).
도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from common_util import common_util_spreadsheet as sheet_node
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
logger = logging.getLogger(__name__)
LINKS = "연결표"
def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> dict[str, Any]:
"""그 열 면적산출(저장본) 시트 · 범위 → `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400."""
book = store.read("area", column)["문서"]
try:
got = sheet_node.area_tables([{"book": book, "시트": sheet, "범위": rng, "글자크기": font}])
except sheet_node.RecalcError as e:
raise store.StoreError(503, str(e)) from e
if not got[0].get("ok"):
raise store.StoreError(400, got[0].get("까닭"))
return {k: got[0][k] for k in ("표", "시트", "범위", "값")}
def _style_link(entity: Any) -> Any:
if not isinstance(entity, dict) or entity.get("type") != "Table":
return None
return ((entity.get("shapeData") or {}).get("style") or {}).get("연결")
def refresh_drawing(column: str) -> list[str]:
"""지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
try:
drawing, area = store._path("structure", column), store._path("area", column)
except store.StoreError:
return []
if not drawing.is_file() or not area.is_file():
return []
doc = json.loads(drawing.read_bytes())
links = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")]
if not links:
return []
book = json.loads(area.read_bytes())
asks = [
{"book": book, "시트": x.get("시트"), "범위": x.get("범위"), "글자크기": x.get("글자크기")}
for x in links
]
try:
results = sheet_node.area_tables(asks)
except sheet_node.RecalcError as e:
logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e)
return []
entities = (doc.get("도면") or {}).get("entities") or []
done: list[str] = []
for link, got in zip(links, results):
if not got.get("ok"):
continue
link["값"] = got["값"]
new = got["표"]["shapeData"]
for entity in entities:
if _style_link(entity) != link["연결"]:
continue
shape = entity["shapeData"]
if (len(shape.get("rowHeights") or []), len(shape.get("columnWidths") or [])) != (
len(new["rowHeights"]),
len(new["columnWidths"]),
):
shape["rowHeights"], shape["columnWidths"] = new["rowHeights"], new["columnWidths"]
shape["cells"] = new["cells"]
done.append(link["연결"])
with store._LOCK:
atomic_write_json(drawing, doc)
return done
+1 -1
View File
@@ -67,7 +67,7 @@ def clean(doc: Any) -> dict[str, Any]:
whole = m01.loaded()[1]
lines, seen = [], set()
for at, one in enumerate(doc.get("담은로직") or [], start=1):
where = f"담은 로직 {at}째"
where = f"단가산출 로직 {at}째"
if not isinstance(one, dict):
raise _bad(f"{where} · 묶음 아님")
code = str(one.get("로직") or "")
+1 -1
View File
@@ -127,7 +127,7 @@ const VALUE_TEXT = {
title: "넣을 값",
missing: "빠진 로직 {value}",
edit: "넣을 값 수정",
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음",
auto: "자동값",
unconfirmed: "확인 안 함",
confirmed: "✓ 확인함",
@@ -135,6 +135,14 @@ export interface StructureDoc {
열: string;
도번: string;
도면: { entities: unknown[] } & Record<string, unknown>;
/** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */
연결표?: {
연결: string;
시트: string;
범위: string;
글자크기: number;
값: Record<string, string>;
}[];
}
/** 시스템 층 구조물 도면 목록 */
@@ -0,0 +1,313 @@
/* =============================================================================
* M02_MasterTemplete_AreaToDrawing.ts
* 면적산출 범위 → 도면 표(PLAN 41-3) — 편집 칸(`UI_Main`)은 갈고리만 · 몸은 여기.
* ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5)
* → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열
* 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2)
* ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} ·
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
* ④ 표 여러 개 — id 마다 한 줄
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */
interface TableCell {
text?: string;
[key: string]: unknown;
}
/** CAD 표 객체 JSON(`TableEntity.toJson` 꼴에서 쓰는 것만) */
interface TableEntityJson {
type: string;
shapeData: { cells: (TableCell | null)[][]; style?: Record<string, unknown> } & Record<
string,
unknown
>;
[key: string]: unknown;
}
/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */
export type TableLink = NonNullable<StructureDoc["연결표"]>[number];
type LinkSpec = Pick<TableLink, "시트" | "범위" | "글자크기">;
/** 표 변환 길 답(41-1) — 시트 = 시트 id */
interface AreaTable {
표: TableEntityJson;
시트: string;
범위: string;
값: Record<string, string>;
}
/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */
interface TableHandle {
placeTable?: (entity: Record<string, unknown>, link: string) => void;
updateTable?: (link: string, cells: unknown[][]) => Promise<number>;
}
const TEXT = {
send: "도면으로 보내기",
sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름",
title: "도면으로 보내기",
range: "범위",
size: "글자 크기",
ok: "보내기",
close: "닫기",
badSize: "글자 크기는 0 보다 큰 수",
saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?",
saveLabel: "저장하고 보내기",
failed: "표를 못 만듦 — {value}",
noRange: "보낼 범위를 시트에서 먼저 고름",
empty: "보낼 칸에 내용이 없음",
place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)",
noPlace: "이 도면 화면은 표 놓기를 아직 못 함",
stale: "면적산출 값이 바뀐 표 {n}개",
update: "업데이트",
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
} as const;
const SIZE = 2.5; // 표 글자 크기 기본(정함 41)
const LINK = "연결"; // 표 style 안 표지 칸
const fill = (text: string, key: string, value: string | number): string =>
text.replace(`{${key}}`, String(value));
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const enc = encodeURIComponent;
/** 범위 → 표 객체 · 칸 글(41-1 서버 · 저장본 면적 · 저장 안 함) — 지금 보는 층 · 주인 */
async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise<AreaTable> {
const owner = getActiveOwner();
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/table${q}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }),
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial<AreaTable>;
if (!res.ok || !data.표) {
const d = data.detail;
throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`);
}
return data as AreaTable;
}
const isTable = (e: unknown): e is TableEntityJson =>
!!e && typeof e === "object" && (e as { type?: unknown }).type === "Table";
const linkOf = (e: TableEntityJson): string | null => {
const id = e.shapeData?.style?.[LINK];
return typeof id === "string" && id ? id : null;
};
/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */
const sameValues = (a: Record<string, string> = {}, b: Record<string, string> = {}): boolean => {
const ka = Object.keys(a);
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
};
export interface AreaToDrawingOptions {
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
open: (sel: Selection) => Promise<void>;
/** 지금 화면 [저장] */
save: () => Promise<void>;
/** 저장 안 한 고침이 남았나 */
isDirty: () => boolean;
/** 편집 칸 알림 줄 */
showNotice: (nodes: (HTMLElement | string)[]) => void;
}
export interface AreaToDrawing {
/** 면적 시트 고름(`onSelect`) — 범위를 들고 있음 */
select: (s: { 시트: string; 범위: string }) => void;
/** 면적 상세가 섰음 — 머리 칸에 [도면으로 보내기](고칠 수 있는 층 · 프로젝트 층 아님) */
onArea: (at: Selection, readOnly: boolean, slot: HTMLElement) => void;
/** 도면 상세가 섰음 — 넘겨받은 표 놓기 · 연결표 다름 알림 */
onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void;
/** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */
waiting: () => boolean;
/** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */
drop: () => void;
/** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */
withLinks: (doc: StructureDoc) => StructureDoc;
}
export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
let range: { 시트: string; 범위: string } | null = null;
/** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */
let pending: { layer: Layer; column: string; got: AreaTable; 글자크기: number } | null = null;
/** 이 도면에서 새로 놓은 표 · [업데이트]로 바뀐 값(저장 때 기록) · 열어 둔 도면 */
let fresh = new Map<string, TableLink>();
let latest = new Map<string, Record<string, string>>();
let shown: string | null = null;
/** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */
let waiting = false;
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
const askSize = (spec: { 범위: string }): Promise<number | null> =>
new Promise((resolve) => {
let done = false;
const finish = (v: number | null): void => {
if (!done) resolve(v);
done = true;
};
const size = createInputField({ label: TEXT.size, type: "number", value: String(SIZE) });
size.input.step = "0.5";
size.input.min = "0.1";
const { close } = openModal({
title: TEXT.title,
closeLabel: TEXT.close,
dialogClass: "m02-modal",
beforeClose: async () => (finish(null), true),
mount: (body) => {
const ok = createButton({ label: TEXT.ok, variant: "filled" });
ok.addEventListener("click", () => {
const v = Number(size.input.value);
if (!(v > 0)) return showToast(TEXT.badSize, "error");
finish(v);
close();
});
size.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) ok.click();
});
body.append(el("p", { text: `${TEXT.range} ${spec.범위}` }), size.root, ok);
size.input.select();
},
});
});
async function send(at: Selection): Promise<void> {
if (!range) return showToast(TEXT.noRange, "error");
if (opt.isDirty()) {
if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return;
await opt.save();
if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움
}
const picked = range;
const get = (size: number): Promise<AreaTable | null> =>
fetchTable(at.layer, at.name, { 시트: picked.시트, 범위: picked.범위, 글자크기: size }).catch(
(error: unknown) => (showToast(fill(TEXT.failed, "value", why(error)), "error"), null),
);
// 보이는 글이 하나도 없는 범위(빈 칸만)는 크기를 묻기 전에 막음
let got = await get(SIZE);
if (!got) return;
if (!Object.values(got.값 ?? {}).some((v) => String(v ?? "").trim())) {
return showToast(TEXT.empty, "error");
}
const size = await askSize(picked);
if (size === null) return;
if (size !== SIZE) got = await get(size);
if (!got) return;
pending = { layer: at.layer, column: at.name, got, 글자크기: size };
await opt.open({
layer: at.layer,
projectId: null,
kind: "structure",
name: at.name,
label: at.label,
});
}
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
for (const row of doc.연결표 ?? []) taken.add(row.연결);
for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? "");
let n = 1;
while (taken.has(`T${n}`)) n += 1;
return `T${n}`;
};
/** 연결표마다 지금 면적 값 → 마지막 값과 다르면 알림 + [업데이트] */
async function checkStale(handle: TableHandle, at: Selection, doc: StructureDoc): Promise<void> {
const rows = doc.연결표 ?? [];
if (!rows.length || !handle.updateTable) return;
const key = `${at.layer}:${at.name}`;
const got = await Promise.all(
rows.map((row) =>
fetchTable(at.layer, at.name, row).then(
(e) => ({ row, e }),
() => null,
),
),
);
if (shown !== key) return; // 그 사이 다른 화면
const stale = got.filter(
(x): x is { row: TableLink; e: AreaTable } => !!x && !sameValues(x.row.값, x.e.값),
);
if (!stale.length) return;
const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" });
update.addEventListener("click", async () => {
update.disabled = true;
let n = 0;
for (const { row, e } of stale) {
const changed = (await handle.updateTable?.(row.연결, e.표.shapeData.cells)) ?? 0;
if (changed) latest.set(row.연결, e.값);
n += changed ? 1 : 0;
}
if (shown !== key) return;
opt.showNotice([]);
showToast(fill(TEXT.updated, "n", n), "success");
});
opt.showNotice([fill(TEXT.stale, "n", stale.length), update]);
}
return {
select: (s) => (range = { 시트: s.시트, 범위: s.범위 }),
waiting: () => waiting,
drop: () => (waiting = false),
onArea: (at, readOnly, slot) => {
waiting = false;
// 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림
if (readOnly || at.layer === "project") return;
const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" });
button.title = TEXT.sendTip;
button.dataset.areaToDrawing = "";
button.addEventListener("click", () => void send(at));
slot.append(button);
},
onDrawing: (raw, at, doc) => {
waiting = false;
const handle = raw as TableHandle;
shown = `${at.layer}:${at.name}`;
range = null; // 면적 상세를 떠남
fresh = new Map();
latest = new Map();
const give =
pending && pending.layer === at.layer && pending.column === at.name ? pending : null;
pending = null;
if (give) {
if (!handle.placeTable) showToast(TEXT.noPlace, "error");
else {
const id = nextId(doc, new Set());
const { 표, 시트, 범위, 값 } = give.got;
const style = { ...(표.shapeData.style ?? {}), [LINK]: id };
fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 });
handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id);
waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂)
showToast(TEXT.place, "info");
}
}
void checkStale(handle, at, doc);
},
withLinks: (doc) => {
waiting = false;
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
const rows: TableLink[] = [];
for (const e of doc.도면?.entities ?? []) {
if (!isTable(e)) continue;
const id = linkOf(e);
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
}
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
},
};
}
@@ -77,6 +77,9 @@ function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): voi
export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
placeTable: (entity: Record<string, unknown>, link: string) => void;
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
destroy: () => void;
}
@@ -277,6 +280,8 @@ export function mountDrawingTemplate(
return {
getDoc,
placeTable: cad.placeTable,
updateTable: cad.updateTable,
destroy: () => {
areaModal?.close();
cad.destroy();
@@ -532,8 +532,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
c.host.replaceChildren();
}
const source = opts.source();
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7)
const stops = cards.map((c) => c.shell.loading());
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌:
// 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1)
const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading());
const [drawing, ...books] = await Promise.allSettled([
source.read("structure", col.id),
...sheets.map((c) => source.read(c.kind, col.id)),
@@ -94,7 +94,7 @@ export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
NAME,
FOLDER,
{ head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3)
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
{ head: "단가산출 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
{ head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note },
changed("labor"),
],
@@ -62,7 +62,7 @@ const PRACTICE_COLS: Col<ComboSummary>[] = [
{ head: "구분", get: (c) => c.구분 ?? "" },
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
{ head: "단위", get: (c) => c.단위 ?? "" },
{ head: "담은 로직 수", get: (c) => String(c.count) },
{ head: "단가산출 로직 수", get: (c) => String(c.count) },
];
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
@@ -282,6 +282,52 @@
}
}
/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 ·
머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */
@container m02-labview (max-width: 340px) {
.m02-labline,
.m02-labline thead,
.m02-labline tbody,
.m02-labline tfoot {
display: block;
width: 100%;
}
.m02-labline tr {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-bottom: 1px solid var(--color-border);
}
.m02-labline th,
.m02-labline td {
display: block;
min-width: 0;
padding: 2px;
border: 0;
}
.m02-labline th:first-child,
.m02-labline .m02-labrow__name {
grid-column: span 3;
}
.m02-labline th:nth-child(2),
.m02-labline .m02-labrow__qty {
text-align: right;
white-space: normal;
}
.m02-labline th:nth-child(3),
.m02-labline .m02-labrow__unit {
display: none;
}
.m02-labline .m02-labrow__num {
font-size: 11px;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
.m02-labrow__qty {
white-space: nowrap;
@@ -196,10 +196,16 @@ export function mountLaborDetail(
});
if (!got || !alive) return false;
const info = pills.get(pill);
const next = linkAreaCell(area.doc, pill, got.칸, {
...(info?.이름 ? { 이름: info.이름 } : {}),
...(info?.단위 ? { 단위: info.단위 } : {}),
});
const next = linkAreaCell(
area.doc,
pill,
got.칸,
{
...(info?.이름 ? { 이름: info.이름 } : {}),
...(info?.단위 ? { 단위: info.단위 } : {}),
},
got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안)
);
if (!next) {
showToast(tc("Labor_BadCell"), "error");
return false;
@@ -381,10 +381,11 @@ export function compactTable(data: CompactData): HTMLElement {
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
});
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
const heads = [
tc("Col_Name"),
tc("Labor_Item"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
tc("Labor_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
];
const lines = data.rows.map((row) => {
@@ -57,6 +57,7 @@ const TEXT = {
Labor_AddMat: ["자재 추가", "Add material"],
Labor_Qty: ["수량", "Quantity"],
Labor_QtyCol: ["수량산출", "Quantity"],
Labor_Item: ["항목", "Item"],
Labor_Hand: ["손", "Manual"],
Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"],
In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"],
@@ -317,6 +317,29 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]:
return store.copy_from("labor", column, key, body=labor.from_combo(key))
class AreaTableBody(BaseModel):
"""면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `글자크기` 기본 2.5."""
시트: str
범위: str
글자크기: float | None = None
@router.post("/layers/{layer}/area/{column}/table")
def post_area_table(
layer: Layer,
column: str,
body: AreaTableBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""그 열 면적산출(저장본) 범위 → CAD 표 `{표, 시트, 범위, 값}` — 저장 안 함 · 층 읽기 권한."""
from M02_MasterTemplete import M02_Area_Table as area_table
folder = _root(session, layer, owner)
return _call(folder, area_table.to_table, column, body.시트, body.범위, body.글자크기)
@router.post("/layers/{layer}/{kind}/{column}/clear")
def post_clear(
layer: Layer,
@@ -361,9 +361,18 @@ async def save_layer_template(
) from error
except ValueError as error:
raise _bad(error) from error
if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가)
await asyncio.to_thread(_refresh_drawing, folder, name)
return {"종류": kind, "이름": name, "판": version, "층": layer}
def _refresh_drawing(folder: Path, column: str) -> None:
from M02_MasterTemplete import M02_Area_Table as area_table
with store.at(folder):
area_table.refresh_drawing(column)
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
with store.at(folder):
try:
@@ -170,6 +170,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
info = _info(kind, path)
if kind == "area":
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
_refresh_drawing(name) # 도면 연결표 칸 글도(41-1)
if kind == "table" and name == STRUCTURE_TABLE:
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
for part in _KEY:
@@ -362,6 +363,7 @@ def clear(kind: str, column: str) -> dict[str, Any]:
info = _info(kind, _path(kind, column))
if kind == "area":
_refresh_labor(column)
_refresh_drawing(column)
sync_combos()
return info
@@ -391,6 +393,7 @@ def copy_from(
info = _info(kind, _path(kind, column))
if kind == "area":
_refresh_labor(column)
_refresh_drawing(column)
return info
@@ -482,6 +485,13 @@ def _server(kind: str, doc: Any) -> Any:
return doc
def _refresh_drawing(column: str) -> None:
"""그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1)."""
from M02_MasterTemplete import M02_Area_Table as area_table
area_table.refresh_drawing(column)
def _refresh_labor(column: str) -> None:
"""그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠."""
path = _path("labor", column)
@@ -51,7 +51,7 @@ const TEXT = {
pickCopy: "{value} 복사해 가져오기",
copied: "복사해 가져옴",
copyFailed: "복사 실패 — {value}",
noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
};
@@ -28,6 +28,7 @@ import {
} from "./M02_MasterTemplete_Structure";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
export interface Selection {
@@ -184,7 +185,7 @@ export function createMain(
let seq = 0;
let dirty = false;
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */
const busy = (): boolean => dirty || !!panel?.dirty();
const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting(); // + 놓기 기다리는 표(41)
const guard = (event: BeforeUnloadEvent): void => {
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
@@ -203,6 +204,7 @@ export function createMain(
return false;
}
dirty = false;
toDrawing.drop();
return true;
};
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
@@ -220,6 +222,9 @@ export function createMain(
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
};
// 면적산출 범위 → 도면 표(41-3) — 몸은 `AreaToDrawing` · 여기는 갈고리만
const toDrawing = createAreaToDrawing({ open, save: doSave, isDirty: busy, showNotice });
const drop = (): void => {
editor?.destroy();
editor = null;
@@ -275,12 +280,13 @@ export function createMain(
dirty = !readOnly;
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
},
onSelect: (s) => (picked = s.칸),
onSelect: (s) => ((picked = s.칸), toDrawing.select(s)),
links,
},
);
editor = handle;
sheetHandle = handle;
toDrawing.onArea(at, readOnly, slot); // 머리 [도면으로 보내기]
if (at.layer === "project") return;
if (kind === "area") {
// 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달
@@ -411,6 +417,7 @@ export function createMain(
originMarker: at.kind === "structure",
noArea: at.kind === "structure",
});
if (at.kind === "structure") toDrawing.onDrawing(editor, at, doc as StructureDoc);
return;
}
empty(L("M02_NoDrawing"));
@@ -470,7 +477,10 @@ export function createMain(
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
const doc =
at.kind === "structure" && loaded
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
? toDrawing.withLinks({
...(loaded as StructureDoc),
도면: edited as StructureDoc["도면"],
})
: at.kind === "area" && loaded
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
: edited;
+11
View File
@@ -54,3 +54,14 @@ def with_server_values(book: dict[str, Any]) -> dict[str, Any]:
clean.pop("계산값", None)
clean["계산값"] = recalc_workbooks([clean])[0]
return clean
def area_tables(asks: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""면적산출 시트 · 범위 → CAD 표 객체(41-1 · 변환은 번들 `areaTable` 한 벌) — 물음 차례 그대로
`{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`."""
if _stale() and not build_bundle(NPM_SCRIPT):
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"tables": asks})
if out is None:
raise RecalcError("스프레드시트 계산 번들 실행 실패")
return out["tables"]
+145 -5
View File
@@ -6,15 +6,145 @@
* 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { books: Workbook[] }
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂
* 입력 { books?: Workbook[], tables?: TableAsk[] }
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[],
* tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂
* 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 ·
* 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함.
* 끝 코드: 0 성공 / 2 인자 오류
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import {
colName,
parseRange,
rangeToA1,
rangesOverlap,
toA1,
} from "../A00_Common/spreadsheet/spreadsheet_address";
import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle";
import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers";
import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */
interface TableAsk {
book: Workbook;
시트: string;
범위: string;
글자크기?: number;
한도?: number;
}
/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */
interface TableCell {
text: string;
colSpan?: number;
rowSpan?: number;
align?: "left" | "center" | "right";
bold?: boolean;
key: string;
}
const FONT = 2.5;
const LIMIT = 2000;
/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */
const COL_K = 0.7;
const ROW_K = 1.8;
const ALIGN: Partial<Record<HAlign, TableCell["align"]>> = {
left: "left",
center: "center",
right: "right",
distributed: "center",
centerContinuous: "center",
};
function areaTable(ask: TableAsk) {
const book = ask.book;
const sheet =
book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트);
if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`);
const range = parseRange(ask.범위);
if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`);
const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT;
const limit = ask.한도 ?? LIMIT;
const span = (a: number, b: number): number[] =>
Array.from({ length: b - a + 1 }, (_, i) => a + i);
const rowShown = (r: number): boolean => {
const info = sheet.행?.[String(r + 1)];
return !info?.숨김 && !info?.걸러짐;
};
const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김;
if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4)
throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음
const rows = span(range.r0, range.r1).filter(rowShown);
const cols = span(range.c0, range.c1).filter(colShown);
if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음");
if (rows.length * cols.length > limit)
throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`);
const engine = createCalcEngine(book);
const merges: { whole: CellRange; part: CellRange }[] = [];
for (const text of sheet.병합 ?? []) {
const whole = parseRange(text);
if (!whole || !rangesOverlap(whole, range)) continue;
const part = {
r0: Math.max(whole.r0, range.r0),
c0: Math.max(whole.c0, range.c0),
r1: Math.min(whole.r1, range.r1),
c1: Math.min(whole.c1, range.c1),
}; // 범위 밖 병합은 잘라 냄
merges.push({ whole, part });
}
const mergeAt = (r: number, c: number) =>
merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1);
const 값: Record<string, string> = {};
const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => {
const style = cellStyleAt(book, sheet, at.r, at.c);
const has = sheet.칸[toA1(at.r, at.c)];
const shown =
has && (has.값 !== undefined || has.식 !== undefined)
? formatValue(engine.value(sheet.id, at.r, at.c), style.형식)
: { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
};
const cells = rows.map((r) =>
cols.map((c): TableCell | null => {
const merge = mergeAt(r, c);
if (!merge) return cellOf(r, c, { r, c });
const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1);
const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1);
if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리
const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸
return {
...cell,
...(inCols.length > 1 ? { colSpan: inCols.length } : {}),
...(inRows.length > 1 ? { rowSpan: inRows.length } : {}),
};
}),
);
const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS;
const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT;
const 표 = {
type: "Table",
lineWidth: 1,
shapeData: {
origin: { x: 0, y: 0 },
columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K),
rowHeights: rows.map(
(r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K,
),
cells,
style: { fontSize: font, padding: 1, 연결: "" },
},
};
return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 };
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
@@ -22,7 +152,10 @@ if (!inputPath || !outputPath) {
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] };
const input = JSON.parse(readFileSync(inputPath, "utf8")) as {
books?: Workbook[];
tables?: TableAsk[];
};
const results = (input.books ?? []).map((book) => {
try {
return { ok: true, 계산값: recalcWorkbook(book) };
@@ -30,4 +163,11 @@ const results = (input.books ?? []).map((book) => {
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
}
});
writeFileSync(outputPath, JSON.stringify({ results }));
const tables = (input.tables ?? []).map((ask) => {
try {
return { ok: true, ...areaTable(ask) };
} catch (error) {
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
}
});
writeFileSync(outputPath, JSON.stringify({ results, tables }));
@@ -0,0 +1,41 @@
/* 표 기하(`openwebcad/src/helpers/table-geometry.ts`) 거울 헬퍼 — 서버 DXF `table_parts` 와 대조.
* stdin JSON [표 shapeData] → stdout JSON [{lines, texts}] (TS 규칙 그대로). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"B07_DesignDetail",
"openwebcad",
"src",
"helpers",
"table-geometry.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const g = box.exports;
const shapes = JSON.parse(fs.readFileSync(0, "utf8"));
const out = shapes.map((s) => {
const lines = g
.tableBorders(s.origin, s.columnWidths, s.rowHeights, s.cells)
.map((b) => [b.x1, b.y1, b.x2, b.y2]);
const padding = s.style?.padding ?? 1;
const texts = [];
for (let row = 0; row < s.rowHeights.length; row += 1)
for (let column = 0; column < s.columnWidths.length; column += 1) {
const cell = s.cells[row]?.[column];
if (!cell?.text) continue;
const rect = g.cellRect(s.origin, s.columnWidths, s.rowHeights, row, column, cell);
const at = g.cellTextAnchor(rect, cell.align, padding);
texts.push({ text: cell.text, x: at.x, y: at.y, align: cell.align ?? "center" });
}
return { lines, texts };
});
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,119 @@
"""DXF 내보내기 표 = 선 + 글(PLAN 41-2) — `B07_DesignDetail_Engine_Frame_Export`.
- 거울: `table_parts` 경계선 · 글 자리 = openwebcad `table-geometry.ts`(Node 헬퍼) —
병합 · 정렬 · 여백
- DXF: M02 면적산출 표 · B07 수량표(`table_entity`) 모두 LINE · TEXT 로 나옴(전에는 조용히 빠짐) ·
글 정렬(MIDDLE_LEFT · CENTER · RIGHT) · 높이 = 칸 글자 크기 → 표 글자 크기
"""
from __future__ import annotations
import json
import os
import subprocess
import ezdxf
import pytest
from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity
HERE = os.path.dirname(os.path.abspath(__file__))
AREA_TABLE = {
"origin": {"x": 10, "y": 50},
"columnWidths": [30, 15, 15],
"rowHeights": [5, 5, 5],
"cells": [
[{"text": "항목", "colSpan": 3}, None, None],
[
{"text": "면적", "rowSpan": 2, "align": "left"},
{"text": "12.5", "align": "right"},
{"text": "㎡"},
],
[None, {"text": "1,250", "align": "right", "fontSize": 3}, {"text": ""}],
],
"style": {"fontSize": 2.5, "padding": 1, "연결": "T1"},
}
PLAIN = {
"origin": {"x": 0, "y": 0},
"columnWidths": [10, 10],
"rowHeights": [4],
"cells": [[{"text": "a"}, {"text": "b", "align": "left"}]],
"style": {"padding": 0},
}
def _mirror(shapes: list[dict]) -> list[dict]:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b07_table_geometry.cjs")],
input=json.dumps(shapes, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_거울_경계선_글_자리가_TS_와_같음():
shapes = [AREA_TABLE, PLAIN]
for shape, ts in zip(shapes, _mirror(shapes)):
lines, texts = export.table_parts(shape)
flat = [value for line in lines for value in line]
assert flat == pytest.approx([value for line in ts["lines"] for value in line])
assert len(lines) == len(ts["lines"])
assert [(t["text"], t["x"], t["y"], t["align"]) for t in texts] == [
(t["text"], pytest.approx(t["x"]), pytest.approx(t["y"]), t["align"])
for t in ts["texts"]
]
def test_병합_자리는_선을_끊음():
lines, _ = export.table_parts(AREA_TABLE)
# 바깥 4 · 안쪽 세로 4(1행 병합 자리 끊김) · 가로 5(「면적」 세로 병합 자리 끊김)
vertical = [line for line in lines[4:] if line[0] == line[2]]
horizontal = [line for line in lines[4:] if line[1] == line[3]]
assert len(vertical) == 4 and len(horizontal) == 5
def _dxf(entities: list[dict], tmp_path) -> ezdxf.document.Drawing:
raw, skipped = export.drawing_to_dxf({"entities": entities, "layers": []})
assert skipped == {}
path = tmp_path / "out.dxf"
path.write_bytes(raw)
return ezdxf.readfile(path)
def test_면적산출_표가_LINE_TEXT_로_나옴(tmp_path):
doc = _dxf([{"type": "Table", "layerId": "기본", "shapeData": AREA_TABLE}], tmp_path)
lines, texts = export.table_parts(AREA_TABLE)
space = doc.modelspace()
assert len(space.query("LINE")) == len(lines) == 13
found = {t.dxf.text: t for t in space.query("TEXT")}
assert set(found) == {"항목", "면적", "12.5", "㎡", "1,250"} and len(texts) == 5
assert found["면적"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_LEFT
assert found["12.5"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_RIGHT
assert found["항목"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER
assert found["1,250"].dxf.height == 3 and found["항목"].dxf.height == 2.5
def test_B07_수량표도_같이_나옴(tmp_path):
qtable = table_entity(
"t:qtable",
(0, 0),
[20, 20],
[5, 5],
[
[{"text": "토공", "colSpan": 2}, None],
[{"text": "절토"}, {"text": "12.30", "align": "right"}],
],
"b08-cross-table",
"#ffffff",
2.0,
"#ffffff",
)
space = _dxf([qtable], tmp_path).modelspace()
assert len(space.query("LINE")) == 7 # 바깥 4 · 세로 1(1행 병합 자리 끊김) · 가로 2
assert sorted(t.dxf.text for t in space.query("TEXT")) == ["12.30", "절토", "토공"]
+201
View File
@@ -0,0 +1,201 @@
"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환(Node 번들 `areaTable`) · 길 · 저장 때 도면 표 맞춤.
시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 마스터 양식 · 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 import common_util_spreadsheet as sheet_node
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Area_Table as area_table
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_Router_Layers as layers_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
SHEET = {
"id": "s1",
"이름": "면적산출",
"칸": {
"A1": {"값": "제목", "서식": 2},
"B1": {"값": 1234.5, "서식": 1},
"A2": {"값": "합"},
"B2": {"식": "B1*2", "서식": 1},
"A3": {"값": "숨김행"},
"C1": {"값": "x"},
},
"병합": ["A1:A2", "C1:E5"],
"행": {"3": {"숨김": True}},
"열": {"D": {"숨김": True}, "B": {"폭": 12}},
}
BOOK = {
"종류": "통합문서",
"판": 1,
"열": "rv",
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
"시트": [SHEET],
"활성": "s1",
}
def _one(**ask) -> dict:
return sheet_node.area_tables([{"book": BOOK, **ask}])[0]
def test_표시_글_형식_굵게_정렬_칸_key():
got = _one(시트="면적산출", 범위="A1:B2")
cells = got["표"]["shapeData"]["cells"]
assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"}
assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식
assert cells[0][0]["align"] == "center" # 가로 서식
assert (got["시트"], got["범위"]) == ("s1", "A1:B2")
assert got["값"]["s1!B2"] == "2,469.00"
assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""}
def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"]
cells = shape["cells"]
assert len(cells) == 3 and all(len(row) == 3 for row in cells) # 행 3 · 열 D 뺌
assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None
assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김)
assert cells[1][2] is None and cells[2][2] is None
assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸
assert shape["style"]["fontSize"] == 5
assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율
assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3
@pytest.mark.parametrize(
"ask, why",
[
({"시트": "없음", "범위": "A1"}, "없는 시트"),
({"시트": "s1", "범위": "가나"}, "범위 꼴 아님"),
({"시트": "s1", "범위": "A3"}, "보이는 칸이 없음"),
({"시트": "s1", "범위": "A1:Z200", "한도": 100}, "칸이 너무 많음"),
({"시트": "s1", "범위": "A1:J20", "한도": 100}, "칸이 너무 많음 171"),
],
)
def test_못_바꾸는_범위는_까닭(ask, why):
got = _one(**ask)
assert not got["ok"] and why in got["까닭"]
# ── 길 · 저장 때 맞춤 ──────────────────────────────────────────────────
@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):
store.create_structure("rv")
got = store.read("area", "rv")
store.write("area", "rv", got["판"], {**got["문서"], "서식": BOOK["서식"], "시트": [SHEET]})
items_store.ensure(layers.personal_dir(7, 99))
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}
URL = "/api/m02/layers/{}/area/{}/table"
def test_길_읽기_권한이면_표_저장_안_함_남의_층_403_없는_열_404(world):
client, system = world["client"], world["system"]
before = (system / "area" / "rv.json").read_bytes()
got = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "A1:B2"})
assert got.status_code == 200, got.text
assert got.json()["표"]["shapeData"]["cells"][0][1]["text"] == "1,234.50"
assert (system / "area" / "rv.json").read_bytes() == before # 저장 안 함
bad = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "가"})
assert bad.status_code == 400
other = client.post(
URL.format("personal", "rv") + "?owner=7/99", json={"시트": "s1", "범위": "A1"}
)
assert other.status_code == 403
assert (
client.post(URL.format("system", "없는열"), json={"시트": "s1", "범위": "A1"}).status_code
== 404
)
def _link_drawing(folder: Path) -> None:
"""도면에 연결표 둘(T1 = 표 있음 · T2 = 표 없음) + 낡은 칸 글의 T1 표 하나 · 다른 표 하나."""
with store.at(folder):
got = store.read("structure", "rv")
doc = got["문서"]
stale = {
"type": "Table",
"shapeData": {
"origin": {"x": 10, "y": 20},
"columnWidths": [9, 9],
"rowHeights": [3, 3],
"cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]],
"style": {"fontSize": 2.5, "연결": "T1"},
},
}
other = json.loads(json.dumps(stale))
other["shapeData"]["style"]["연결"] = "남의표"
doc["도면"]["entities"] = [stale, other]
doc["연결표"] = [
{"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}},
{"연결": "T2", "시트": "s1", "범위": "B1", "값": {}},
]
store.write("structure", "rv", got["판"], doc)
def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없는_기록은_둠(world):
system = world["system"]
_link_drawing(system)
with store.at(system):
got = store.read("area", "rv")
got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1}
store.write("area", "rv", got["판"], got["문서"])
doc = store.read("structure", "rv")["문서"]
t1, other = doc["도면"]["entities"]
shape = t1["shapeData"]
assert shape["cells"][0][1]["text"] == "10.00" and shape["cells"][1][1]["text"] == "20.00"
assert (shape["origin"], shape["columnWidths"], shape["rowHeights"]) == (
{"x": 10, "y": 20},
[9, 9],
[3, 3],
) # 칸 수 같으면 크기 그대로
assert other["shapeData"]["cells"][0][0]["text"] == "낡음" # 다른 연결 표는 안 건드림
t1_rec, t2_rec = doc["연결표"]
assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록
def test_프로젝트_층_길도_같은_맞춤(world, tmp_path):
project = tmp_path / "project"
import shutil
shutil.copytree(world["system"], project) # 프로젝트 작업본 꼴(같은 종류 폴더)
_link_drawing(project)
area = json.loads((project / "area" / "rv.json").read_bytes())
area["시트"][0]["칸"]["B1"] = {"값": 7, "서식": 1}
(project / "area" / "rv.json").write_text(
json.dumps(area, ensure_ascii=False), encoding="utf-8"
)
layers_router._refresh_drawing(project, "rv")
doc = json.loads((project / "structure" / "rv.json").read_bytes())
assert doc["도면"]["entities"][0]["shapeData"]["cells"][0][1]["text"] == "7.00"
system_doc = json.loads((world["system"] / "structure" / "rv.json").read_bytes())
assert "연결표" not in system_doc # 시스템 층은 그대로
assert area_table.refresh_drawing("없는열") == []
@@ -0,0 +1,186 @@
"""면적산출 범위 → 도면 표(PLAN 41-3 · `M02_MasterTemplete_AreaToDrawing.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 면적 상세 [도면으로 보내기] = 고칠 수 있는 층만(읽기 · 프로젝트 층 단추 0).
- 보냄 → 표 변환 길(POST …/area/{열}/table) → 같은 열 도면 상세를 엶 → placeTable(style.연결 = T번호).
- 다른 열 도면이면 안 놓음 · 기존 기록 · 표지와 안 겹치는 id(T1 이 있으면 T2).
- [저장] 문서 `연결표` = 도면에 남은 표만(지운 표는 빠짐) · 값 = 그 표 칸 글 · 기록 없던 도면은 칸 안 만듦.
- 다시 열 때 지금 면적 값 ≠ 마지막 값이면 알림 + [업데이트] → updateTable(id, 칸).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts", "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
// 화면 부품 · 길은 가짜 — 단추 · 창 · 알림만 흉내
const made = [];
const button = (o) => {
const b = { label: o.label, title: "", dataset: {}, on: {}, addEventListener(k, f) { this.on[k] = f; } };
made.push(b);
return b;
};
const toasts = [];
const stubs = {
"@config/config_frontend": { API_BASE_URL: "/api" },
"@ui/ui_template_elements": {
createButton: button,
createInputField: (o) => ({ root: {}, input: { value: o.value, step: "", min: "", select() {}, addEventListener() {} } }),
el: (tag, o) => ({ tag, ...o }),
showConfirmDialog: async () => true,
showToast: (m, k) => toasts.push([k, m]),
},
"@ui/ui_template_modal": {
// 창이 뜨자마자 [보내기]
openModal: (o) => {
const body = { parts: [], append(...x) { this.parts.push(...x); } };
const res = { close() {} };
setTimeout(() => o.mount(body, res.close), 0);
setTimeout(() => made.at(-1).on.click(), 1);
return res;
},
},
"./M02_MasterTemplete_Api_Fetch": { getActiveOwner: () => null },
};
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n));
const { createAreaToDrawing } = box.exports;
const table = (texts, style = {}) => ({
type: "Table",
shapeData: { cells: texts.map((r) => r.map((t) => ({ text: t }))), style },
});
let now = [["1.5", "2"]];
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push([url, JSON.parse(init.body)]);
const b = JSON.parse(init.body);
return { ok: true, json: async () => ({ 표: table(now), 시트: "s1", 범위: b.범위, 값: { "s1!B2": now[0][0], "s1!C2": now[0][1] } }) };
};
(async () => {
const opened = [];
const notices = [];
const ad = createAreaToDrawing({
open: async (s) => opened.push(s),
save: async () => {},
isDirty: () => false,
showNotice: (n) => notices.push(n),
});
const slot = { kids: [], append(x) { this.kids.push(x); } };
const area = { layer: "system", projectId: null, kind: "area", name: "item1", label: "기슭막이" };
ad.onArea({ ...area, layer: "project" }, false, slot);
ad.onArea(area, true, slot);
const ro = slot.kids.length;
ad.onArea(area, false, slot);
ad.select({ 시트: "산출", 칸: "B2", 범위: "B2:C2" });
await slot.kids[0].on.click();
await new Promise((r) => setTimeout(r, 20));
const sent = { calls: calls.slice(), opened: opened.slice() };
// 같은 열 도면 — 기록 T1 이 있으니 새 표는 T2
const placed = [];
const handle = { placeTable: (e, id) => placed.push([e, id]), updateTable: async (id, c) => (placed.push(["u", id, c]), 1) };
const old = { 연결: "T1", 시트: "s1", 범위: "A1:A1", 글자크기: 3, 값: { "s1!A1": "옛" } };
const drawing = (ents, links) => ({ 양식: "구조물도면", 종류: "structure", 판: 1, 열: "item1", 도번: "SD-1", 도면: { entities: ents }, ...(links ? { 연결표: links } : {}) });
ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([table([["옛"]], { 연결: "T1" })], [old]));
const waitPlaced = ad.waiting(); // 놓기 기다림 = 저장 안 한 고침
ad.drop();
const waitDropped = ad.waiting();
await new Promise((r) => setTimeout(r, 20));
const notice = notices.at(-1) ?? [];
const upd = notice.find((x) => x && x.on);
if (upd) await upd.on.click();
// 저장 — T1(업데이트로 값 바뀜) · T2 · 지운 T3 기록은 빠짐
const t3 = { ...old, 연결: "T3" };
const saved = ad.withLinks(drawing([{ type: "Line" }, table([["옛"]], { 연결: "T1" }), table([["9", "8"]], { 연결: "T2" })], [old, t3]));
const plain = ad.withLinks(drawing([table([["x"]])]));
// 다른 열 도면이면 안 놓음
const before = placed.length;
ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" });
await slot.kids[0].on.click();
await new Promise((r) => setTimeout(r, 20));
ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([]));
// 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶
now = [["", " "]];
const openedBefore = opened.length;
ad.select({ 시트: "산출", 칸: "E1", 범위: "E1:F1" });
await slot.kids[0].on.click();
await new Promise((r) => setTimeout(r, 20));
const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) };
console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty }));
})();
"""
@pytest.fixture(scope="module")
def got():
if shutil.which("node") is None:
pytest.skip("node 가 없음")
proc = subprocess.run(
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_단추는_고칠_수_있는_층만(got):
assert got["ro"] == 0 and got["buttons"] == 1
def test_보냄은_표_변환_길_뒤_같은_열_도면을_엶(got):
url, body = got["sent"]["calls"][0]
assert url == "/api/m02/layers/system/area/item1/table"
assert body == {"시트": "산출", "범위": "B2:C2", "글자크기": 2.5}
assert got["sent"]["opened"][0]["kind"] == "structure"
assert got["sent"]["opened"][0]["name"] == "item1"
def test_놓을_표는_겹치지_않는_연결_id(got):
entity, link = got["placed"][0]
assert link == "T2" and entity["shapeData"]["style"]["연결"] == "T2"
def test_다시_열면_다름_알림과_업데이트(got):
assert got["notice"][0].startswith("면적산출 값이 바뀐 표 1")
assert got["notice"][1] == "업데이트"
assert ["u", "T1", [[{"text": "1.5"}, {"text": "2"}]]] in got["placed"]
def test_저장_연결표는_남은_표만(got):
now = {"s1!B2": "1.5", "s1!C2": "2"}
assert got["saved"] == [
{"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값
{"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now},
]
assert got["plain"] is False
def test_다른_열_도면에는_안_놓음(got):
assert got["other"] == 0
def test_편집_칸은_갈고리만():
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
# select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인)
assert main.count("toDrawing.") == 6
assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0]
def test_놓기_기다림은_저장_안_한_고침(got):
assert got["waitPlaced"] is True and got["waitDropped"] is False and got["waitSaved"] is False
def test_빈_범위는_막고_알림(got):
assert got["empty"] == {"opened": 0, "toast": ["error", "보낼 칸에 내용이 없음"]}
+1 -1
View File
@@ -43,7 +43,7 @@ def test_종류마다_칸(ts):
"이름",
"폴더",
"단위",
"담은 로직 수",
"단가산출 로직 수",
"합계",
"고친 날",
]
+5 -1
View File
@@ -92,7 +92,11 @@ def test_집계표만_컨테이너를_붙임():
body,
)
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body
# 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
assert re.search(
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)',
body,
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
+3 -1
View File
@@ -82,5 +82,7 @@ def test_실패_글과_다시_불러오기_M02_자리():
assert "const stop = showLoading(view);" in practice
assert "loadingError(why(error), () => void drawList())" in practice
assert "loadingError(why(error), () => void drawOne(key))" in practice
assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
# 일위대가 카드는 뺌 — 몸(Labor_View)을 덮었다 지우면 빈 카드(40 D1)
assert "cards.filter((c) => c !== laborCard).map((c) => c.shell.loading())" in cards
assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts")
+51
View File
@@ -28,6 +28,10 @@ const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save";
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
/** 표(M02 41장) — 짝 = CAD `integration/aislo-table-bridge.ts` */
const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table";
const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table";
const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated";
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
export interface CadHostDrawing {
@@ -93,6 +97,11 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
) => void;
/** CAD 의 지금 편집본을 받는다. */
requestSave: () => Promise<CadSaveResult<D, Q>>;
/** 표 놓기(41장) — `entity` = 표 JSON(TableEntity 꼴) · CAD 가 `style.연결 = link` 로 커서에 붙여 놓게 함.
* 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */
placeTable: (entity: Record<string, unknown>, link: string) => void;
/** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
destroy: () => void;
}
@@ -177,6 +186,35 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
frame.contentWindow?.postMessage(message, window.location.origin);
};
/** 도면이 열린 뒤에만 보낼 것(표) — 열리기 전에 보내면 싣기가 덮어씀 */
let drawingLoaded = false;
let afterLoad: Record<string, unknown>[] = [];
const postAfterLoad = (message: Record<string, unknown>): void => {
if (drawingLoaded) post(message);
else afterLoad.push(message);
};
const tableWaits = new Map<string, ((count: number) => void)[]>();
const placeTable: CadHost<D, M, Q>["placeTable"] = (entity, link) =>
postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link });
const updateTable: CadHost<D, M, Q>["updateTable"] = (link, cells) =>
new Promise((resolve, reject) => {
const waits = tableWaits.get(link) ?? [];
waits.push(resolve);
tableWaits.set(link, waits);
postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells });
window.setTimeout(() => {
const left = tableWaits.get(link) ?? [];
if (!left.includes(resolve)) return;
tableWaits.set(
link,
left.filter((one) => one !== resolve),
);
reject(new Error("CAD 표 갈기 응답 시간이 초과되었습니다."));
}, 5000);
});
const load: CadHost<D, M, Q>["load"] = (
drawing,
meta,
@@ -185,6 +223,7 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
extra = {},
) => {
pendingLoad = { drawing, meta, frameEdit, frameFields, extra };
drawingLoaded = false; // 새로 싣는 중 — 표는 열린 뒤에
if (!cadReady) return;
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
pendingLoad = undefined;
@@ -215,6 +254,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
text?: string;
actionId?: string;
durationMs?: number;
link?: string;
count?: number;
};
if (message.type === CAD_TOAST_MESSAGE) {
const kind = (["info", "success", "warning", "error"] as const).find(
@@ -240,6 +281,14 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
} else if (message.type === CAD_LOADED_MESSAGE) {
stopLoadWatchdog();
element.dataset.loading = "false";
drawingLoaded = true;
const queued = afterLoad;
afterLoad = [];
queued.forEach(post);
} else if (message.type === CAD_TABLE_UPDATED_MESSAGE && message.link) {
const [first, ...rest] = tableWaits.get(message.link) ?? [];
tableWaits.set(message.link, rest);
first?.(message.count ?? 0);
} else if (message.type === CAD_ERROR_MESSAGE) {
stopLoadWatchdog();
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
@@ -272,6 +321,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
},
load,
requestSave,
placeTable,
updateTable,
destroy: () => {
stopLoadWatchdog();
window.removeEventListener("message", onMessage);
+1 -1
View File
@@ -36,7 +36,7 @@ const TEXT = {
loadFailed: "복사본을 못 읽음 — {value}",
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
outOfRange: "범위 밖 값이 있어 저장하지 못함",
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음",
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
drawing: "도면",
area: "면적산출",
inputs: "넣을 값",
+1 -1
View File
@@ -14,7 +14,7 @@ const TEXT = {
title: "일위대가",
none: "일위대가 없음",
failed: "일위대가를 못 읽음 — {value}",
logics: "담은 로직 {value}개",
logics: "단가산출 로직 {value}개",
materials: "자재 {value}개",
base: "기준 합 {value}원",
reflected: "반영 합 {value}원",
+1 -1
View File
@@ -181,7 +181,7 @@ export const ui_locales_m2 = {
M02_PracticeLogics: ["로직 수", "Logics"],
M02_PracticeOrder: ["차례", "No."],
M02_PracticeMemo: ["메모", "Memo"],
M02_PracticeNoLogic: ["담은 로직 없음", "No logics"],
M02_PracticeNoLogic: ["단가산출 로직 없음", "No logics"],
M02_PracticeBack: ["목록으로", "Back to list"],
M02_PracticeDrop: ["통째 지우기", "Delete all"],
M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"],