diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 54cd49c06..dfb65ccc5 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -79,8 +79,8 @@ export interface SpreadsheetHandle { /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · - * 주소가 틀리거나 읽기만이면 false */ - setLink(key: string, cell: string | null, info?: LinkInfo): boolean; + * 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean; destroy(): void; } @@ -295,9 +295,9 @@ export function createSpreadsheet( } return out; }, - setLink(key, cell, info) { + setLink(key, cell, info, replace) { if (readOnly) return false; - const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace); if (command === false) return false; if (command) dispatch(command); loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 diff --git a/A00_Common/spreadsheet/spreadsheet_link_ask.ts b/A00_Common/spreadsheet/spreadsheet_link_ask.ts new file mode 100644 index 000000000..ab83067d1 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_ask.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * spreadsheet_link_ask.ts (주인 D) + * 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리). + * 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 · + * 모양은 공용 확인창(`ui-confirm`) 그대로. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; + +const TEXT = { + ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?", + together: "함께 잇기", + replace: "바꾸기", + cancel: "취소", +}; + +/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */ +export type ShareChoice = "함께" | "바꾸기" | null; + +export function askShare(cell: string, names: string[]): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: ShareChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const together = createButton({ + label: TEXT.together, + variant: "filled", + onClick: () => done("함께"), + }); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done(null); + }); + const message = TEXT.ask + .replace("{cell}", cell) + .replace("{names}", names.map((n) => `「${n}」`).join(" · ")); + overlay.append( + el("div", { + className: "ui-confirm__panel", + children: [ + el("p", { className: "ui-confirm__message", text: message }), + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }), + createButton({ + label: TEXT.replace, + variant: "ghost", + onClick: () => done("바꾸기"), + }), + together, + ], + }), + ], + }), + ); + document.body.append(overlay); + together.focus(); // Enter = 함께 잇기(기본) + }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 65822f7be..0a2db64b1 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -1,8 +1,8 @@ /* ============================================================================= * spreadsheet_link_set.ts (주인 D) * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). - * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · - * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러 + * 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). * ========================================================================== */ import { parseA1, toA1 } from "./spreadsheet_address"; @@ -15,13 +15,28 @@ export interface LinkInfo { 자재?: LinkMaterial; } -/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */ +export function normA1(cell: string): string | null { + const at = parseA1(cell.trim().toUpperCase()); + return at ? toA1(at.r, at.c) : null; +} + +/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */ +export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] { + const a1 = normA1(cell); + const sheet = book.시트.find((s) => s.id === sheetId); + return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key); +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false · + * `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */ export function linkCommand( book: Workbook, sheetId: string, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): Command | null | false { const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); if (cell !== null && !at) return false; @@ -29,7 +44,8 @@ export function linkCommand( const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); const home = book.시트.find((s) => s.id === sheetId); if (a1 && !home) return false; - if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0; + if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; if (one) { const 이름 = info.이름 ?? old?.이름; @@ -42,7 +58,7 @@ export function linkCommand( const commands: Command[] = []; for (const s of book.시트) { const was = s.연결 ?? []; - const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1)); const next = s === home && one ? [...rest, one] : rest; if (next.length === was.length && next.every((l, i) => l === was[i])) continue; const copy = structuredClone(s); @@ -55,16 +71,17 @@ export function linkCommand( } /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · - * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( book: T, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): T | null { const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; if (!sheet) return null; - const command = linkCommand(book, sheet.id, key, cell, info); + const command = linkCommand(book, sheet.id, key, cell, info, replace); if (command === false) return null; const next = structuredClone(book); const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index edecb144f..01a82b2c9 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -177,3 +177,21 @@ opacity: 0; } } + +/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */ +.ss-link-count { + position: absolute; + z-index: 3; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); + color: var(--color-canvas); + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + transform: translate(-100%, 0); + pointer-events: none; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 45e9a1572..978a9c8b4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -1,9 +1,11 @@ /* ============================================================================= * spreadsheet_links.ts (주인 D) * 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). - * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면 - * 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 + - * 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 — + * 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는 + * 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 · + * 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · + * 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 @@ -12,10 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; +import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; +import { linkCommand, linksAt } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -59,8 +63,6 @@ export interface Links extends PartHandle { const TEXT = { bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기", free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결", - swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?", - swapOk: "바꿈", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", remove: "목록에서 빼기", @@ -160,16 +162,22 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */ - function link(item: LinkItem, at?: string): void { + /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */ + async function link(item: LinkItem, at?: string): Promise { if (ctx.readOnly) return; const sheet = ctx.sheet(); const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c); - const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); - const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; - if (item.단위) one.단위 = item.단위; - if (item.자재) one.자재 = item.자재; - put(sheet, [...rest, one]); + const others = linksAt(ctx.book, sheet.id, 칸, item.키); + const choice = others.length + ? await askShare( + 칸, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice) return back(); + const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 }; + const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기"); + if (command) ctx.dispatch(command); back(); } @@ -224,7 +232,7 @@ export function mountLinks( }); // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); - b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); + b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); const unlink = (): void => { if (hit) put( @@ -317,22 +325,46 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); - for (const l of ctx.sheet().연결 ?? []) { - const p = parseA1(l.칸); + // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 + const byCell = new Map(); + for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); + for (const [a1, list] of byCell) { + const p = parseA1(a1); if (!p) continue; const { layer, box } = slot(ctx, p.r, p.c); if (!box.w || !box.h) continue; // 숨긴 행열 - const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": l.키 } }); + const first = list[0]; + const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } }); tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; const dot = el("div", { className: "ss-link-dot" }); dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; - for (const d of [tint, dot]) d.style.setProperty("--ss-link-h", String(hues.get(l.키) ?? 0)); - layer.append(tint, dot); - dots.push(tint, dot); + const marks = [tint, dot]; + if (list.length > 1) { + const count = el("div", { className: "ss-link-count", text: String(list.length) }); + count.style.left = `${box.x + box.w}px`; + count.style.top = `${box.y}px`; + marks.push(count); + } + for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0)); + layer.append(...marks); + dots.push(...marks); } } + /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ + let tipped = false; + const onHover = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null; + const names = a1 + ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) + : []; + if (names.length) ctx.grid.root.title = names.join(" · "); + else if (tipped) ctx.grid.root.removeAttribute("title"); + tipped = names.length > 0; + }; + // ── 알약 끌어 칸에 놓기(40-3) ────────────────────────────────────────── /** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */ function cellAt(e: DragEvent): { r: number; c: number } | null { @@ -358,19 +390,8 @@ export function mountLinks( dragging = null; if (!item || !at) return; e.preventDefault(); - const 칸 = toA1(at.r, at.c); - const other = (ctx.sheet().연결 ?? []).find((l) => l.칸 === 칸 && l.키 !== item.키); - const name = other?.이름 ?? other?.키 ?? ""; - if ( - other && - !(await showConfirmDialog( - TEXT.swap.replace("{cell}", 칸).replace("{name}", name), - TEXT.swapOk, - )) - ) - return back(); ctx.select(selectCell(ctx.sheet(), at.r, at.c), true); - link(item, 칸); + await link(item, toA1(at.r, at.c)); }; // ── 자재 고르기 창 ────────────────────────────────────────────────────── @@ -457,6 +478,7 @@ export function mountLinks( ctx.grid.root.addEventListener("dragover", onDragOver); ctx.grid.root.addEventListener("dragleave", onDragLeave); ctx.grid.root.addEventListener("drop", onDropEvent); + ctx.grid.root.addEventListener("mousemove", onHover); return { root: opts ? bar : null, @@ -470,6 +492,7 @@ export function mountLinks( ctx.grid.root.removeEventListener("dragover", onDragOver); ctx.grid.root.removeEventListener("dragleave", onDragLeave); ctx.grid.root.removeEventListener("drop", onDropEvent); + ctx.grid.root.removeEventListener("mousemove", onHover); dropMark.remove(); closePick(); for (const d of dots) d.remove(); diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py new file mode 100644 index 000000000..3dcea0a1c --- /dev/null +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -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 diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 60be6edbe..eaf3979d3 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -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 "") diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index 1171ce553..e70f8b978 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -22,7 +22,7 @@ import "./M02_Labor_Values.css"; const fill = (text: string, values: Record): string => text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); -const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; +const CONTROL_TEXT = { outOfRange: "범위 {value} 밖" }; const autoValue = (input: ItemInput): string | number | null => { const auto = input.자동값; @@ -60,17 +60,20 @@ export function createValueControl( }, }) ) as HTMLInputElement | HTMLSelectElement; + // 고르기 칸 — 「자동값 xx」 빈 선택지는 두지 않음(40-1 ③ · 자동값 = 늘 첫 선택지) · 값이 없으면 자동값을 + // 고른 채 흐리게 보이고 저장은 사용자가 바꿀 때만 · 자동값도 없으면 「—」 빈 선택지 하나 if (control instanceof HTMLSelectElement) { control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, - }), + ...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []), ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), ); } const empty = value === null || value === ""; - control.value = empty ? "" : String(value); + control.value = empty + ? control instanceof HTMLSelectElement && auto !== null + ? String(auto) + : "" + : String(value); control.classList.toggle("is-auto", empty); const error = el("span", { className: "m02-inputval__error" }); const out: ValueControl = { control, error, wasBad: false }; @@ -81,7 +84,7 @@ export function createValueControl( error.textContent = bad ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) : ""; - control.classList.toggle("is-auto", raw === ""); + control.classList.toggle("is-auto", raw === ""); // 고르기 칸은 고르면 늘 저장 값 onChange(storedOf(input, raw), bad); }); return out; @@ -124,7 +127,7 @@ const VALUE_TEXT = { title: "넣을 값", missing: "빠진 로직 {value}", edit: "넣을 값 수정", - none: "넣을 값이 없음 — 담은 로직에 입력이 없음", + none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 2b1982f93..03b7a9d51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -135,6 +135,14 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; + /** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */ + 연결표?: { + 연결: string; + 시트: string; + 범위: string; + 글자크기: number; + 값: Record; + }[]; } /** 시스템 층 구조물 도면 목록 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts index 8012ac0e6..712cdba99 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -5,10 +5,13 @@ * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import "./M02_MasterTemplete_AreaPanel.css"; @@ -40,8 +43,9 @@ export interface AreaCellPickOptions { label: string; } -/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ -export type AreaCellPick = { 칸: string | null } | null; +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) · + * `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ +export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null; export function pickAreaCell(opts: AreaCellPickOptions): Promise { return new Promise((resolve) => { @@ -76,10 +80,24 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), host, ); - const choose = (): void => (finish({ 칸: picked }), close()); + let book: Workbook | null = null; + const choose = async (): Promise => { + const at = picked; + const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id; + const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : []; + const choice = others.length + ? await askShare( + at, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice || done) return; + finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); + close(); + }; void (async () => { try { - const [{ createSpreadsheet }, book] = await Promise.all([ + const [{ createSpreadsheet }, got] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), opts.doc ? Promise.resolve(opts.doc) @@ -88,10 +106,11 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { ), ]); if (done) return; - const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + book = got; + const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; head.textContent = at ? fill(TEXT.now, at) : TEXT.none; unlink.hidden = !at; - handle = createSpreadsheet(host, structuredClone(book), { + handle = createSpreadsheet(host, structuredClone(got), { readOnly: true, tabs: false, onSelect: (s) => { @@ -103,9 +122,9 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { const grid = host.querySelector(".ss-grid-host"); grid?.addEventListener("mousedown", () => (moved = false), true); grid?.addEventListener("mouseup", (e) => { - if ((e as MouseEvent).button === 0 && moved) choose(); + if ((e as MouseEvent).button === 0 && moved) void choose(); }); - host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); } catch (error) { if (done) return; head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 6ba71c3c2..7219678a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -8,6 +8,8 @@ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; const TEXT = { @@ -68,8 +70,21 @@ export function openAreaLink(opts: AreaLinkOptions): void { now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; unlink.disabled = opts.readOnly || !text; }; - const done = (cell: string | null): boolean => { - if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + /** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */ + const done = async (cell: string | null): Promise => { + const a1 = cell === null ? null : normA1(cell); + if (cell !== null && !a1) return false; + const book = opts.sheet.getDoc(); + const sheetId = (book.시트.find((s) => s.id === book.활성) ?? book.시트[0])?.id ?? ""; + const others = a1 ? linksAt(book, sheetId, a1, opts.key) : []; + const choice = others.length + ? await askShare( + a1 ?? "", + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice) return true; + if (!opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")) return false; close(); return true; }; @@ -85,9 +100,8 @@ export function openAreaLink(opts: AreaLinkOptions): void { label: TEXT.link, variant: "ghost", size: "sm", - onClick: () => { - if (!done(address.input.value)) address.setError(TEXT.bad); - }, + onClick: () => + void done(address.input.value).then((ok) => ok || address.setError(TEXT.bad)), }); const unlink = createButton({ label: TEXT.unlink, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts new file mode 100644 index 000000000..3f8878431 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -0,0 +1,293 @@ +/* ============================================================================= + * 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 } & Record< + string, + unknown + >; + [key: string]: unknown; +} +/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */ +export type TableLink = NonNullable[number]; +type LinkSpec = Pick; +/** 표 변환 길 답(41-1) — 시트 = 시트 id */ +interface AreaTable { + 표: TableEntityJson; + 시트: string; + 범위: string; + 값: Record; +} + +/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */ +interface TableHandle { + placeTable?: (entity: Record, link: string) => void; + updateTable?: (link: string, cells: unknown[][]) => Promise; +} + +const TEXT = { + send: "도면으로 보내기", + sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름", + title: "도면으로 보내기", + range: "범위", + size: "글자 크기", + ok: "보내기", + close: "닫기", + badSize: "글자 크기는 0 보다 큰 수", + saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?", + saveLabel: "저장하고 보내기", + failed: "표를 못 만듦 — {value}", + 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 { + 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; + 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 = {}, b: Record = {}): 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; + /** 지금 화면 [저장] */ + save: () => Promise; + /** 저장 안 한 고침이 남았나 */ + 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; + /** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */ + 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(); + let latest = new Map>(); + let shown: string | null = null; + + /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ + const askSize = (spec: { 범위: string }): Promise => + 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 { + if (!range) return; + if (opt.isDirty()) { + if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return; + await opt.save(); + if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움 + } + const picked = range; + const size = await askSize(picked); + if (size === null) return; + const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; + let got: AreaTable; + try { + got = await fetchTable(at.layer, at.name, spec); + } catch (error) { + return showToast(fill(TEXT.failed, "value", why(error)), "error"); + } + 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 => { + 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 { + 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.범위 }), + onArea: (at, readOnly, slot) => { + range = null; + 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) => { + const handle = raw as TableHandle; + shown = `${at.layer}:${at.name}`; + 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); + showToast(TEXT.place, "info"); + } + } + void checkStale(handle, at, doc); + }, + withLinks: (doc) => { + 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; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 1df2b007c..a51a88c67 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -94,7 +94,7 @@ export const ITEM_COLS: Record[]> = { 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"), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 34deef6f7..30fbaea13 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -62,7 +62,7 @@ const PRACTICE_COLS: Col[] = [ { 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)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 964c7ddcd..6bccbd95a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -181,6 +181,23 @@ text-align: right; } + /* 수량산출 칸 — 윗줄에(차례 · 단위와 한 줄) · 칸 이름 없이 */ + .m02-labrow:not(.m02-labline) tr:has(.m02-labrow__qty) .m02-labrow__no, + .m02-labrow tr:has(.m02-labrow__qty) .m02-labrow__unit:not([data-label]) { + grid-column: span 1; + } + + .m02-labrow .m02-labrow__qty { + order: 0; + grid-column: span 2; + text-align: left; + } + + .m02-labrow .m02-labrow__qty::before { + content: none; + } + + .m02-labrow__sum .m02-labrow__qty, .m02-labrow__sum .m02-labrow__no, .m02-labrow__sum .m02-labrow__unit { display: none; @@ -264,3 +281,27 @@ padding: var(--spacing-4) 3px; } } + +/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ +.m02-labrow__qty { + white-space: nowrap; + vertical-align: top; +} + +.m02-labrow__qtybtn { + display: block; + width: 100%; + padding: 2px var(--spacing-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 4px); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-labrow__qtybtn:hover { + border-color: var(--color-primary); + background: var(--color-mist-violet); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 35704c6f4..21fc12072 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -11,7 +11,15 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; -import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; +import { + readTemplate, + saveTemplate, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; +import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -50,6 +58,9 @@ export interface LaborDetailOptions { export interface LaborDetailHandle { getDoc: () => LaborDoc; + /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — + * 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */ + saveArea: () => Promise; destroy: () => void; } @@ -72,6 +83,12 @@ export function mountLaborDetail( const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); const given = (): Record => Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); + /** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */ + let area: { doc: Workbook; 판: string } | null = null; + let areaDirty = false; + /** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */ + let pills = new Map(); + const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; const logicHost = el("div"); const matHost = el("div"); @@ -112,7 +129,7 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) - edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( @@ -133,9 +150,13 @@ export function mountLaborDetail( fail = null; draw(); try { - const got = await calcLabor(opt.layer, opt.column, cur); + // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) + const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; calc = got; + pills = new Map( + (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), + ); } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); @@ -146,7 +167,7 @@ export function mountLaborDetail( /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ const changed = (qtyOnly = false): void => { - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); @@ -160,6 +181,36 @@ export function mountLaborDetail( return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; }; + /** [엑셀에서 고르기] — 그 열 면적산출 칸을 골라 그 알약을 이음(화면 문서 · 저장은 [저장]) · 이었으면 참 */ + const pickCell = async (pill: string | null | undefined, label: string): Promise => { + if (!pill || !area) { + showToast(tc(area ? "Labor_PickWait" : "Labor_NoArea"), "error"); + return false; + } + const got = await pickAreaCell({ + doc: area.doc, + layer: opt.layer, + column: opt.column, + pill, + label, + }); + if (!got || !alive) return false; + const info = pills.get(pill); + const next = linkAreaCell(area.doc, pill, got.칸, { + ...(info?.이름 ? { 이름: info.이름 } : {}), + ...(info?.단위 ? { 단위: info.단위 } : {}), + }); + if (!next) { + showToast(tc("Labor_BadCell"), "error"); + return false; + } + next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈 + area.doc = next; + links = areaLinks(next); + areaDirty = true; + return true; + }; + /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); @@ -200,6 +251,7 @@ export function mountLaborDetail( qty: row.수량, linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", + pick: () => pickRow(i), }, readOnly: !edit, goArea: opt.goArea, @@ -214,6 +266,37 @@ export function mountLaborDetail( }); } + /** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */ + async function pickRow(i: number): Promise { + const row = cur.담은로직[i]; + const logic = logics.get(row.로직); + if (!(await pickCell(lineOf(row.로직)?.알약, logic?.이름 ?? row.로직))) return false; + row.수량 = null; + changed(true); + return true; + } + + /** 수량산출 칸 누름 — 손 입력 · 엑셀에서 고르기 */ + function openQty(i: number): void { + const row = cur.담은로직[i]; + const line = lineOf(row.로직); + openQtyModal( + `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), + { + qty: row.수량, + linked: linkedText(line?.알약, line?.분모 ?? ""), + unit: line?.분모 ?? "", + pick: () => pickRow(i), + }, + !edit, + opt.goArea, + (qty) => { + row.수량 = qty; + changed(true); + }, + ); + } + function openMat(i: number): void { const mat = cur.자재[i]; const line = matOf(mat.코드); @@ -223,6 +306,12 @@ export function mountLaborDetail( qty: mat.수량, linked: linkedText(line?.알약, mat.단위 ?? ""), unit: mat.단위 ?? "", + pick: async () => { + if (!(await pickCell(line?.알약, mat.이름 ?? mat.코드))) return false; + mat.수량 = null; + changed(true); + return true; + }, }, !edit, opt.goArea, @@ -236,7 +325,6 @@ export function mountLaborDetail( const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", - size: "sm", onClick: () => openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) @@ -248,7 +336,6 @@ export function mountLaborDetail( const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", - size: "sm", onClick: () => pickMaterial((m) => { if (cur.자재.some((x) => x.코드 === m.코드)) @@ -268,7 +355,7 @@ export function mountLaborDetail( unit.input.setAttribute("data-labor-field", "단위"); unit.input.addEventListener("input", () => { cur.단위 = unit.input.value.trim() || null; - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); }); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); @@ -306,7 +393,10 @@ export function mountLaborDetail( void Promise.all([ loadLogics(), readTemplate(opt.layer, "area", opt.column, null).then( - (got) => (links = areaLinks(got.문서)), + (got) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; + links = areaLinks(got.문서); + }, () => undefined, ), ]).then(() => { @@ -315,6 +405,13 @@ export function mountLaborDetail( return { getDoc: () => cur, + saveArea: async () => { + if (!area || !areaDirty) return false; + const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); + area.판 = info.판; + areaDirty = false; + return true; + }, destroy: () => { alive = false; window.clearTimeout(timer); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index ce7aa9db1..2b0b7c445 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -33,6 +33,8 @@ export interface QtySeat { linked: string | null; /** 손 입력 단위 표기(분모 · 자재 단위) */ unit: string; + /** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */ + pick?: (() => Promise) | null; } interface QtyPart { @@ -42,7 +44,12 @@ interface QtyPart { } /** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ -function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { +function qtyPart( + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + close: () => void, +): QtyPart { const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; const radio = (value: "cell" | "hand"): HTMLInputElement => { const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; @@ -67,6 +74,16 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): const go = goArea ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) : null; + const pickFn = seat.pick; + const pick = + pickFn && !readOnly + ? createButton({ + label: tc("In_QtyPick"), + variant: "ghost", + size: "sm", + onClick: () => void pickFn().then((linked) => linked && close()), + }) + : null; const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => el("label", { className: "m02-labin__qty-line", @@ -83,6 +100,7 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): className: "m02-labin__muted", text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), }), + ...(pick ? [pick] : []), ...(go ? [go] : []), ), line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), @@ -174,7 +192,7 @@ export function openLaborInput(opt: LaborInputOptions): void { }) as HTMLInputElement; memo.value = opt.memo ?? ""; memo.readOnly = opt.readOnly; - const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close)); + const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close), close); const ok = (): void => { if (bad.size) return showToast(tc("In_Bad"), "error"); const 수량 = qty.read(); @@ -226,7 +244,7 @@ export function openQtyModal( closeLabel: tc("In_Close"), dialogClass: "m02-modal m02-labin", mount: (body, close) => { - const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close)); + const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close), close); const ok = (): void => { const 수량 = qty.read(); if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index efa456a74..5189b76b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -73,6 +73,36 @@ export function qtyShort( return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; } +/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) · + * `open` 이 있으면 단추(줄 누름과 따로) */ +function qtyCell( + line: LaborCalcLine | undefined, + cells: ReadonlyMap | undefined, + open: (() => void) | null, +): HTMLElement { + const parts = [ + el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }), + el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }), + ]; + const body = open + ? el("button", { + className: "m02-labrow__qtybtn", + attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" }, + children: parts, + }) + : el("span", { children: parts }); + if (open) + body.addEventListener("click", (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + open(); + }); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [body], + }); +} + /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, @@ -120,6 +150,8 @@ const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLE /** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ export interface RowHandlers { open: (i: number) => void; + /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ + qty?: (i: number) => void; move?: (i: number, d: number) => void; remove?: (i: number) => void; } @@ -146,14 +178,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; const values = valueLine(logic?.입력 ?? [], row.입력값); - const qty = qtyText(line, line?.분모 ?? "", data.cells); - const note = [values.text, qty].filter(Boolean).join(" · "); + const note = values.text; const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = (label: string, run: () => void): HTMLElement => createButton({ label, variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); // 줄 누름(입력 모달)과 따로 run(); @@ -189,6 +219,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), ], }), + qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), ...(move && remove @@ -217,6 +248,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), ...COSTS.map((c) => moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])), @@ -229,6 +261,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const heads = [ tc("Col_Order"), tc("Col_Name"), + tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), @@ -300,7 +333,6 @@ export function matTable(data: MatTableData, on?: Omit): HT createButton({ label: tc("Remove"), variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); remove(i); @@ -349,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) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 5e8742a8c..cc1b30de5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -57,7 +57,13 @@ 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"], + Labor_PickWait: ["계산이 끝난 뒤 고름", "Wait for the calculation"], + Labor_NoArea: ["이 열 면적산출을 못 읽음", "Area sheet not loaded"], + Labor_BadCell: ["칸 주소가 틀림", "Invalid cell"], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 413e5409c..3b709f945 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -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, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 639f400c2..ac26926b7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -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: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0873d5d5e..6fcb2f702 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -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) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 44750848b..b51a094e2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -51,7 +51,7 @@ const TEXT = { pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", copyFailed: "복사 실패 — {value}", - noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", + noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 64343243f..7e6d10079 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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 { @@ -220,6 +221,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 +279,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") { // 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달 @@ -365,7 +370,7 @@ export function createMain( if (at.kind === "labor") { // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); - editor = mountLaborDetail(host, doc, { + const labor = mountLaborDetail(host, doc, { layer: at.layer, column: at.name, readOnly, @@ -376,6 +381,16 @@ export function createMain( return true; }, }); + // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) + editor = { + getDoc: async () => { + // 면적 저장이 일위대가 계산값을 다시 적어 판이 바뀜 — 판을 다시 읽어 409 를 막음 + if (await labor.saveArea()) + version = (await readTemplate(at.layer, "labor", at.name, at.projectId)).판 ?? ""; + return labor.getDoc(); + }, + destroy: labor.destroy, + }; return; } if (at.kind === "area") { @@ -401,6 +416,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")); @@ -460,7 +476,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), ...(edited as Record) } : edited; diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py index 17044da69..68afa4218 100644 --- a/common_util/common_util_spreadsheet.py +++ b/common_util/common_util_spreadsheet.py @@ -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"] diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index 420f7448a..fbf7a7736 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -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> = { + 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 = {}; + 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 })); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index a2ba19fb1..45acbf3be 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -2,7 +2,11 @@ * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; -import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { + linkAreaCell, + linkCommand, + linksAt, +} from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -58,10 +62,34 @@ b = book(); run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); -// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +// 같은 칸에 다른 키 — 기본 = 함께(칸 하나에 알약 여럿 · 40 마무리) · 바꾸기 = 그 칸 옛 키 빠짐 b = book(); run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); -eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +eq("칸 하나 알약 여럿", b.시트[0].연결, [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }, +]); +eq( + "그 칸 다른 알약", + linksAt(b, "s1", " b6 ", "새|m").map((l) => l.키), + ["깔기|㎡"], +); +b = book(); +const undoSwap = run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }, true)); +eq("바꾸기", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +run(b, undoSwap); +eq("바꾸기 되돌림", links(b), links(book())); +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6")); +eq("함께 둔 칸에 바꾸기 = 명령 있음", !!linkCommand(b, "s1", "새|m", "B6", {}, true), true); +eq( + "면적 문서 함께 · 바꾸기", + [ + linkAreaCell(book(), "새|m", "B6")!.시트[0].연결!.length, + linkAreaCell(book(), "새|m", "B6", {}, true)!.시트[0].연결!.length, + ], + [2, 1], +); // 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 b = book(); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index d9a79db7c..e53925a02 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -42,7 +42,10 @@ def test_패널_몸은_보기_갈래_줄_누름은_칸_지정(): def test_칸_지정_모달은_setLink_만(): link = _text("M02_MasterTemplete_AreaLink.ts") - assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link + assert 'opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")' in link + assert ( + "linksAt(book, sheetId, a1, opts.key)" in link and "askShare(" in link + ) # 칸 하나 알약 여럿 assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link @@ -95,7 +98,9 @@ def test_엑셀_칸_고르기_모달_부품(): pick = _text("M02_MasterTemplete_AreaCellPick.ts") assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick - assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick - assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "finish({ 칸: null })" in pick + assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick + assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick assert "handle?.destroy();" in pick assert "saveTemplate" not in pick diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py new file mode 100644 index 000000000..0f93efab7 --- /dev/null +++ b/resources/tester/test_m02_area_table.py @@ -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("없는열") == [] diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py new file mode 100644 index 000000000..b0cf649be --- /dev/null +++ b/resources/tester/test_m02_area_to_drawing.py @@ -0,0 +1,166 @@ +"""면적산출 범위 → 도면 표(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])); + 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([])); + 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 })); +})(); +""" + + +@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") + assert main.count("toDrawing.") == 4 # select · onArea · onDrawing · withLinks diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 604f3d7ca..45d69c367 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -43,7 +43,7 @@ def test_종류마다_칸(ts): "이름", "폴더", "단위", - "담은 로직 수", + "단가산출 로직 수", "합계", "고친 날", ] diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 554ca3aa9..fbb65186c 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,15 +55,18 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 24 + assert result["pass"] == 29 def test_손잡이_setLink_몸(): src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") - assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src - body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert ( + "setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;" + in src + ) + body = src.split(" setLink(key, cell, info, replace) {")[1].split(" destroy() {")[0] assert "if (readOnly) return false;" in body - assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace)" in body assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) assert "loadLinks();" in body @@ -72,7 +75,12 @@ def test_알약_끌어_놓기_색(): """40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기.""" src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src - assert "showConfirmDialog(" in src and "link(item, 칸);" in src + assert "await link(item, toA1(at.r, at.c));" in src + # 칸 하나에 알약 여럿 — 다른 알약 칸이면 함께 · 바꾸기 · 취소 물음(알약 누름 · 끌어 놓기 한 길) + body = src.split("async function link(")[1].split("\n }\n")[0] + assert "linksAt(ctx.book, sheet.id, 칸, item.키)" in body and "askShare(" in body + assert 'choice === "바꾸기"' in body + assert 'className: "ss-link-count"' in src and 'ctx.grid.root.title = names.join(" · ")' in src assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음 assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text( diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index 892c5a84e..0958442c7 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -36,7 +36,7 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음", + noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음", drawing: "도면", area: "면적산출", inputs: "넣을 값", diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts index 69f14eaf1..2666dbebc 100644 --- a/ui_template/ui_template_item_picker_labor.ts +++ b/ui_template/ui_template_item_picker_labor.ts @@ -14,7 +14,7 @@ const TEXT = { title: "일위대가", none: "일위대가 없음", failed: "일위대가를 못 읽음 — {value}", - logics: "담은 로직 {value}개", + logics: "단가산출 로직 {value}개", materials: "자재 {value}개", base: "기준 합 {value}원", reflected: "반영 합 {value}원", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 78aa7c1c7..e6817a238 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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?"],