Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_5
This commit is contained in:
@@ -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; // 병합에 먹힌 자리는 직접 고치지 않는다
|
||||
|
||||
@@ -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,114 @@
|
||||
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(), 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(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 style = table.getStyle();
|
||||
setPendingTable(
|
||||
new TableEntity(
|
||||
table.layerId,
|
||||
table.getOrigin(),
|
||||
table.getColumnWidths(),
|
||||
table.getRowHeights(),
|
||||
table.getCells(),
|
||||
{ ...style, 연결: link }
|
||||
)
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 "")
|
||||
|
||||
@@ -127,7 +127,7 @@ const VALUE_TEXT = {
|
||||
title: "넣을 값",
|
||||
missing: "빠진 로직 {value}",
|
||||
edit: "넣을 값 수정",
|
||||
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
|
||||
none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,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("없는열") == []
|
||||
@@ -43,7 +43,7 @@ def test_종류마다_칸(ts):
|
||||
"이름",
|
||||
"폴더",
|
||||
"단위",
|
||||
"담은 로직 수",
|
||||
"단가산출 로직 수",
|
||||
"합계",
|
||||
"고친 날",
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,7 +36,7 @@ const TEXT = {
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음",
|
||||
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
inputs: "넣을 값",
|
||||
|
||||
@@ -14,7 +14,7 @@ const TEXT = {
|
||||
title: "일위대가",
|
||||
none: "일위대가 없음",
|
||||
failed: "일위대가를 못 읽음 — {value}",
|
||||
logics: "담은 로직 {value}개",
|
||||
logics: "단가산출 로직 {value}개",
|
||||
materials: "자재 {value}개",
|
||||
base: "기준 합 {value}원",
|
||||
reflected: "반영 합 {value}원",
|
||||
|
||||
@@ -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?"],
|
||||
|
||||
Reference in New Issue
Block a user