From 7c1266bcb44989cb9d226725b632e222126be6f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:01:29 +0900 Subject: [PATCH 01/38] =?UTF-8?q?feat(M01):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9C=84=20=EA=B0=80=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=E2=80=94=20=EC=83=81=EC=9E=90=20=C2=B7=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=20=C2=B7=20=EB=81=8C=EC=96=B4=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0(PLAN=2035-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 251 +++++++++ M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 504 +++++++++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Diagram.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css new file mode 100644 index 000000000..22c75a9ab --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.css @@ -0,0 +1,251 @@ +/* 시트 로직 — 위 가로 다이어그램 */ +.m01-sldg { + --sldg-in: var(--color-accent); + --sldg-out: #c2610c; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + font-size: var(--text-body-sm); + outline: none; +} + +:root[data-theme="dark"] .m01-sldg { + --sldg-out: #e5a866; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .m01-sldg { + --sldg-out: #e5a866; + } +} + +.m01-sldg__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + min-height: 2rem; +} + +.m01-sldg__note { + color: var(--color-danger); +} + +.m01-sldg__muted { + margin: 0; + color: var(--color-text-muted); +} + +.m01-sldg__menu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m01-sldg__menu[hidden] { + display: none; +} + +/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */ +.m01-sldg__band { + max-height: 33vh; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); +} + +.m01-sldg__inner { + position: relative; + display: grid; + grid-template-columns: repeat(5, minmax(11rem, 1fr)); + gap: var(--spacing-24); + min-width: max-content; + padding: var(--spacing-12) var(--spacing-16); +} + +.m01-sldg__lines { + position: absolute; + inset: 0 auto auto 0; + overflow: visible; + pointer-events: none; + z-index: 0; +} + +.m01-sldg__col { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 11rem; +} + +.m01-sldg__col-head { + color: var(--color-text-muted); + font-size: var(--text-caption); + letter-spacing: 0.04em; +} + +/* 상자 */ +.m01-sldg__box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + cursor: pointer; +} + +.m01-sldg__box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.m01-sldg__box.is-selected { + border: 2px solid var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +.m01-sldg__box-head { + display: flex; + flex-direction: column; +} + +.m01-sldg__kind { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sldg__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m01-sldg__pills { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m01-sldg__ins, +.m01-sldg__outs { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m01-sldg__outs { + align-items: flex-end; +} + +/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */ +.m01-sldg__pill { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + max-width: 10rem; + padding: 0 var(--spacing-8); + border: 1px solid var(--sldg-in); + border-radius: 999px; + background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface)); + color: var(--sldg-in); + font: inherit; + font-size: var(--text-caption); + cursor: pointer; + white-space: nowrap; +} + +.m01-sldg__pill.is-out { + border-color: var(--sldg-out); + background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface)); + color: var(--sldg-out); + cursor: grab; + touch-action: none; +} + +.m01-sldg__pill.is-empty { + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); +} + +.m01-sldg__pill.is-chosen { + box-shadow: 0 0 0 2px currentColor; +} + +.m01-sldg.is-linking .m01-sldg__pill.is-target { + box-shadow: 0 0 0 2px var(--sldg-in); + animation: m01-sldg-blink 1s ease-in-out infinite alternate; +} + +@keyframes m01-sldg-blink { + to { + background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface)); + } +} + +.m01-sldg__pill-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.m01-sldg__pill-value { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m01-sldg__pill-value:empty { + display: none; +} + +/* 선 */ +.m01-sldg__edge { + pointer-events: auto; + cursor: pointer; +} + +.m01-sldg__hit { + fill: none; + stroke: transparent; + stroke-width: 12; +} + +.m01-sldg__wire { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; +} + +.m01-sldg__edge.is-strong .m01-sldg__wire { + stroke: var(--color-accent); + stroke-width: 3; +} + +.m01-sldg__edge.is-dim .m01-sldg__wire { + opacity: 0.3; +} + +.m01-sldg__drag { + fill: none; + stroke: var(--sldg-out); + stroke-width: 2; + stroke-dasharray: 5 4; +} + +@media (prefers-reduced-motion: reduce) { + .m01-sldg.is-linking .m01-sldg__pill.is-target { + animation: none; + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts new file mode 100644 index 000000000..c32d15315 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts @@ -0,0 +1,504 @@ +/* ============================================================================= + * M01_SL_Diagram.ts + * 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」) + * 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) · + * 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG. + * 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect · + * 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」. + * 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model"; +import "./M01_SL_Diagram.css"; + +const TEXT = { + Stage0: ["입력값", "Inputs"], + Stage1: ["표", "Tables"], + Stage2: ["계산", "Calculations"], + Stage3: ["항목", "Items"], + Stage4: ["결과", "Result"], + K_input: ["입력값", "Input"], + K_const: ["고정값", "Fixed value"], + K_table: ["표", "Table"], + K_calc: ["계산", "Calculation"], + K_item: ["항목", "Item"], + K_logic: ["다른 로직", "Other logic"], + K_result: ["결과", "Result"], + Add: ["+ 상자", "+ Box"], + AddTitle: ["더할 상자 종류", "Kind of box to add"], + Cancel: ["취소", "Cancel"], + Connect: ["잇기", "Connect"], + Disconnect: ["끊기", "Disconnect"], + Remove: ["상자 지우기", "Remove box"], + AskRemove: [ + "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", + "Remove box “{v}”? Its lines go too", + ], + PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"], + Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"], + NoOut: ["내보내는 알약이 없음", "No output pills"], + NoIn: ["받는 알약이 없음", "No input pills"], + SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], + NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"], + NoMatch: [ + "이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요", + "No input pill with the same name — drop on the pill itself", + ], + Taken: [ + "이미 이어진 입력 알약 — 먼저 그 선을 끊으세요", + "That input already has a line — disconnect it first", + ], + From: ["어디서", "From"], + To: ["어디로", "To"], + Value: ["지금 값", "Now"], + LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], +} as const; + +const td = (key: keyof typeof TEXT, fill: Record = {}): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); + +/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */ +export const STAGE_OF: Record = { + input: 0, + const: 0, + logic: 0, + table: 1, + calc: 2, + item: 3, + result: 4, +}; + +const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"]; +const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT); + +export interface DiagramOn { + select(id: string | null): void; + /** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */ + connect(from: SlEnd, to: SlEnd): string | null | void; + disconnect(edgeId: string): void; + add(kind: SlKind, stage: number): void; + remove(nodeId: string): void; +} + +export interface DiagramOpts { + /** 상자 id → 알약 이름 → 지금 값 */ + values?: Record>; + /** 고른 상자 id · 선 id */ + selected?: string | null; +} + +/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */ + +/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */ +export function layout(nodes: SlNode[]): SlNode[][] { + const cols: SlNode[][] = [[], [], [], [], []]; + for (const n of nodes) { + const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind]; + cols[Math.min(4, Math.max(0, s ?? 0))].push(n); + } + return cols; +} + +/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */ +export function resolveDrop( + sl: SheetLogic, + from: SlEnd, + target: { node: string; pill: string | null; dir: "in" | "out" | null } | null, +): { to: SlEnd } | { why: string } { + if (!target) return { why: td("NotIn") }; + if (target.node === from.node) return { why: td("SameBox") }; + if (target.dir === "out") return { why: td("NotIn") }; + const node = sl.nodes.find((n) => n.id === target.node); + if (!node?.pillsIn.length) return { why: td("NoIn") }; + const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name; + if (!name) return { why: td("NoMatch") }; + if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name)) + return { why: td("Taken") }; + return { to: { node: node.id, pill: name } }; +} + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +interface HostState { + selected: string | null; + /** 잇기 중 — 출력 알약 */ + linking: SlEnd | null; + /** 고른 알약(정보 줄) */ + pill: { node: string; pill: string; dir: "in" | "out" } | null; + note: string; + scroll: number; +} + +const states = new WeakMap(); + +const showValue = (v: unknown): string => { + if (v === null || v === undefined || v === "") return ""; + if (typeof v === "number") + return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); + return String(v).slice(0, 12); +}; + +const edgeTouches = (e: SlEdge, node: string): boolean => + e.from.node === node || e.to.node === node; + +export function renderDiagram( + host: HTMLElement, + sl: SheetLogic, + on: DiagramOn, + opts: DiagramOpts = {}, +): void { + const old = states.get(host); + const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 }; + if (opts.selected !== undefined) st.selected = opts.selected; + states.set(host, st); + const values = opts.values ?? {}; + const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? ""; + const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected }); + const select = (id: string | null): void => { + st.selected = id; + st.pill = null; + on.select(id); + redraw(); + }; + const tryConnect = (from: SlEnd, target: Parameters[2]): void => { + const got = resolveDrop(sl, from, target); + st.linking = null; + if ("why" in got) st.note = got.why; + else st.note = on.connect(from, got.to) || ""; + redraw(); + }; + + /* 상자 */ + const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => { + const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name); + const linking = st.linking; + const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir; + const b = el("button", { + className: [ + "m01-sldg__pill", + `is-${dir}`, + wired ? "" : "is-empty", + chosen ? "is-chosen" : "", + linking && dir === "in" && n.id !== linking.node ? "is-target" : "", + ].join(" "), + attrs: { + type: "button", + "data-node": n.id, + "data-pill": name, + "data-dir": dir, + title: unit ? `${name} (${unit})` : name, + }, + children: [ + el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }), + el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }), + ], + }); + b.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking && dir === "in") + return tryConnect(st.linking, { node: n.id, pill: name, dir }); + st.pill = chosen ? null : { node: n.id, pill: name, dir }; + st.linking = null; + st.note = ""; + redraw(); + }); + if (dir === "out") + b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name })); + return b; + }; + + const boxEl = (n: SlNode): HTMLElement => { + const box = el("div", { + className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join( + " ", + ), + attrs: { + "data-node": n.id, + tabindex: "0", + role: "button", + "aria-label": `${kindLabel(n.kind)} · ${n.name}`, + }, + children: [ + el("div", { + className: "m01-sldg__box-head", + children: [ + el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }), + el("strong", { className: "m01-sldg__name", text: n.name }), + ], + }), + el("div", { + className: "m01-sldg__pills", + children: [ + el("div", { + className: "m01-sldg__ins", + children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)), + }), + el("div", { + className: "m01-sldg__outs", + children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)), + }), + ], + }), + ], + }); + box.addEventListener("click", () => { + if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null }); + select(st.selected === n.id ? null : n.id); + }); + box.addEventListener("keydown", (ev) => { + if (ev.target !== box) return; + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + box.click(); + } + }); + return box; + }; + + const cols = layout(sl.nodes); + const inner = el("div", { + className: "m01-sldg__inner", + children: cols.map((list, i) => + el("div", { + className: "m01-sldg__col", + children: [ + el("div", { + className: "m01-sldg__col-head", + text: td(`Stage${i}` as keyof typeof TEXT), + }), + ...list.map(boxEl), + ], + }), + ), + }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("m01-sldg__lines"); + inner.prepend(svg); + inner.addEventListener("click", (ev) => { + if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) { + st.linking = null; + select(null); + } + }); + + /* 위 줄 — 더하기 · 고른 것 · 할 일 */ + const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } }); + addMenu.append( + el("span", { className: "m01-sldg__muted", text: td("AddTitle") }), + ...KINDS.map((k) => + createButton({ + label: kindLabel(k), + size: "sm", + variant: "ghost", + onClick: () => { + addMenu.hidden = true; + on.add(k, STAGE_OF[k]); + }, + }), + ), + ); + const addBtn = createButton({ + label: td("Add"), + size: "sm", + onClick: () => (addMenu.hidden = !addMenu.hidden), + }); + const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null; + const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null; + const tools: HTMLElement[] = []; + if (st.linking) { + tools.push( + el("span", { text: td("PickTarget", { v: st.linking.pill }) }), + createButton({ + label: td("Cancel"), + size: "sm", + variant: "ghost", + onClick: () => ((st.linking = null), redraw()), + }), + ); + } else if (st.pill) { + const p = st.pill; + const linked = sl.edges.filter((e) => + p.dir === "in" + ? e.to.node === p.node && e.to.pill === p.pill + : e.from.node === p.node && e.from.pill === p.pill, + ); + const where = linked + .map((e) => + p.dir === "in" + ? `${nodeName(e.from.node)} · ${e.from.pill}` + : `${nodeName(e.to.node)} · ${e.to.pill}`, + ) + .join(", "); + tools.push( + el("span", { + text: [ + `${p.pill}`, + `${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`, + where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "", + ] + .filter(Boolean) + .join(" · "), + }), + ); + if (p.dir === "out") + tools.push( + createButton({ + label: td("Connect"), + size: "sm", + onClick: () => { + st.linking = { node: p.node, pill: p.pill }; + st.pill = null; + redraw(); + }, + }), + ); + } else if (selEdge) { + tools.push( + el("span", { + text: td("LineOf", { + a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`, + b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`, + }), + }), + createButton({ + label: td("Disconnect"), + size: "sm", + variant: "danger", + onClick: () => on.disconnect(selEdge.id), + }), + ); + } else if (selNode) { + tools.push( + createButton({ + label: td("Remove"), + size: "sm", + variant: "danger", + onClick: () => void askRemove(selNode), + }), + ); + } + const askRemove = async (n: SlNode): Promise => { + if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id); + }; + + const band = el("div", { className: "m01-sldg__band", children: [inner] }); + const root = el("div", { + className: `m01-sldg${st.linking ? " is-linking" : ""}`, + attrs: { tabindex: "-1" }, + children: [ + el("div", { + className: "m01-sldg__bar", + children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })], + }), + addMenu, + sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }), + ], + }); + root.addEventListener("keydown", (ev) => { + if (ev.key === "Escape" && (st.linking || st.pill)) { + st.linking = null; + st.pill = null; + redraw(); + } + if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (selEdge) on.disconnect(selEdge.id); + else if (selNode) void askRemove(selNode); + }); + band.addEventListener("scroll", () => (st.scroll = band.scrollLeft)); + host.replaceChildren(root); + band.scrollLeft = st.scroll; + + /* 선 — 붙인 뒤 알약 자리를 재서 그림 */ + const drawLines = (): void => { + const box = inner.getBoundingClientRect(); + svg.setAttribute("width", String(inner.scrollWidth)); + svg.setAttribute("height", String(inner.scrollHeight)); + svg.replaceChildren(); + const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => { + const pill = inner.querySelector( + `.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`, + ); + if (!pill) return null; + const r = pill.getBoundingClientRect(); + return { + x: (dir === "out" ? r.right : r.left) - box.left, + y: r.top + r.height / 2 - box.top, + }; + }; + for (const e of sl.edges) { + const a = at(e.from, "out"); + const b = at(e.to, "in"); + if (!a || !b) continue; + const dx = Math.max(30, Math.abs(b.x - a.x) / 2); + const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; + const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id)); + const dim = st.selected !== null && !strong; + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`); + g.setAttribute("data-edge", e.id); + g.setAttribute("data-x1", a.x.toFixed(1)); + g.setAttribute("data-y1", a.y.toFixed(1)); + g.setAttribute("data-x2", b.x.toFixed(1)); + g.setAttribute("data-y2", b.y.toFixed(1)); + for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) { + const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); + p.setAttribute("d", d); + p.setAttribute("class", cls); + g.append(p); + } + g.addEventListener("click", (ev) => { + ev.stopPropagation(); + select(st.selected === e.id ? null : e.id); + }); + svg.append(g); + } + }; + requestAnimationFrame(drawLines); + + /* 끌어 잇기 */ + function startDrag(ev: PointerEvent, from: SlEnd): void { + if (ev.button !== 0) return; + const pill = ev.currentTarget as HTMLElement; + const box = inner.getBoundingClientRect(); + const r = pill.getBoundingClientRect(); + const x0 = r.right - box.left; + const y0 = r.top + r.height / 2 - box.top; + let line: SVGPathElement | null = null; + const move = (m: PointerEvent): void => { + const x = m.clientX - box.left; + const y = m.clientY - box.top; + if (!line && Math.hypot(x - x0, y - y0) < 6) return; + if (!line) { + line = document.createElementNS("http://www.w3.org/2000/svg", "path"); + line.setAttribute("class", "m01-sldg__drag"); + svg.append(line); + } + line.setAttribute("d", `M${x0},${y0} L${x},${y}`); + }; + const up = (u: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (!line) return; // 끌지 않음 = 누름(click 이 처리) + line.remove(); + const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; + const pillHit = hit?.closest(".m01-sldg__pill"); + const boxHit = hit?.closest(".m01-sldg__box"); + const target = pillHit + ? { + node: pillHit.dataset.node ?? "", + pill: pillHit.dataset.pill ?? null, + dir: (pillHit.dataset.dir as "in" | "out") ?? null, + } + : boxHit + ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } + : null; + // 끈 뒤 따라오는 click 을 한 번 삼킴 + window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); + tryConnect(from, target); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + } +} From 920783a9b03dee576cc514aad21b4e11ba74abe6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:05:41 +0900 Subject: [PATCH 02/38] =?UTF-8?q?fix(M01):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=E2=80=94=20=EC=84=A0=EC=9D=84=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=BC(=ED=81=AC=EA=B8=B0=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EB=A9=B4=20=EB=8B=A4=EC=8B=9C)=20=C2=B7=20=EB=81=88=20?= =?UTF-8?q?=EB=92=A4=20=ED=81=B4=EB=A6=AD=20=EC=82=BC=ED=82=B4=EC=9D=B4=20?= =?UTF-8?q?=EB=82=A8=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=EA=B9=8C=EB=8B=AD=20=EC=A4=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80(PLAN=2035-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SheetLogic 전부 검사 초록 - ORCA 견본(상자 7 · 선 8): 단계마다 x 16 · 299 · 582 · 866 · 1149 · 선 끝 알약에서 0.05px 안 · 띠 321 / 화면 1076 · 끌어 잇기 → on.connect · 상자 몸통에 놓으면 같은 이름 자리 · 막히면 한 줄 까닭 · 선 고름 → Delete → on.disconnect · 상자 고르면 이어진 선 넷만 진하게 · 잇기 단추 길 · + 상자 → on.add · Delete → 물음 → on.remove · 밝음 · 어두움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M01_MasterData/SheetLogic/M01_SL_Diagram.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts index c32d15315..f9bbf1801 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.ts @@ -129,6 +129,8 @@ interface HostState { pill: { node: string; pill: string; dir: "in" | "out" } | null; note: string; scroll: number; + /** 선 다시 그리기 — 띠 크기 바뀜 */ + watch?: ResizeObserver; } const states = new WeakMap(); @@ -159,6 +161,7 @@ export function renderDiagram( const select = (id: string | null): void => { st.selected = id; st.pill = null; + st.note = ""; on.select(id); redraw(); }; @@ -455,7 +458,11 @@ export function renderDiagram( svg.append(g); } }; - requestAnimationFrame(drawLines); + // 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기) + st.watch?.disconnect(); + st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines); + st.watch?.observe(inner); + if (inner.isConnected) drawLines(); /* 끌어 잇기 */ function startDrag(ev: PointerEvent, from: SlEnd): void { @@ -494,8 +501,10 @@ export function renderDiagram( : boxHit ? { node: boxHit.dataset.node ?? "", pill: null, dir: null } : null; - // 끈 뒤 따라오는 click 을 한 번 삼킴 - window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); + // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 + const swallow = (c: MouseEvent): void => c.stopPropagation(); + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); tryConnect(from, target); }; window.addEventListener("pointermove", move); From 35207e211d173340bc82f9d9c41cca9b31385f7e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:06:29 +0900 Subject: [PATCH 03/38] =?UTF-8?q?perf(m01-sl):=20Entry=20=EA=B0=80=20Sheet?= =?UTF-8?q?Logic=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20import()=20=EB=A1=9C=20?= =?UTF-8?q?=EB=8A=A6=EA=B2=8C=20=EB=B6=88=EB=9F=AC=EC=98=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=EC=9D=B4=20M01=20=EC=B2=AB=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=EC=97=90=20=EC=95=88=20=EB=93=A4=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts index d8ac397c7..3398ed630 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -17,8 +17,8 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; -import { slList, type SlListItem } from "./M01_SL_Api"; -import { newLogic, type SheetLogic } from "./M01_SL_Model"; +import type { SlListItem } from "./M01_SL_Api"; +import type { SheetLogic } from "./M01_SL_Model"; import "./M01_SL_Entry.css"; const TEXT = { @@ -76,15 +76,12 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ -const loadMine = (): Promise => slList().catch(() => []); -const blankLogic = (name: string, unit: string): SheetLogic | null => { - try { - return newLogic(name, unit); - } catch { - return null; - } -}; +/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게. + * ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */ +const loadMine = (): Promise => + import("./M01_SL_Api").then((m) => m.slList()).catch(() => []); +const blankLogic = (name: string, unit: string): Promise => + import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null); /** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createSheetLogicButton(): HTMLButtonElement { @@ -235,7 +232,9 @@ function openSheetLogic(): void { const showPaper = (): void => { let dirty = false; const units = unitChoices(); - paper = { dirty: () => dirty, sl: blankLogic("", units[0]) }; + const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null }; + paper = mine; + void blankLogic("", units[0]).then((sl) => (mine.sl = sl)); const unsaved = createTag(te("Unsaved"), "warning"); unsaved.hidden = true; From 7f98edac2c54bdece28daf5d3388e0011d8615f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:07:36 +0900 Subject: [PATCH 04/38] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=86=90=EC=9E=A1=EC=9D=B4(Item)=20=C2=B7=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=B9=B8(Result)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 항목 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 틀 · 단가 바깥 값 자리 · 바탕 이음, 큰 합계 · 일위대가표 · 예시값 띠 · 빈 입력 · 오류. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/SheetLogic/M01_SL_Item.css | 76 +++ M01_MasterData/SheetLogic/M01_SL_Item.ts | 642 ++++++++++++++++++++ M01_MasterData/SheetLogic/M01_SL_Result.css | 98 +++ M01_MasterData/SheetLogic/M01_SL_Result.ts | 243 ++++++++ 4 files changed, 1059 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Item.ts create mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Result.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.css b/M01_MasterData/SheetLogic/M01_SL_Item.css new file mode 100644 index 000000000..45f8a6fa5 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Item.css @@ -0,0 +1,76 @@ +.m01-slitem__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-slitem__body { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-slitem__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-slitem__input { + box-sizing: border-box; + width: 120px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + text-align: right; +} + +.m01-slitem__input[readonly] { + background: var(--color-surface); +} + +.m01-slitem__chosen { + margin: 0; + color: var(--color-text-body); + font-weight: 600; +} + +.m01-slitem__prices { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-slitem__price { + display: flex; + flex-direction: column; + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; +} + +.m01-slitem__price strong { + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-slitem__muted { + flex-basis: 100%; + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-slitem__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts new file mode 100644 index 000000000..9161771de --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Item.ts @@ -0,0 +1,642 @@ +/* ============================================================================= + * M01_SL_Item.ts + * 시트 로직 — 항목 상자 손잡이. 항목 상자를 고르면 시트 판 위에 붙음. + * 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 고르기 → 상자 시트를 항목 틀로 채움 · 알약 정하기 + * 단가 칸 = 바깥 값(노임 · 자재 · 장비 = 기계경비 로직 · 일위대가 = 기존 로직 결과) — Api `slOutside` 로 받아 넣음 + * 수량 칸 = 입력 알약 · 금액 칸 = 수량 × 단가(ROUND 소수 첫째) · 출력 알약 노무비 / 재료비 / 경비 + * 비율 = 「노무비의 [3]%」 꼴 — 바탕 = 다른 항목 상자의 그 비목 합(시트 사이 참조) + * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { searchElements } from "../M01_MasterData_UI_Logic_Api"; +import { formatPrice } from "../M01_MasterData_UI_Logic_Money"; +import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; +import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model"; +import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api"; +import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; +import "./M01_SL_Item.css"; + +const TEXT = { + Title: ["항목", "Item"], + Kind: ["종류", "Kind"], + Labor: ["노무", "Labor"], + Material: ["재료", "Material"], + Machine: ["장비", "Machine"], + Logic: ["일위대가", "Unit-price job"], + Rate: ["비율", "Rate"], + ToolLoss: ["공구손료", "Small tools"], + Pick: ["고르기", "Choose"], + Repick: ["다시 고르기", "Choose again"], + None: ["아직 고르지 않았어요", "Nothing chosen yet"], + Qty: ["수량", "Quantity"], + Price: ["단가", "Unit price"], + PriceLabor: ["노무 단가", "Labor price"], + PriceMat: ["재료 단가", "Material price"], + PriceExp: ["경비 단가", "Expense price"], + Reload: ["단가 다시 가져오기", "Reload prices"], + Loading: ["단가 가져오는 중", "Loading prices"], + Linked: ["다른 상자에서 이어진 값", "Value from another box"], + Split: [ + "장비는 노무비 · 재료비 · 경비 셋으로 나뉘어요", + "Machines split into labor, material and expense", + ], + Formula: ["금액 = 수량 × 단가 (소수 첫째 자리까지)", "Amount = quantity × price (one decimal)"], + RatePct: ["율(%)", "Rate (%)"], + Base: ["무엇의", "Of what"], + Into: ["넣을 자리", "Goes into"], + Fit: ["바탕 맞추기", "Refresh base"], + BaseHint: ["바탕 = 다른 항목 상자의 그 합계", "Base = total of the other item boxes"], + Labor3: ["노무비", "Labor cost"], + Mat3: ["재료비", "Material cost"], + Exp3: ["경비", "Expense"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + NoPrice: ["단가를 찾지 못했어요", "Price not found"], + Won: ["원", "KRW"], +} as const; + +type TextKey = keyof typeof TEXT; +const ti = (key: TextKey): string => TEXT[key][currentLanguageIndex]; + +export type ItemKind = "노무" | "재료" | "장비" | "일위대가" | "비율"; +export type CostName = "노무비" | "재료비" | "경비"; +const COSTS: CostName[] = ["노무비", "재료비", "경비"]; + +/** 항목 상자가 `node.ref` 에 글(JSON)로 들고 있는 것 */ +export interface ItemRef { + what: ItemKind; + /** 고른 것의 PickResult.element 그대로 */ + element?: unknown; + /** 크기(규격) */ + spec?: string; + /** 수량 단위 */ + unit?: string; + /** 고른 것이 받는 값(장비 지역 · 일위대가 넘길 값)과 지금 값 */ + need?: LogicInput[]; + inputs?: Record; + /** 비율 */ + base?: CostName; + into?: CostName; +} + +/** 칸 자리 — 항목 틀 둘(돈 · 비율) */ +const MONEY = { + qty: "B1", + labor: "B2", + mat: "B3", + exp: "B4", + unit: "B5", + amount: "B6", + cost: { 노무비: "B7", 재료비: "B8", 경비: "B9" }, + used: "B10", +}; +const RATE = { + rate: "B1", + base: "B2", + baseKind: "B3", + amount: "B4", + into: "B5", + cost: { 노무비: "B6", 재료비: "B7", 경비: "B8" }, + used: "B9", + unit: "B10", +}; + +export function itemRefOf(node: SlNode): ItemRef | null { + try { + const r = node.ref ? (JSON.parse(node.ref) as ItemRef) : null; + return r && r.what ? r : null; + } catch { + return null; + } +} + +/** 항목 상자의 비목 칸 자리 — 합계 · 바탕이 같은 자리를 읽음 */ +export const costCell = (ref: ItemRef, cost: CostName): string => + ref.what === "비율" ? RATE.cost[cost] : MONEY.cost[cost]; + +/* ---- 시트 칸 고치기(받은 것은 안 바꿈) ---- */ +const text = (v: string): Cell => ({ 값: v }); +const num = (v: number): Cell => ({ 값: v }); +const calc = (f: string): Cell => ({ 식: f }); + +function editSheet( + sl: SheetLogic, + node: SlNode, + edit: (cells: Record, s: Sheet) => void, +): SheetLogic { + const 시트 = sl.workbook.시트.map((s) => { + if (s.id !== node.sheetId) return s; + const next = { ...s, 칸: { ...s.칸 } }; + edit(next.칸, next); + return next; + }); + return { ...sl, workbook: { ...sl.workbook, 시트 } }; +} +const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => + sl.workbook.시트.find((s) => s.id === node.sheetId); +const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined => + sl.nodes.find((n) => n.id === id); +const withNode = (sl: SheetLogic, node: SlNode): SheetLogic => ({ + ...sl, + nodes: sl.nodes.map((n) => (n.id === node.id ? node : n)), +}); +const listRule = (id: string, cell: string, values: string[]) => ({ + id, + 범위: [{ r0: Number(cell.slice(1)) - 1, c0: 1, r1: Number(cell.slice(1)) - 1, c1: 1 }], + 규칙: { 종류: "목록" as const, 값들: values }, + 어기면: "막음" as const, +}); + +/* ---- 항목 틀 ---- */ +/** 일위대가표가 읽는 출력 알약 이름 */ +export const USED = "사용 수량"; +export const PILL_PRICE = "단가"; +export const PILL_AMOUNT = "금액"; +const PILL_BASE = "바탕 금액"; + +function moneyLayout(ref: ItemRef, qty: Cell | undefined, outside: SlField[]) { + const label = (cell: string, name: string): [string, Cell] => ["A" + cell.slice(1), text(name)]; + const cells: Record = Object.fromEntries([ + label(MONEY.qty, ti("Qty")), + label(MONEY.labor, ti("PriceLabor")), + label(MONEY.mat, ti("PriceMat")), + label(MONEY.exp, ti("PriceExp")), + label(MONEY.unit, PILL_PRICE), + label(MONEY.amount, PILL_AMOUNT), + label(MONEY.cost.노무비, ti("Labor3")), + label(MONEY.cost.재료비, ti("Mat3")), + label(MONEY.cost.경비, ti("Exp3")), + label(MONEY.used, USED), + ]); + if (qty) cells[MONEY.qty] = qty; + // 바깥 값이 아닌 단가 칸은 0 으로 고정 + for (const [cell, field] of [ + [MONEY.labor, "노무비"], + [MONEY.mat, "재료비"], + [MONEY.exp, "경비"], + ] as const) { + if (!outside.includes(field)) cells[cell] = num(0); + } + cells[MONEY.unit] = calc("B2+B3+B4"); + cells[MONEY.amount] = calc("ROUND(B1*B5,1)"); + cells[MONEY.cost.노무비] = calc("ROUND(B1*B2,1)"); + cells[MONEY.cost.재료비] = calc("ROUND(B1*B3,1)"); + cells[MONEY.cost.경비] = calc("ROUND(B1*B4,1)"); + cells[MONEY.used] = calc("B1"); + const unit = ref.unit ?? ""; + const won = ti("Won"); + const out = (name: string, cell: string, u = won): SlPill => ({ name, cell, unit: u }); + const pillsIn: SlPill[] = [ + { name: ti("Qty"), cell: MONEY.qty, unit }, + ...(outside.includes("노무비") + ? [{ name: ti("PriceLabor"), cell: MONEY.labor, unit: won, outside: true }] + : []), + ...(outside.includes("재료비") + ? [{ name: ti("PriceMat"), cell: MONEY.mat, unit: won, outside: true }] + : []), + ...(outside.includes("경비") + ? [{ name: ti("PriceExp"), cell: MONEY.exp, unit: won, outside: true }] + : []), + ]; + const pillsOut: SlPill[] = [ + out(PILL_PRICE, MONEY.unit), + out(PILL_AMOUNT, MONEY.amount), + out(ti("Labor3"), MONEY.cost.노무비), + out(ti("Mat3"), MONEY.cost.재료비), + out(ti("Exp3"), MONEY.cost.경비), + out(USED, MONEY.used, unit), + ]; + return { cells, pillsIn, pillsOut, rules: [] as ReturnType[] }; +} + +function rateLayout(ref: ItemRef, rate: Cell | undefined) { + const base = ref.base ?? "노무비"; + const into = ref.into ?? "재료비"; + const cells: Record = { + A1: text(ti("RatePct")), + B1: rate ?? num(3), + A2: text(PILL_BASE), + B2: num(0), + A3: text(ti("Base")), + B3: text(base), + A4: text(PILL_AMOUNT), + B4: calc("ROUND(B2*B1/100,1)"), + A5: text(ti("Into")), + B5: text(into), + A6: text(ti("Labor3")), + B6: calc('IF(B5="노무비",B4,0)'), + A7: text(ti("Mat3")), + B7: calc('IF(B5="재료비",B4,0)'), + A8: text(ti("Exp3")), + B8: calc('IF(B5="경비",B4,0)'), + A9: text(USED), + B9: calc("B1"), + A10: text(ti("Price")), + B10: calc("B2"), + }; + const pillsIn: SlPill[] = [ + { name: ti("RatePct"), cell: RATE.rate, unit: "%" }, + { name: PILL_BASE, cell: RATE.base, unit: ti("Won") }, + ]; + const pillsOut: SlPill[] = [ + { name: PILL_PRICE, cell: RATE.unit, unit: ti("Won") }, + { name: PILL_AMOUNT, cell: RATE.amount, unit: ti("Won") }, + { name: ti("Labor3"), cell: RATE.cost.노무비, unit: ti("Won") }, + { name: ti("Mat3"), cell: RATE.cost.재료비, unit: ti("Won") }, + { name: ti("Exp3"), cell: RATE.cost.경비, unit: ti("Won") }, + { name: USED, cell: RATE.used, unit: "%" }, + ]; + const rules = [listRule("sl-base", RATE.baseKind, COSTS), listRule("sl-into", RATE.into, COSTS)]; + return { cells, pillsIn, pillsOut, rules }; +} + +/** 어떤 바깥 값이 필요한가 — 노임 = 노무 · 자재 = 재료 · 장비 · 일위대가 = 셋 */ +const outsideFields = (what: ItemKind): SlField[] => + what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : what === "비율" ? [] : COSTS; +const FIELD_CELL: Record = { + 노무비: MONEY.labor, + 재료비: MONEY.mat, + 경비: MONEY.exp, +}; + +/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ +function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic { + const sheet = sheetOf(sl, node); + const oldRef = itemRefOf(node); + const keep = + oldRef && oldRef.what !== "비율" && ref.what !== "비율" ? sheet?.칸[MONEY.qty] : undefined; + const keepRate = + oldRef?.what === "비율" && ref.what === "비율" ? sheet?.칸[RATE.rate] : undefined; + const given: Cell | undefined = qty !== undefined && qty !== null ? num(qty) : undefined; + const lay = + ref.what === "비율" + ? rateLayout(ref, keepRate) + : moneyLayout(ref, given ?? keep, outsideFields(ref.what)); + const next = editSheet(sl, node, (cells, s) => { + for (const k of Object.keys(cells)) delete cells[k]; + Object.assign(cells, lay.cells); + if (lay.rules.length) s.유효성 = lay.rules; + else delete s.유효성; + }); + const names = new Set([...lay.pillsIn.map((p) => p.name), ...lay.pillsOut.map((p) => p.name)]); + const kept = (e: { from: { node: string; pill: string }; to: { node: string; pill: string } }) => + (e.from.node !== node.id || names.has(e.from.pill)) && + (e.to.node !== node.id || names.has(e.to.pill)); + return { + ...withNode(next, { + ...node, + ref: JSON.stringify(ref), + pillsIn: lay.pillsIn, + pillsOut: lay.pillsOut, + stage: 3, + }), + edges: next.edges.filter(kept), + }; +} + +/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */ +export function refreshBases(sl: SheetLogic): SheetLogic { + let out = sl; + for (const node of sl.nodes) { + const ref = itemRefOf(node); + if (!ref || ref.what !== "비율") continue; + const base = ref.base ?? "노무비"; + const sources = out.nodes.filter((n) => { + const r = itemRefOf(n); + return n.kind === "item" && r && r.what !== "비율"; + }); + const refs = sources + .map((n) => { + const s = sheetOf(out, n); + return s ? `${quoteSheet(s.이름)}!${costCell(itemRefOf(n)!, base)}` : ""; + }) + .filter(Boolean); + const formula = refs.length ? refs.join("+") : "0"; + out = editSheet(out, node, (cells) => { + cells[RATE.base] = refs.length ? calc(formula) : num(0); + }); + const mine = (id: string): boolean => id.startsWith(`e-base-${node.id}-`); + out = { + ...out, + edges: [ + ...out.edges.filter((e) => !mine(e.id)), + ...sources.map((n) => ({ + id: `e-base-${node.id}-${n.id}`, + from: { node: n.id, pill: base }, + to: { node: node.id, pill: PILL_BASE }, + })), + ], + }; + } + return out; +} + +/** 이 항목 상자들이 필요로 하는 바깥 값 자리 — Api `slOutside` · 저장이 같은 목록을 씀 */ +export function itemOutsides(sl: SheetLogic): SlOutsideAt[] { + return sl.nodes.flatMap((node) => { + const ref = itemRefOf(node); + const sheet = sheetOf(sl, node); + if (!ref || !sheet) return []; + const items = outsideItems(ref, node.id); + return items.map(({ field, item }) => ({ item, sheet: sheet.id, cell: FIELD_CELL[field] })); + }); +} + +function outsideItems(ref: ItemRef, id: string): { field: SlField; item: SlOutside }[] { + const key = typeof ref.element === "string" ? ref.element : ""; + const name = (f: string): string => `${id}:${f}`; + if (ref.what === "노무") + return [{ field: "노무비", item: { id: name("노무비"), kind: "노임", key } }]; + if (ref.what === "재료") { + const k = (ref.element as { 키?: string } | null)?.키 ?? ""; + return [{ field: "재료비", item: { id: name("재료비"), kind: "자재", key: k } }]; + } + const inputs = ref.inputs ?? {}; + if (ref.what === "장비") { + return COSTS.map((f) => ({ + field: f, + item: { id: name(f), kind: "장비" as const, element: key, inputs, field: f }, + })); + } + if (ref.what === "일위대가") { + const logicKey = key.match(/로직\(\s*([A-Z]{2}\d{6})/)?.[1] ?? ""; + return COSTS.map((f) => ({ + field: f, + item: { id: name(f), kind: "로직" as const, key: logicKey, inputs, field: f }, + })); + } + return []; +} + +/** 단가를 서버에서 받아 단가 칸에 넣음 — 못 구한 것은 까닭을 돌려줌(쉬운 말) */ +export async function loadPrices( + sl: SheetLogic, + nodeId: string, +): Promise<{ sl: SheetLogic; why: string | null }> { + const node = nodeOf(sl, nodeId); + const ref = node && itemRefOf(node); + if (!node || !ref) return { sl, why: null }; + const items = outsideItems(ref, node.id); + if (!items.length) return { sl, why: null }; + const got = await slOutside(items.map((i) => i.item)); + let why: string | null = null; + const next = editSheet(sl, node, (cells) => { + for (const { field, item } of items) { + const v = got.find((g) => g.id === item.id); + if (v?.ok && typeof v.value === "number") cells[FIELD_CELL[field]] = num(v.value); + else why = v?.reason ?? ti("NoPrice"); + } + }); + return { sl: next, why }; +} + +const PICK_KIND = { 노무: "person", 재료: "material", 장비: "machine", 일위대가: "logic" } as const; +const KIND_OF_PICK: Record = { + person: "노무", + material: "재료", + machine: "장비", + logic: "일위대가", +}; + +/** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */ +async function materialKey(el_: Record): Promise { + const found = await searchElements("재료", el_["검색어"] ?? ""); + const hit = found.items.find( + (it) => `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() === (el_["검색어"] ?? "").trim(), + ); + return (hit ?? found.items[0])?.ref ?? ""; +} + +/** 고른 것으로 항목 상자를 채움(단가는 따로 loadPrices) */ +export async function applyPick( + sl: SheetLogic, + nodeId: string, + pick: PickResult, +): Promise { + const node = nodeOf(sl, nodeId); + if (!node) return sl; + const what = KIND_OF_PICK[pick.kind]; + let element = pick.element; + if (what === "재료") { + const e = { ...(pick.element as Record) }; + element = { ...e, 키: await materialKey(e) }; + } + const inputs: Record = {}; + for (const n of pick.needInputs ?? []) inputs[n.이름] = n.고르기?.[0] ?? n.범위?.[0]; + const ref: ItemRef = { + what, + element, + spec: pick.spec, + unit: pick.unit, + need: pick.needInputs, + inputs, + }; + const named = withNode(sl, { ...node, name: `${pick.name}${pick.spec ? ` ${pick.spec}` : ""}` }); + return refreshBases(applyLayout(named, nodeOf(named, nodeId)!, ref, pick.qty)); +} + +/* ---- 손잡이 ---- */ +const cellValue = (sl: SheetLogic, node: SlNode, cell: string): Cell | undefined => + sheetOf(sl, node)?.칸[cell]; + +export function renderItemTools( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: { change(sl: SheetLogic): void }, +): void { + const node = nodeOf(sl, nodeId); + const msg = el("p", { className: "m01-slitem__msg" }); + if (!node) { + host.replaceChildren(); + return; + } + const ref = itemRefOf(node); + const emit = (make: () => SheetLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch (e) { + msg.textContent = slPlain(e) || ti("Cant"); + } + }; + const setRef = (patch: Partial): SheetLogic => { + const next: ItemRef = { ...ref!, ...patch }; + return refreshBases(applyLayout(sl, node, next)); + }; + const chosen = (): HTMLElement => + el("p", { + className: "m01-slitem__chosen", + text: ref && ref.what !== "비율" ? node.name : ti("None"), + }); + + const pickAndLoad = async (kind: ItemKind): Promise => { + if (kind === "비율") { + emit(() => { + const named = /^항목 \d+$/.test(node.name) + ? withNode(sl, { ...node, name: ti("ToolLoss") }) + : sl; + return refreshBases( + applyLayout(named, nodeOf(named, node.id)!, { + what: "비율", + base: "노무비", + into: "재료비", + }), + ); + }); + return; + } + const got = await openEasyPick(PICK_KIND[kind]); + if (!got) return; + msg.textContent = ti("Loading"); + try { + const filled = await applyPick(sl, node.id, got); + on.change(filled); + const priced = await loadPrices(filled, node.id); + msg.textContent = priced.why ?? ""; + on.change(priced.sl); + } catch (e) { + msg.textContent = slPlain(e) || ti("Cant"); + } + }; + + const kindField = createSelectField({ + label: ti("Kind"), + options: (["노무", "재료", "장비", "일위대가", "비율"] as ItemKind[]).map((k) => ({ + value: k, + text: ti( + k === "노무" + ? "Labor" + : k === "재료" + ? "Material" + : k === "장비" + ? "Machine" + : k === "비율" + ? "Rate" + : "Logic", + ), + })), + value: ref?.what ?? "노무", + compact: true, + onChange: (v) => void pickAndLoad(v as ItemKind), + }); + + const numberBox = (cell: string, label: string, unit: string): HTMLElement => { + const c = cellValue(sl, node, cell); + const linked = c?.식 !== undefined; + const box = el("input", { + className: "m01-slitem__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": label }, + }); + box.value = linked ? "" : c?.값 === undefined ? "" : String(c.값); + box.readOnly = linked; + box.addEventListener("change", () => { + const raw = box.value.trim(); + const n = Number(raw); + if (raw === "" || Number.isNaN(n)) { + msg.textContent = ti("Bad"); + return; + } + emit(() => editSheet(sl, node, (cells) => (cells[cell] = num(n)))); + }); + return el("label", { + className: "m01-slitem__field", + children: [ + el("span", { text: `${label}${unit ? ` (${unit})` : ""}` }), + box, + ...(linked ? [el("small", { className: "m01-slitem__muted", text: ti("Linked") })] : []), + ], + }); + }; + const costSelect = ( + label: string, + value: CostName, + patch: (c: CostName) => Partial, + ): HTMLElement => { + const f = createSelectField({ + label, + options: COSTS.map((c) => ({ value: c, text: c })), + value, + compact: true, + onChange: (v) => emit(() => setRef(patch(v as CostName))), + }); + return f.root; + }; + + const body: HTMLElement[] = [kindField.root]; + if (ref && ref.what !== "비율") { + const price = (cell: string, label: string): HTMLElement => + el("div", { + className: "m01-slitem__price", + children: [ + el("span", { text: label }), + el("strong", { text: formatPrice(cellValue(sl, node, cell)?.값 ?? 0) }), + ], + }); + body.push( + chosen(), + createButton({ + label: ref.element === undefined ? ti("Pick") : ti("Repick"), + size: "sm", + onClick: () => void pickAndLoad(ref.what), + }), + numberBox(MONEY.qty, ti("Qty"), ref.unit ?? ""), + el("div", { + className: "m01-slitem__prices", + children: [ + price(MONEY.labor, ti("PriceLabor")), + price(MONEY.mat, ti("PriceMat")), + price(MONEY.exp, ti("PriceExp")), + ], + }), + ); + if (ref.element !== undefined) { + body.push( + createButton({ + label: ti("Reload"), + size: "sm", + variant: "ghost", + onClick: () => + void loadPrices(sl, node.id).then( + (r) => { + msg.textContent = r.why ?? ""; + on.change(r.sl); + }, + (e) => (msg.textContent = slPlain(e) || ti("NoPrice")), + ), + }), + ); + } + body.push( + el("p", { + className: "m01-slitem__muted", + text: ref.what === "장비" || ref.what === "일위대가" ? ti("Split") : ti("Formula"), + }), + ); + } else if (ref) { + body.push( + numberBox(RATE.rate, ti("RatePct"), "%"), + costSelect(ti("Base"), ref.base ?? "노무비", (c) => ({ base: c })), + costSelect(ti("Into"), ref.into ?? "재료비", (c) => ({ into: c })), + createButton({ + label: ti("Fit"), + size: "sm", + variant: "ghost", + onClick: () => emit(() => refreshBases(sl)), + }), + el("p", { className: "m01-slitem__muted", text: ti("BaseHint") }), + ); + } else { + body.push(chosen()); + } + host.replaceChildren( + el("strong", { className: "m01-slitem__title", text: `${ti("Title")} · ${node.name}` }), + el("div", { className: "m01-slitem__body", children: body }), + msg, + ); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.css b/M01_MasterData/SheetLogic/M01_SL_Result.css new file mode 100644 index 000000000..d173658af --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Result.css @@ -0,0 +1,98 @@ +.m01-slresult__big { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m01-slresult__big strong { + color: var(--color-text-body); + font-size: 28px; + font-variant-numeric: tabular-nums; +} + +.m01-slresult__costs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-16); + margin: 0 0 var(--spacing-12); + font-size: var(--text-body-sm); +} + +.m01-slresult__costs dt { + display: inline; + color: var(--color-text-secondary); +} + +.m01-slresult__costs dd { + display: inline; + margin: 0 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-slresult__strip { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-slresult__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-slresult__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-slresult__input { + box-sizing: border-box; + width: 140px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-slresult__input[readonly] { + background: var(--color-surface); +} + +.m01-slresult__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-slresult__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-slresult__problems { + margin: 0 0 var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-slresult__problems ul { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts new file mode 100644 index 000000000..213fdd077 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Result.ts @@ -0,0 +1,243 @@ +/* ============================================================================= + * M01_SL_Result.ts + * 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음) + * · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄 + * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. + * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import type { + Cell, + DataValidationRule, + Sheet, +} from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; +import type { SlEvalResult } from "./M01_SL_Eval"; +import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item"; +import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; +import "./M01_SL_Result.css"; + +const TEXT = { + Per: ["{per} 당", "per {per}"], + Total: ["합계", "Total"], + Won: ["원", "KRW"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Sample: ["예시값", "Sample values"], + SampleHint: [ + "여기서 바꾸면 입력값 상자의 예시값도 바뀌어요", + "Changing here changes the input box too", + ], + Problems: ["고칠 것", "To fix"], + Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Pick: ["고르세요", "Choose"], + RateOf: ["{base}의 %", "% of {base}"], +} as const; + +type TextKey = keyof typeof TEXT; +const tr = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); + +const COSTS = ["노무비", "재료비", "경비"] as const; +const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + +const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => + sl.workbook.시트.find((s) => s.id === node.sheetId); +const colOf = (cell: string): { r: number; c: number } => ({ + r: Number(cell.replace(/\D/g, "")) - 1, + c: cell.charCodeAt(0) - 65, +}); + +/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */ +function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] { + return sl.nodes + .filter((n) => n.kind === "item") + .map((n) => { + const ref = itemRefOf(n); + const p = values.pills[n.id] ?? {}; + const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? ""; + const pickName = (name: string): number => n0(p[name]); + return { + 이름: n.name, + 단위: unit, + 수량: pickName(USED), + 단가: pickName(PILL_PRICE), + 금액: pickName(PILL_AMOUNT), + 비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])), + ...(ref?.what === "비율" + ? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } } + : ref?.spec + ? { 품목: { 키: "", 규격: ref.spec } } + : {}), + }; + }); +} + +/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */ +function emptyInputs(sl: SheetLogic): string[] { + return sl.nodes.flatMap((n) => { + const cells = sheetOf(sl, n)?.칸 ?? {}; + return n.pillsIn + .filter( + (p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])), + ) + .map((p) => tr("Empty", { box: n.name, pill: p.name })); + }); +} + +/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */ +function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null { + const { r, c } = colOf(cell); + const hit = (sheet.유효성 ?? []).find((v) => + v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1), + ); + return hit?.규칙 ?? null; +} + +function sampleField( + sl: SheetLogic, + node: SlNode, + pill: SlPill, + put: (cell: string, v: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const sheet = sheetOf(sl, node); + const cur: Cell | undefined = sheet?.칸[pill.cell]; + const rule = sheet ? ruleAt(sheet, pill.cell) : null; + const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); + let control: HTMLElement; + if (rule?.종류 === "목록") { + const field = createSelectField({ + options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))], + value: cur?.값 === undefined ? "" : String(cur.값), + compact: true, + ariaLabel: pill.name, + onChange: (v) => { + if (v !== "") put(pill.cell, v); + }, + }); + control = field.root; + } else { + const box = el("input", { + className: "m01-slresult__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, + }); + if (rule?.종류 === "수범위") { + box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") }); + } + const linked = cur?.식 !== undefined; + box.value = linked || cur?.값 === undefined ? "" : String(cur.값); + box.readOnly = linked; + box.addEventListener("change", () => { + const raw = box.value.trim(); + const n = Number(raw); + if (raw === "" || Number.isNaN(n)) { + msg.textContent = tr("Bad"); + return; + } + put(pill.cell, n); + }); + control = box; + } + return el("label", { className: "m01-slresult__field", children: [label, control] }); +} + +/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */ +export function renderResult( + host: HTMLElement, + sl: SheetLogic, + values: SlEvalResult, + on: { change(sl: SheetLogic): void }, +): void { + const { 계 } = values.result; + const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; + const big = el("p", { + className: "m01-slresult__big", + children: [ + el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }), + el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }), + ], + }); + const costs = el("dl", { + className: "m01-slresult__costs", + children: COSTS.flatMap((c, i) => [ + el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }), + el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }), + ]), + }); + + const head = { name: sl.name, resultUnit: sl.resultUnit }; + const problems = [...values.errors, ...emptyInputs(sl)]; + const ans: CalcAnswer = + values.errors.length || 계 === null + ? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" } + : { + ok: true, + lines: linesOf(sl, values), + sums: { + 노무비: n0(values.result.노무비), + 재료비: n0(values.result.재료비), + 경비: n0(values.result.경비), + 계: n0(계), + }, + middle: {}, + }; + const sheetHost = el("div", { className: "m01-slresult__sheet" }); + renderSheet(sheetHost, head, ans); + + const msg = el("p", { className: "m01-slresult__msg" }); + const put = + (node: SlNode) => + (cell: string, v: number | string): void => { + msg.textContent = ""; + try { + const 시트 = sl.workbook.시트.map((s) => + s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s, + ); + on.change({ ...sl, workbook: { ...sl.workbook, 시트 } }); + } catch { + msg.textContent = tr("Cant"); + } + }; + const fields = sl.nodes + .filter((n) => n.kind === "input") + .flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg))); + const strip = fields.length + ? [ + el("div", { + className: "m01-slresult__strip", + children: [ + el("strong", { text: tr("Sample") }), + el("div", { className: "m01-slresult__fields", children: fields }), + el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }), + msg, + ], + }), + ] + : []; + host.replaceChildren( + big, + costs, + ...strip, + ...(problems.length + ? [ + el("div", { + className: "m01-slresult__problems", + children: [ + el("strong", { text: tr("Problems") }), + el("ul", { children: problems.map((t) => el("li", { text: t })) }), + ], + }), + ] + : []), + sheetHost, + ); +} From ef0b4ae3850b02e9ac20f47a03639f7cdd493f5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:07:56 +0900 Subject: [PATCH 05/38] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=9E=90=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B8=B0=20=C2=B7=20=EB=B0=94=EA=B9=A5=20?= =?UTF-8?q?=EA=B0=92=20=EC=95=BD=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renameNode — 엔진 시트 이름 명령으로 시트 이름 · 그 시트를 가리키던 선 수식이 따라감 · 1~31 글자 · 못 쓰는 글자 · 같은 이름은 막고 까닭 - 상자 더하기 이름도 시트 이름 규칙대로 고쳐 만듦(못 쓰는 글자 → · · 31 글자) - SlSource(서버 바깥 값 물음 한 줄) · SlField · SlOutside.id(상자 id!칸) · source · SlNode.source - 표 범위 칸 약속 RANGE_LOW · RANGE_HIGH · rangeHead(이름) = 「이름·아래」 「이름·위」 - answersToValues — 서버 답(id · ok · value · rows) → 넣을 값 · 못 받은 것은 쉬운 말 - 시험 13개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Eval.ts | 50 ++++++++++++++- M01_MasterData/SheetLogic/M01_SL_Model.ts | 75 +++++++++++++++++++--- resources/tester/test_m01_sl_model.py | 77 ++++++++++++++++++++++- 3 files changed, 190 insertions(+), 12 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts index 0c8b3550d..69bc12453 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -9,7 +9,13 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { nodeSheet, type SheetLogic, type SlNode, type SlOutside } from "./M01_SL_Model"; +import { + nodeSheet, + type SheetLogic, + type SlNode, + type SlOutside, + type SlSource, +} from "./M01_SL_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ export type SlValue = number | string | boolean | null; @@ -48,6 +54,11 @@ const TEXT = { "Outside value 「{pill}」 of 「{box}」 not filled yet", ], NoResult: ["결과 상자가 없음", "There is no result box"], + /** {cell} {why} */ + NoAnswer: [ + "{cell} 칸 바깥 값을 못 받음 {why}", + "Couldn't get the outside value for {cell} {why}", + ], NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"], } as const; @@ -63,13 +74,48 @@ const tl = (key: keyof typeof TEXT, fill: Record = {}): string = export function outsideCells(sl: SheetLogic): SlOutside[] { const kindOf = (n: SlNode): SlOutside["kind"] => n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price"; + const sourceOf = (n: SlNode): SlSource | undefined => + n.source ?? + (n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ?? + (n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined); return sl.nodes.flatMap((n) => [...n.pillsIn, ...n.pillsOut] .filter((p) => p.outside) - .map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })), + .map((p) => { + const source = sourceOf(n); + const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) }; + return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) }; + }), ); } +/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */ +export interface SlOutsideAnswer { + id: string; + ok: boolean; + value?: number | null; + rows?: (string | number | null)[][]; + reason?: string; +} + +/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */ +export function answersToValues( + cells: SlOutside[], + answers: SlOutsideAnswer[], +): { values: SlOutsideValue[]; errors: string[] } { + const values: SlOutsideValue[] = []; + const errors: string[] = []; + for (const c of cells) { + const a = answers.find((x) => x.id === c.id); + if (!a || !a.ok) { + errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); + continue; + } + values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); + } + return { values, errors }; +} + /** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ function putValue(칸: Record, cell: string, value: SlValue): void { const style = 칸[cell]?.서식; diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index 6d3ba6ca8..746509c34 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -9,6 +9,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands"; import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; /** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ @@ -34,6 +35,8 @@ export interface SlNode { pillsOut: SlPill[]; /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ ref?: string; + /** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */ + source?: SlSource; /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ stage: number; } @@ -45,14 +48,33 @@ export interface SlEdge { to: { node: string; pill: string }; } -/** 바깥 값 칸 */ +/** 돈 로직 결과에서 꺼낼 칸 */ +export type SlField = "노무비" | "재료비" | "경비" | "계"; + +/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로 + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */ +export type SlSource = + | { kind: "표"; table: string } + | { kind: "노임" | "자재"; key: string } + | { kind: "장비"; element: string; inputs: Record; field?: SlField } + | { kind: "로직"; key: string; inputs: Record; field?: SlField }; + +/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */ export interface SlOutside { + id: string; node: string; cell: string; kind: "table" | "price" | "logic"; ref: string; + source?: SlSource; } +/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */ +export const RANGE_LOW = "·아래"; +export const RANGE_HIGH = "·위"; +export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH]; + /** 내 표 — 품셈에 없는 표(사용자가 만듦) */ export interface SlUserTable { id: string; @@ -100,6 +122,13 @@ const TEXT = { Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], Twice: ["이미 이어져 있음", "Already linked"], + NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"], + NameChar: [ + "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", + "Name can't use [ ] : * ? / \\ or a leading/trailing '", + ], + NameSame: ["같은 이름 상자가 있음", "A box with this name exists"], + NoBox: ["상자가 없음", "No such box"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; @@ -130,12 +159,25 @@ function nextId(prefix: string, ids: string[]): string { return `${prefix}${Math.max(0, ...nums) + 1}`; } -/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */ -function uniqueName(book: Workbook, name: string): string { - const base = name.trim() || "상자"; - const used = new Set(book.시트.map((s) => s.이름)); - if (!used.has(base)) return base; - for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`; +/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */ +const BAD_CHARS = /[[\]:*?/\\]/g; +const cleanName = (name: string): string => + name + .replace(BAD_CHARS, "·") + .replace(/^'+|'+$/g, "") + .trim() + .slice(0, 31) || "상자"; + +/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */ +function uniqueName(book: Workbook, name: string, self?: string): string { + const base = cleanName(name); + const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase())); + if (!used.has(base.toLowerCase())) return base; + for (let n = 2; ; n++) { + const tail = ` (${n})`; + const next = base.slice(0, 31 - tail.length) + tail; + if (!used.has(next.toLowerCase())) return next; + } } const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => { @@ -435,3 +477,22 @@ export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null { const node = nodeOf(sl, nodeId); return node ? sheetOf(sl, node) : null; } + +/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */ +export function renameNode( + sl: SheetLogic, + nodeId: string, + name: string, +): { sl: SheetLogic; why: string | null } { + const node = nodeOf(sl, nodeId); + if (!node) return { sl, why: tl("NoBox") }; + const next = name.trim(); + if (!next || next.length > 31) return { sl, why: tl("NameLen") }; + if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'")) + return { sl, why: tl("NameChar") }; + if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") }; + const out = clone(sl); + applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next }); + nodeOf(out, nodeId)!.name = next; + return { sl: out, why: null }; +} diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index 03c040d21..204c9fcb9 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -202,9 +202,23 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ) cells, e1, f2, e3, f3, lg, check = got assert cells == [ - {"node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, - {"node": "n4", "cell": "E1", "kind": "table", "ref": "QF000421"}, - {"node": "n5", "cell": "B2", "kind": "logic", "ref": "GF000114"}, + {"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + { + "id": "n4!E1", + "node": "n4", + "cell": "E1", + "kind": "table", + "ref": "QF000421", + "source": {"kind": "표", "table": "QF000421"}, + }, + { + "id": "n5!B2", + "node": "n5", + "cell": "B2", + "kind": "logic", + "ref": "GF000114", + "source": {"kind": "로직", "key": "GF000114", "inputs": {}}, + }, ] assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) assert lg == {"결과": 12.5} @@ -261,3 +275,60 @@ def test_화면_글에_금지_낱말_없음() -> None: for text in korean: for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): assert bad not in text, (name, bad, text) + + +def test_상자_이름_바꾸기_수식이_따라감() -> None: + got = _run( + TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n" + "const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why," + " M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why," + " M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n" + "out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름," + " sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);" + ) + why, name, sheet_name, cell, total, tries, before = got + assert why is None and name == sheet_name == "석공 (돌쌓기)" + assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000 + assert tries == [ + "같은 이름 상자가 있음", + "이름은 1~31 글자", + "이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '", + "상자가 없음", + "이름은 1~31 글자", + ] + assert before == "석공" + + +def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None: + got = _run( + "let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;" + " r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;" + " out(sl.workbook.시트.map(s => s.이름));" + ) + assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"] + + +def test_바깥값_답을_넣을값으로_범위머리() -> None: + got = _run( + ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n" + "sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n" + "sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n" + "const cells = E.outsideCells(sl);\n" + "const head = M.rangeHead('높이');\n" + "const got = E.answersToValues(cells, [\n" + " { id: 'n2!B3', ok: true, value: 172068 },\n" + " { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" + "]);\n" + "const s2 = E.applyOutside(sl, got.values);\n" + "out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);" + ) + head, got_values, f1, g2, total = got + assert head == ["높이·아래", "높이·위"] + assert got_values["errors"] == [] + assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068} + assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) + missing = _run( + ONE + + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));" + ) + assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]} From 7bd6e8d7d1061dc648b087750f4e4c2f777676e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:08:02 +0900 Subject: [PATCH 06/38] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=91=9C=20=EC=83=81=EC=9E=90=20=EC=86=90?= =?UTF-8?q?=EC=9E=A1=EC=9D=B4=20Table=20=EC=B4=88=EC=95=88=20=E2=80=94=20?= =?UTF-8?q?=ED=92=88=EC=85=88=20=ED=91=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=82=B4=20=ED=91=9C=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EC=8B=9D=20=EC=A7=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/SheetLogic/M01_SL_Table.css | 138 ++++ M01_MasterData/SheetLogic/M01_SL_Table.ts | 612 ++++++++++++++++++ .../SheetLogic/M01_SL_Table_Build.ts | 446 +++++++++++++ resources/tester/test_m01_sl_table_build.py | 175 +++++ 4 files changed, 1371 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Table.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_Table.ts create mode 100644 M01_MasterData/SheetLogic/M01_SL_Table_Build.ts create mode 100644 resources/tester/test_m01_sl_table_build.py diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.css b/M01_MasterData/SheetLogic/M01_SL_Table.css new file mode 100644 index 000000000..582c89597 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table.css @@ -0,0 +1,138 @@ +.m01-sltbl { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + color: var(--color-text); +} +.m01-sltbl__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} +.m01-sltbl__box { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); +} +.m01-sltbl__box > summary { + cursor: pointer; + font-weight: 600; +} +.m01-sltbl__box[open] > summary { + margin-bottom: var(--spacing-8); +} +.m01-sltbl__box > :not(summary) { + margin-top: var(--spacing-8); +} +.m01-sltbl__info { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} +.m01-sltbl__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 22vh; + overflow: auto; +} +.m01-sltbl__row, +.m01-sltbl__role { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8) var(--spacing-12); + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.m01-sltbl__row:hover, +.m01-sltbl__row:focus-visible, +.m01-sltbl__role:hover, +.m01-sltbl__role:focus-visible { + border-color: var(--color-accent); +} +.m01-sltbl__roles { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} +.m01-sltbl__roles > p { + flex: 1 0 100%; +} +.m01-sltbl__role span { + font-size: var(--text-caption); + color: var(--color-text-secondary); +} +.m01-sltbl__role.is-cond { + border-color: var(--color-accent); +} +.m01-sltbl__role.is-out { + border-style: dashed; + border-color: var(--color-accent); +} +.m01-sltbl__role.is-off { + opacity: 0.6; +} +.m01-sltbl__status { + margin: 0; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + font-weight: 600; +} +.m01-sltbl__status.is-ok { + border-left: 4px solid var(--color-accent); +} +.m01-sltbl__status.is-bad { + border-left: 4px solid var(--color-danger); + color: var(--color-danger); +} +.m01-sltbl__scroll { + max-height: 24vh; + overflow: auto; +} +.m01-sltbl__view table { + border-collapse: collapse; + font-size: var(--text-body-sm); +} +.m01-sltbl__view th, +.m01-sltbl__view td { + padding: 2px 8px; + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: center; +} +.m01-sltbl__view tr.is-hit td { + background: rgba(240, 190, 40, 0.35); + font-weight: 700; +} +.m01-sltbl__formula { + display: flex; + flex-direction: column; + gap: 2px; +} +.m01-sltbl__formula code { + overflow-wrap: anywhere; + font-size: var(--text-caption); +} +.m01-sltbl__warn { + margin: 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} +.m01-sltbl__paste { + width: 100%; + box-sizing: border-box; + font: inherit; +} +.m01-sltbl h5 { + margin: var(--spacing-4) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.ts b/M01_MasterData/SheetLogic/M01_SL_Table.ts new file mode 100644 index 000000000..8d046d340 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table.ts @@ -0,0 +1,612 @@ +/* ============================================================================= + * M01_SL_Table.ts + * 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음 + * · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음) + * · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로 + * · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음) + * · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄 + * 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { ApiError } from "../M01_MasterData_UI_Logic_Api"; +import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; +import type { SheetLogic, SlNode } from "./M01_SL_Model"; +import { + applyTable, + blockCells, + infoFromSheet, + layoutOf, + parsePaste, + planLookup, + type TblInfo, + type TblRoles, +} from "./M01_SL_Table_Build"; +import "./M01_SL_Table.css"; + +const TEXT = { + NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"], + Import: ["품셈 표 가져오기", "Import a standard table"], + Mine: ["내 표", "My table"], + Book: ["적용 품셈", "Standard"], + Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], + Loading: ["찾는 중…", "Searching…"], + NoSection: ["찾는 절 없음", "No section"], + SecLine: ["표 {t} 개", "{t} tables"], + Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], + TableLine: [ + "{b} · 조건 {c} · 결과 {v} · {n} 줄", + "{b} · {c} conditions · {v} results · {n} rows", + ], + ImportNote: [ + "표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음", + "Rows are filled from the standard when opened or saved", + ], + Empty: ["빈 표", "Empty table"], + Paste: ["엑셀에서 붙여넣기", "Paste from Excel"], + Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"], + RowCount: ["줄 수", "Rows"], + MakeEmpty: ["빈 표 만들기", "Create"], + PasteHint: [ + "엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)", + "Paste with the header row (tab separated)", + ], + PutIn: ["시트에 넣기", "Put in sheet"], + TableName: ["표 이름", "Table name"], + Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"], + NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"], + Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"], + Notes: ["주(註)", "Notes"], + Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"], + RoleCond: ["조건 → 입력 알약", "Condition → input pill"], + RoleOut: ["결과 → 출력 알약", "Result → output pill"], + RoleOff: ["쓰지 않음", "Unused"], + Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"], + Found: ["{n} 번째 줄이 걸림", "Row {n} matched"], + NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"], + TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"], + Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"], + NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"], + View: ["걸린 줄 보기", "Matched row"], + Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"], + Warn: ["알림", "Note"], + Swap: ["다른 표로 바꾸기", "Change table"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +/* ── 서버 읽기 — 절 · 표 ── */ +async function get(path: string, params: Record): Promise { + const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { + credentials: "include", + }); + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText); + return data; +} + +interface SectionItem { + section: string; + title: string; + chapter: string; + tables: number; +} +interface TableHead { + file: string; + 키: string; + 이름: string; + 기준: string; + 조건: Record; + 값칸: Record; + count: number; +} +interface RawTable { + 키: string; + 원문번호: string; + 이름: string; + 기준: string; + 출처: string; + 조건: Record; + 값칸: Record; + 범위규칙?: string | null; + 줄: TblInfo["rows"]; + 주?: string[]; +} + +const BOOKS = ["산림품셈", "건설품셈"]; +const cache = new Map(); +const fileOf = new Map(); + +const toInfo = (r: RawTable): TblInfo => ({ + key: r.키, + name: r.이름, + base: r.기준 ?? "", + source: r.출처 ?? "", + no: r.원문번호 ?? "", + conds: r.조건, + vals: r.값칸, + rule: r.범위규칙 ?? null, + rows: r.줄, + notes: r.주 ?? [], +}); + +/** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */ +async function loadInfo(key: string): Promise { + const hit = cache.get(key); + if (hit) return hit; + let file = fileOf.get(key); + if (!file) { + const group = /^C[FC]/.test(key) ? "계수" : "소요량"; + const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( + (x) => x.키 === key, + ); + if (!found) throw new Error(t("Failed")); + file = found.file; + } + const info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table); + cache.set(key, info); + return info; +} + +/* ── 역할 · 알약 ── */ +const rolesOf = (node: SlNode): TblRoles => ({ + cond: node.pillsIn.map((p) => p.name), + out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name), +}); + +/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */ +function mineInfo(sl: SheetLogic, node: SlNode): TblInfo { + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + const name = node.name.replace(/^표 · /, ""); + const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"])); + return sheet + ? infoFromSheet(sheet, { conds, name }) + : { + key: "", + name, + base: "", + source: "", + no: "", + conds, + vals: {}, + rule: null, + rows: [], + notes: [], + }; +} + +/** userTables 한 줄을 시트 모양으로 맞춤 */ +function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic { + const cols = layoutOf(info); + const rows = info.rows.map((r) => + cols.map( + (c) => + (Array.isArray(r[c.of]) + ? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null) + : (r[c.of] ?? null)) as number | string | null, + ), + ); + const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows }; + const list = (sl.userTables ?? []).filter((u) => u.id !== id); + return { ...sl, userTables: [...list, entry] }; +} + +/** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */ +async function solve( + sl: SheetLogic, + node: SlNode, + info: TblInfo, + withBlock: boolean, +): Promise<{ status: string; idx: number | null }> { + const plan = planLookup(info, rolesOf(node)); + const book = structuredClone(sl.workbook); + const sheet = book.시트.find((s) => s.id === node.sheetId); + if (!sheet) return { status: "", idx: null }; + if (withBlock) Object.assign(sheet.칸, blockCells(info)); + const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph"); + const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {}; + const read = (a: string): string | null => { + const v = vals[a] as Record | undefined; + if (!v) return null; + return String(v["수"] ?? v["글"] ?? v["오류"] ?? ""); + }; + const status = read(plan.statusCell) ?? ""; + const idx = Number(read(plan.idxCell)); + return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null }; +} + +/* ── 화면 ── */ +export function renderTableTools( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: { change(sl: SheetLogic): void }, +): void { + host.className = "m01-sltbl"; + const node = sl.nodes.find((n) => n.id === nodeId); + if (!node || node.kind !== "table") { + host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") })); + return; + } + const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined; + const state = !node.ref ? "empty" : mine ? "mine" : "base"; + const parts: HTMLElement[] = []; + if (state !== "empty") parts.push(infoBox(sl, node, state, on)); + parts.push( + importBox(sl, nodeId, state === "empty", on), + mineBox(sl, nodeId, state === "empty", on), + ); + host.replaceChildren(...parts); +} + +const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => { + const d = el("details", { + className: "m01-sltbl__box", + children: [el("summary", { text: title }), ...kids], + }); + d.open = open; + return d; +}; + +/** 품셈 표 가져오기 */ +function importBox( + sl: SheetLogic, + nodeId: string, + open: boolean, + on: { change(sl: SheetLogic): void }, +): HTMLElement { + let book = BOOKS[0]; + const bookBox = createSelectField({ + label: t("Book"), + options: BOOKS.map((b) => ({ value: b, text: b })), + value: book, + compact: true, + onChange: (v) => ((book = v), load()), + }); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m01-sltbl__list" }); + const tables = el("div", { className: "m01-sltbl__list" }); + let seq = 0; + const load = async (): Promise => { + const mineSeq = ++seq; + list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") })); + try { + const r = await get<{ sections: SectionItem[] }>("/sections", { + book, + q: q.input.value.trim(), + limit: "60", + }); + if (mineSeq !== seq) return; + list.replaceChildren( + ...(r.sections.length + ? r.sections.map((s) => { + const b = el("button", { + className: "m01-sltbl__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: `${s.section} ${s.title}` }), + el("span", { + className: "m01-sltbl__muted", + text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, + }), + ], + }); + b.addEventListener("click", () => void pickSection(s)); + return b; + }) + : [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]), + ); + } catch (e) { + if (mineSeq === seq) showToast(plainError(e), "error"); + } + }; + const pickSection = async (s: SectionItem): Promise => { + try { + const heads: TableHead[] = []; + for (const group of ["소요량", "계수"]) + heads.push( + ...( + await get<{ tables: TableHead[] }>("/tables", { + group, + section: s.section, + sub: book, + size: "100", + }) + ).tables, + ); + tables.replaceChildren( + el("h5", { text: t("Tables") }), + ...heads.map((h) => { + const b = el("button", { + className: "m01-sltbl__row", + attrs: { type: "button" }, + children: [ + el("strong", { text: h.이름 }), + el("span", { + className: "m01-sltbl__muted", + text: t("TableLine", { + b: h.기준 || "—", + c: Object.keys(h.조건).length, + v: Object.keys(h.값칸).length, + n: h.count, + }), + }), + ], + }); + b.addEventListener("click", () => void pickTable(h)); + return b; + }), + ); + } catch (e) { + showToast(plainError(e), "error"); + } + }; + const pickTable = async (h: TableHead): Promise => { + try { + fileOf.set(h.키, h.file); + const info = await loadInfo(h.키); + const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) }; + on.change( + applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl, + ); + } catch (e) { + showToast(plainError(e), "error"); + } + }; + let timer = 0; + q.input.addEventListener( + "input", + () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), + ); + const box = sub( + t("Import"), + open, + el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }), + bookBox.root, + q.root, + list, + tables, + ); + box.addEventListener("toggle", () => box.open && !list.childElementCount && void load()); + if (open) void load(); + return box; +} + +/** 내 표 — 빈 표 · 붙여넣기 */ +function mineBox( + sl: SheetLogic, + nodeId: string, + open: boolean, + on: { change(sl: SheetLogic): void }, +): HTMLElement { + const name = createInputField({ + type: "text", + label: t("TableName"), + value: "내 표", + compact: true, + }); + const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true }); + const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true }); + const paste = el("textarea", { + className: "m01-sltbl__paste", + attrs: { rows: "5", placeholder: t("PasteHint") }, + }); + const put = (info: TblInfo, roles: TblRoles): void => { + const id = `UT${(sl.userTables?.length ?? 0) + 1}`; + const done = applyTable( + sl, + nodeId, + { ...info, name: name.input.value.trim() || "내 표" }, + roles, + { ref: id, fillBlock: true, outside: false }, + ); + on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" })); + }; + const makeEmpty = createButton({ + label: t("MakeEmpty"), + onClick: () => { + const cols = heads.input.value + .split(/[,,\t]/) + .map((s) => s.trim()) + .filter(Boolean); + const n = Math.floor(Number(rows.input.value)); + if (cols.length < 2) return heads.setError(t("Need2")); + if (!(n >= 1)) return rows.setError(t("NeedRows")); + const info: TblInfo = { + key: "", + name: "", + base: "", + source: "", + no: "", + conds: { [cols[0]]: "고르기" }, + vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])), + rule: null, + rows: Array.from({ length: n }, () => ({})), + notes: [], + }; + put(info, { cond: [cols[0]], out: cols.slice(1) }); + }, + }); + const pasteBtn = createButton({ + label: t("PutIn"), + onClick: () => { + const r = parsePaste(paste.value); + if (r.error) return void showToast(r.error, "error"); + put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) }); + }, + }); + return sub( + t("Mine"), + open, + name.root, + el("h5", { text: t("Empty") }), + heads.root, + rows.root, + makeEmpty, + el("h5", { text: t("Paste") }), + paste, + pasteBtn, + ); +} + +/** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */ +function infoBox( + sl: SheetLogic, + node: SlNode, + state: "base" | "mine", + on: { change(sl: SheetLogic): void }, +): HTMLElement { + const box = el("div", { className: "m01-sltbl__info" }); + const meta = el("p", { className: "m01-sltbl__muted" }); + const notes = el("details", { className: "m01-sltbl__notes" }); + const view = el("div", { className: "m01-sltbl__view" }); + const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } }); + const roleBox = el("div", { className: "m01-sltbl__roles" }); + const formulas = el("div", { className: "m01-sltbl__formulas" }); + const roles = rolesOf(node); + + const paint = (info: TblInfo): void => { + const withBlock = state === "base"; + meta.textContent = + info.no || info.source + ? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" }) + : ""; + notes.replaceChildren( + ...(info.notes.length + ? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))] + : []), + ); + notes.hidden = !info.notes.length; + // 칸 역할 + const all = [ + ...Object.keys(info.conds).map((c) => ({ c, isCond: true })), + ...Object.keys(info.vals).map((c) => ({ c, isCond: false })), + ]; + const cur = (c: string): "cond" | "out" | "off" => + roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off"; + roleBox.replaceChildren( + el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }), + ...all.map(({ c, isCond }) => { + const r = cur(c); + const b = el("button", { + className: `m01-sltbl__role is-${r}`, + attrs: { type: "button" }, + children: [ + el("strong", { text: c }), + el("span", { + text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"), + }), + ], + }); + b.addEventListener("click", () => { + const next: TblRoles = { + cond: roles.cond.filter((x) => x !== c), + out: roles.out.filter((x) => x !== c), + }; + const to = r === "off" ? (isCond ? "cond" : "out") : "off"; + if (to === "cond") next.cond.push(c); + if (to === "out") next.out.push(c); + const order = Object.keys(info.conds); + next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z)); + const done = applyTable(sl, node.id, info, next, { + ref: node.ref ?? "", + fillBlock: false, + outside: state === "base", + }); + on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl); + }); + return b; + }), + ); + // 수식 보기 + const plan = planLookup(info, roles); + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + const line = (label: string, a1: string): HTMLElement => + el("div", { + className: "m01-sltbl__formula", + children: [ + el("span", { className: "m01-sltbl__muted", text: label }), + el("code", { + text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""), + }), + ], + }); + formulas.replaceChildren( + el("h5", { text: t("Formulas") }), + ...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)), + ...(plan.warnings.length + ? [ + el("p", { + className: "m01-sltbl__warn", + text: `${t("Warn")}: ${plan.warnings.join(" · ")}`, + }), + ] + : []), + ); + // 걸린 줄 칠하기 + const cols = layoutOf(info); + const table = el("table"); + table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) })); + info.rows.forEach((r, i) => + table.append( + el("tr", { + attrs: { "data-row": String(i) }, + children: cols.map((c) => { + const v = r[c.of]; + const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v; + return el("td", { text: x === null || x === undefined ? "" : String(x) }); + }), + }), + ), + ); + view.replaceChildren( + el("h5", { text: t("View") }), + el("div", { className: "m01-sltbl__scroll", children: [table] }), + ); + void solve(sl, node, info, withBlock) + .then(({ status: s, idx }) => { + status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`; + status.textContent = + s === "찾음" && idx !== null + ? t("Found", { n: idx + 1 }) + : s === "맞는 줄 없음" + ? t("NoRow") + : s === "맞는 줄 둘 이상" + ? t("TwoRows") + : s + ? t("Err") + : ""; + table + .querySelectorAll("tr[data-row]") + .forEach((tr) => + tr.classList.toggle( + "is-hit", + idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx), + ), + ); + table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" }); + }) + .catch(() => (status.textContent = t("Err"))); + }; + + box.append(meta, notes, roleBox, status, view, formulas); + if (state === "base") { + const key = node.ref ?? ""; + void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error")); + } else paint(mineInfo(sl, node)); + return box; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts new file mode 100644 index 000000000..3e62745ac --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -0,0 +1,446 @@ +/* ============================================================================= + * M01_SL_Table_Build.ts + * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 + * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` + * + * 시트 모양(표 상자 시트 하나): + * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 + * 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로) + * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) + * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A + * ========================================================================== */ + +import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; + +type Rec = Record; + +/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */ +export interface TblInfo { + key: string; + name: string; + /** 기준 「1㎡」 */ + base: string; + source: string; + /** 원문번호 「13-4-1」 */ + no: string; + /** 조건 칸 → 「수」 「고르기」 「범위」 */ + conds: Record; + /** 값 칸 → 단위 */ + vals: Record; + rule: string | null; + rows: Rec[]; + notes: string[]; +} + +/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */ +export interface TblRoles { + cond: string[]; + out: string[]; +} + +/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */ +export interface TblCol { + header: string; + of: string; + part: "값" | "아래" | "위"; +} + +export interface TblPlan { + cols: TblCol[]; + /** 블록 안 줄 수(머리 줄 빼고) */ + rowCount: number; + /** 블록 밖에 쓸 칸 — A1 → 칸 */ + cells: Record; + /** 조건 칸 이름 → 입력 칸 */ + inCells: Record; + /** 결과 칸 이름 → 출력 칸 */ + outCells: Record; + idxCell: string; + statusCell: string; + /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */ + helperFrom: number; + warnings: string[]; +} + +/** 열 글자 0 → A */ +export const colName = (c: number): string => { + let s = ""; + for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; +}; +const colIndex = (letters: string): number => + [...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; +const at = (r: number, c: number): string => `${colName(c)}${r}`; + +const MARGIN = 20; +const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; + +/** 블록 열 차례 — 조건 칸 · 값 칸 차례 그대로 · 범위 칸은 둘로 */ +export function layoutOf(info: TblInfo): TblCol[] { + const out: TblCol[] = []; + for (const c of Object.keys(info.conds)) + if (info.conds[c] === "범위") + out.push( + { header: `${c}·아래`, of: c, part: "아래" }, + { header: `${c}·위`, of: c, part: "위" }, + ); + else out.push({ header: c, of: c, part: "값" }); + for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" }); + return out; +} + +const cellOf = (v: Rec[string]): Cell | null => + v === null || v === undefined || v === "" + ? null + : typeof v === "number" + ? { 값: v } + : { 값: String(v) }; + +/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */ +export function blockCells(info: TblInfo): Record { + const cells: Record = {}; + const cols = layoutOf(info); + cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header })); + info.rows.forEach((row, i) => + cols.forEach((col, c) => { + const v = row[col.of]; + const x = Array.isArray(v) + ? col.part === "아래" + ? v[0] + : v[1] + : col.part === "값" + ? v + : null; + const cell = cellOf(x); + if (cell) cells[at(i + 2, c)] = cell; + }), + ); + return cells; +} + +/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */ +const sampleOf = (info: TblInfo, c: string): string | number => { + const v = info.rows[0]?.[c]; + if (Array.isArray(v)) + return v[0] !== null && v[1] !== null + ? Number(((v[0] + v[1]) / 2).toFixed(6)) + : (v[1] ?? v[0] ?? 0); + return v === null || v === undefined ? "" : v; +}; + +/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */ +export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { + const cols = layoutOf(info); + const warnings: string[] = []; + const W = cols.length; + const n = info.rows.length; + const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header)); + const conds = roles.cond.filter((c) => c in info.conds); + const rangeCols = conds.filter((c) => info.conds[c] === "범위"); + const exact = conds.filter((c) => info.conds[c] !== "범위"); + const range = rangeCols[0]; + if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌"); + const blankable = exact.filter((c) => + info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""), + ); + if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원"); + if (range && blankable.length) + warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); + const rule = info.rule ?? "초과·이하"; + const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); + const R = (range ? n : n + MARGIN) + 1; + const rng = (L: string): string => `${L}$2:${L}$${R}`; + + // 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 + const variants: string[][] = [[]]; + if (!range) { + const bl = blankable.slice(0, 2); + const subsets = + bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; + variants.push(...subsets); + } + const keyCols = exact.length ? variants : []; + const kStart = W + 1; + const kLetters = keyCols.map((_, i) => colName(kStart + i)); + const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열 + const V = L + 1; + const cells: Record = {}; + keyCols.forEach((skip, v) => { + cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" }; + const rest = exact.filter((c) => !skip.includes(c)); + for (let r = 2; r <= R; r++) { + const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&'); + if (!skip.length) cells[at(r, kStart + v)] = { 식: key }; + else { + const guard = [ + ...skip.map((c) => `${letter(c)}${r}=""`), + ...rest.map((c) => `${letter(c)}${r}<>""`), + ].join(","); + cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` }; + } + } + }); + + // 찾기 판 + let row = 1; + cells[at(row++, L)] = { 값: "표에서 찾기" }; + const inCells: Record = {}; + const inputOf: Record = {}; + for (const c of conds) { + cells[at(row, L)] = { 값: c }; + const sample = sampleOf(info, c); + if (sample !== "") cells[at(row, V)] = { 값: sample }; + inCells[c] = at(row, V); + inputOf[c] = at(row, V); + row++; + } + const keyOf = (skip: string[]): string => + exact + .filter((c) => !skip.includes(c)) + .map((c) => inputOf[c]) + .join('&"|"&'); + const idxRow = (label: string, f: string): string => { + cells[at(row, L)] = { 값: label }; + cells[at(row, V)] = { 식: f }; + return at(row++, V); + }; + const up = range ? rng(letter(`${range}·위`)) : ""; + const lo = range ? rng(letter(`${range}·아래`)) : ""; + const x = range ? inputOf[range] : ""; + let idx = ""; + let count = ""; + if (exact.length) { + const keyCell = idxRow("열쇠", keyOf([])); + const ks = keyCols.map((skip, v) => { + if (v === 0) return keyCell; + cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` }; + cells[at(row, V)] = { 식: keyOf(skip) }; + return at(row++, V); + }); + if (range) { + idx = idxRow( + "걸린 줄", + `MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`, + ); + count = idxRow( + "일치 수", + `IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, + ); + } else { + const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); + count = idxRow( + "일치 수", + found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"), + ); + const chain = ks + .map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`) + .reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()"); + idx = idxRow("걸린 줄", chain); + } + } else if (range) { + idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`); + const inside = lowBased + ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`; + count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`); + } else { + warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); + count = idxRow("일치 수", "0"); + idx = idxRow("걸린 줄", "NA()"); + } + if (lowBased && range && exact.length) + warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음"); + const statusCell = idxRow( + "상태", + `IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`, + ); + const outCells: Record = {}; + for (const c of roles.out.filter((o) => o in info.vals)) { + outCells[c] = idxRow( + c, + `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`, + ); + } + return { + cols, + rowCount: n, + cells, + inCells, + outCells, + idxCell: idx, + statusCell, + helperFrom: W, + warnings, + }; +} + +/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */ +export function pillsOf( + info: TblInfo, + plan: TblPlan, + outside: boolean, +): { pillsIn: SlPill[]; pillsOut: SlPill[] } { + const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell })); + const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({ + name, + cell, + unit: info.vals[name] || undefined, + })); + if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true }); + return { pillsIn, pillsOut }; +} + +const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => + sl.workbook.시트.findIndex((s) => s.id === node.sheetId); + +/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ +export function applyTable( + sl: SheetLogic, + nodeId: string, + info: TblInfo, + roles: TblRoles, + opts: { ref: string; fillBlock: boolean; outside: boolean }, +): { sl: SheetLogic; plan: TblPlan } { + const next = structuredClone(sl); + const node = next.nodes.find((n) => n.id === nodeId); + if (!node) throw new Error("없는 상자"); + const si = sheetIndexOf(next, node); + if (si < 0) throw new Error("상자의 시트가 없음"); + const plan = planLookup(info, roles); + const sheet: Sheet = next.workbook.시트[si]; + const keep = Object.entries(sheet.칸).filter( + ([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom, + ); + sheet.칸 = Object.fromEntries(keep); + if (opts.fillBlock) { + for (const a of Object.keys(sheet.칸)) delete sheet.칸[a]; + Object.assign(sheet.칸, blockCells(info)); + } + Object.assign(sheet.칸, plan.cells); + const pills = pillsOf(info, plan, opts.outside); + const gone = (dir: "in" | "out", name: string): boolean => + !(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name); + next.edges = next.edges.filter( + (e) => + !(e.from.node === nodeId && gone("out", e.from.pill)) && + !(e.to.node === nodeId && gone("in", e.to.pill)), + ); + node.name = `표 · ${info.name}`; + node.ref = opts.ref; + node.pillsIn = pills.pillsIn; + node.pillsOut = pills.pillsOut; + return { sl: next, plan }; +} + +/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */ +export function parsePaste(text: string): { info: TblInfo; error: string | null } { + const lines = text + .replace(/\r/g, "") + .split("\n") + .filter((l) => l.trim() !== ""); + const empty: TblInfo = { + key: "", + name: "", + base: "", + source: "", + no: "", + conds: {}, + vals: {}, + rule: null, + rows: [], + notes: [], + }; + if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" }; + const grid = lines.map((l) => l.split("\t").map((c) => c.trim())); + const head = grid[0]; + if (head.some((h) => h === "") || new Set(head).size !== head.length) + return { info: empty, error: "머리 칸 이름이 비었거나 겹침" }; + const num = (s: string): number | null => + s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null; + const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/; + const body = grid.slice(1); + const isRange = (c: number): boolean => + body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) && + body.every((r) => !r[c] || rangeRe.test(r[c])); + const rows: Rec[] = body.map((r) => { + const rec: Rec = {}; + head.forEach((h, c) => { + const s = r[c] ?? ""; + if (isRange(c)) { + const m = rangeRe.exec(s); + rec[h] = m + ? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])] + : null; + } else rec[h] = s === "" ? null : (num(s) ?? s); + }); + return rec; + }); + // 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값 + const conds: Record = {}; + const vals: Record = {}; + head.forEach((h, c) => { + const numeric = body.every((r) => !r[c] || num(r[c]) !== null); + if (isRange(c)) conds[h] = "범위"; + else if (!numeric) conds[h] = "고르기"; + else if (c === 0 && head.length > 1) conds[h] = "수"; + else vals[h] = ""; + }); + if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" }; + return { info: { ...empty, conds, vals, rows }, error: null }; +} + +/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */ +export function infoFromSheet(sheet: Sheet, prev: Pick): TblInfo { + const heads: string[] = []; + for (let c = 0; ; c++) { + const h = sheet.칸[at(1, c)]?.값; + if (h === undefined || h === "") break; + heads.push(String(h)); + } + let last = 1; + for (const a of Object.keys(sheet.칸)) { + const m = /^([A-Z]+)(\d+)$/.exec(a); + if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2])); + } + const conds: Record = {}; + const vals: Record = {}; + const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = []; + heads.forEach((h, c) => { + const m = /^(.+)·(아래|위)$/.exec(h); + names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c }); + }); + for (const nm of names) { + if (nm.part !== "값") conds[nm.of] = "범위"; + else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of]; + else vals[nm.of] = ""; + } + const rows: Rec[] = []; + for (let r = 2; r <= last; r++) { + const rec: Rec = {}; + for (const nm of names) { + const v = sheet.칸[at(r, nm.c)]?.값; + const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v; + if (nm.part === "값") rec[nm.of] = x; + else { + const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null]; + prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null; + rec[nm.of] = prevPair; + } + } + rows.push(rec); + } + return { + key: "", + name: prev.name, + base: "", + source: "", + no: "", + conds, + vals, + rule: null, + rows, + notes: [], + }; +} diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py new file mode 100644 index 000000000..c135e728c --- /dev/null +++ b/resources/tester/test_m01_sl_table_build.py @@ -0,0 +1,175 @@ +"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조. +견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from common_util.common_util_spreadsheet import recalc_workbooks + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "plan") { + const plan = m.planLookup(job.info, job.roles); + const cells = { ...m.blockCells(job.info), ...plan.cells }; + for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; + return { cells, plan }; + } + if (job.op === "paste") return m.parsePaste(job.text); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + +R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []} +EXACT = { + **R, + "conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, + "vals": {"석공": "인", "보통": "인"}, + "rows": [ + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12}, + {"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09}, + {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21}, + {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18}, + {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5}, + ], +} +RATE = { + **R, + "conds": {"높이": "범위"}, + "vals": {"증가율": "%"}, + "rule": "초과·이하", + "rows": [ + {"높이": [3, 4], "증가율": 30}, + {"높이": [4, 5.5], "증가율": 40}, + {"높이": [5.5, None], "증가율": 60}, + ], +} +MIXED = { + **R, + "conds": {"구분": "고르기", "높이": "범위"}, + "vals": {"율": "%"}, + "rule": "초과·이하", + "rows": [ + {"구분": "가", "높이": [0, 2], "율": 1}, + {"구분": "가", "높이": [2, 4], "율": 2}, + {"구분": "나", "높이": [0, 3], "율": 5}, + ], +} +MINE = { + **R, + "conds": {"경사": "고르기"}, + "vals": {"할증": "%"}, + "rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}], +} + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict: + """견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}.""" + roles = {"cond": cond or list(info["conds"]), "out": out} + plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0] + book = { + "종류": "통합문서", + "판": 1, + "열": "t", + "서식": [{}], + "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], + "활성": "s1", + } + values = recalc_workbooks([book])[0]["s1"] + p = plan["plan"] + + def got(a1: str): + v = values.get(a1) or {} + return v.get("수", v.get("글", v.get("오류"))) + + res = {c: got(a) for c, a in p["outCells"].items()} + return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res} + + +def test_같음_조건_셋은_열쇠로_찾음(): + r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"]) + assert r["상태"] == "찾음" and r["줄"] == "3" + assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21 + + +def test_빈칸은_무관_줄로_찾음(): + r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"]) + assert r["상태"] == "찾음" and float(r["석공"]) == 0.1 + + +def test_맞는_줄_없음과_둘_이상(): + none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"]) + assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A" + dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"]) + assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A" + + +def test_범위_조건은_아래_위_두_칸으로_초과_이하(): + assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하) + assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40 + assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음 + assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님 + assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음" + + +def test_같음_조건과_범위_조건이_섞임(): + assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2 + assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5 + assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음" + assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음" + + +def test_내_표_경사에서_할증_두_줄(): + assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10 + assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0 + + +def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜(): + r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"]) + assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋 + + +def test_붙여넣기_읽기(): + text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10" + out = run([{"op": "paste", "text": text}])[0] + info = out["info"] + assert out["error"] is None + assert info["conds"] == {"경사": "고르기", "범위": "범위"} + assert info["vals"] == {"할증": ""} + assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10} + assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"] From 817826c1afb6d35e8cf98e47606209a3a831963b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:10:49 +0900 Subject: [PATCH 07/38] =?UTF-8?q?feat(m01-sl):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=ED=8B=80=EC=9D=84=20Item=20=EB=85=B8?= =?UTF-8?q?=EB=AC=B4=20=ED=8B=80=EA=B3=BC=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addNode(item) = M01_SL_Item moneyLayout(노무)과 같은 칸 — B1 수량 · B2~B4 노무/재료/경비 단가(노무 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 노무비/재료비/경비 · B10 사용 수량 · 머리 줄 없음 - 출력 알약 단가 · 금액 · 노무비 · 재료비 · 경비 · 사용 수량 · node.ref = {"what":"노무"} - 시험 14개(틀 칸 대조 더함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Model.ts | 82 +++++++++++++++++------ resources/tester/test_m01_sl_model.py | 63 ++++++++++++----- 2 files changed, 108 insertions(+), 37 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index 746509c34..d457e5676 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -118,6 +118,13 @@ const TEXT = { PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], Sum: ["계", "Total"], + PriceLabor: ["노무 단가", "Labor price"], + PriceMat: ["재료 단가", "Material price"], + PriceExp: ["경비 단가", "Expense price"], + Labor3: ["노무비", "Labor cost"], + Mat3: ["재료비", "Material cost"], + Exp3: ["경비", "Expense"], + Won: ["원", "KRW"], NoPill: ["알약이 없음", "No such pill"], Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], @@ -136,6 +143,9 @@ const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex == /** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ const COSTS = ["노무비", "재료비", "경비"] as const; +/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */ +const USED = "사용 수량"; + /* ── 작은 도움 ──────────────────────────────────────────────────────────── */ const STAGE: Record = { @@ -228,24 +238,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out outs: [{ name: tl("Found"), cell: "B4" }], }; case "item": - return { - rows: [ - [tl("Qty"), null, tl("QtyNote")], - [tl("Price"), null, tl("PriceNote")], - [tl("Amount"), f("B2*B3"), ""], - ["노무비", f("B4"), ""], - ["재료비", v(0), ""], - ["경비", v(0), ""], - ], - ins: [ - { name: tl("Qty"), cell: "B2" }, - { name: tl("Price"), cell: "B3", outside: true }, - ], - outs: [ - { name: tl("Amount"), cell: "B4" }, - ...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })), - ], - }; + // 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`) + return { rows: [], ins: [], outs: [] }; case "logic": return { rows: [[tl("Found"), null, tl("LogicNote")]], @@ -266,6 +260,52 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out } } +/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움) + * B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */ +function itemSheet(): { cells: Record; rows: Row[]; ins: SlPill[]; outs: SlPill[] } { + const won = tl("Won"); + const labels: [string, string][] = [ + ["A1", tl("Qty")], + ["A2", tl("PriceLabor")], + ["A3", tl("PriceMat")], + ["A4", tl("PriceExp")], + ["A5", tl("Price")], + ["A6", tl("Amount")], + ["A7", tl("Labor3")], + ["A8", tl("Mat3")], + ["A9", tl("Exp3")], + ["A10", USED], + ]; + const cells: Record = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }])); + Object.assign(cells, { + B3: { 값: 0 }, + B4: { 값: 0 }, + B5: { 식: "B2+B3+B4" }, + B6: { 식: "ROUND(B1*B5,1)" }, + B7: { 식: "ROUND(B1*B2,1)" }, + B8: { 식: "ROUND(B1*B3,1)" }, + B9: { 식: "ROUND(B1*B4,1)" }, + B10: { 식: "B1" }, + }); + const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit }); + return { + cells, + rows: [], + ins: [ + { name: tl("Qty"), cell: "B1", unit: "" }, + { name: tl("PriceLabor"), cell: "B2", unit: won, outside: true }, + ], + outs: [ + out(tl("Price"), "B5"), + out(tl("Amount"), "B6"), + out(tl("Labor3"), "B7"), + out(tl("Mat3"), "B8"), + out(tl("Exp3"), "B9"), + out(USED, "B10", ""), + ], + }; +} + /* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ /** 빈 시트 로직 */ @@ -293,8 +333,9 @@ export function addNode( book.시트.map((s) => s.id), ); const title = uniqueName(book, name); - const t = template(kind, title); - const 칸: Record = { + const item = kind === "item" ? itemSheet() : null; + const t = item ?? template(kind, title); + const 칸: Record = item?.cells ?? { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") }, @@ -316,6 +357,7 @@ export function addNode( sheetId, pillsIn: t.ins, pillsOut: t.outs, + ...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}), stage: STAGE[kind], }; out.nodes.push(node); diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index 204c9fcb9..ee944877a 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -71,7 +71,7 @@ let sl = M.newLogic("시험 하나", "원/㎡"); let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node; r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002"; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; -sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]); +sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]); sl = link(sl, pum, "품", man, "수량"); sl = link(sl, man, "노무비", res, "노무비"); """ @@ -87,7 +87,7 @@ r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node; r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node; sl = E.applyOutside(sl, [ { node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 }, - { node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 }, + { node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 }, ]); sl = link(sl, pum, "품", calc, "값1"); sl = link(sl, rate, "할증률", calc, "값2"); @@ -101,12 +101,12 @@ sl = link(sl, man, "노무비", res, "노무비"); def test_입력값_항목_결과_수식과_값() -> None: got = _run( ONE - + "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," + + "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage])," " sl.workbook.시트.map(s => s.이름)]);" ) qty, total_in, ev, edges, kinds, names = got assert qty == {"식": "품!B2"} - assert total_in == {"식": "'보통 인부'!B5"} + assert total_in == {"식": "'보통 인부'!B7"} assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000} assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5 assert ev["errors"] == [] @@ -132,7 +132,7 @@ def test_계산_두_항목_더함_값바뀜() -> None: # 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000 assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000} assert c == {"결과": 0.26} - assert cell == {"식": "석공!B5+보통인부!B5"} + assert cell == {"식": "석공!B7+보통인부!B7"} assert b["계"] == 0.2 * 1.5 * 200000 + 20000 @@ -146,7 +146,7 @@ def test_선_끊기_다시_잇기() -> None: " E.evalLogic(c).result, a.edges.length]);" ) cell_a, ra, cell_b, check_b, rc, n_edges = got - assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000 + assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000 assert cell_b is None assert "결과 상자에 이어진 선이 없음" in check_b assert not any("입력 알약" in x for x in check_b) @@ -202,7 +202,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ) cells, e1, f2, e3, f3, lg, check = got assert cells == [ - {"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"}, + {"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"}, { "id": "n4!E1", "node": "n4", @@ -230,16 +230,16 @@ def test_알약_정하기_이름바꾸면_선이_따라감() -> None: ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n" "const e = s2.edges[0].from;\n" "s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n" - "const moved = sheet(s2, man.id).칸.B2;\n" + "const moved = sheet(s2, man.id).칸.B1;\n" "const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n" "const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n" - "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" + "out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);" ) e, moved, n_edges, cleared, ins = got assert e == {"node": "n1", "pill": "품셈 품"} assert moved == {"식": "품!D2"} assert n_edges == 1 and cleared is None - assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]] + assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]] def test_상자_지우기() -> None: @@ -250,7 +250,7 @@ def test_상자_지우기() -> None: ) names, sheets, n_edges, cell, total, before = got assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names - assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000 + assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000 assert before == 6 # 받은 것은 안 바뀜 @@ -288,11 +288,11 @@ def test_상자_이름_바꾸기_수식이_따라감() -> None: ) why, name, sheet_name, cell, total, tries, before = got assert why is None and name == sheet_name == "석공 (돌쌓기)" - assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000 + assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000 assert tries == [ "같은 이름 상자가 있음", "이름은 1~31 글자", - "이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '", + "이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '", "상자가 없음", "이름은 1~31 글자", ] @@ -316,7 +316,7 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: "const cells = E.outsideCells(sl);\n" "const head = M.rangeHead('높이');\n" "const got = E.answersToValues(cells, [\n" - " { id: 'n2!B3', ok: true, value: 172068 },\n" + " { id: 'n2!B2', ok: true, value: 172068 },\n" " { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n" "]);\n" "const s2 = E.applyOutside(sl, got.values);\n" @@ -325,10 +325,39 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None: head, got_values, f1, g2, total = got assert head == ["높이·아래", "높이·위"] assert got_values["errors"] == [] - assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068} + assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068} assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034) missing = _run( ONE - + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));" + + "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));" ) - assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]} + assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]} + + +def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: + got = _run( + "let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');" + " out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);" + ) + cells, ins, outs, ref = got + assert {k: v for k, v in cells.items() if k.startswith("B")} == { + "B3": {"값": 0}, + "B4": {"값": 0}, + "B5": {"식": "B2+B3+B4"}, + "B6": {"식": "ROUND(B1*B5,1)"}, + "B7": {"식": "ROUND(B1*B2,1)"}, + "B8": {"식": "ROUND(B1*B3,1)"}, + "B9": {"식": "ROUND(B1*B4,1)"}, + "B10": {"식": "B1"}, + } + assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [ + "수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량", + ] # fmt: skip + assert ins == [ + {"name": "수량", "cell": "B1", "unit": ""}, + {"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True}, + ] + assert outs == [ + ["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"], + ] # fmt: skip + assert ref == '{"what":"노무"}' From eb9082dc307b4de1e4f7056127d80d955d2c8c22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:13:03 +0900 Subject: [PATCH 08/38] =?UTF-8?q?feat(m01-sl):=20=ED=91=9C=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20Table=20=EC=99=84=EC=84=B1=20=E2=80=94=20=EB=82=B4?= =?UTF-8?q?=20=ED=91=9C=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20=EB=B9=88?= =?UTF-8?q?=EC=B9=B8=20=EB=AC=B4=EA=B4=80=20=C2=B7=20=EC=84=A0=20=EC=88=98?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=B4=EC=A1=B4=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../SheetLogic/M01_SL_Table_Build.ts | 6 ++++- resources/tester/test_m01_sl_table_build.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts index 3e62745ac..8f8984437 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -159,7 +159,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const bl = blankable.slice(0, 2); const subsets = bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; - variants.push(...subsets); + variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음 } const keyCols = exact.length ? variants : []; const kStart = W + 1; @@ -310,6 +310,8 @@ export function applyTable( if (si < 0) throw new Error("상자의 시트가 없음"); const plan = planLookup(info, roles); const sheet: Sheet = next.workbook.시트[si]; + // 이미 있던 입력 칸 내용(예시값 · 선을 그은 참조 수식)은 같은 이름이면 그대로 가져감 + const oldIn = Object.fromEntries(node.pillsIn.map((p) => [p.name, sheet.칸[p.cell]])); const keep = Object.entries(sheet.칸).filter( ([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom, ); @@ -319,6 +321,8 @@ export function applyTable( Object.assign(sheet.칸, blockCells(info)); } Object.assign(sheet.칸, plan.cells); + for (const [name, cell] of Object.entries(plan.inCells)) + if (oldIn[name]) sheet.칸[cell] = oldIn[name]; const pills = pillsOf(info, plan, opts.outside); const gone = (dir: "in" | "out", name: string): boolean => !(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name); diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py index c135e728c..2676fd680 100644 --- a/resources/tester/test_m01_sl_table_build.py +++ b/resources/tester/test_m01_sl_table_build.py @@ -36,6 +36,17 @@ const out = jobs.map((job) => { return { cells, plan }; } if (job.op === "paste") return m.parsePaste(job.text); + if (job.op === "reapply") { + const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], + workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, + nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; + const opts = { ref: "T1", fillBlock: true, outside: false }; + const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl; + const cell = first.nodes[0].pillsIn[0].cell; + first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" }; + const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl; + return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name }; + } return null; }); process.stdout.write(JSON.stringify(out)); @@ -173,3 +184,16 @@ def test_붙여넣기_읽기(): assert info["vals"] == {"할증": ""} assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10} assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"] + + +def test_다시_짜도_선_수식은_그대로(): + out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0] + assert out["kept"] == {"식": "'다른 시트'!B2"} + assert out["pills"] == ["경사"] and out["name"] == "표 · 견본" + + +def test_표가_비었어도_수식이_깨지지_않음(): + """내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨.""" + empty = {**MINE, "rows": [{}, {}]} + r = solve(empty, {"경사": "급"}, ["할증"]) + assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A" From 5f9240c9e31d49424bdfb95b6da69e333ace3d60 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:14:02 +0900 Subject: [PATCH 09/38] =?UTF-8?q?fix(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=84=9C=EB=B2=84=20=C2=B7=20Api=20?= =?UTF-8?q?=EB=A5=BC=20sub7=20Model=20=EB=AA=A8=EC=96=91=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=E2=80=94=20SheetLogic=20=C2=B7=20SlSource=20=C2=B7=20SlOuts?= =?UTF-8?q?ideAnswer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 몸 = Model SheetLogic(workbook · nodes · edges) · 바깥 값 칸 = Eval outsideCells 규칙(알약 outside · 상자 source · 표 · 로직 ref 짐작 · id 「상자!칸」) - 몸의 outside: SlOutsideAt[](Item 항목 단가 — 칸마다 비목) 도 받음 · 같은 칸은 그쪽이 이김 · 값은 늘 마스터에서 - /outside 물음 = {id, ...SlSource} · 답 = Eval SlOutsideAnswer · 표 범위 칸 「·아래」 「·위」(Model RANGE_LOW · RANGE_HIGH) - Api 겹친 타입(SlLogic · SlField · SlOutsideValue) 지우고 Model · Eval 것을 씀 · Item 이 쓰는 SlOutside · SlOutsideAt 는 SlQuery 별칭으로 남김 - 시험 test_m01_sl_router.py 를 Model 모양으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/M01_SL_Api.ts | 81 ++++-------- M01_MasterData/SheetLogic/M01_SL_Router.py | 140 ++++++++++++++------- resources/tester/test_m01_sl_router.py | 109 +++++++++------- 3 files changed, 182 insertions(+), 148 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts index d937f784c..6f5917799 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Api.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -6,65 +6,29 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; import { plainError } from "../EasyCalc/M01_EasyCalc_Api"; import { ApiError } from "../M01_MasterData_UI_Logic_Api"; +import type { SlOutsideAnswer } from "./M01_SL_Eval"; +import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model"; -/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음) - * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸) - * · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) - * · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값 - * · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */ -export type SlOutside = - | { id: string; kind: "표"; table: string } - | { id: string; kind: "노임"; key: string } - | { id: string; kind: "자재"; key: string } - | { - id: string; - kind: "장비"; - element: string; - inputs: Record; - field?: SlField; - } - | { - id: string; - kind: "로직"; - key: string; - inputs: Record; - field?: SlField; - }; -export type SlField = "노무비" | "재료비" | "경비" | "계"; +/* 모양 = sub7 Model(SheetLogic · SlSource · SlField) · 답 = Eval(SlOutsideAnswer) · 아래는 Api 몫만. + * 저장 때 서버가 바깥 값을 넣는 칸 = ① Model 규칙(알약 outside 참 · 상자 source · 없으면 표 · 로직 ref) + * ② 저장 몸의 `outside: SlOutsideAt[]`(Item `itemOutsides` — 장비처럼 칸마다 비목이 다른 것) · 값은 늘 마스터에서. */ -/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ +export type { SlField }; + +/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` */ +export type SlQuery = { id: string } & SlSource; +/** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */ +export type SlOutside = SlQuery; + +/** 바깥 값 자리 하나 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ export interface SlOutsideAt { - item: SlOutside; + item: SlQuery; sheet: string; cell: string; } -/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ -export interface SlOutsideValue { - id: string; - ok: boolean; - value?: number | null; - rows?: (string | number | null)[][]; - name?: string; - unit?: string; - reason?: string; -} - -/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */ -export interface SlLogic { - /** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */ - key: string | null; - 이름: string; - 결과단위: string; - book: Workbook; - outside: SlOutsideAt[]; - /** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */ - [extra: string]: unknown; -} - /** 목록 한 줄 */ export interface SlListItem { key: string; @@ -74,11 +38,11 @@ export interface SlListItem { saved: string; } -/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ +/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ export interface SlSaved { key: string; - logic: SlLogic; - values: SlOutsideValue[]; + logic: SheetLogic; + values: SlOutsideAnswer[]; } async function send(path: string, body?: unknown): Promise { @@ -103,12 +67,13 @@ export async function slList(): Promise { return (await send<{ logics: SlListItem[] }>("/list")).logics; } -export async function slGet(key: string): Promise { - return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic; +export async function slGet(key: string): Promise { + return (await send<{ logic: SheetLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)) + .logic; } /** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */ -export async function slSave(logic: SlLogic): Promise { +export async function slSave(logic: SheetLogic & { outside?: SlOutsideAt[] }): Promise { return send("/save", { logic }); } @@ -117,8 +82,8 @@ export async function slDelete(key: string): Promise { } /** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */ -export async function slOutside(items: SlOutside[]): Promise { - return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values; +export async function slOutside(items: SlQuery[]): Promise { + return (await send<{ values: SlOutsideAnswer[] }>("/outside", { items })).values; } /** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */ diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py index 72794c134..4719049ed 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -2,10 +2,11 @@ GET /api/m01/sl/list GET /api/m01/sl/get?key=SL-0001 - POST /api/m01/sl/save {logic} → {key, logic, values} + POST /api/m01/sl/save {logic: SheetLogic} → {key, logic, values: SlOutsideAnswer[]} POST /api/m01/sl/delete {key} - POST /api/m01/sl/outside {items: SlOutside[]} → {values: SlOutsideValue[]} + POST /api/m01/sl/outside {items: [{id, ...SlSource}]} → {values: SlOutsideAnswer[]} +모양은 sub7 Model(`M01_SL_Model.ts` — SheetLogic · SlSource) · 답은 Eval `SlOutsideAnswer`. 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 — 저장 때 바깥 값(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 우리 스프레드시트 엔진(Node · `common_util_spreadsheet`)으로 다시 셈 · 브라우저 계산값은 버림. 단가는 `/calc`(`Store.calc`)와 @@ -35,7 +36,6 @@ router = APIRouter(prefix="/api/m01/sl", tags=["M01 SheetLogic (시험)"]) DATA = Path(__file__).resolve().parent / "_data" # 시험은 임시 폴더로 바꿈 _KEY = re.compile(r"^SL-(\d{4,})$") _CELL = re.compile(r"^([A-Z]+)(\d+)$") -FIELDS = (*cm.mf.COST_ITEMS, "계") class SaveBody(BaseModel): @@ -76,8 +76,8 @@ def listing() -> dict: out.append( { "key": path.stem, - "이름": logic.get("이름") or "", - "결과단위": logic.get("결과단위") or "", + "이름": logic.get("name") or "", + "결과단위": logic.get("resultUnit") or "", "saved": logic.get("saved") or "", } ) @@ -95,6 +95,15 @@ def delete(key: str) -> dict: # ── 바깥 값 ──────────────────────────────────────────────────────────── +# 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}` +# 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`) +# 노임 · 자재 {kind, key} → 그 줄 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) +# 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1) +# 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움) +RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음 +FIELDS = (*cm.mf.COST_ITEMS, "계") + + def _row(line: dict, names) -> dict: """바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" return { @@ -111,11 +120,21 @@ def _row(line: dict, names) -> dict: } +def _money(got: dict, field: str | None) -> dict: + if not got["ok"]: + return got + if "result" in got: + return {"ok": True, "value": got["result"]} + pick = field or "계" + if pick not in FIELDS: + return {"ok": False, "reason": f"모르는 칸 「{pick}」"} + return {"ok": True, "value": got["sums"][pick]} + + def _price(kind: str, key: str) -> dict: """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" - whole = store.loaded()[1] try: - brief = whole.get(key) + brief = store.loaded()[1].get(key) except cm.mf.FormulaError as e: return {"ok": False, "reason": str(e)} group = store.mk.GROUP_OF.get(store.mk.id_of(key)) @@ -128,21 +147,17 @@ def _price(kind: str, key: str) -> dict: if not got["ok"]: return got one = got["lines"][0] - return {"ok": one["단가"] is not None, "value": one["단가"], "reason": one.get("까닭")} | { - "name": brief.get("이름"), - "unit": unit, - } + out = {"ok": one["단가"] is not None, "value": one["단가"], "name": brief.get("이름")} + return {**out, "unit": unit, **({"reason": one["까닭"]} if one.get("까닭") else {})} -def _money(got: dict, field: str | None) -> dict: - if not got["ok"]: - return got - if "result" in got: - return {"ok": True, "value": got["result"]} - pick = field or "계" - if pick not in FIELDS: - return {"ok": False, "reason": f"모르는 칸 「{pick}」"} - return {"ok": True, "value": got["sums"][pick]} +def _logic(key: str, inputs: dict, field: str | None) -> dict: + """기존 로직 결과 — 준 입력이 이기고 빈 입력은 자동값으로.""" + try: + auto = store.auto(key)["값"] + except store.StoreError: + auto = {} + return _money(store.calc(key, {**auto, **inputs}), field) def _table(key: str) -> dict: @@ -167,7 +182,7 @@ def _table(key: str) -> dict: if (table.get("조건") or {}).get(n) == "범위" or any(isinstance(r.get(n), list) for r in table.get("줄") or []) } - head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] + head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] rows = [] for r in table.get("줄") or []: line = [] @@ -183,7 +198,8 @@ def _table(key: str) -> dict: def one_outside(item: dict) -> dict: - kind, at = item.get("kind"), item.get("id") or "" + """물음 한 줄 `{id, ...SlSource}` → 답 `SlOutsideAnswer`.""" + kind = item.get("kind") try: if kind == "표": got = _table(str(item.get("table") or "")) @@ -195,14 +211,12 @@ def one_outside(item: dict) -> dict: row = _row({**line, "단위": "시간", "수량": "1"}, inputs) got = _money(store.calc("", inputs, row, None), item.get("field")) elif kind == "로직": - got = _money( - store.calc(str(item.get("key") or ""), item.get("inputs") or {}), item.get("field") - ) + got = _logic(str(item.get("key") or ""), item.get("inputs") or {}, item.get("field")) else: got = {"ok": False, "reason": f"모르는 바깥 값 「{kind}」"} except store.StoreError as e: got = {"ok": False, "reason": str(e.detail)} - return {"id": at, **got} + return {"id": item.get("id") or "", **got} def outside(items: list[dict]) -> dict: @@ -210,6 +224,36 @@ def outside(items: list[dict]) -> dict: return {"values": [one_outside(i) for i in items]} +def _source(node: dict) -> dict | None: + """Eval `outsideCells` 와 같은 규칙 — 상자 source · 없으면 표 · 로직은 ref 로 짐작.""" + if node.get("source"): + return node["source"] + ref = node.get("ref") + if ref and node.get("kind") == "table": + return {"kind": "표", "table": ref} + if ref and node.get("kind") == "logic": + return {"kind": "로직", "key": ref, "inputs": {}} + return None + + +def outside_cells(logic: dict) -> list[tuple[dict, str]]: + """바깥 값 칸 → [({id, ...source, _cell}, 시트)]. + + ① 알약 outside 참 — id 「상자!칸」 · 출처 없으면 뺌(Eval `outsideCells` 규칙) + ② 몸의 `outside: [{item, sheet, cell}]` — Item 항목 단가(칸마다 비목) · 같은 칸은 ② 가 이김 + """ + found: dict[tuple[str, str], dict] = {} + for node in logic.get("nodes") or []: + source = _source(node) + for pill in [*(node.get("pillsIn") or []), *(node.get("pillsOut") or [])]: + if pill.get("outside") and source: + sheet, cell = node.get("sheetId") or "", pill.get("cell") or "" + found[(sheet, cell)] = {"id": f"{node.get('id')}!{cell}", **source} + for at in logic.get("outside") or []: + found[(at.get("sheet") or "", at.get("cell") or "")] = dict(at.get("item") or {}) + return [({**item, "_cell": cell}, sheet) for (sheet, cell), item in found.items()] + + # ── 저장 ────────────────────────────────────────────────────────────── def _col(letters: str) -> int: n = 0 @@ -227,45 +271,45 @@ def _letters(n: int) -> str: def _put(book: dict, sheet_id: str, cell: str, value: Any) -> None: - """바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 칸을 비움.""" + """바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠).""" sheet = next((s for s in book.get("시트") or [] if s.get("id") == sheet_id), None) if sheet is None: raise store.StoreError(400, f"없는 시트 「{sheet_id}」") cells = sheet.setdefault("칸", {}) - old = cells.get(cell) or {} - if value is None: + keep = {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {} + if value is None and not keep: cells.pop(cell, None) - return - keep = {"서식": old["서식"]} if "서식" in old else {} - cells[cell] = {"값": value, **keep} + else: + cells[cell] = {**({"값": value} if value is not None else {}), **keep} -def _fill(book: dict, at: dict, got: dict) -> None: - m = _CELL.match(str(at.get("cell") or "").upper()) +def _fill(book: dict, sheet: str, cell: str, got: dict) -> None: + m = _CELL.match(str(cell or "").upper()) if not m: - raise store.StoreError(400, f"칸 주소 모양이 아님 「{at.get('cell')}」") - sheet, col, row = at.get("sheet") or "", _col(m.group(1)), int(m.group(2)) - if "rows" in got: + raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」") + col, row = _col(m.group(1)), int(m.group(2)) + if "rows" in got: # 표 — cell 이 왼위 for dy, line in enumerate(got["rows"]): - for dx, value in enumerate(line): - _put(book, sheet, f"{_letters(col + dx)}{row + dy}", value) + for dx, one in enumerate(line): + _put(book, sheet, f"{_letters(col + dx)}{row + dy}", one) else: _put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None) def save(logic: dict) -> dict: - if not isinstance(logic.get("book"), dict): - raise store.StoreError(400, "통합문서(book)가 없음") - if not str(logic.get("이름") or "").strip(): + """SheetLogic 저장 — 바깥 값 다시 넣기 → Node 로 다시 셈(브라우저 계산값 버림) → `_data`.""" + if not isinstance(logic.get("workbook"), dict): + raise store.StoreError(400, "통합문서(workbook)가 없음") + if not str(logic.get("name") or "").strip(): raise store.StoreError(400, "이름을 넣어 주세요") key = logic.get("key") or None if key: _read(key) # 있는 것만 고침 — 없으면 404 - places = logic.get("outside") or [] - values = [one_outside(at.get("item") or {}) for at in places] - book = _num(logic["book"]) - for at, got in zip(places, values): - _fill(book, at, _num(got)) + places = outside_cells(logic) + values = [one_outside({k: v for k, v in item.items() if k != "_cell"}) for item, _ in places] + book = _num(logic["workbook"]) + for (item, sheet), got in zip(places, values): + _fill(book, sheet, item["_cell"], _num(got)) try: book = spreadsheet.with_server_values(book) # 브라우저 계산값은 버리고 서버 값 except spreadsheet.RecalcError as e: @@ -273,7 +317,7 @@ def save(logic: dict) -> dict: DATA.mkdir(parents=True, exist_ok=True) key = key or _next_key() stamp = datetime.now().isoformat(timespec="seconds") - done = {**logic, "key": key, "book": book, "saved": stamp} + done = {**logic, "key": key, "workbook": book, "saved": stamp} tmp = DATA / f".{key}.tmp" tmp.write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") os.replace(tmp, DATA / f"{key}.json") diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index 7aea0c036..b8d0a7265 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -89,26 +89,59 @@ def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: def _logic(labor: str, key: str | None = None) -> dict: - """노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리.""" + """Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터) + · 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리.""" return { "key": key, - "이름": "견본 시트 로직", - "결과단위": "원/㎡", - "book": { + "name": "견본 시트 로직", + "resultUnit": "원/㎡", + "workbook": { "종류": "통합문서", "판": 1, "서식": [{}], "시트": [ - {"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}}, + {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}}, {"id": "s2", "이름": "표", "칸": {}}, ], "계산값": {"s1": {"B1": {"수": "999"}}}, }, + "nodes": [ + { + "id": "n1", + "kind": "item", + "name": "노무 석공", + "sheetId": "s1", + "stage": 3, + "pillsIn": [{"name": "노임", "cell": "A1", "outside": True}], + "pillsOut": [{"name": "금액", "cell": "B1"}], + "source": {"kind": "노임", "key": labor}, + }, + { + "id": "n2", + "kind": "table", + "name": "견본 표", + "sheetId": "s2", + "stage": 1, + "pillsIn": [], + "pillsOut": [{"name": "표", "cell": "A1", "outside": True}], + "ref": SAMPLE, + }, + { + "id": "n3", + "kind": "calc", + "name": "계산", + "sheetId": "s1", + "stage": 2, + "pillsIn": [], + "pillsOut": [{"name": "x", "cell": "C1", "outside": True}], + }, + ], # fmt: skip + "edges": [ + {"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}} + ], "outside": [ - {"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"}, - {"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"}, + {"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"} ], - "상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌 } @@ -119,9 +152,11 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) assert saved["key"] == "SL-0001" - book = saved["logic"]["book"] - assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음 + book = saved["logic"]["workbook"] + cells = book["시트"][0]["칸"] + assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②) assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) + assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음 table = book["시트"][1]["칸"] assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [ "돌", @@ -132,12 +167,15 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, assert ( "B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7 ) # 빈 끝은 빈 칸 - assert saved["logic"]["상자"] == [{"id": "x", "종류": "계산"}] - assert [v["id"] for v in saved["values"]] == ["a", "b"] + assert saved["logic"]["edges"] == _logic(labor)["edges"] + answers = {v["id"]: v for v in saved["values"]} + assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all( + v["ok"] for v in answers.values() + ) second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) assert second["key"] == "SL-0002" - renamed = {**saved["logic"], "이름": "고친 이름"} + renamed = {**saved["logic"], "name": "고친 이름"} assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" listed = client.get("/api/m01/sl/list").json()["logics"] @@ -146,13 +184,13 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, ("SL-0002", "견본 시트 로직"), ] got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"] - assert got["book"]["계산값"] == book["계산값"] + assert got["workbook"]["계산값"] == book["계산값"] _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}) assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404 _post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404) _post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404) - _post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "이름": " "}}, want=400) + _post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, want=400) assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400 assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로 @@ -161,32 +199,18 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: labor, price = _labor() - made = _post( - client, - "/api/m01/logic/new", - { - "logic": { - "원문번호": "", - "이름": "견본 노무", - "결과단위": "원/㎡", - "출처": "자체", - "입력": [{"이름": "품"}], - "중간": [], - "호표": [ - { - "종류": "인력", - "요소": labor, - "이름": "석공", - "단위": "인", - "수량": "품", - "비목": "노무비", - } - ], - "덧줄": [], - "끝수": None, - } - }, - ) + line = { + "종류": "인력", + "요소": labor, + "이름": "석공", + "단위": "인", + "수량": "품", + "비목": "노무비", + } + row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체", + "입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [], + "끝수": None} # fmt: skip + made = _post(client, "/api/m01/logic/new", {"logic": row}) items = [ {"id": "t", "kind": "표", "table": SAMPLE}, {"id": "l", "kind": "노임", "key": labor}, @@ -202,7 +226,8 @@ def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price assert got["g2"]["value"] == 0 - assert [got[k]["ok"] for k in ("w", "x", "z")] == [False, False, False] + for k in ("w", "x", "z"): + assert got[k]["ok"] is False and got[k]["reason"], got[k] def test_시스템_관리자가_아니면_403(folder: Path) -> None: From dd24ebc5c93529d0cc842952f86a19508f9a7dd0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:14:59 +0900 Subject: [PATCH 10/38] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=C2=B7=206=EA=B0=9C=20/c?= =?UTF-8?q?alc=20=EA=B0=99=EC=9D=80=20=EA=B0=92=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01_SL_Import.ts — 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · 만약/최소/최대/올림/버림/반올림 → IF/MIN/MAX/ROUNDUP/ROUNDDOWN/ROUND · 찾기 → 표 상자(맞음 칸 + INDEX/MATCH) · 로직 → 다른 로직 상자 · 바깥 값 자리 outsideOf - M01_SL_Import_Parse.ts — 엔진 문법 식 읽기 · 품셈 표 칸 놓기 - 시험 test_m01_sl_import.py — 견본 6개 시트 셈 = /calc · 마스터 1,469 중 1,386 다 바뀜 · 바뀐 통합문서 #NAME? 없음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 686 ++++++++++++++++++ .../SheetLogic/M01_SL_Import_Parse.ts | 183 +++++ resources/tester/test_m01_sl_import.py | 274 +++++++ 3 files changed, 1143 insertions(+) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Import.ts create mode 100644 M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts create mode 100644 resources/tester/test_m01_sl_import.py diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts new file mode 100644 index 000000000..f94905ddf --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -0,0 +1,686 @@ +/* ============================================================================= + * M01_SL_Import.ts + * 시트 로직 — 기존 로직 가져오기(정함 9) · 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · DOM 없음 + * + * 읽기 = LogicBuilder Blocks(`readLogic`)로 입력값을 받고 · 식은 엔진 문법(`master_formula`) 그대로 읽어 + * 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND · + * == → = · != → <> · 그리고/또는 → AND/OR · 찾기 → 표 상자 · 로직 → 다른 로직 상자 · 노임 키 → 바깥 값. + * 다른 상자 값은 받는 상자에 입력 알약 한 칸(「'시트'!칸」 참조 수식) + 선 하나 — 식은 제 상자 칸만 봄. + * + * 상자 시트 꼴 — A 이름 · B 값 · C 설명(1행 머리) · 알약은 B 칸. + * 표 상자 = 조건 알약(B) · 결과 알약(B) · E1 부터 품셈 표(머리 줄 + 줄 · 조건 칸 차례 → 값 칸 차례 · + * 범위 조건은 「이름·아래」 「이름·위」 두 칸) · 그 오른쪽에 「맞음」 칸(줄마다 조건 셈 → 1/0) · + * 결과 = 맞는 줄이 하나일 때 INDEX/MATCH · 아니면 #N/A(엔진 `find` 의 해당 없음 · 중복). + * 빈 조건 칸은 무엇이든 맞음 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하)은 엔진과 같음. + * 바깥 값(노임 · 자재 · 장비 · 로직 · 표)은 알약 `outside` · 실제 값은 열 때 · 저장 때 넣음(`outsideOf`). + * 못 바꾸는 꼴 = 그 칸 #N/A + 칸 메모(원래 글) + 쉬운 말 한 줄(`problems`). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import type { + CalcValues, + Cell, + Sheet, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { readLogic } from "../LogicBuilder/M01_LB_Blocks"; +import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model"; +import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { + parseFormula, + RULES, + tableCols, + tableRows, + type Ast, + type SlImportTable, +} from "./M01_SL_Import_Parse"; + +const TEXT = { + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Qty: ["수량", "Quantity"], + Price: ["단가", "Unit price"], + Amount: ["금액", "Amount"], + Result: ["결과", "Result"], + Sum: ["항목 합", "Item total"], + Input: ["입력값", "Input"], + Table: ["표", "Table"], + Calc: ["계산", "Calc"], + Item: ["항목", "Item"], + Logic: ["다른 로직", "Other logic"], + Const: ["고정값", "Constant"], + Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], + Match: ["맞음", "Match"], + Pass: ["넘길 값", "Passed value"], + Cant: ["못 바꿈 — ", "Not converted — "], + NoTable: ["품셈 표 「{v}」 를 못 받음", "Table “{v}” was not given"], + NoCol: ["표에 없는 칸 「{v}」", "Column “{v}” is not in the table"], + Rule: ["모르는 범위규칙 「{v}」", "Unknown range rule “{v}”"], + Unknown: ["모르는 이름 「{v}」", "Unknown name “{v}”"], + Hole: ["입력에 따라 바뀌는 요소 「{v}」", "Element changes with an input “{v}”"], + Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"], + FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"], + Parse: ["읽을 수 없는 글", "Text that can't be read"], +} as const; +const t = (k: keyof typeof TEXT, v = ""): string => + TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); + +export type { SlImportTable }; + +export interface SlImportOptions { + /** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */ + tables?: Record; + /** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */ + examples?: Record; +} + +export interface SlImportReport { + sl: SheetLogic; + /** 못 바꾼 꼴(쉬운 말) — 비면 다 바뀜 */ + problems: string[]; +} + +/* ── 짓기 ───────────────────────────────────────────────────────────────── */ + +class Fail extends Error {} +const KEY = /^[A-Z]{2}\d{6}$/; +const COSTS = ["노무비", "재료비", "경비"] as const; +type Cost = (typeof COSTS)[number]; +const lit = (v: unknown): string => + typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; +const cellOf = (v: unknown): Cell | null => + typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : null; + +/** 표 상자 하나 = 표 + 조건 묶음 하나(같은 표 · 같은 조건이면 결과 알약만 더함) */ +interface TableBox { + node: SlNode; + table: SlImportTable; + /** 결과 칸 이름 → 표 열 글자 */ + colOf: Map; + rows: number; + /** 다음 빈 열(맞음 · 결과 셈 칸) */ + free: number; + /** 결과 셈에 쓸 「맞음」 열(엔진 `find` 의 위 끝 고르기까지) */ + hit: string; +} + +class Build { + readonly book: Workbook = { 종류: "통합문서", 판: 1, 열: "sheet-logic", 서식: [{}], 시트: [] }; + readonly nodes: SlNode[] = []; + readonly edges: SlEdge[] = []; + readonly problems: string[] = []; + private readonly next = new Map(); + private readonly sheets = new Map(); + private readonly linked = new Map(); + readonly inputs = new Map(); + readonly mids = new Map(); + readonly tables = new Map(); + readonly logics = new Map(); + readonly consts = new Map(); + + constructor(readonly opts: SlImportOptions) {} + + node(kind: SlKind, name: string, stage: number, ref?: string): SlNode { + const n = this.nodes.length + 1; + const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore + const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31); + let title = base; + for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++) + title = `${base.slice(0, 28)} ${k}`; + const sheet: Sheet = { + id: `s${n}`, + 이름: title, + 칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } }, + }; + this.book.시트.push(sheet); + this.sheets.set(sheet.id, sheet); + const node: SlNode = { + id: `n${n}`, + kind, + name, + sheetId: sheet.id, + pillsIn: [], + pillsOut: [], + stage, + ...(ref ? { ref } : {}), + }; + this.nodes.push(node); + this.next.set(node.id, 2); + return node; + } + + sheet(node: SlNode): Sheet { + return this.sheets.get(node.sheetId)!; + } + + /** 줄 하나 더함 — A 이름 · B 값(또는 식) · C 설명 → B 칸 주소 */ + row(node: SlNode, label: string, cell: Cell, note = ""): string { + const r = this.next.get(node.id)!; + this.next.set(node.id, r + 1); + const s = this.sheet(node); + s.칸[`A${r}`] = { 값: label }; + s.칸[`B${r}`] = cell; + if (note) s.칸[`C${r}`] = { 값: note }; + return `B${r}`; + } + + pill(node: SlNode, dir: "in" | "out", pill: SlPill): void { + const list = dir === "in" ? node.pillsIn : node.pillsOut; + let name = pill.name; + for (let k = 2; list.some((p) => p.name === name); k++) name = `${pill.name} ${k}`; + list.push({ ...pill, name }); + } + + /** 출력 알약 하나 — 같은 이름이 있으면 그 칸 */ + out(node: SlNode, name: string, cell: Cell, note = ""): string { + const had = node.pillsOut.find((p) => p.name === name); + if (had) return had.cell; + const at = this.row(node, name, cell, note); + this.pill(node, "out", { name, cell: at }); + return at; + } + + /** 다른 상자 출력 알약 → 이 상자 입력 알약 한 칸 + 선 · 이 상자 안 칸 주소 + * `into` = 이미 있는 입력 알약(넘길 값 칸)에 바로 이음 */ + link(to: SlNode, from: SlNode, pill: string, into?: SlPill): string { + const id = `${to.id}<${from.id}:${pill}`; + const had = this.linked.get(id); + if (had && !into) return had; + const src = from.pillsOut.find((p) => p.name === pill)!; + const ref: Cell = { 식: `${quoteSheet(this.sheet(from).이름)}!${src.cell}` }; + let target = into; + if (target) this.sheet(to).칸[target.cell] = ref; + else { + const name = from.kind === "input" || from.kind === "calc" ? pill : `${pill} · ${from.name}`; + this.pill(to, "in", { name, cell: this.row(to, name, ref) }); + target = to.pillsIn[to.pillsIn.length - 1]; + } + this.edges.push({ + id: `e${this.edges.length + 1}`, + from: { node: from.id, pill }, + to: { node: to.id, pill: target.name }, + }); + if (!had) this.linked.set(id, target.cell); + return target.cell; + } + + /** 못 바꾸는 꼴 — 칸 #N/A + 메모(원래 글) + 쉬운 말 */ + cant(node: SlNode, at: string, why: string, src: string): void { + const s = this.sheet(node); + s.칸[at] = { 식: "NA()" }; + (s.comments ??= {})[at] = src; + s.칸[`C${at.slice(1)}`] = { 값: `${t("Cant")}${why}` }; + this.problems.push(`${node.name} — ${why}`); + } +} + +/* ── 식 → 엑셀 ───────────────────────────────────────────────────────────── */ + +interface Scope { + b: Build; + /** 식이 놓일 상자 */ + here: SlNode; + /** 덧줄 — 비목 합(앞 덧줄까지) 을 주는 상자 · 알약 */ + costs?: Record; + /** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */ + extras?: Map; + lines?: Map; +} + +function excel(n: Ast, sc: Scope): string { + const { b, here } = sc; + const wrap = (x: Ast): string => (x.t === "bin" ? `(${excel(x, sc)})` : excel(x, sc)); + switch (n.t) { + case "num": + return n.v; + case "str": + return lit(n.v); + case "neg": + return `-${wrap(n.a)}`; + case "eref": + throw new Fail(t("Hole", n.v)); + case "bin": { + if (n.op === "그리고" || n.op === "또는") + return `${n.op === "그리고" ? "AND" : "OR"}(${excel(n.a, sc)},${excel(n.b, sc)})`; + const op = n.op === "==" ? "=" : n.op === "!=" ? "<>" : n.op; + return `${wrap(n.a)}${op}${wrap(n.b)}`; + } + case "call": { + const items = n.items.map((x) => excel(x, sc)); + if (n.f === "만약") return `IF(${items.join(",")})`; + if (n.f === "최소" || n.f === "최대") + return `${n.f === "최소" ? "MIN" : "MAX"}(${items.join(",")})`; + const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[n.f]!; + return `${fn}(${items[0]},${items[1] ?? "0"})`; + } + case "find": + throw new Fail(t("FindAll")); + case "logic": + return b.link(here, logicBox(b, n), t("Result")); + case "field": { + const a = n.a; + if (a.t === "find") return b.link(here, ...findBox(b, a, n.f)); + if (a.t === "logic") return b.link(here, logicBox(b, a, n.f), n.f); + if (a.t === "name" && a.v === "줄" && sc.lines?.has(n.f)) + return b.link(here, ...sc.lines.get(n.f)!); + throw new Fail(t("Field", `${a.t === "name" ? a.v : ""}.${n.f}`)); + } + case "name": { + const v = n.v; + if (b.inputs.has(v)) return b.link(here, b.inputs.get(v)!, v); + if (b.mids.has(v)) return b.link(here, b.mids.get(v)!, v); + if (sc.extras?.has(v)) return b.link(here, ...sc.extras.get(v)!); + if (sc.costs && (COSTS as readonly string[]).includes(v)) { + const refs = sc.costs[v as Cost].map(([node, pill]) => b.link(here, node, pill)); + return refs.length ? `(${refs.join("+")})` : "0"; + } + if (KEY.test(v)) return b.link(here, constBox(b, v), t("Price")); + throw new Fail(t("Unknown", v)); + } + } +} + +/** 식 글 하나를 상자 칸에 — 못 바꾸면 #N/A + 메모 */ +function put(sc: Scope, label: string, text: string, pill: "out" | null, note = ""): string { + const ast = parseFormula(text); + let cell: Cell; + let why = ""; + try { + if (!ast) throw new Fail(t("Parse")); + cell = ast.t === "num" ? { 값: Number(ast.v) } : { 식: excel(ast, sc) }; + } catch (e) { + if (!(e instanceof Fail)) throw e; + why = e.message; + cell = { 식: "NA()" }; + } + const at = + pill === "out" ? sc.b.out(sc.here, label, cell, note) : sc.b.row(sc.here, label, cell, note); + if (why) sc.b.cant(sc.here, at, why, text); + return at; +} + +/** 엔진 노임 키(식 속 LB000006 등) → 고정값 상자 · 바깥 값 알약 */ +function constBox(b: Build, key: string): SlNode { + const had = b.consts.get(key); + if (had) return had; + const node = b.node("const", key, 0, key); + const at = b.row(node, t("Price"), {}, t("Outside")); + b.pill(node, "in", { name: t("Price"), cell: at, outside: true }); + b.pill(node, "out", { name: t("Price"), cell: at }); + b.consts.set(key, node); + return node; +} + +/** 넘길 값(로직 부름 인자 · 표 조건) → 입력 알약 — 글 · 수는 그 칸에 값 · 입력값 · 계산 이름은 선 · 식은 셈 */ +function args(b: Build, node: SlNode, list: [string, Ast][]): void { + for (const [k, v] of list) { + const at = b.row(node, k, {}, t("Pass")); + b.pill(node, "in", { name: k, cell: at }); + const pill = node.pillsIn[node.pillsIn.length - 1]; + const s = b.sheet(node); + try { + if (v.t === "num") s.칸[at] = { 값: Number(v.v) }; + else if (v.t === "str") s.칸[at] = { 값: v.v }; + else if (v.t === "name" && b.inputs.has(v.v)) b.link(node, b.inputs.get(v.v)!, v.v, pill); + else if (v.t === "name" && b.mids.has(v.v)) b.link(node, b.mids.get(v.v)!, v.v, pill); + else s.칸[at] = { 식: excel(v, { b, here: node }) }; + } catch (e) { + if (!(e instanceof Fail)) throw e; + b.cant(node, at, e.message, k); + } + } +} + +/** 다른 로직 상자(식 속 `로직(키, …)`) — 결과 · 비목 칸은 바깥 값 */ +function logicBox(b: Build, n: Extract, field?: string): SlNode { + const sig = n.src.replace(/\s+/g, ""); + let node = b.logics.get(sig); + if (!node) { + if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head)); + node = b.node("logic", n.head, 2, n.head); + args(b, node, n.args); + b.logics.set(sig, node); + } + const name = field ?? t("Result"); + if (!node.pillsOut.some((p) => p.name === name)) { + const at = b.row(node, name, {}, t("Outside")); + b.pill(node, "in", { name, cell: at, outside: true }); + b.pill(node, "out", { name, cell: at }); + } + return node; +} + +/* ── 표 상자 ─────────────────────────────────────────────────────────────── */ + +/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */ +function findBox(b: Build, n: Extract, col: string): [SlNode, string] { + const sig = `${n.head}|${n.args.map(([k]) => k).join(",")}|${n.src.replace(/\s+/g, "").replace(/\)\..*$/, "")}`; + let box = b.tables.get(sig); + if (!box) box = newTable(b, n, sig); + if (!box.colOf.has(col)) throw new Fail(t("NoCol", col)); + if (!box.node.pillsOut.some((p) => p.name === col)) resultCol(b, box, col); + return [box.node, col]; +} + +function newTable(b: Build, n: Extract, sig: string): TableBox { + const table = b.opts.tables?.[n.head]; + if (!table) throw new Fail(t("NoTable", n.head)); + const rule = String(table.범위규칙 ?? "초과·이하") + .split(" · ")[0] + .split(" (")[0]; + for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k)); + if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule)) + throw new Fail(t("Rule", String(table.범위규칙))); + const node = b.node("table", table.이름 || n.head, 1, n.head); + const s = b.sheet(node); + // 조건 알약 — 받는 값은 넘긴 식 + const cond = new Map(); + args(b, node, n.args); + for (const [k] of n.args) cond.set(k, node.pillsIn.find((p) => p.name === k)!.cell); + // 표 E1 부터 + const cols = tableCols(table); + const rows = tableRows(table); + b.row(node, t("Table"), { 값: n.head }, t("Outside")); + b.pill(node, "in", { name: t("Table"), cell: "E1", outside: true }); + rows.forEach((r, i) => + r.forEach((v, j) => { + const c = cellOf(v); + if (c) s.칸[`${colName(4 + j)}${i + 1}`] = c; + }), + ); + const letter = (key: string, part: "v" | "lo" | "hi"): string => + colName(4 + cols.findIndex((c) => c.key === key && c.part === part)); + const N = table.줄.length; + const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + let free = 4 + cols.length + 1; + // 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로) + const M = colName(free++); + s.칸[`${M}1`] = { 값: t("Match") }; + const top = Math.max( + ...table.줄.flatMap((r) => + Object.keys(table.조건) + .filter((k) => table.조건[k] === "범위" && Array.isArray(r[k])) + .map((k) => (r[k] as (number | null)[])[1] ?? -Infinity), + ), + ); + const closed = String(table.범위규칙 ?? "").includes("이하 포함"); + for (let r = 2; r <= N + 1; r++) { + const parts = [...cond].map(([k, pin]) => { + const x = `$${pin.slice(0, 1)}$${pin.slice(1)}`; + if (table.조건[k] !== "범위") { + const X = `${letter(k, "v")}${r}`; + return `OR(${X}="",${X}=${x})`; + } + const L = `${letter(k, "lo")}${r}`; + const H = `${letter(k, "hi")}${r}`; + const low = rule === "이상·이하" || rule === "이상·미만" ? ">=" : ">"; + const up = + rule === "이상·미만" + ? `OR(${H}="",${x}<${H}${closed ? `,AND(${H}=${top},${x}=${H})` : ""})` + : `OR(${H}="",${x}<=${H})`; + return `AND(OR(${L}="",${x}${low}${L}),${up})`; + }); + s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" }; + } + // 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`) + let hit = M; + const ranged = [...cond.keys()].filter((k) => table.조건[k] === "범위"); + if ((rule === "이하" || rule === "이상·이하") && ranged.length) { + hit = colName(free++); + s.칸[`${hit}1`] = { 값: `${t("Match")} 2` }; + for (let r = 2; r <= N + 1; r++) { + const least = ranged.map((k) => { + const H = letter(k, "hi"); + return `OR(${H}${r}="",${H}${r}=MINIFS(${range(H)},${range(M)},1))`; + }); + s.칸[`${hit}${r}`] = { + 식: `IF(AND(${M}${r}=1,OR(COUNTIF(${range(M)},1)<2,${least.join(",")})),1,0)`, + }; + } + } + const colOf = new Map(); + for (const c of cols) if (c.part === "v") colOf.set(c.key, colName(4 + cols.indexOf(c))); + const box: TableBox = { node, table, colOf, rows: N, free, hit }; + b.tables.set(sig, box); + return box; +} + +/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A */ +function resultCol(b: Build, box: TableBox, col: string): void { + const s = b.sheet(box.node); + const N = box.rows; + const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + const X = box.colOf.get(col)!; + const W = colName(box.free++); + s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` }; + for (let r = 2; r <= N + 1; r++) + s.칸[`${W}${r}`] = { + 식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`, + }; + b.out(box.node, col, { + 식: `IF(COUNTIF(${range(W)},1)=1,INDEX(${range(X)},MATCH(1,${range(W)},0)),NA())`, + }); +} + +/* ── 상자들 ─────────────────────────────────────────────────────────────── */ + +const example = (spec: LogicInput, given?: number | string): Cell => { + if (given !== undefined) return { 값: given }; + if (spec.고르기?.length) return { 값: spec.고르기[0] }; + if (spec.범위) return { 값: spec.범위[0] }; + return { 값: 0 }; +}; + +function inputBox(b: Build, spec: LogicInput): void { + const node = b.node("input", spec.이름, 0); + const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? ""); + const r = Number(at.slice(1)) - 1; + const whole = { r0: r, c0: 1, r1: r, c1: 1 }; + if (spec.고르기?.length) + b.sheet(node).유효성 = [ + { + id: "v1", + 범위: [whole], + 규칙: { 종류: "목록", 값들: spec.고르기.map(String) }, + 어기면: "막음", + }, + ]; + else if (spec.범위) + b.sheet(node).유효성 = [ + { + id: "v1", + 범위: [whole], + 규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] }, + 어기면: "막음", + }, + ]; + const pill = node.pillsOut[0]; + if (spec.단위) pill.unit = spec.단위; + b.inputs.set(spec.이름, node); +} + +/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */ +function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | number { + const ast = parseFormula(item.수량); + if (ast?.t === "num") return Number(ast.v); + if (ast?.t === "name" && b.inputs.has(ast.v)) return [b.inputs.get(ast.v)!, ast.v]; + if (ast?.t === "name" && b.mids.has(ast.v)) return [b.mids.get(ast.v)!, ast.v]; + const label = `${name} ${t("Qty")}`; + const node = b.node("calc", label, 2); + put({ b, here: node }, label, item.수량, "out"); + return [node, label]; +} + +/** 항목 상자(호표 줄) → 비목 출력 알약들 */ +function itemBox(b: Build, item: HoLine, lines: Map): [SlNode, Cost][] { + const name = item.이름 || String(item.요소); + const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소); + const call = + typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null; + const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore + const q = qtyFrom(b, item, name); + const qty = + typeof q === "number" + ? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "") + : b.link(node, q[0], q[1]); + if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 }); + const out: [SlNode, Cost][] = []; + if (call?.t === "logic") { + args(b, node, call.args); + const unit = COSTS.map((k) => { + const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside")); + b.pill(node, "in", { name: k, cell: at, outside: true }); + return at; + }); + const fixed = item.비목 as Cost | undefined; + const total = `${qty}*(${unit.join("+")})`; + if (fixed && (COSTS as readonly string[]).includes(fixed)) { + b.out(node, fixed, { 식: total }); + out.push([node, fixed]); + lines.set(name, [node, fixed]); + } else { + COSTS.forEach((k, i) => { + b.out(node, k, { 식: `${qty}*${unit[i]}` }); + out.push([node, k]); + }); + b.out(node, t("Amount"), { 식: total }); + lines.set(name, [node, t("Amount")]); + } + return out; + } + const price = b.row(node, t("Price"), {}, t("Outside")); + b.pill(node, "in", { name: t("Price"), cell: price, outside: true }); + const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {}); + if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v))) + b.cant(node, price, t("Hole", element), element); + const cost = ( + (COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비" + ) as Cost; + b.out(node, cost, { 식: `${qty}*${price}` }); + lines.set(name, [node, cost]); + return [[node, cost]]; +} + +type Cut = { 대상?: string; 자리?: number; 방법?: string }; +const cutText = (row: LogicRow, on: string, x: string): string => { + const rule = row.끝수 as unknown as Cut | null; + if (!rule || typeof rule !== "object" || rule.대상 !== on) return x; + const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[String(rule.방법)]; + return fn ? `${fn}(${x},${rule.자리 ?? 0})` : x; +}; + +/* ── 밖으로 ─────────────────────────────────────────────────────────────── */ + +/** 기존 로직 → 시트 로직 + 못 바꾼 꼴 */ +export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): SlImportReport { + const lb = readLogic(row, { key: row.키 || null, version: null, isBase: true }); + const b = new Build(opts); + for (const spec of lb.inputs) inputBox(b, spec); + for (const m of row.중간 ?? []) b.mids.set(m.이름, b.node("calc", m.이름, 2)); + for (const m of row.중간 ?? []) + put({ b, here: b.mids.get(m.이름)! }, m.이름, m.식, "out", m.출처 ?? ""); + const result = b.node("result", t("Result"), 4); + if (row.결과) { + const at = put({ b, here: result }, t("Result"), row.결과.식, null); + const s = b.sheet(result); + if (s.칸[at].식 && s.칸[at].식 !== "NA()") + s.칸[at] = { 식: cutText(row, "결과", s.칸[at].식!) }; + else if (s.칸[at].값 !== undefined) + s.칸[at] = { 식: cutText(row, "결과", String(s.칸[at].값)) }; + b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 }); + } else { + const lines = new Map(); + const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines)); + // 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합 + const sum = b.node("calc", t("Sum"), 3); + const costs = {} as Record; + for (const k of COSTS) { + const refs = outs.filter(([, c]) => c === k).map(([n, c]) => b.link(sum, n, c)); + b.out(sum, k, refs.length ? { 식: `SUM(${refs.join(",")})` } : { 값: 0 }); + costs[k] = [[sum, k]]; + } + const extras = new Map(); + for (const extra of row.덧줄 ?? []) { + const cost = ( + (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" + ) as Cost; + const node = b.node("item", extra.이름, 3, "비율"); + put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? ""); + extras.set(extra.이름, [node, cost]); + costs[cost] = [...costs[cost], [node, cost]]; + } + const total: string[] = []; + for (const k of COSTS) { + const refs = costs[k].map(([n, c]) => b.link(result, n, c)); + const at = b.out(result, k, { + 식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]), + }); + total.push(at); + } + b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위); + } + b.book.활성 = b.book.시트[0]?.id; + const sl: SheetLogic = { + key: null, + name: row.이름, + resultUnit: row.결과단위, + workbook: b.book, + nodes: b.nodes, + edges: b.edges, + }; + return { sl, problems: b.problems }; +} + +/** 기존 로직 → 시트 로직(정함 9) */ +export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic { + return importLogicReport(row, opts).sl; +} + +/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ +function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { + const got = calc?.[sheetId]?.[cell]; + if (got && "수" in got) return Number(got.수); + if (got && "글" in got) return got.글; + if (got && "참" in got) return got.참; + return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; +} + +/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ +export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { + const out: SlOutsideAt[] = []; + for (const node of sl.nodes) { + const passed: Record = {}; + const outside = node.pillsIn.filter((p) => p.outside); + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + for (const p of node.pillsIn) { + const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; + if (node.kind !== "table" && TEXT.Pass.includes(note as never)) + passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); + } + const ref = node.ref ?? ""; + for (const p of outside) { + const id = `${node.id}:${p.name}`; + const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) + ? (p.name as SlField) + : undefined; + let item: SlOutside; + if (node.kind === "table") item = { id, kind: "표", table: ref }; + else if ( + node.kind === "logic" || + ref.startsWith("GF") || + ref.startsWith("GC") || + ref.startsWith("GX") + ) + item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("로직(")) + item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; + else item = { id, kind: "자재", key: ref }; + out.push({ item, sheet: node.sheetId, cell: p.cell }); + } + } + return out; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts new file mode 100644 index 000000000..851582d78 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * M01_SL_Import_Parse.ts + * 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로 + * 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 · + * - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null + * 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +export type Ast = + | { t: "num"; v: string } + | { t: "str"; v: string } + | { t: "name"; v: string } + | { t: "eref"; v: string } + | { t: "neg"; a: Ast } + | { t: "field"; a: Ast; f: string } + | { t: "call"; f: string; items: Ast[] } + | { t: "find"; head: string; args: [string, Ast][]; src: string } + | { t: "logic"; head: string; args: [string, Ast][]; src: string } + | { t: "bin"; op: string; a: Ast; b: Ast }; + +const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; +const CALLS = ["만약", "최소", "최대", "올림", "버림", "반올림"]; + +export function parseFormula(s: string): Ast | null { + let i = 0; + const fail = (): never => { + throw new Error("parse"); + }; + const ws = (): void => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const take = (w: string): boolean => { + ws(); + if (!s.startsWith(w, i)) return false; + i += w.length; + return true; + }; + const need = (w: string): void => void (take(w) || fail()); + const word = (): string => { + ws(); + const m = /[\p{L}\p{N}_]+/uy; + m.lastIndex = i; + const got = m.exec(s) ?? fail(); + i = m.lastIndex; + return got[0]; + }; + const quoted = (): string => { + const e = s.indexOf("'", i); + if (e < 0) fail(); + const v = s.slice(i, e); + i = e + 1; + return v; + }; + /** 로직 · 표 머리 글 — 「,」 「)」 에서 멈춤(괄호 · 중괄호 안은 넘김) */ + const head = (): string => { + ws(); + const st = i; + for (let d = 0; i < s.length; i++) { + const c = s[i]; + if ("({".includes(c)) d++; + else if (")}".includes(c)) { + if (!d) break; + d--; + } else if (!d && c === ",") break; + } + return s.slice(st, i).trim(); + }; + const fields = (n: Ast): Ast => { + while (take(".")) n = { t: "field", a: n, f: take("'") ? quoted() : word() }; + return n; + }; + const primary = (): Ast => { + ws(); + const st = i; + if (take("(")) { + const a = expr(); + need(")"); + return fields(a); + } + if (take("'")) return { t: "str", v: quoted() }; + const num = /\d+(?:\.\d+)?/y; + num.lastIndex = i; + const n = num.exec(s); + if (n) { + i = num.lastIndex; + return { t: "num", v: n[0] }; + } + const w = word(); + if (w === "찾기" || w === "로직") { + need("("); + const h = head(); + const args: [string, Ast][] = []; + while (take(",")) { + const k = take("'") ? quoted() : word(); + need("="); + args.push([k, expr()]); + } + need(")"); + const call = { head: h, args, src: s.slice(st, i) }; + return fields(w === "찾기" ? { t: "find", ...call } : { t: "logic", ...call }); + } + if (CALLS.includes(w)) { + need("("); + const items = [expr()]; + while (take(",")) items.push(expr()); + need(")"); + return fields({ t: "call", f: w, items }); + } + if (s[i] === ":") { + for (i++; i < s.length && !/[\s,)*/+-]/.test(s[i]); i++); + return { t: "eref", v: s.slice(st, i) }; + } + return fields({ t: "name", v: w }); + }; + const unary = (): Ast => (take("-") ? { t: "neg", a: unary() } : primary()); + const chain = (next: () => Ast, ops: string[], once = false): Ast => { + let n = next(); + for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) + n = { t: "bin", op, a: n, b: next() }; + return n; + }; + const mul = (): Ast => chain(unary, ["*", "/"]); + const add = (): Ast => chain(mul, ["+", "-"]); + const cmp = (): Ast => chain(add, CMPS, true); + const both = (): Ast => chain(cmp, ["그리고"]); + const expr = (): Ast => chain(both, ["또는"]); + try { + const node = expr(); + ws(); + return i === s.length ? node : null; + } catch { + return null; + } +} + +/* ── 품셈 표 칸 놓기 ─────────────────────────────────────────────────────── */ + +/** 가져올 때 같이 받는 품셈 표 — 서버 `/table` 답의 쓸 칸만 */ +export interface SlImportTable { + 키?: string; + 이름?: string; + 조건: Record; + 값칸: Record; + 범위규칙?: string | null; + 줄: Record[]; +} + +/** 범위 칸 꼬리 — 한 · 영 */ +const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위"]; + +export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"]; + +/** 표 칸 놓기 — 조건 칸 차례(범위는 아래 · 위 두 칸) → 값 칸 차례 */ +export function tableCols( + table: SlImportTable, +): { key: string; head: string; part: "v" | "lo" | "hi" }[] { + const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = []; + for (const [k, kind] of Object.entries(table.조건)) { + if (kind === "범위") { + out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" }); + out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" }); + } else out.push({ key: k, head: k, part: "v" }); + } + for (const k of Object.keys(table.값칸)) + if (!(k in table.조건)) out.push({ key: k, head: k, part: "v" }); + return out; +} + +/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 모양(`M01_SL_Api` 「표」)과 같음 */ +export function tableRows(table: SlImportTable): (string | number | null)[][] { + const cols = tableCols(table); + const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => { + if (Array.isArray(v)) { + if (part === "v") return v.map((x) => x ?? "").join("~"); + const x = v[part === "lo" ? 0 : 1]; + return typeof x === "number" ? x : null; + } + return typeof v === "number" || typeof v === "string" ? v : null; + }; + return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; +} diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py new file mode 100644 index 000000000..84a0d44c2 --- /dev/null +++ b/resources/tester/test_m01_sl_import.py @@ -0,0 +1,274 @@ +"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림. + +· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만 +· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈 + → 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만 +· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음) +· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ──────────── +HOOKS = r""" +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/; +export async function resolve(spec, ctx, next) { + if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { + const u = new URL(spec + ".ts", ctx.parentURL); + if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true }; + } + return next(spec, ctx); +} +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\s+"[^"]+\.css";/g, ""); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => { + if (from.startsWith(".") && ALLOW.test(from)) return m; + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" +STUB = ( + "globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : " + "() => undefined });\n" +) + + +def _plain(x: object) -> object: + return float(x) if isinstance(x, Decimal) else str(x) + + +def _node(body: str, payload: object) -> object: + """`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답""" + script = ( + "import { register } from 'node:module';\n" + "import { readFileSync } from 'node:fs';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + + STUB + + f"const I = await import({json.dumps(IMPORT.as_uri())});\n" + "const A = JSON.parse(readFileSync(0, 'utf8'));\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + input=json.dumps(payload, ensure_ascii=False, default=_plain), + capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-3000:] + return json.loads(result.stdout) + + +# ── 견본 줄 여섯(정함 9) + 넣을 값 ──────────────────────────────────────────── +H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None} +SAMPLES = [ + ( + {**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [], + "호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"}, + "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}], + "덧줄": []}, + {}, + ), + ( + {**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", + "입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]}, + {"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, + {"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}], + "중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}], + "호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"}, + {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", + "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}], + "덧줄": []}, + {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80}, + ), + ( + {**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡", + "입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}], + "중간": [], + "호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비", + "수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}], + "덧줄": []}, + {"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"}, + ), + ( + {**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥", + "입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}], + "중간": [{"이름": "작업량", "식": "로직(GF000114)"}], + "호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton", + "단위": "시간", "수량": "1 / 작업량"}], + "덧줄": []}, + {"지역": "서울"}, + ), + ( + {**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간", + "입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}], + "중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}}, + {"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25}, + ), + ( + {**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [], + "중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}], + "호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}], + "덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비", + "식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]}, + {}, + ), +] # fmt: skip + + +# ── 마스터(읽기만) ───────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def master(): + from M01_MasterData import M01_MasterData_Store as store + + cm = store.cm + files = cm.load(folder=store.FOLDER) + tables = { + str(t["키"]): t + for data in files.values() + if data.get("그룹") in cm.mf.TABLE_GROUPS + for t in data.get("표") or [] + } + return cm, files, tables + + +def _tables_of(row: dict, tables: dict) -> dict: + keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False))) + return {k: tables[k] for k in keys if k in tables} + + +def _num(v) -> float: + return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan") + + +def _fill(sl: dict, places: list, py_lines: list, master) -> None: + """바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`""" + cm, files, _ = master + m = cm.mf.Master(files) + nodes = {n["id"]: n for n in sl["nodes"]} + for at in places: + item = at["item"] + kind = item["kind"] + if kind == "표": + continue + if kind == "노임": + value = cm.mf.element(m, item["key"], {})[0] + elif kind == "자재": + name = nodes[item["id"].split(":")[0]]["name"] + value = next(x["단가"] for x in py_lines if x["이름"] == name) + else: + key = ( + item["key"] + if kind == "로직" + else re.match(r"로직\((\w+)", item["element"]).group(1) + ) + got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v + for k, v in item["inputs"].items()}) # fmt: skip + value = got["결과"] if "결과" in got else got[item.get("field") or "계"] + if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0) + continue + sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"]) + sheet["칸"][at["cell"]] = {"값": float(value)} + + +def _result_cells(sl: dict) -> dict: + node = next(n for n in sl["nodes"] if n["kind"] == "result") + return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]} + + +@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES]) +def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None: + from common_util.common_util_spreadsheet import recalc_workbooks + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + rep = _node( + "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });" + "out({ ...r, places: I.outsideOf(r.sl) });", + {"row": row, "tables": _tables_of(row, master[2]), "given": given}, + ) + assert rep["problems"] == [] + sl = rep["sl"] + # 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리 + sheets = {s["id"] for s in sl["workbook"]["시트"]} + assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"]) + ids = {n["id"]: n for n in sl["nodes"]} + for e in sl["edges"]: + assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) + assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) + + want = lb.calc(row, given, {}) + assert want["ok"], want + # 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈 + first = recalc_workbooks([sl["workbook"]])[0] + places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first}) + _fill(sl, places, want.get("lines", []), master) + got = recalc_workbooks([sl["workbook"]])[0] + cells = _result_cells(sl) + read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()} + print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}") + if "result" in want: + assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"]) + return + for k in ("노무비", "재료비", "경비", "계"): + diff = abs(read[k] - float(want["sums"][k])) + assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}" + + +def test_마스터_1469_가져오기_세기(master) -> None: + from common_util.common_util_spreadsheet import recalc_workbooks + + cm, files, tables = master + rows = [ + r + for name, data in files.items() + if data.get("그룹") == "로직" and name != "로직_자체.json" + for r in data.get("줄") or [] + ] + assert len(rows) == 1469 + got = _node( + "const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });" + "return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }" + "catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });" + "out(res);", + {"rows": rows, "tables": tables}, + ) + thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])] + assert thrown == [] + full = [g for g in got if not g["problems"]] + # 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음 + values = recalc_workbooks([g["book"] for g in full]) + named = [ + g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False) + ] + assert named == [] + reasons: dict[str, int] = {} + for g in got: + for p in g["problems"]: + why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1]) + reasons[why] = reasons.get(why, 0) + 1 + print( + f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}" + ) + assert len(full) > 1000 From f8d7776284eccc09db20ff5b90cc3ae5a2542f59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:16:21 +0900 Subject: [PATCH 11/38] =?UTF-8?q?fix(m01-sl):=20=ED=92=88=EC=85=88=20?= =?UTF-8?q?=ED=91=9C=20=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=EC=97=B4=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=EB=A5=BC=20Table=20=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EA=B2=8C=20=E2=80=94=20=EB=B2=94=EC=9C=84=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=A7=8C=20=EB=91=90=20=EC=B9=B8=20=C2=B7=20=EA=B0=92=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=80=20=ED=95=9C=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조건 칸(범위 조건은 「·아래」 「·위」) → 값 칸 · 머리 줄 포함 블록을 A1 부터(M01_SL_Table_Build layoutOf · blockCells 와 같음) - 범위 칸에 목록이 아니면 빈 칸 · 값 칸 범위 값은 「80,100」 글(JS String 과 같음) - 시험 견본에 값 칸 범위 · 범위 아닌 칸 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/M01_SL_Router.py | 28 +++++++++++-------- resources/tester/test_m01_sl_router.py | 32 ++++++++++++---------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py index 4719049ed..f9578f586 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -161,7 +161,10 @@ def _logic(key: str, inputs: dict, field: str | None) -> dict: def _table(key: str) -> dict: - """품셈 표 통째 → 머리 줄 + 줄(범위 · 범위 값은 「이름·아래」 「이름·위」 두 칸).""" + """품셈 표 통째 → 머리 줄 + 줄 — `M01_SL_Table_Build.layoutOf · blockCells` 와 같은 열 차례. + + 조건 칸(범위 조건은 「이름·아래」 「이름·위」 두 칸) → 값 칸(늘 한 칸 · 범위 값은 「아래,위」). + """ files = store.loaded()[0] table = next( ( @@ -175,24 +178,25 @@ def _table(key: str) -> dict: ) if table is None: return {"ok": False, "reason": f"없는 표 「{key}」"} - names = [*(table.get("조건") or {}), *(table.get("값칸") or {})] - split = { - n - for n in names - if (table.get("조건") or {}).get(n) == "범위" - or any(isinstance(r.get(n), list) for r in table.get("줄") or []) - } + conds = table.get("조건") or {} + names = [*conds, *(table.get("값칸") or {})] + split = {n for n in conds if conds[n] == "범위"} head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] + + def plain(v: Any) -> Any: # JS String(배열) 과 같게 — 「80,100」 · 빈 끝은 빈 글 + if isinstance(v, list): + return ",".join("" if x is None else str(x) for x in v) + return None if v == "" else v + rows = [] for r in table.get("줄") or []: line = [] for n in names: v = r.get(n) - if n in split: - low, high = v if isinstance(v, list) else (v, v) - line += [low, high] + if n in split: # 범위 칸 — 목록이 아니면 둘 다 빈 칸 + line += list(v) if isinstance(v, list) else [None, None] else: - line.append(v if not isinstance(v, (list, dict)) else str(v)) + line.append(plain(v)) rows.append(line) return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index b8d0a7265..6b1819830 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -36,10 +36,10 @@ TABLE = { "출처": "자체", "조건": {"돌": "고르기", "높이": "범위"}, "범위규칙": "초과·이하", - "값칸": {"석공": "인"}, + "값칸": {"석공": "인", "증가율": "%"}, "줄": [ - {"돌": "깬돌", "높이": [None, 3], "석공": 0.5}, - {"돌": "깬돌", "높이": [3, None], "석공": 0.7}, + {"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]}, + {"돌": "견치돌", "높이": 4, "석공": 0.7}, ], "주": [], } @@ -158,15 +158,10 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음 table = book["시트"][1]["칸"] - assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [ - "돌", - "높이·아래", - "높이·위", - "석공", - ] - assert ( - "B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7 - ) # 빈 끝은 빈 칸 + head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1")] + assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율"] # 머리 줄 · A1 부터 + assert "B2" not in table and table["C2"]["값"] == 3 and table["E2"]["값"] == "80,100" + assert "B3" not in table and "C3" not in table and table["D3"]["값"] == 0.7 # 범위 아님 → 빈 칸 assert saved["logic"]["edges"] == _logic(labor)["edges"] answers = {v["id"]: v for v in saved["values"]} assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all( @@ -221,8 +216,17 @@ def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: {"id": "z", "kind": "모름"}, ] got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} - assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"] - assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]] + assert got["t"]["rows"][0] == [ + "돌", + "높이·아래", + "높이·위", + "석공", + "증가율", + ] # sub3 layoutOf 차례 + assert got["t"]["rows"][1:] == [ + ["깬돌", None, 3, 0.5, "80,100"], + ["견치돌", None, None, 0.7, None], + ] assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price assert got["g2"]["value"] == 0 From 34165ec66a395386edb6998409d517522a4891db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:17:10 +0900 Subject: [PATCH 12/38] =?UTF-8?q?test(m01-sl):=20=ED=92=88=EC=85=88=20?= =?UTF-8?q?=ED=91=9C=20=EB=B8=94=EB=A1=9D=20=EC=99=BC=EC=9C=84=20=3D=20?= =?UTF-8?q?=E3=80=8C=ED=91=9C=E3=80=8D=20=EB=B0=94=EA=B9=A5=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=B9=B8(E1)=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 저장 다시 넣기는 이미 알약 칸에서 펼침 — 고정 A1 아님을 시험으로 남김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- resources/tester/test_m01_sl_router.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index 6b1819830..e524e9f11 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -168,8 +168,13 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, v["ok"] for v in answers.values() ) - second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)}) + # 블록 왼위 = 표 상자 「표」 바깥 알약 칸(고정 A1 아님 — Import 는 E1) + at_e1 = _logic(labor) + at_e1["nodes"][1]["pillsOut"][0]["cell"] = "E1" + second = _post(client, "/api/m01/sl/save", {"logic": at_e1}) assert second["key"] == "SL-0002" + moved = second["logic"]["workbook"]["시트"][1]["칸"] + assert moved["E1"]["값"] == "돌" and moved["I2"]["값"] == "80,100" and "A1" not in moved renamed = {**saved["logic"], "name": "고친 이름"} assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" From ebf51d8b35a7c0b46cc93b7bcc44bbdea1a8a0ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:17:13 +0900 Subject: [PATCH 13/38] =?UTF-8?q?feat(m01-sl):=20=ED=91=9C=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=E2=80=94=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=EC=9D=80=20=ED=91=9C=20=EC=95=8C=EC=95=BD=20=EC=B9=B8?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20=C2=B7=20=EC=A4=84=EB=A9=B4=20=EC=98=9B=20?= =?UTF-8?q?=EC=A4=84=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=A4=84=20=EB=B0=94=EB=80=9C=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(A1 · E1 무엇이든) · applyOutside 가 그 칸부터 머리 줄 포함 채움 - 표가 줄었으면 블록 열 아래 남은 옛 값 칸을 지움(찾기 판 수식은 블록 열 밖이라 그대로) - answersToValues(cells, 답, sl) — 시트 머리 줄과 받은 머리 줄이 다르면 쉬운 말 - addNode(table) = 빈 시트(Table 이 블록 · 찾기 판 · 알약을 채움 — 틀 칸이 블록과 겹치지 않게) - 시험 15개(Table_Build 와 잇는 시험 더함) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Eval.ts | 33 ++++++++++++++ M01_MasterData/SheetLogic/M01_SL_Model.ts | 24 ++++------- resources/tester/test_m01_sl_model.py | 52 ++++++++++++++++++++++- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts index 69bc12453..0dd408093 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Eval.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -54,6 +54,11 @@ const TEXT = { "Outside value 「{pill}」 of 「{box}」 not filled yet", ], NoResult: ["결과 상자가 없음", "There is no result box"], + /** {box} */ + HeadChanged: [ + "「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함", + "Table headers of 「{box}」 changed — choose the table again", + ], /** {cell} {why} */ NoAnswer: [ "{cell} 칸 바깥 값을 못 받음 {why}", @@ -102,6 +107,7 @@ export interface SlOutsideAnswer { export function answersToValues( cells: SlOutside[], answers: SlOutsideAnswer[], + sl?: SheetLogic, ): { values: SlOutsideValue[]; errors: string[] } { const values: SlOutsideValue[] = []; const errors: string[] = []; @@ -111,11 +117,29 @@ export function answersToValues( errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" })); continue; } + const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null; + if (head) errors.push(head); values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null }); } return { values, errors }; } +/** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */ +function headChanged( + sl: SheetLogic, + c: SlOutside, + head: (string | number | null)[], +): string | null { + const sheet = nodeSheet(sl, c.node); + const at = parseA1(c.cell); + if (!sheet || !at) return null; + const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null); + if (now.every((x) => x === null)) return null; // 처음 채움 + const same = head.length === now.length && head.every((h, i) => h === now[i]); + const box = sl.nodes.find((n) => n.id === c.node)?.name ?? ""; + return same ? null : tl("HeadChanged", { box }); +} + /** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */ function putValue(칸: Record, cell: string, value: SlValue): void { const style = 칸[cell]?.서식; @@ -139,6 +163,15 @@ export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLog v.value.forEach((line, dr) => line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)), ); + // 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림 + const width = Math.max(0, ...v.value.map((line) => line.length)); + const last = at.r + v.value.length - 1; + for (const a of Object.keys(sheet.칸)) { + const p = parseA1(a); + const c = sheet.칸[a]; + if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined) + putValue(sheet.칸, a, null); + } } return out; } diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index d457e5676..a9253d047 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -52,7 +52,8 @@ export interface SlEdge { export type SlField = "노무비" | "재료비" | "경비" | "계"; /** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로 - * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`) + * 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움 * · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */ export type SlSource = | { kind: "표"; table: string } @@ -107,7 +108,6 @@ const TEXT = { Fixed: ["늘 이 값", "Always this value"], Cond: ["조건", "Condition"], Found: ["결과", "Result"], - FromTable: ["표에서 찾은 값", "Value found in the table"], ValueA: ["값1", "Value 1"], ValueB: ["값2", "Value 2"], Times: ["값1 × 값2", "Value 1 × Value 2"], @@ -216,14 +216,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out case "const": return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] }; case "table": - return { - rows: [ - [tl("Cond"), null, tl("QtyNote")], - [tl("Found"), null, tl("FromTable")], - ], - ins: [{ name: tl("Cond"), cell: "B2" }], - outs: [{ name: tl("Found"), cell: "B3" }], - }; + // 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게) + return { rows: [], ins: [], outs: [] }; case "calc": return { rows: [ @@ -335,11 +329,11 @@ export function addNode( const title = uniqueName(book, name); const item = kind === "item" ? itemSheet() : null; const t = item ?? template(kind, title); - const 칸: Record = item?.cells ?? { - A1: { 값: tl("Name") }, - B1: { 값: tl("Value") }, - C1: { 값: tl("Note") }, - }; + const 칸: Record = + item?.cells ?? + (kind === "table" + ? {} + : { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") } }); t.rows.forEach(([label, cell, note], i) => { 칸[`A${i + 2}`] = { 값: label }; if (cell) 칸[`B${i + 2}`] = cell; diff --git a/resources/tester/test_m01_sl_model.py b/resources/tester/test_m01_sl_model.py index ee944877a..81b68cf4c 100644 --- a/resources/tester/test_m01_sl_model.py +++ b/resources/tester/test_m01_sl_model.py @@ -222,7 +222,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None: ] assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None) assert lg == {"결과": 12.5} - assert "「메쌓기 표」의 입력 알약 「조건」이 비어 있음" in check + assert check == [] def test_알약_정하기_이름바꾸면_선이_따라감() -> None: @@ -361,3 +361,53 @@ def test_항목_틀은_Item_노무_틀과_같은_칸() -> None: ["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"], ] # fmt: skip assert ref == '{"what":"노무"}' + + +# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기 +TABLE = r""" +const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts")); +let sl = M.newLogic("표 시험"); +let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node; +const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1", + conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [], + rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 }, + { 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] }; +sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] }, + { ref: "QF000421", fillBlock: false, outside: true }).sl; +r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node; +r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node; +sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]); +sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이"); +sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌"); +const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]]; +const cells = E.outsideCells(sl); +""" + + +def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None: + got = _run( + TABLE + + "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n" + "const s1 = E.applyOutside(sl, first.values);\n" + "const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n" + "const s2 = E.applyOutside(s1, shrink.values);\n" + "const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true," + " rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n" + "out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null," + " sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);" + ) + cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got + assert cells == [ + { + "id": "n1!A1", + "node": "n1", + "cell": "A1", + "kind": "table", + "ref": "QF000421", + "source": {"kind": "표", "table": "QF000421"}, + } + ] + assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이" + assert shrink_err == [] and a4 is None and c4 is None + assert gone["석공"] == "#N/A" + assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"] From 520c7dc406f9bff355c796526e1b935f101a8498 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:17:36 +0900 Subject: [PATCH 14/38] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A8=20=ED=91=9C=20=EC=83=81=EC=9E=90=20=E3=80=8C=ED=91=9C?= =?UTF-8?q?=E3=80=8D=20=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD(E1)=20=E2=80=94=20Table=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=EC=99=80=20=EA=B0=99=EC=9D=80=20=EA=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 5 +++-- resources/tester/test_m01_sl_import.py | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts index f94905ddf..93db36b6c 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -384,7 +384,8 @@ function newTable(b: Build, n: Extract, sig: string): TableB const cols = tableCols(table); const rows = tableRows(table); b.row(node, t("Table"), { 값: n.head }, t("Outside")); - b.pill(node, "in", { name: t("Table"), cell: "E1", outside: true }); + // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 + b.pill(node, "out", { name: "표", cell: "E1", outside: true }); rows.forEach((r, i) => r.forEach((v, j) => { const c = cellOf(v); @@ -653,7 +654,7 @@ export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { const out: SlOutsideAt[] = []; for (const node of sl.nodes) { const passed: Record = {}; - const outside = node.pillsIn.filter((p) => p.outside); + const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); for (const p of node.pillsIn) { const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py index 84a0d44c2..3187d11a3 100644 --- a/resources/tester/test_m01_sl_import.py +++ b/resources/tester/test_m01_sl_import.py @@ -214,6 +214,9 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m sheets = {s["id"] for s in sl["workbook"]["시트"]} assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"]) ids = {n["id"]: n for n in sl["nodes"]} + for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴) + if n["kind"] == "table": + assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"] for e in sl["edges"]: assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) From 499e52ca7331babf89ed175a71ea2b2e5bd9fbdc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:21:15 +0900 Subject: [PATCH 15/38] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=87=EA=B8=B0=20=E2=80=94=20=EC=97=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=ED=8C=90=20=C2=B7=20=ED=91=9C/=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=C2=B7=20=EC=A0=80=EC=9E=A5(=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B0=92=20=EB=8C=80=EC=A1=B0)=20=C2=B7=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=8B=A4=ED=96=89=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry.css | 11 + M01_MasterData/SheetLogic/M01_SL_Entry.ts | 274 ++++++------ .../SheetLogic/M01_SL_Entry_Paper.ts | 393 ++++++++++++++++++ 3 files changed, 526 insertions(+), 152 deletions(-) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css index 4849660f5..5f6cd2c1a 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.css +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -167,3 +167,14 @@ overflow: auto; } } + +.m01-sl__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m01-sl__note:empty { + display: none; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts index 3398ed630..978af0f4e 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -1,33 +1,34 @@ /* ============================================================================= * M01_SL_Entry.ts - * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠 + * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). - * 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음. + * 고치는 화면은 M01_SL_Entry_Paper.ts · SheetLogic 의 다른 파일은 창을 열 때 import() 로 늦게 불러옴. + * 열기 = slGet → 바깥 값(slOutside → applyOutside) → 고치는 화면 · 가져오기 = 기존 로직 줄 + 품셈 표 → importLogicReport. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, - createSelectField, - createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { listEasy, openEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; import type { SlListItem } from "./M01_SL_Api"; import type { SheetLogic } from "./M01_SL_Model"; +import type { SlImportTable } from "./M01_SL_Import"; +import { fillOutside, loadMods, showPaper, type Mods } from "./M01_SL_Entry_Paper"; import "./M01_SL_Entry.css"; const TEXT = { Open: ["시트 로직(시험)", "Sheet logic (trial)"], Title: ["시트 로직", "Sheet logic"], CloseWindow: ["창 닫기", "Close window"], - Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Start_Mine: ["내 시트 로직", "My sheet logics"], Start_MineNone: ["아직 없음", "None yet"], @@ -38,17 +39,7 @@ const TEXT = { Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], Per: ["{u} 당", "per {u}"], - Name: ["이름", "Name"], - NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Diagram: ["상자와 선", "Boxes and lines"], - Panel: ["고른 상자의 시트", "Sheet of the chosen box"], - Result: ["결과", "Result"], - NotYet: ["준비 중", "Coming soon"], + Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -69,19 +60,13 @@ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", /** 찾은 것 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; -/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */ +/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */ const amountOf = (resultUnit: string): string => { const unit = resultUnit.replace(/^원\//, ""); return /^\d/.test(unit) ? unit : `1${unit}`; }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); - -/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게. - * ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */ -const loadMine = (): Promise => - import("./M01_SL_Api").then((m) => m.slList()).catch(() => []); -const blankLogic = (name: string, unit: string): Promise => - import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null); +const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); /** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createSheetLogicButton(): HTMLButtonElement { @@ -91,58 +76,137 @@ export function createSheetLogicButton(): HTMLButtonElement { function openSheetLogic(): void { let body: HTMLElement; /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null; + let paper: ReturnType | null = null; let logics: EasyListItem[] | null = null; let query = ""; + const mods = loadMods(); const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + + /** Ctrl+Z · Ctrl+S — 이 창 안에서만(시트 편집기가 먼저 먹은 키는 그쪽 몫) */ + const onKey = (ev: KeyboardEvent): void => { + if (!paper || !(ev.ctrlKey || ev.metaKey) || ev.defaultPrevented) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } + }; + + /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ + const unitChoices = (): string[] => { + const count = new Map(); + for (const x of logics ?? []) { + if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; + }; + + /** 고치는 화면 열기 — 바깥 값을 넣은 뒤 · 새로 · 가져오기는 처음부터 「저장 안 함」 */ + const open = async ( + M: Mods, + sl: SheetLogic, + saved: boolean, + notes: string[] = [], + ): Promise => { + let filled = sl; + try { + const got = await fillOutside(M, sl); + filled = got.sl; + if (got.errors.length) notes = [...notes, te("Outside", { v: got.errors.join(" · ") })]; + } catch (e) { + notes = [...notes, te("Outside", { v: M.api.slPlain(e) })]; + } + paper = showPaper(M, body, filled, { + saved, + units: unitChoices(), + notes, + onClose: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + }; + + const run = (job: (M: Mods) => Promise): void => { + body.replaceChildren(muted(te("Loading"))); + void mods.then(job).catch((e: unknown) => { + showToast(te("OpenFailed", { v: plainError(e) }), "error"); + void showStart(); + }); + }; + + const openSaved = (key: string): void => run(async (M) => open(M, await M.api.slGet(key), true)); + + const openNew = (): void => + run(async (M) => open(M, M.model.newLogic("", unitChoices()[0]), false)); + + /** 기존 로직 가져오기 — 로직 줄 + 그 줄이 쓰는 품셈 표(열 때 받음) → 시트 로직(저장 전) */ + const doImport = (key: string): void => + run(async (M) => { + const { row } = await openEasy(key); + const lb = await import("../LogicBuilder/M01_LB_Api"); + const keys = [...new Set(JSON.stringify(row).match(/Q[A-Z]\d{6}/g) ?? [])]; + const got = await Promise.all( + keys.map((k) => + lb.fetchLbTable(k).then( + (t) => [k, t.table] as const, + () => null, + ), + ), + ); + // 서버 표 통째(조건 · 값칸 · 줄 포함) — LB Api 타입이 느슨해 Import 모양으로 봄 + const tables = Object.fromEntries(got.filter((x) => x !== null)) as unknown as Record< + string, + SlImportTable + >; + const report = M.imp.importLogicReport(row, { tables }); + await open(M, report.sl, false, report.problems); + }); /* ---------- 시작 화면 ---------- */ - const importRow = (item: EasyListItem): HTMLElement => { + const listButton = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m01-sl__item", - attrs: { type: "button", title: item.where }, + attrs: { type: "button", title }, children: [ - el("span", { className: "m01-sl__item-name", text: item.name }), - el("span", { - className: "m01-sl__item-unit", - text: isMoney(item.resultUnit) - ? te("Per", { u: amountOf(item.resultUnit) }) - : item.resultUnit, - }), + el("span", { className: "m01-sl__item-name", text: name }), + el("span", { className: "m01-sl__item-unit", text: unit }), ], }); - row.addEventListener("click", notYet); - return row; - }; - - const mineRow = (item: SlListItem): HTMLElement => { - const row = el("button", { - className: "m01-sl__item", - attrs: { type: "button" }, - children: [ - el("span", { className: "m01-sl__item-name", text: item.이름 }), - el("span", { className: "m01-sl__item-unit", text: item.결과단위 }), - ], - }); - row.addEventListener("click", notYet); + row.addEventListener("click", onClick); return row; }; + const perText = (u: string): string => (isMoney(u) ? te("Per", { u: amountOf(u) }) : u); + const importRow = (item: EasyListItem): HTMLElement => + listButton(item.name, perText(item.resultUnit), () => doImport(item.key), item.where); + const mineRow = (item: SlListItem): HTMLElement => + listButton(item.이름, perText(item.결과단위), () => openSaved(item.key), item.saved); const showStart = async (): Promise => { paper = null; const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-sl__list" }); const foundHost = el("div", { className: "m01-sl__list" }); - const mineHost = el("div", { className: "m01-sl__list" }); - void loadMine().then((mine) => - mineHost.replaceChildren( - ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), - ), - ); + const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] }); + void mods + .then((M) => M.api.slList()) + .then( + (mine) => + mineHost.replaceChildren( + ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), + ), + (e: unknown) => mineHost.replaceChildren(muted(te("LoadFailed", { v: plainError(e) }))), + ); const search = createInputField({ type: "search", placeholder: te("Start_Search"), @@ -177,7 +241,7 @@ function openSheetLogic(): void { el("div", { className: "m01-sl__start-col", children: [ - bigButton(te("Start_New"), () => showPaper()), + bigButton(te("Start_New"), openNew), el("h4", { text: te("Start_Mine") }), mineHost, ], @@ -214,88 +278,6 @@ function openSheetLogic(): void { drawFound(); }; - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ - const unitChoices = (): string[] => { - const count = new Map(); - for (const x of logics ?? []) { - if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; - }; - - /* ---------- 고치는 화면 ---------- */ - - const showPaper = (): void => { - let dirty = false; - const units = unitChoices(); - const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null }; - paper = mine; - void blankLogic("", units[0]).then((sl) => (mine.sl = sl)); - - const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; - }; - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - onInput: touched, - }); - name.root.classList.add("m01-sl__name"); - const unit = createSelectField({ - label: te("Unit"), - options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: units[0], - onChange: touched, - }); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; - const close = createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }); - - body.replaceChildren( - el("div", { - className: "m01-sl__strip", - children: [ - name.root, - unit.root, - unsaved, - el("div", { - className: "m01-sl__actions", - children: [ - undo, - createButton({ label: te("Save"), onClick: notYet }), - createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), - close, - ], - }), - ], - }), - el("p", { className: "m01-sl__trial-note", text: te("Trial") }), - el("div", { - className: "m01-sl__layout", - children: [ - part(te("Diagram"), "m01-sl__diagram"), - part(te("Panel"), "m01-sl__panel"), - part(te("Result"), "m01-sl__result"), - ], - }), - ); - name.input.focus(); - }; - // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), @@ -304,24 +286,12 @@ function openSheetLogic(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } -const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); - -/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */ -function part(title: string, className: string): HTMLElement { - return el("section", { - className: `m01-sl__part ${className}`, - children: [ - el("h4", { text: title }), - el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }), - ], - }); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-sl__big"); diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts new file mode 100644 index 000000000..a5012a1b3 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -0,0 +1,393 @@ +/* ============================================================================= + * M01_SL_Entry_Paper.ts + * 시트 로직 고치는 화면 — 위 띠 · 위 가로 다이어그램 · 아래 시트 판 + 종류별 손잡이 · 결과 · 잇기 + * 바뀔 때마다 기록(객체 그대로) → evalLogic(화면 즉시) → 저장 slSave(서버가 다시 셈한 값으로 바꿔 보임) + * SheetLogic 의 다른 파일은 Entry 가 import() 로 늦게 불러 `mods` 로 넘김(엔진이 M01 첫 묶음에 안 들어가게). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import type { SlOutsideAt, SlQuery } from "./M01_SL_Api"; +import type { SlEvalResult, SlOutsideValue } from "./M01_SL_Eval"; +import type { SheetLogic, SlKind } from "./M01_SL_Model"; + +/** SheetLogic 의 다른 파일 — 창을 열 때 한 번에 불러옴 */ +export const loadMods = () => + Promise.all([ + import("./M01_SL_Model"), + import("./M01_SL_Eval"), + import("./M01_SL_Api"), + import("./M01_SL_Diagram"), + import("./M01_SL_Panel"), + import("./M01_SL_Table"), + import("./M01_SL_Item"), + import("./M01_SL_Result"), + import("./M01_SL_Import"), + ]).then(([model, ev, api, diagram, panel, table, item, result, imp]) => ({ + model, + ev, + api, + diagram, + panel, + table, + item, + result, + imp, + })); +export type Mods = Awaited>; + +const TEXT = { + Name: ["이름", "Name"], + NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Close: ["닫기", "Close"], + Unsaved: ["저장 안 함", "Not saved"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Diagram: ["상자와 선", "Boxes and lines"], + Panel: ["고른 상자의 시트", "Sheet of the chosen box"], + PickBox: [ + "위에서 상자를 고르면 그 상자의 시트가 여기 보임", + "Choose a box above to see its sheet", + ], + Result: ["결과", "Result"], + NoName: ["이름을 넣어 주세요", "Enter a name"], + SaveAnyway: [ + "고칠 곳이 있음 — {v} · 그래도 저장할까요?", + "There are issues — {v} · Save anyway?", + ], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], + Differs: [ + "저장 때 다시 셈한 값 {s} 원 — 화면 값 {c} 원과 다름", + "Recalculated on save: {s} — differs from the screen value {c}", + ], + Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["지웠음", "Deleted"], + Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], + Kind_input: ["입력값", "Input"], + Kind_const: ["고정값", "Fixed value"], + Kind_table: ["표", "Table"], + Kind_calc: ["계산", "Calculation"], + Kind_item: ["항목", "Item"], + Kind_logic: ["다른 로직", "Other logic"], + Kind_result: ["결과", "Result"], +} as const; + +type TextKey = keyof typeof TEXT; + +const tp = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); + +/** 바깥 값 자리 — 항목 단가 · 출처가 있는 알약 · 가져온 로직의 바깥 값(같은 칸은 앞의 것) */ +export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { + const at = new Map(); + const put = (p: SlOutsideAt): void => { + const k = `${p.sheet}!${p.cell.toUpperCase()}`; + if (!at.has(k)) at.set(k, p); + }; + M.item.itemOutsides(sl).forEach(put); + for (const c of M.ev.outsideCells(sl)) { + const node = sl.nodes.find((n) => n.id === c.node); + if (node && c.source) + put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); + } + M.imp.outsideOf(sl).forEach(put); + return [...at.values()]; +} + +/** 열 때 바깥 값 넣기 — 받은 것만 넣고 못 받은 것은 쉬운 말 목록 */ +export async function fillOutside( + M: Mods, + sl: SheetLogic, +): Promise<{ sl: SheetLogic; errors: string[] }> { + const places = outsidePlaces(M, sl); + if (!places.length) return { sl, errors: [] }; + const answers = await M.api.slOutside(places.map((p) => p.item)); + const values: SlOutsideValue[] = []; + const errors: string[] = []; + for (const p of places) { + const node = sl.nodes.find((n) => n.sheetId === p.sheet); + const a = answers.find((x) => x.id === p.item.id); + if (!node) continue; + if (!a?.ok) errors.push(`${node.name} ${p.cell}${a?.reason ? ` — ${a.reason}` : ""}`); + else values.push({ node: node.id, cell: p.cell, value: a.rows ?? a.value ?? null }); + } + return { sl: M.ev.applyOutside(sl, values), errors }; +} + +/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */ +function serverTotal(sl: SheetLogic): number | null { + const box = sl.nodes.find((n) => n.kind === "result"); + const pill = box?.pillsOut.find((p) => p.name === "계"); + if (!box || !pill) return null; + const cell = pill.cell.toUpperCase(); + const got = sl.workbook.계산값?.[box.sheetId]?.[cell]; + if (got && "수" in got) return Number(got.수); + const plain = sl.workbook.시트.find((s) => s.id === box.sheetId)?.칸[cell]?.값; + return typeof plain === "number" ? plain : null; +} + +export interface PaperCtx { + /** 저장해 둔 것을 열었으면 true — 새로 · 가져오기는 처음부터 「저장 안 함」 */ + saved: boolean; + units: string[]; + notes: string[]; + onClose(): void; +} + +/** 고치는 화면 — 돌려준 손잡이로 Entry 가 닫기 물음 · Ctrl+Z · Ctrl+S 를 부름 */ +export function showPaper( + M: Mods, + host: HTMLElement, + first: SheetLogic, + ctx: PaperCtx, +): { dirty(): boolean; save(): Promise; undo(): void } { + let sl = first; + let savedSl: SheetLogic | null = ctx.saved ? first : null; + let stack: SheetLogic[] = []; + let selected: string | null = first.nodes[0]?.id ?? null; + let values: SlEvalResult; + let busy = false; + let notes = ctx.notes; + + const unsaved = createTag(tp("Unsaved"), "warning"); + const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } }); + const diagramHost = el("div", { className: "m01-sl__part-body" }); + const panelHost = el("div"); + const toolsHost = el("div"); + const resultHost = el("div", { className: "m01-sl__part-body" }); + + const evalNow = (): SlEvalResult => { + try { + return M.ev.evalLogic(sl); + } catch (e) { + const none = { 노무비: null, 재료비: null, 경비: null, 계: null }; + return { pills: {}, pillsIn: {}, result: none, errors: [M.api.slPlain(e)] }; + } + }; + + /** 바뀐 것을 받아 쌓음 — 구조가 바뀌면 비율 바탕을 다시 엮음 */ + const edit = (next: SheetLogic, structural = false): void => { + if (next === sl) return; + stack = [...stack, sl]; + sl = structural ? M.item.refreshBases(next) : next; + redraw(); + }; + + const name = createInputField({ + label: tp("Name"), + placeholder: tp("NamePlaceholder"), + value: sl.name, + onInput: (v) => edit({ ...sl, name: v }), + }); + name.root.classList.add("m01-sl__name"); + const units = ctx.units.includes(sl.resultUnit) ? ctx.units : [sl.resultUnit, ...ctx.units]; + const unit = createSelectField({ + label: tp("Unit"), + options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: sl.resultUnit, + onChange: (v) => edit({ ...sl, resultUnit: v }), + }); + + const kindName = (k: SlKind): string => tp(`Kind_${k}` as TextKey); + const diagramOn = { + select: (id: string | null): void => { + selected = id; + drawPanel(); + }, + connect: (from: Parameters[1], to: typeof from): string | null => { + const got = M.model.connect(sl, from, to); + if (got.why) return got.why; + edit(got.sl, true); + return null; + }, + disconnect: (edgeId: string): void => edit(M.model.disconnect(sl, edgeId), true), + add: (kind: SlKind): void => { + const got = M.model.addNode(sl, kind, kindName(kind)); + selected = got.node.id; + edit(got.sl, true); + }, + remove: (nodeId: string): void => { + if (selected === nodeId) selected = null; + edit(M.model.removeNode(sl, nodeId), true); + }, + }; + + /** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */ + const loadPrices = (nodeId: string): void => { + void M.item.loadPrices(sl, nodeId).then(({ sl: next, why }) => { + if (why) note.textContent = why; + if (next !== sl) { + sl = next; + redraw(); + } + }); + }; + + const drawPanel = (): void => { + const node = sl.nodes.find((n) => n.id === selected); + if (!node) { + panelHost.replaceChildren(muted(tp("PickBox"))); + toolsHost.replaceChildren(); + return; + } + M.panel.renderPanel(panelHost, sl, node.id, { + change: (next) => edit(next), + pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), + }); + if (node.kind === "table") + M.table.renderTableTools(toolsHost, sl, node.id, { change: (next) => edit(next, true) }); + else if (node.kind === "item") + M.item.renderItemTools(toolsHost, sl, node.id, { + change: (next) => { + edit(next, true); + loadPrices(node.id); + }, + }); + else toolsHost.replaceChildren(); + }; + + const redraw = (): void => { + values = evalNow(); + unsaved.hidden = sl === savedSl; + undo.disabled = !stack.length; + deleteButton.hidden = !sl.key; + const shown = Object.fromEntries( + sl.nodes.map((n) => [n.id, { ...values.pillsIn[n.id], ...values.pills[n.id] }]), + ); + M.diagram.renderDiagram(diagramHost, sl, diagramOn, { values: shown, selected }); + drawPanel(); + M.result.renderResult(resultHost, sl, values, { change: (next) => edit(next) }); + note.textContent = [...notes, ...values.errors].slice(0, 4).join("\n"); + }; + + const save = async (): Promise => { + if (busy) return; + if (!sl.name.trim()) { + note.textContent = tp("NoName"); + return void name.input.focus(); + } + let problems: string[] = []; + try { + problems = M.ev.checkLogic(sl); + } catch { + problems = []; + } + const ask = tp("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); + if (problems.length && !(await showConfirmDialog(ask, tp("Save")))) return; + busy = true; + note.textContent = tp("Saving"); + const screen = values.result.계; + try { + const got = await M.api.slSave({ ...sl, outside: outsidePlaces(M, sl) }); + const server = serverTotal(got.logic); + sl = got.logic; + savedSl = sl; + stack = []; + notes = []; + redraw(); + const differs = server !== null && screen !== null && Math.abs(server - screen) > 0.5; + if (differs) notes = [tp("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; + note.textContent = [...notes, ...values.errors].join("\n"); + showToast(tp("Saved"), "success"); + } catch (e) { + note.textContent = tp("SaveFailed", { v: M.api.slPlain(e) }); + } finally { + busy = false; + } + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + sl = back; + name.input.value = sl.name; + unit.select.value = sl.resultUnit; + redraw(); + }; + + const doDelete = async (): Promise => { + if (!sl.key || busy) return; + if (!(await showConfirmDialog(tp("Delete_Ask", { v: sl.name }), tp("Delete")))) return; + try { + await M.api.slDelete(sl.key); + savedSl = sl; + showToast(tp("Deleted"), "success"); + ctx.onClose(); + } catch (e) { + note.textContent = M.api.slPlain(e); + } + }; + + const undo = createButton({ label: tp("Undo"), variant: "ghost", onClick: doUndo }); + const deleteButton = createButton({ + label: tp("Delete"), + variant: "danger", + onClick: () => void doDelete(), + }); + const close = createButton({ label: tp("Close"), variant: "ghost", onClick: ctx.onClose }); + + host.replaceChildren( + el("div", { + className: "m01-sl__strip", + children: [ + name.root, + unit.root, + unsaved, + el("div", { + className: "m01-sl__actions", + children: [ + undo, + createButton({ label: tp("Save"), onClick: () => void save() }), + deleteButton, + close, + ], + }), + ], + }), + el("p", { className: "m01-sl__trial-note", text: tp("Trial") }), + note, + el("div", { + className: "m01-sl__layout", + children: [ + el("section", { + className: "m01-sl__part m01-sl__diagram", + children: [el("h4", { text: tp("Diagram") }), diagramHost], + }), + el("section", { + className: "m01-sl__part m01-sl__panel", + children: [ + el("h4", { text: tp("Panel") }), + el("div", { className: "m01-sl__part-body", children: [toolsHost, panelHost] }), + ], + }), + el("section", { + className: "m01-sl__part m01-sl__result", + children: [el("h4", { text: tp("Result") }), resultHost], + }), + ], + }), + ); + redraw(); + if (!sl.name) name.input.focus(); + return { dirty: () => sl !== savedSl, save, undo: doUndo }; +} From 74d2ba25f8c50aaa3ee150e3b72c5cd0782a4535 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:27:35 +0900 Subject: [PATCH 16/38] =?UTF-8?q?chore(m01-sl):=20Model=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=A3=BC=EC=84=9D=EC=97=90=20=ED=91=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=95=AD=EB=AA=A9=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EC=95=BD=EC=86=8D=20=EB=91=90=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 표 블록 칸 차례 = 서버 _table 하나(범위는 조건 · 값 모두 이름·아래/위 · 숫자 하나면 같은 수 · 왼위 = 표 알약 칸) ② 항목 단가 바깥 값 주인 = Item itemOutsides · 같은 칸이면 그쪽이 이김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/SheetLogic/M01_SL_Model.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts index a9253d047..a25652913 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Model.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -5,6 +5,10 @@ * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 * * 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만 + * + * 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든 + * 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸 + * 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; From d9a57338655ae3fe564b9b00fc32c5dbccc976f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:27:48 +0900 Subject: [PATCH 17/38] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A8=20=EB=A1=9C=EC=A7=81=20=EC=9E=A5=EB=B9=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EA=B0=80=200=20=EC=9D=B4=EB=8D=98=20=ED=9D=A0=20?= =?UTF-8?q?=E2=80=94=20=EB=84=98=EA=B8=B8=20=EC=9E=85=EB=A0=A5=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EA=B3=84=EC=82=B0=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EB=B0=94=EA=B9=A5=20?= =?UTF-8?q?=EA=B0=92=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../SheetLogic/M01_SL_Entry_Paper.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index a5012a1b3..f53097022 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -32,7 +32,8 @@ export const loadMods = () => import("./M01_SL_Item"), import("./M01_SL_Result"), import("./M01_SL_Import"), - ]).then(([model, ev, api, diagram, panel, table, item, result, imp]) => ({ + import("../../A00_Common/spreadsheet/spreadsheet_recalc"), + ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({ model, ev, api, @@ -42,6 +43,7 @@ export const loadMods = () => item, result, imp, + recalc, })); export type Mods = Awaited>; @@ -109,7 +111,8 @@ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { if (node && c.source) put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); } - M.imp.outsideOf(sl).forEach(put); + // 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0) + M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put); return [...at.values()]; } @@ -167,6 +170,29 @@ export function showPaper( let values: SlEvalResult; let busy = false; let notes = ctx.notes; + /** 마지막으로 받은 바깥 값 물음 — 넘길 입력값이 바뀌면 다시 받음 */ + const sigOf = (x: SheetLogic): string => + JSON.stringify(outsidePlaces(M, x).map((p) => [p.sheet, p.cell, p.item])); + let outsideSig = sigOf(first); + let outsideTimer: ReturnType | undefined; + /** 바깥 값 다시 받기 — 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ + const refreshOutside = (): void => { + const sig = sigOf(sl); + if (sig === outsideSig) return; + outsideSig = sig; + clearTimeout(outsideTimer); + outsideTimer = setTimeout(() => { + const from = sl; + void fillOutside(M, from).then(({ sl: next, errors }) => { + if (sl !== from) return; // 그 사이 또 바뀜 — 다음 그리기가 다시 받음 + if (savedSl === from) savedSl = next; + sl = next; + outsideSig = sigOf(next); + notes = errors; + redraw(); + }); + }, 300); + }; const unsaved = createTag(tp("Unsaved"), "warning"); const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } }); @@ -267,6 +293,7 @@ export function showPaper( const redraw = (): void => { values = evalNow(); + refreshOutside(); unsaved.hidden = sl === savedSl; undo.disabled = !stack.length; deleteButton.hidden = !sl.key; From 892e156efec4bdeb2f64032f8b8ccd64542d576f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:28:25 +0900 Subject: [PATCH 18/38] =?UTF-8?q?fix(m01-sl):=20=ED=91=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EC=B9=B8=20=EC=B0=A8=EB=A1=80=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=ED=95=A8=EB=8C=80=EB=A1=9C=20=E2=80=94=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=EA=B0=80=20=EC=9E=88=EB=8A=94=20=EC=B9=B8=EC=9D=80=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=C2=B7=20=EA=B0=92=20=EB=AA=A8=EB=91=90=20=EB=91=90?= =?UTF-8?q?=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브레인 정함: 서버가 칸 차례 정본 · 조건 칸 → 값 칸 · 범위 조건 · 범위 값(「20~45」)은 「이름·아래」 「이름·위」 - 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸 · 블록 왼위 = 「표」 바깥 알약 칸 - f8d777628(값 칸 한 칸 · 「80,100」 글)을 되돌림 · 시험 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/M01_SL_Router.py | 25 ++++++++++------------ resources/tester/test_m01_sl_router.py | 25 ++++++++++------------ 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py index f9578f586..a47d9f48e 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -161,9 +161,10 @@ def _logic(key: str, inputs: dict, field: str | None) -> dict: def _table(key: str) -> dict: - """품셈 표 통째 → 머리 줄 + 줄 — `M01_SL_Table_Build.layoutOf · blockCells` 와 같은 열 차례. + """품셈 표 통째 → 머리 줄 + 줄 — 표 블록 칸 차례의 정본(브레인 정함 · 화면도 이것을 따름). - 조건 칸(범위 조건은 「이름·아래」 「이름·위」 두 칸) → 값 칸(늘 한 칸 · 범위 값은 「아래,위」). + 조건 칸 → 값 칸 · 범위가 있는 칸(범위 조건 · 「20~45」 같은 범위 값)은 「이름·아래」 「이름·위」 + 두 칸 · 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸. """ files = store.loaded()[0] table = next( @@ -178,25 +179,21 @@ def _table(key: str) -> dict: ) if table is None: return {"ok": False, "reason": f"없는 표 「{key}」"} - conds = table.get("조건") or {} + conds, lines = table.get("조건") or {}, table.get("줄") or [] names = [*conds, *(table.get("값칸") or {})] - split = {n for n in conds if conds[n] == "범위"} + split = { + n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) + } head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] - - def plain(v: Any) -> Any: # JS String(배열) 과 같게 — 「80,100」 · 빈 끝은 빈 글 - if isinstance(v, list): - return ",".join("" if x is None else str(x) for x in v) - return None if v == "" else v - rows = [] - for r in table.get("줄") or []: + for r in lines: line = [] for n in names: v = r.get(n) - if n in split: # 범위 칸 — 목록이 아니면 둘 다 빈 칸 - line += list(v) if isinstance(v, list) else [None, None] + if n in split: # 범위 칸 — 수 하나면 아래 · 위 같은 수 + line += list(v) if isinstance(v, list) else [v, v] else: - line.append(plain(v)) + line.append(None if v == "" else v if not isinstance(v, dict) else str(v)) rows.append(line) return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index e524e9f11..9a5e8c8cf 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -158,10 +158,12 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림) assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음 table = book["시트"][1]["칸"] - head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1")] - assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율"] # 머리 줄 · A1 부터 - assert "B2" not in table and table["C2"]["값"] == 3 and table["E2"]["값"] == "80,100" - assert "B3" not in table and "C3" not in table and table["D3"]["값"] == 0.7 # 범위 아님 → 빈 칸 + head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1", "F1")] + assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] # A1 부터 + assert ( + "B2" not in table and table["C2"]["값"] == 3 and table["F2"]["값"] == 100 + ) # 빈 끝은 빈 칸 + assert table["B3"]["값"] == table["C3"]["값"] == 4 and "E3" not in table # 수 하나 → 같은 수 assert saved["logic"]["edges"] == _logic(labor)["edges"] answers = {v["id"]: v for v in saved["values"]} assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all( @@ -174,7 +176,7 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, second = _post(client, "/api/m01/sl/save", {"logic": at_e1}) assert second["key"] == "SL-0002" moved = second["logic"]["workbook"]["시트"][1]["칸"] - assert moved["E1"]["값"] == "돌" and moved["I2"]["값"] == "80,100" and "A1" not in moved + assert moved["E1"]["값"] == "돌" and moved["J2"]["값"] == 100 and "A1" not in moved renamed = {**saved["logic"], "name": "고친 이름"} assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001" @@ -221,16 +223,11 @@ def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None: {"id": "z", "kind": "모름"}, ] got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} - assert got["t"]["rows"][0] == [ - "돌", - "높이·아래", - "높이·위", - "석공", - "증가율", - ] # sub3 layoutOf 차례 + # 정함: 조건 → 값 · 범위가 있는 칸(조건이든 값이든)은 두 칸 · 수 하나면 아래 · 위 같은 수 + assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] assert got["t"]["rows"][1:] == [ - ["깬돌", None, 3, 0.5, "80,100"], - ["견치돌", None, None, 0.7, None], + ["깬돌", None, 3, 0.5, 80, 100], + ["견치돌", 4, 4, 0.7, None, None], ] assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"] assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price From 679a759e840e8df42d3fbd5dc55d340d4cc8bb72 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:28:59 +0900 Subject: [PATCH 19/38] =?UTF-8?q?feat(m01-sl):=20=ED=91=9C=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EC=97=B4=20=EC=B0=A8=EB=A1=80=EB=A5=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=91=9C=20=EB=B8=94=EB=A1=9D=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=EA=B0=92=20=EC=B9=B8=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EB=8F=84=20=EC=95=84=EB=9E=98=20=C2=B7=20=EC=9C=84=20?= =?UTF-8?q?=EB=91=90=20=EC=B9=B8=20=C2=B7=20=EC=88=AB=EC=9E=90=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=8A=94=20=EA=B0=99=EC=9D=80=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/SheetLogic/M01_SL_Table.ts | 6 ++- .../SheetLogic/M01_SL_Table_Build.ts | 37 ++++++++++--------- resources/tester/test_m01_sl_table_build.py | 33 +++++++++++++++++ 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table.ts b/M01_MasterData/SheetLogic/M01_SL_Table.ts index 8d046d340..97a341af4 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table.ts @@ -165,7 +165,11 @@ async function loadInfo(key: string): Promise { /* ── 역할 · 알약 ── */ const rolesOf = (node: SlNode): TblRoles => ({ cond: node.pillsIn.map((p) => p.name), - out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name), + out: [ + ...new Set( + node.pillsOut.filter((p) => !p.outside).map((p) => p.name.replace(/·(아래|위)$/, "")), + ), + ], }); /** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */ diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts index 8f8984437..977fe606a 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -77,17 +77,20 @@ const at = (r: number, c: number): string => `${colName(c)}${r}`; const MARGIN = 20; const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; -/** 블록 열 차례 — 조건 칸 · 값 칸 차례 그대로 · 범위 칸은 둘로 */ +/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */ +export const isSplit = (info: TblInfo, c: string): boolean => + info.conds[c] === "범위" || (c in info.vals && info.rows.some((r) => Array.isArray(r[c]))); + +/** 블록 열 차례 — 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 둘로(서버 `_table` 과 같은 차례) */ export function layoutOf(info: TblInfo): TblCol[] { const out: TblCol[] = []; - for (const c of Object.keys(info.conds)) - if (info.conds[c] === "범위") + for (const c of [...Object.keys(info.conds), ...Object.keys(info.vals)]) + if (isSplit(info, c)) out.push( { header: `${c}·아래`, of: c, part: "아래" }, { header: `${c}·위`, of: c, part: "위" }, ); else out.push({ header: c, of: c, part: "값" }); - for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" }); return out; } @@ -106,13 +109,8 @@ export function blockCells(info: TblInfo): Record { info.rows.forEach((row, i) => cols.forEach((col, c) => { const v = row[col.of]; - const x = Array.isArray(v) - ? col.part === "아래" - ? v[0] - : v[1] - : col.part === "값" - ? v - : null; + // 범위 칸에 숫자 하나면 아래 · 위에 같은 수 + const x = Array.isArray(v) ? (col.part === "아래" ? v[0] : v[1]) : v; const cell = cellOf(x); if (cell) cells[at(i + 2, c)] = cell; }), @@ -258,10 +256,11 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { ); const outCells: Record = {}; for (const c of roles.out.filter((o) => o in info.vals)) { - outCells[c] = idxRow( - c, - `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`, - ); + for (const h of isSplit(info, c) ? [`${c}·아래`, `${c}·위`] : [c]) + outCells[h] = idxRow( + h, + `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(h))},${idx}),NA())`, + ); } return { cols, @@ -286,7 +285,7 @@ export function pillsOf( const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({ name, cell, - unit: info.vals[name] || undefined, + unit: info.vals[name.replace(/·(아래|위)$/, "")] || undefined, })); if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true }); return { pillsIn, pillsOut }; @@ -416,8 +415,10 @@ export function infoFromSheet(sheet: Sheet, prev: Pick Date: Thu, 1 Oct 2026 13:29:16 +0900 Subject: [PATCH 20/38] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A8=20=ED=91=9C=20=EB=B8=94=EB=A1=9D=20=EC=B9=B8=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=EB=A5=BC=20=EC=A0=95=ED=95=A8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EA=B0=92=20=EC=B9=B8=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EB=8F=84=20=EC=95=84=EB=9E=98=20=C2=B7=20=EC=9C=84=20?= =?UTF-8?q?=EB=91=90=20=EC=B9=B8=20=C2=B7=20=EC=88=AB=EC=9E=90=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A9=B4=20=EB=91=90=20=EC=B9=B8=EC=97=90=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 22 ++++++++++------- .../SheetLogic/M01_SL_Import_Parse.ts | 23 ++++++++++-------- resources/tester/test_m01_sl_import.py | 24 ++++++++++++++++++- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts index 93db36b6c..1b5639b87 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -98,8 +98,8 @@ const cellOf = (v: unknown): Cell | null => interface TableBox { node: SlNode; table: SlImportTable; - /** 결과 칸 이름 → 표 열 글자 */ - colOf: Map; + /** 결과 칸 이름 → 표 열 글자(범위 칸은 아래 · 위) */ + colOf: Map; rows: number; /** 다음 빈 열(맞음 · 결과 셈 칸) */ free: number; @@ -442,28 +442,32 @@ function newTable(b: Build, n: Extract, sig: string): TableB }; } } - const colOf = new Map(); - for (const c of cols) if (c.part === "v") colOf.set(c.key, colName(4 + cols.indexOf(c))); + const colOf = new Map(); + cols.forEach((c, j) => { + if (c.part === "hi") colOf.set(c.key, { ...colOf.get(c.key)!, hi: colName(4 + j) }); + else if (!colOf.has(c.key)) colOf.set(c.key, { x: colName(4 + j) }); + }); const box: TableBox = { node, table, colOf, rows: N, free, hit }; b.tables.set(sig, box); return box; } -/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A */ +/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A · + * 범위 값 칸은 아래 = 위(숫자 하나)일 때만 값 · 아니면 #N/A(엔진 「값이 범위」) */ function resultCol(b: Build, box: TableBox, col: string): void { const s = b.sheet(box.node); const N = box.rows; const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; - const X = box.colOf.get(col)!; + const { x: X, hi } = box.colOf.get(col)!; const W = colName(box.free++); s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` }; for (let r = 2; r <= N + 1; r++) s.칸[`${W}${r}`] = { 식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`, }; - b.out(box.node, col, { - 식: `IF(COUNTIF(${range(W)},1)=1,INDEX(${range(X)},MATCH(1,${range(W)},0)),NA())`, - }); + const at = (L: string): string => `INDEX(${range(L)},MATCH(1,${range(W)},0))`; + const value = hi ? `IF(${at(X)}=${at(hi)},${at(X)},NA())` : at(X); + b.out(box.node, col, { 식: `IF(COUNTIF(${range(W)},1)=1,${value},NA())` }); } /* ── 상자들 ─────────────────────────────────────────────────────────────── */ diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts index 851582d78..0bdcffee6 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -152,32 +152,35 @@ const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위" export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"]; -/** 표 칸 놓기 — 조건 칸 차례(범위는 아래 · 위 두 칸) → 값 칸 차례 */ +/** 표 칸 놓기(서버 `M01_SL_Router._table` 과 같음 · 정함) — 조건 칸 → 값 칸 차례 · 범위(조건 「범위」 · 값 칸에 + * 「20~45」 꼴이 한 줄이라도 있음)는 「이름·아래」 「이름·위」 두 칸 · 범위 칸에 숫자 하나면 두 칸에 같은 수 */ export function tableCols( table: SlImportTable, ): { key: string; head: string; part: "v" | "lo" | "hi" }[] { const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = []; - for (const [k, kind] of Object.entries(table.조건)) { - if (kind === "범위") { + const conds = Object.keys(table.조건); + for (const k of [...conds, ...Object.keys(table.값칸)]) { + const split = conds.includes(k) + ? table.조건[k] === "범위" + : table.줄.some((r) => Array.isArray(r[k])); + if (split) { out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" }); out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" }); } else out.push({ key: k, head: k, part: "v" }); } - for (const k of Object.keys(table.값칸)) - if (!(k in table.조건)) out.push({ key: k, head: k, part: "v" }); return out; } -/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 모양(`M01_SL_Api` 「표」)과 같음 */ +/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 「표」 답(`rows`)과 같음 */ export function tableRows(table: SlImportTable): (string | number | null)[][] { const cols = tableCols(table); const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => { - if (Array.isArray(v)) { - if (part === "v") return v.map((x) => x ?? "").join("~"); - const x = v[part === "lo" ? 0 : 1]; + if (part !== "v") { + const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v; return typeof x === "number" ? x : null; } - return typeof v === "number" || typeof v === "string" ? v : null; + if (Array.isArray(v)) return v.map((x) => x ?? "").join(","); + return typeof v === "number" || (typeof v === "string" && v !== "") ? v : null; }; return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; } diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py index 3187d11a3..750efad84 100644 --- a/resources/tester/test_m01_sl_import.py +++ b/resources/tester/test_m01_sl_import.py @@ -20,6 +20,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts" +PARSE = IMPORT.with_name("M01_SL_Import_Parse.ts") pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -66,7 +67,8 @@ def _node(body: str, payload: object) -> object: "import { readFileSync } from 'node:fs';\n" f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + STUB - + f"const I = await import({json.dumps(IMPORT.as_uri())});\n" + + f"const I = {{ ...(await import({json.dumps(IMPORT.as_uri())}))," + f" ...(await import({json.dumps(PARSE.as_uri())})) }};\n" "const A = JSON.parse(readFileSync(0, 'utf8'));\n" "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body ) @@ -275,3 +277,23 @@ def test_마스터_1469_가져오기_세기(master) -> None: f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}" ) assert len(full) > 1000 + + +def test_표_블록_칸_차례_정함() -> None: + """조건 칸 → 값 칸 · 범위(조건 「범위」 · 값 칸에 범위가 한 줄이라도)는 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸에 같은 수""" + table = { + "조건": {"높이": "범위", "구분": "고르기"}, + "값칸": {"증가율": "%", "단위": "글"}, + "줄": [ + {"높이": [3, 4], "구분": "가", "증가율": 30, "단위": "%"}, + {"높이": 5, "구분": "나", "증가율": [80, 100]}, + {"높이": [7.5, None], "증가율": 50}, + ], + } + rows = _node("out(I.tableRows(A));", table) + assert rows == [ + ["높이·아래", "높이·위", "구분", "증가율·아래", "증가율·위", "단위"], + [3, 4, "가", 30, 30, "%"], + [5, 5, "나", 80, 100, None], + [7.5, None, None, 50, 50, None], + ] From fdc06bbc2633deac442d643d3526a13e71382565 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:35:50 +0900 Subject: [PATCH 21/38] =?UTF-8?q?fix(m01-sl):=20=EC=84=A0=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=81=8A=EA=B8=B0=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=EB=8D=94=ED=95=98=EA=B8=B0/=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=92=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=EC=9D=B4=20=EC=98=9B=20=EA=B7=B8=EB=A6=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8D=AE=EB=8D=98=20=ED=9D=A0=20=E2=80=94=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=ED=8B=B1=EC=97=90=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index f53097022..592167476 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -217,6 +217,11 @@ export function showPaper( sl = structural ? M.item.refreshBases(next) : next; redraw(); }; + /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그리므로 다음 틱에 다시 그림 */ + const editFromDiagram = (next: SheetLogic): void => { + edit(next, true); + setTimeout(redraw); + }; const name = createInputField({ label: tp("Name"), @@ -242,18 +247,18 @@ export function showPaper( connect: (from: Parameters[1], to: typeof from): string | null => { const got = M.model.connect(sl, from, to); if (got.why) return got.why; - edit(got.sl, true); + editFromDiagram(got.sl); return null; }, - disconnect: (edgeId: string): void => edit(M.model.disconnect(sl, edgeId), true), + disconnect: (edgeId: string): void => editFromDiagram(M.model.disconnect(sl, edgeId)), add: (kind: SlKind): void => { const got = M.model.addNode(sl, kind, kindName(kind)); selected = got.node.id; - edit(got.sl, true); + editFromDiagram(got.sl); }, remove: (nodeId: string): void => { if (selected === nodeId) selected = null; - edit(M.model.removeNode(sl, nodeId), true); + editFromDiagram(M.model.removeNode(sl, nodeId)); }, }; From 5c1a5e14bc58bf937c84d5941a52dc8cf90ea5d1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:39:24 +0900 Subject: [PATCH 22/38] =?UTF-8?q?test(m01-sl):=20=ED=91=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EC=97=B4=EC=9D=B4=20=EC=84=9C=EB=B2=84=20=C2=B7=20?= =?UTF-8?q?Table=5FBuild=20=C2=B7=20Import=5FParse=20=EC=85=8B=20=EB=8B=A4?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견본 표(범위 조건 · 값 칸 범위 80~100 · 범위 칸에 수 하나)로 서버 /outside 표 답 = sub3 blockCells = sub6 tableRows Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- resources/tester/test_m01_sl_router.py | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index 9a5e8c8cf..c17cecb79 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -10,7 +10,9 @@ from __future__ import annotations import json +import re import shutil +import subprocess from decimal import Decimal from pathlib import Path @@ -23,6 +25,7 @@ from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store from M01_MasterData.SheetLogic import M01_SL_Router as sl +ROOT = Path(__file__).resolve().parents[2] REAL = store.FOLDER OWN = "로직_자체.json" SAMPLE = "QF999997" @@ -247,3 +250,57 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None: assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403 app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None} assert client.get("/api/m01/sl/list").json() == {"logics": []} + + +def _node_json(script: str) -> object: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None: + """서버 표 답(머리 줄 + 줄) = sub3 Table_Build `blockCells` = sub6 Import_Parse `tableRows`.""" + got = _post( + client, "/api/m01/sl/outside", {"items": [{"id": "t", "kind": "표", "table": SAMPLE}]} + ) + rows = got["values"][0]["rows"] + table = json.dumps( + {"조건": TABLE["조건"], "값칸": TABLE["값칸"], "줄": TABLE["줄"]}, ensure_ascii=False + ) + info = json.dumps( + {"conds": TABLE["조건"], "vals": TABLE["값칸"], "rows": TABLE["줄"]}, ensure_ascii=False + ) + + build = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Table_Build.ts").as_uri() + cells = _node_json( + f"import {{ blockCells }} from '{build}';console.log(JSON.stringify(blockCells({info})));" + ) + letters = "ABCDEFGHIJ" + mine = { + f"{letters[c]}{r + 1}": {"값": v} + for r, line in enumerate(rows) + for c, v in enumerate(line) + if v is not None + } + assert cells == mine + + src = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts").read_text(encoding="utf-8") + body = re.search(r"/\*\* 범위 칸 꼬리[\s\S]*?\nexport function tableRows[\s\S]*?\n}\n", src) + assert body, "Import_Parse 의 tableCols · tableRows 를 못 찾음" + script = ( + "const currentLanguageIndex = 0;\n" + + body.group(0).replace("export ", "") + + f"\nconsole.log(JSON.stringify(tableRows({table})));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == rows From 90f4e6ab2089e691002639dd2fa4df73ad356ef7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:42:50 +0900 Subject: [PATCH 23/38] =?UTF-8?q?fix(m01-sl):=20=EB=B0=94=EA=B9=A5=20?= =?UTF-8?q?=EA=B0=92=20=E2=80=94=20=EC=98=9B=20=EB=8B=B5=EC=9D=80=20?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=95=84=EC=A7=81=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=83=81=EC=9E=90=EB=8A=94=20?= =?UTF-8?q?=EB=AC=BB=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 592167476..43a42169b 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -103,6 +103,9 @@ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { const at = new Map(); const put = (p: SlOutsideAt): void => { const k = `${p.sheet}!${p.cell.toUpperCase()}`; + // 아직 고르지 않은 상자(빈 번호)는 묻지 않음 + const it = p.item as { key?: string; table?: string; element?: string }; + if (!(it.key ?? it.table ?? it.element)) return; if (!at.has(k)) at.set(k, p); }; M.item.itemOutsides(sl).forEach(put); @@ -184,7 +187,11 @@ export function showPaper( outsideTimer = setTimeout(() => { const from = sl; void fillOutside(M, from).then(({ sl: next, errors }) => { - if (sl !== from) return; // 그 사이 또 바뀜 — 다음 그리기가 다시 받음 + if (sl !== from) { + // 그 사이 또 바뀜 — 지금 것으로 다시 받음(옛 답 · 옛 까닭은 버림) + outsideSig = ""; + return refreshOutside(); + } if (savedSl === from) savedSl = next; sl = next; outsideSig = sigOf(next); From 2d64a76fc301e4fa7f7f7096b4adc531dd494b69 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:46:16 +0900 Subject: [PATCH 24/38] =?UTF-8?q?fix(m01-sl):=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EC=95=88=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=90=EC=9D=84=20=EC=88=A8=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=E2=80=94=20=EA=B0=88=EC=95=84=EB=81=BC=EC=9A=B0?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=20=EC=83=81=EC=9E=90=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20=EB=8B=A4=EC=8B=9C=20=EC=95=88=20=EB=9C=A8?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 43a42169b..942a536f3 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -204,7 +204,9 @@ export function showPaper( const unsaved = createTag(tp("Unsaved"), "warning"); const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } }); const diagramHost = el("div", { className: "m01-sl__part-body" }); + // 시트 판은 Panel 이 제 상태로 쥠 — 안을 갈아끼우지 않고 숨기기만(갈아끼우면 Panel 이 편집기가 있다고 여김) const panelHost = el("div"); + const pickHint = muted(tp("PickBox")); const toolsHost = el("div"); const resultHost = el("div", { className: "m01-sl__part-body" }); @@ -283,10 +285,14 @@ export function showPaper( const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { - panelHost.replaceChildren(muted(tp("PickBox"))); + panelHost.hidden = true; + pickHint.hidden = false; toolsHost.replaceChildren(); return; } + panelHost.hidden = false; + pickHint.hidden = true; + toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움 M.panel.renderPanel(panelHost, sl, node.id, { change: (next) => edit(next), pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), @@ -416,7 +422,10 @@ export function showPaper( className: "m01-sl__part m01-sl__panel", children: [ el("h4", { text: tp("Panel") }), - el("div", { className: "m01-sl__part-body", children: [toolsHost, panelHost] }), + el("div", { + className: "m01-sl__part-body", + children: [pickHint, toolsHost, panelHost], + }), ], }), el("section", { From 1fe49ad241a666a61e6a9aea8224492571c20c78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:15 +0900 Subject: [PATCH 25/38] =?UTF-8?q?feat(m01-sl):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=9E=90=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=90=EB=A6=AC(renderLogicTools=20=EA=B0=80=20?= =?UTF-8?q?=EC=98=AC=EB=9D=BC=EC=98=A4=EB=A9=B4=20=EB=B6=80=EB=A6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 942a536f3..7f73101d1 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -282,6 +282,10 @@ export function showPaper( }); }; + /* ponytail: 「다른 로직」 상자 손잡이(sub5 renderLogicTools)가 올라오기 전엔 없음 — 올라오면 바로 M.item.renderLogicTools 로 부름 */ + const logicTools = (M.item as { renderLogicTools?: typeof M.item.renderItemTools }) + .renderLogicTools; + const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { @@ -306,6 +310,13 @@ export function showPaper( loadPrices(node.id); }, }); + else if (node.kind === "logic" && logicTools) + logicTools(toolsHost, sl, node.id, { + change: (next) => { + edit(next, true); + loadPrices(node.id); + }, + }); else toolsHost.replaceChildren(); }; From ab514e8d105e9fb3d2fbed72f42e83f0018e816c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:20 +0900 Subject: [PATCH 26/38] =?UTF-8?q?fix(m01-sl):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=E2=80=94=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=20=EB=AC=B6=EC=9D=8C=EC=9C=BC=EB=A1=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EB=A5=BC=20=EC=B0=BE=EC=9D=8C(=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=97=94=EC=A7=84=20=EC=9E=AC=EB=A3=8C=20=EC=A4=84?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B8=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - {kind: "자재", key} 의 key 가 「{」 로 시작하는 JSON 글(또는 묶음)이면 {구분, 상세구분, 검색어, 규격?} 로 풀어 /calc 재료 줄처럼(검색어 · 자동값 차례 · 단위 환산) · 아니면 키 - unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 · 못 찾으면 쉬운 말(맞는 자재 없음 · 단가가 자료에 비어 있음) - Api SlQuery 에 unit? · 시험 — 검색어 묶음 단가 = /calc 그 줄 단가(글 · 묶음) · 저장 때도 같은 값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/M01_SL_Api.ts | 5 +- M01_MasterData/SheetLogic/M01_SL_Router.py | 46 +++++++++++++++-- resources/tester/test_m01_sl_router.py | 60 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts index 6f5917799..1a6dba9e2 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Api.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -17,8 +17,9 @@ import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model"; export type { SlField }; -/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` */ -export type SlQuery = { id: string } & SlSource; +/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` · 자재 `key` = 키 또는 검색어 묶음 JSON 글 + * (`{구분, 상세구분, 검색어, 규격?}`) · `unit` = 그 줄 단위(주면 기존 로직 재료 줄과 같은 단가) */ +export type SlQuery = { id: string; unit?: string } & SlSource; /** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */ export type SlOutside = SlQuery; diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py index a47d9f48e..fffc2daa1 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -97,7 +97,7 @@ def delete(key: str) -> dict: # ── 바깥 값 ──────────────────────────────────────────────────────────── # 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}` # 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`) -# 노임 · 자재 {kind, key} → 그 줄 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) +# 노임 · 자재 {kind, key, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음 # 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1) # 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움) RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음 @@ -131,8 +131,46 @@ def _money(got: dict, field: str | None) -> dict: return {"ok": True, "value": got["sums"][pick]} -def _price(kind: str, key: str) -> dict: - """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" +def _pick(key: Any) -> dict | None: + """자재 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None.""" + if isinstance(key, dict): + return key + if isinstance(key, str) and key.lstrip().startswith("{"): + try: + got = json.loads(key) + except ValueError: + return None + return got if isinstance(got, dict) else None + return None + + +def _material(cond: dict, unit: str) -> dict: + """검색어 묶음 자재 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산).""" + line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} + got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None) + if not got["ok"]: + return got + one = got["lines"][0] + if one["단가"] is None: + word = cond.get("검색어") or "" + empty = "값 없음" in str(one.get("까닭") or "") # 품목은 찾았는데 단가 칸이 빔 + why = ( + "단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름" + if empty + else "맞는 자재 없음" + ) + return {"ok": False, "reason": f"「{word}」 {why}"} + picked = one.get("품목") or {} + out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None} + return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})} + + +def _price(kind: str, key: Any, unit: str = "") -> dict: + """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음.""" + cond = _pick(key) if kind == "자재" else None + if cond is not None: + return _material(cond, unit) + key = str(key or "") try: brief = store.loaded()[1].get(key) except cm.mf.FormulaError as e: @@ -205,7 +243,7 @@ def one_outside(item: dict) -> dict: if kind == "표": got = _table(str(item.get("table") or "")) elif kind in ("노임", "자재"): - got = _price(kind, str(item.get("key") or "")) + got = _price(kind, item.get("key"), str(item.get("unit") or "")) elif kind == "장비": inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"} diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index c17cecb79..0d856b566 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -304,3 +304,63 @@ def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None: ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == rows + + +def _material_cond() -> tuple[dict, str]: + """값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음).""" + rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"] + row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분")) + cond = { + "구분": row["구분"], + "상세구분": row["상세구분"], + "검색어": f"{row['이름']} {row['규격'] or ''}".strip(), + } + return cond, row["단위"] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None: + cond, unit = _material_cond() + line = { + "종류": "재료", + "요소": cond, + "이름": "재료", + "단위": unit, + "수량": "1", + "비목": "재료비", + } + row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", + "입력": [], + "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip + want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] + assert want is not None + + text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글 + bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False) + items = [ + {"id": "s", "kind": "자재", "key": text, "unit": unit}, + {"id": "d", "kind": "자재", "key": cond, "unit": unit}, + {"id": "x", "kind": "자재", "key": bad, "unit": unit}, + ] + got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} + assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"] + assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음" + + # 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록) + logic = { + "key": None, + "name": "자재 견본", + "resultUnit": "원/㎡", + "workbook": { + "종류": "통합문서", + "판": 1, + "서식": [{}], + "시트": [{"id": "s1", "이름": "재료", "칸": {}}], + }, + "nodes": [], + "edges": [], + "outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}], + } + saved = _post(client, "/api/m01/sl/save", {"logic": logic}) + assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want + _post(client, "/api/m01/sl/delete", {"key": saved["key"]}) From 2c848702ea3652bf0ca6106b5796ec4b22fc5009 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:46 +0900 Subject: [PATCH 27/38] =?UTF-8?q?fix(m01-sl):=20=ED=91=9C=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=ED=91=9C=20=EB=B2=88=ED=98=B8=20=EC=95=88?= =?UTF-8?q?=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=E2=80=94=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=ED=8C=90?= =?UTF-8?q?=20=EB=A8=B8=EB=A6=AC=EB=A5=BC=20=ED=91=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=ED=91=9C=20=EC=A4=84=20=EC=88=98?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../SheetLogic/M01_SL_Table_Build.ts | 57 ++++++++++++++----- resources/tester/test_m01_sl_table_build.py | 46 ++++++++++++++- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts index 977fe606a..3ec4d3f50 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -1,12 +1,12 @@ /* ============================================================================= * M01_SL_Table_Build.ts - * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 + * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` * * 시트 모양(표 상자 시트 하나): * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 - * 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로) - * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) + * 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로) + * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A * ========================================================================== */ @@ -58,7 +58,7 @@ export interface TblPlan { outCells: Record; idxCell: string; statusCell: string; - /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */ + /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */ helperFrom: number; warnings: string[]; } @@ -74,7 +74,8 @@ const colIndex = (letters: string): number => [...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; const at = (r: number, c: number): string => `${colName(c)}${r}`; -const MARGIN = 20; +/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */ +const marginOf = (n: number): number => Math.max(20, n); const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; /** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */ @@ -148,16 +149,16 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); const rule = info.rule ?? "초과·이하"; const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); - const R = (range ? n : n + MARGIN) + 1; + const R = n + marginOf(n) + 1; const rng = (L: string): string => `${L}$2:${L}$${R}`; - // 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 + // 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 const variants: string[][] = [[]]; if (!range) { const bl = blankable.slice(0, 2); const subsets = bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; - variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음 + variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음 } const keyCols = exact.length ? variants : []; const kStart = W + 1; @@ -166,7 +167,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const V = L + 1; const cells: Record = {}; keyCols.forEach((skip, v) => { - cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" }; + cells[at(1, kStart + v)] = { + 값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건", + }; const rest = exact.filter((c) => !skip.includes(c)); for (let r = 2; r <= R; r++) { const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&'); @@ -183,7 +186,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { // 찾기 판 let row = 1; - cells[at(row++, L)] = { 값: "표에서 찾기" }; + cells[at(row++, L)] = { + 값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "), + }; const inCells: Record = {}; const inputOf: Record = {}; for (const c of conds) { @@ -194,6 +199,14 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { inputOf[c] = at(row, V); row++; } + // 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감) + const rowsCell = ((): string => { + cells[at(row, L)] = { 값: "표 줄 수" }; + cells[at(row, V)] = { + 식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`, + }; + return at(row++, V); + })(); const keyOf = (skip: string[]): string => exact .filter((c) => !skip.includes(c)) @@ -210,10 +223,10 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { let idx = ""; let count = ""; if (exact.length) { - const keyCell = idxRow("열쇠", keyOf([])); + const keyCell = idxRow("합친 조건", keyOf([])); const ks = keyCols.map((skip, v) => { if (v === 0) return keyCell; - cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` }; + cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` }; cells[at(row, V)] = { 식: keyOf(skip) }; return at(row++, V); }); @@ -224,7 +237,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { ); count = idxRow( "일치 수", - `IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, + `IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, ); } else { const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); @@ -242,7 +255,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const inside = lowBased ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`; - count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`); + count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`); } else { warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); count = idxRow("일치 수", "0"); @@ -294,6 +307,21 @@ export function pillsOf( const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => sl.workbook.시트.findIndex((s) => s.id === node.sheetId); +/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */ +function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void { + if (sheet.이름 === want) return; + const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름)); + let name = want; + for (let i = 2; taken.has(name); i++) name = `${want} (${i})`; + const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`; + const old = sheet.이름; + const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])]; + for (const s of sl.workbook.시트) + for (const cell of Object.values(s.칸)) + if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`); + sheet.이름 = name; +} + /** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ export function applyTable( sl: SheetLogic, @@ -331,6 +359,7 @@ export function applyTable( !(e.to.node === nodeId && gone("in", e.to.pill)), ); node.name = `표 · ${info.name}`; + renameSheet(next, sheet, node.name); node.ref = opts.ref; node.pillsIn = pills.pillsIn; node.pillsOut = pills.pillsOut; diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py index a1f6eb1df..f353c54e4 100644 --- a/resources/tester/test_m01_sl_table_build.py +++ b/resources/tester/test_m01_sl_table_build.py @@ -31,11 +31,21 @@ const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = jobs.map((job) => { if (job.op === "plan") { const plan = m.planLookup(job.info, job.roles); - const cells = { ...m.blockCells(job.info), ...plan.cells }; + const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells }; for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; return { cells, plan }; } if (job.op === "paste") return m.parsePaste(job.text); + if (job.op === "rename") { + const sl = { key: null, name: "t", resultUnit: "", edges: [], + workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [ + { id: "s1", 이름: "표", 칸: {} }, + { id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] }, + nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; + const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl; + const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? "")); + return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts }; + } if (job.op === "reapply") { const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, @@ -230,3 +240,37 @@ def test_블록_머리_차례(): "폭·아래", "폭·위", ] + + +def test_표가_늘어도_찾기_범위가_따라감(): + """마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다).""" + small = {**RATE, "rows": RATE["rows"][:2]} + extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)] + grown = {**small, "rows": [*small["rows"], *extra]} + roles = {"cond": ["높이"], "out": ["증가율"]} + plan = run( + [{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}] + )[0] + book = { + "종류": "통합문서", + "판": 1, + "열": "t", + "서식": [{}], + "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], + "활성": "s1", + } + values = recalc_workbooks([book])[0]["s1"] + assert values[plan["plan"]["statusCell"]]["글"] == "찾음" + assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72 + + +def test_표_상자에_표_번호가_안_보임_이름으로_보임(): + info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"} + roles = {"cond": list(info["conds"]), "out": ["석공"]} + out = run([{"op": "rename", "info": info, "roles": roles}])[0] + assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"] + assert out["ref"] == "QF000421" # 번호는 상자 ref 에만 + assert "QF000421" not in " ".join(out["texts"]) + assert any("13-4-1" in t and "1㎡" in t for t in out["texts"]) + assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2" + assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1" From e5955b705d8c9a018017c3ea2d2f77704d44e3eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:55:04 +0900 Subject: [PATCH 28/38] =?UTF-8?q?fix(m01-sl):=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=EC=9D=B4=EC=9D=8C=EC=9D=80=20=EC=95=84?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=EC=9E=90=EB=A7=8C=20=C2=B7=20=EC=86=90?= =?UTF-8?q?=EC=84=A0=20=EB=B3=B4=EC=A1=B4,=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B0=92/=EB=8B=A8=EC=9C=84,=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=86=90=EC=9E=A1=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄 - 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기 - 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/SheetLogic/M01_SL_Item.ts | 61 ++-- .../SheetLogic/M01_SL_LogicTools.css | 87 +++++ .../SheetLogic/M01_SL_LogicTools.ts | 297 ++++++++++++++++++ M01_MasterData/SheetLogic/M01_SL_Result.ts | 33 +- 4 files changed, 445 insertions(+), 33 deletions(-) create mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts index 9161771de..38337c9ac 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Item.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Item.ts @@ -10,7 +10,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { LogicInput } from "../M01_MasterData_UI_Logic_Api"; import { searchElements } from "../M01_MasterData_UI_Logic_Api"; @@ -18,7 +17,7 @@ import { formatPrice } from "../M01_MasterData_UI_Logic_Money"; import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model"; import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api"; -import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; +import { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; import "./M01_SL_Item.css"; const TEXT = { @@ -265,6 +264,16 @@ const FIELD_CELL: Record = { }; /** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ +/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */ +export function applyItemLayout( + sl: SheetLogic, + node: SlNode, + ref: ItemRef, + qty?: number | null, +): SheetLogic { + return applyLayout(sl, node, ref, qty); +} + function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic { const sheet = sheetOf(sl, node); const oldRef = itemRefOf(node); @@ -299,39 +308,35 @@ function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | }; } -/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */ +/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함 + * · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음 + * · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */ export function refreshBases(sl: SheetLogic): SheetLogic { let out = sl; for (const node of sl.nodes) { const ref = itemRefOf(node); - if (!ref || ref.what !== "비율") continue; + if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue; const base = ref.base ?? "노무비"; - const sources = out.nodes.filter((n) => { + const known = (n: SlNode): boolean => { const r = itemRefOf(n); - return n.kind === "item" && r && r.what !== "비율"; - }); - const refs = sources - .map((n) => { - const s = sheetOf(out, n); - return s ? `${quoteSheet(s.이름)}!${costCell(itemRefOf(n)!, base)}` : ""; - }) - .filter(Boolean); - const formula = refs.length ? refs.join("+") : "0"; - out = editSheet(out, node, (cells) => { - cells[RATE.base] = refs.length ? calc(formula) : num(0); - }); - const mine = (id: string): boolean => id.startsWith(`e-base-${node.id}-`); - out = { - ...out, - edges: [ - ...out.edges.filter((e) => !mine(e.id)), - ...sources.map((n) => ({ - id: `e-base-${node.id}-${n.id}`, - from: { node: n.id, pill: base }, - to: { node: node.id, pill: PILL_BASE }, - })), - ], + return n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율"; }; + for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) { + const from = out.nodes.find((n) => n.id === e.from.node); + if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill)) + out = disconnect(out, e.id); + } + for (const src of out.nodes.filter(known)) { + const there = out.edges.some( + (e) => + e.from.node === src.id && + e.from.pill === base && + e.to.node === node.id && + e.to.pill === PILL_BASE, + ); + if (!there) + out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl; + } } return out; } diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.css b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css new file mode 100644 index 000000000..b8392d2e8 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css @@ -0,0 +1,87 @@ +.m01-sllogic__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-sllogic__chosen { + margin: var(--spacing-4) 0; + color: var(--color-text-body); + font-weight: 600; +} + +.m01-sllogic__search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-8) 0; +} + +.m01-sllogic__input { + box-sizing: border-box; + width: 200px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-sllogic__input[readonly] { + background: var(--color-surface); +} + +.m01-sllogic__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 240px; + margin: 0 0 var(--spacing-8); + padding: 0; + overflow: auto; + list-style: none; +} + +.m01-sllogic__list .ui-btn { + max-width: 100%; + text-align: left; + white-space: normal; +} + +.m01-sllogic__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; + color: var(--color-text-body); +} + +.m01-sllogic__fields > strong, +.m01-sllogic__fields > .m01-sllogic__muted { + flex-basis: 100%; +} + +.m01-sllogic__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-sllogic__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sllogic__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts new file mode 100644 index 000000000..0ddf8c2b2 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts @@ -0,0 +1,297 @@ +/* ============================================================================= + * M01_SL_LogicTools.ts + * 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움 + * 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약 + * 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과 + * 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦) + * — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호. + * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import type { Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { slPlain } from "./M01_SL_Api"; +import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; +import "./M01_SL_LogicTools.css"; + +const TEXT = { + Title: ["다른 로직", "Other logic"], + Search: ["로직 이름 찾기", "Find a logic by name"], + Find: ["찾기", "Find"], + None: ["아직 고르지 않았어요", "Nothing chosen yet"], + Chosen: ["고른 로직", "Chosen logic"], + NoHit: ["찾은 로직이 없어요", "No logic found"], + More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"], + Loading: ["가져오는 중", "Loading"], + Inputs: ["넘길 값", "Passed values"], + NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"], + Linked: ["다른 상자에서 이어진 값", "Value from another box"], + Pick: ["고르세요", "Choose"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Hint: [ + "결과는 바깥 값 — 열 때 · 저장 때 넣어요", + "Result is an outside value — filled on open and save", + ], + Result: ["결과", "Result"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Sum: ["계", "Total"], + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Pass: ["넘길 값", "Passed value"], + Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], +} as const; + +type TextKey = keyof typeof TEXT; +const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const LIMIT = 30; + +/** 로직 결과가 돈인가 */ +const isMoney = (unit: string): boolean => unit.startsWith("원"); + +/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */ +export function applyLogic( + sl: SheetLogic, + nodeId: string, + row: LogicRow, + trial: Record = {}, +): SheetLogic { + const node = sl.nodes.find((n) => n.id === nodeId); + if (!node) return sl; + if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로 + // 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리) + let work = sl; + for (const e of sl.edges) + if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id); + for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) { + const got = renameNode(work, nodeId, name); + if (!got.why) { + work = got.sl; + break; + } + } + const cells: Record = { + A1: { 값: tt("Name") }, + B1: { 값: tt("Value") }, + C1: { 값: tt("Note") }, + }; + const pillsIn: SlPill[] = []; + const pillsOut: SlPill[] = []; + let r = 2; + for (const spec of row.입력 ?? []) { + const at = `B${r}`; + cells[`A${r}`] = { 값: spec.이름 }; + const v = trial[spec.이름]; + if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v }; + cells[`C${r}`] = { 값: tt("Pass") }; + pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) }); + r++; + } + const unit = row.결과단위 ?? ""; + const money = isMoney(unit); + const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")]; + for (const name of outs) { + const at = `B${r}`; + cells[`A${r}`] = { 값: name }; + cells[`C${r}`] = { 값: tt("Outside") }; + pillsIn.push({ name, cell: at, outside: true }); + pillsOut.push({ name, cell: at, unit: money ? "원" : unit }); + r++; + } + const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s)); + return { + ...work, + workbook: { ...work.workbook, 시트 }, + nodes: work.nodes.map((n) => { + if (n.id !== nodeId) return n; + const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 }; + delete next.source; + return next; + }), + }; +} + +/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */ +const rows = new Map(); + +/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */ +export function renderLogicTools( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: { change(sl: SheetLogic): void }, +): void { + const node = sl.nodes.find((n) => n.id === nodeId); + const msg = el("p", { className: "m01-sllogic__msg" }); + if (!node) { + host.replaceChildren(); + return; + } + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + const results = el("ul", { className: "m01-sllogic__list" }); + const fieldsHost = el("div", { className: "m01-sllogic__fields" }); + const box = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") }, + }); + + const emit = (make: () => SheetLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const choose = async (item: EasyListItem): Promise => { + msg.textContent = tt("Loading"); + try { + const opened = await openEasy(item.key); + rows.set(item.key, opened.row); + msg.textContent = ""; + on.change(applyLogic(sl, node.id, opened.row, opened.trial)); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const search = async (): Promise => { + msg.textContent = tt("Loading"); + try { + const found = await listEasy(box.value.trim()); + msg.textContent = ""; + results.replaceChildren( + ...(found.length + ? found.slice(0, LIMIT).map((it) => { + const b = createButton({ + label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`, + variant: "ghost", + size: "sm", + onClick: () => void choose(it), + }); + return el("li", { children: [b] }); + }) + : [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]), + ...(found.length > LIMIT + ? [el("li", { className: "m01-sllogic__muted", text: tt("More") })] + : []), + ); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + box.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") void search(); + }); + + /** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */ + const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => { + const cur = sheet?.칸[pill.cell]; + const linked = cur?.식 !== undefined; + const put = (v: number | string): void => + emit(() => ({ + ...sl, + workbook: { + ...sl.workbook, + 시트: sl.workbook.시트.map((s) => + s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s, + ), + }, + })); + const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); + let control: HTMLElement; + if (spec?.고르기?.length && !linked) { + control = createSelectField({ + options: [ + { value: "", text: tt("Pick") }, + ...spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + ], + value: cur?.값 === undefined ? "" : String(cur.값), + compact: true, + ariaLabel: pill.name, + onChange: (v) => { + const hit = spec.고르기?.find((o) => String(o) === v); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const input = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, + }); + if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; + input.value = linked || cur?.값 === undefined ? "" : String(cur.값); + input.readOnly = linked; + input.addEventListener("change", () => { + const raw = input.value.trim(); + const n = Number(raw); + if (raw === "") return; + if (Number.isNaN(n)) msg.textContent = tt("Bad"); + else put(n); + }); + control = input; + } + return el("label", { + className: "m01-sllogic__field", + children: [ + label, + control, + ...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []), + ], + }); + }; + + const drawFields = (row: LogicRow | undefined): void => { + const passed = node.pillsIn.filter((p) => !p.outside); + fieldsHost.replaceChildren( + el("strong", { text: tt("Inputs") }), + ...(passed.length + ? passed.map((p) => + fieldFor( + p, + row?.입력?.find((i) => i.이름 === p.name), + ), + ) + : [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]), + el("p", { className: "m01-sllogic__muted", text: tt("Hint") }), + ); + }; + + host.replaceChildren( + el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }), + el("p", { + className: "m01-sllogic__chosen", + text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"), + }), + el("div", { + className: "m01-sllogic__search", + children: [ + box, + createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }), + ], + }), + results, + ...(node.ref ? [fieldsHost] : []), + msg, + ); + if (node.ref) { + const known = rows.get(node.ref); + drawFields(known); + if (!known) { + void openEasy(node.ref).then( + (o) => { + rows.set(o.row.키, o.row); + drawFields(o.row); + }, + () => undefined, + ); + } + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts index 213fdd077..07c04fa7c 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Result.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Result.ts @@ -24,6 +24,7 @@ import "./M01_SL_Result.css"; const TEXT = { Per: ["{per} 당", "per {per}"], Total: ["합계", "Total"], + Value: ["결과값", "Result value"], Won: ["원", "KRW"], Labor: ["노무비", "Labor cost"], Material: ["재료비", "Material cost"], @@ -47,6 +48,7 @@ const tr = (key: TextKey, fill: Record = {}): string => TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); const COSTS = ["노무비", "재료비", "경비"] as const; +const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => @@ -157,13 +159,34 @@ export function renderResult( values: SlEvalResult, on: { change(sl: SheetLogic): void }, ): void { + const money = !sl.resultUnit || sl.resultUnit.startsWith("원"); const { 계 } = values.result; - const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; + /** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */ + const plainValue = (): number | null => { + const box = sl.nodes.find((n) => n.kind === "result"); + const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : []; + const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find( + (v) => typeof v === "number" && Number.isFinite(v), + ); + return typeof first === "number" ? first : null; + }; + const perUnit = sl.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? tr("Total") + : tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const shown = money ? 계 : plainValue(); const big = el("p", { className: "m01-slresult__big", children: [ - el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }), - el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }), + el("span", { text: money ? perText : tr("Value") }), + el("strong", { + text: + shown === null + ? "—" + : money + ? `${formatMoney(shown)} ${tr("Won")}` + : `${num(shown)} ${sl.resultUnit}`.trim(), + }), ], }); const costs = el("dl", { @@ -191,7 +214,7 @@ export function renderResult( middle: {}, }; const sheetHost = el("div", { className: "m01-slresult__sheet" }); - renderSheet(sheetHost, head, ans); + if (money) renderSheet(sheetHost, head, ans); const msg = el("p", { className: "m01-slresult__msg" }); const put = @@ -225,7 +248,7 @@ export function renderResult( : []; host.replaceChildren( big, - costs, + ...(money ? [costs] : []), ...strip, ...(problems.length ? [ From 12ec81be0815acb94601e1ebb64082a165f13bdf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:58:06 +0900 Subject: [PATCH 29/38] =?UTF-8?q?feat(m01-sl):=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=EC=99=80=20=EC=84=A0=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=84=B8=20=EC=B9=B8=EC=9D=84=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=86=92=EC=9D=B4=EB=A7=8C=ED=81=BC=20=E2=80=94=20?= =?UTF-8?q?=EC=95=88=EC=AA=BD=20=EC=84=B8=EB=A1=9C=20=EB=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EC=97=86=EC=95=B0(=EC=B0=BD=EB=A7=8C=20=EB=B0=88)=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=ED=8C=90=20=EB=86=92=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EC=A4=84=20+=20=EB=91=90=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 6 +++--- M01_MasterData/SheetLogic/M01_SL_Entry.css | 15 +++++---------- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 13 +++++++++++++ M01_MasterData/SheetLogic/M01_SL_Panel.css | 2 ++ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css index 22c75a9ab..e02858f66 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.css +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.css @@ -52,10 +52,10 @@ display: none; } -/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */ +/* 띠 — 세로는 상자 높이만큼 다 보임(사용자 요청) · 길면 가로로 밀기 */ .m01-sldg__band { - max-height: 33vh; - overflow: auto; + overflow-x: auto; + overflow-y: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css index 5f6cd2c1a..0e04f2724 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.css +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -8,9 +8,9 @@ font-size: var(--text-body); } +/* 세 칸은 내용 높이만큼 — 넘치면 창(모달) 전체만 세로로 밈 */ .m01-sl .ui-modal__body { - flex: 1; - min-height: 0; + flex: 0 0 auto; gap: var(--spacing-12); } @@ -111,11 +111,10 @@ /* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */ .m01-sl__layout { - flex: 1; - min-height: 0; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr); + grid-template-rows: auto auto; + align-items: start; grid-template-areas: "diagram diagram" "panel result"; @@ -137,7 +136,6 @@ .m01-sl__part { display: flex; flex-direction: column; - min-height: 0; border: 1px solid var(--color-border); border-radius: var(--radius-lg); } @@ -149,9 +147,7 @@ } .m01-sl__part-body { - flex: 1; - min-height: 0; - overflow: auto; + min-width: 0; padding: var(--spacing-12); } @@ -164,7 +160,6 @@ "diagram" "panel" "result"; - overflow: auto; } } diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 7f73101d1..ca3b354a0 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -139,6 +139,18 @@ export async function fillOutside( return { sl: M.ev.applyOutside(sl, values), errors }; } +/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청) + * ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */ +const ROW_PX = 20; +const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대 +function sheetHeight(sheet: { 칸: Record } | null): number { + const rows = Object.keys(sheet?.칸 ?? {}).reduce( + (most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0), + 0, + ); + return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX; +} + /** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */ function serverTotal(sl: SheetLogic): number | null { const box = sl.nodes.find((n) => n.kind === "result"); @@ -297,6 +309,7 @@ export function showPaper( panelHost.hidden = false; pickHint.hidden = true; toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움 + panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`); M.panel.renderPanel(panelHost, sl, node.id, { change: (next) => edit(next), pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.css b/M01_MasterData/SheetLogic/M01_SL_Panel.css index d67df74d1..cda9d83ea 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Panel.css +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.css @@ -54,6 +54,8 @@ .m01-slpanel-sheet { position: relative; min-height: 260px; + /* 그 시트가 쓰는 줄 수만큼(Entry 가 --sl-sheet-h 를 정함) — 안쪽 세로 밀기 없음(사용자 요청) */ + height: var(--sl-sheet-h, 260px); border: 1px solid var(--color-border); border-radius: var(--radius-cards); overflow: hidden; From 8b07250aa049f634d53dcddc6f599ad41e13b217 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:01:08 +0900 Subject: [PATCH 30/38] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=E2=80=94=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=9E=90=20=3D=20Item=20?= =?UTF-8?q?=ED=8B=80(applyItemLayout)=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=3D=20LogicTools=20=ED=8B=80=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=EC=A4=84=20=EC=88=98=20=EB=94=B0=EB=9D=BC=EA=B0=90?= =?UTF-8?q?=20=C2=B7=20=EC=9E=90=EC=9E=AC=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 상자: applyItemLayout 로 B1 수량 · B2~B4 단가 · B6 금액 · B7~B9 비목 · node.ref = ItemRef · 덧줄 「비목 × 율 / 100」 = 비율 틀 · refreshBases - 결과 상자: Model 결과 틀(B2~B4 노무비 · 재료비 · 경비 입력 알약 · 선이 여럿이면 + · B5 계) - 다른 로직 상자: LogicTools 와 같은 틀(ref = 로직 번호 · 넘길 값 칸 · 돈 로직은 노무비 · 재료비 · 경비 · 계 · 단계 1) - 표 상자: 「표」 알약 E1 만 · 맞음 칸 = 표 줄 수 이상 · 최소 20 줄 + 「표 줄 수」 칸 - 자재 검색어 묶음: 바깥 값 key = 묶음 JSON 글 · 상자 이름 = 직종 · 로직 · 표 이름(names) - 시험: 6개 /calc 같은 값(끝수 원 미만) · 1,469 중 1,370 다 바뀜 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 277 +++++++++--------- .../SheetLogic/M01_SL_Import_Parse.ts | 89 +++++- resources/tester/test_m01_sl_import.py | 56 +++- 3 files changed, 273 insertions(+), 149 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts index 1b5639b87..fe9963b40 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -19,15 +19,15 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; import type { - CalcValues, Cell, + DataValidationKind, Sheet, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { readLogic } from "../LogicBuilder/M01_LB_Blocks"; import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model"; -import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item"; import { parseFormula, RULES, @@ -64,6 +64,7 @@ const TEXT = { Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"], FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"], Parse: ["읽을 수 없는 글", "Text that can't be read"], + PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], } as const; const t = (k: keyof typeof TEXT, v = ""): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); @@ -75,6 +76,8 @@ export interface SlImportOptions { tables?: Record; /** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */ examples?: Record; + /** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */ + names?: Record; } export interface SlImportReport { @@ -120,20 +123,26 @@ class Build { readonly tables = new Map(); readonly logics = new Map(); readonly consts = new Map(); + readonly specs = new Map(); + /** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */ + readonly names = new Map(); - constructor(readonly opts: SlImportOptions) {} + constructor(readonly opts: SlImportOptions) { + for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v); + } - node(kind: SlKind, name: string, stage: number, ref?: string): SlNode { + /** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */ + node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode { const n = this.nodes.length + 1; - const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore - const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31); + const word = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore + const base = kind === "result" ? t("Result") : `${t(word)} · ${name}`.slice(0, 31); let title = base; for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++) title = `${base.slice(0, 28)} ${k}`; const sheet: Sheet = { id: `s${n}`, 이름: title, - 칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } }, + 칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {}, }; this.book.시트.push(sheet); this.sheets.set(sheet.id, sheet); @@ -306,7 +315,7 @@ function put(sc: Scope, label: string, text: string, pill: "out" | null, note = function constBox(b: Build, key: string): SlNode { const had = b.consts.get(key); if (had) return had; - const node = b.node("const", key, 0, key); + const node = b.node("const", b.names.get(key) ?? key, 0, key); const at = b.row(node, t("Price"), {}, t("Outside")); b.pill(node, "in", { name: t("Price"), cell: at, outside: true }); b.pill(node, "out", { name: t("Price"), cell: at }); @@ -340,16 +349,17 @@ function logicBox(b: Build, n: Extract, field?: string): Sl let node = b.logics.get(sig); if (!node) { if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head)); - node = b.node("logic", n.head, 2, n.head); + node = b.node("logic", b.names.get(n.head) ?? n.head, 1, n.head); args(b, node, n.args); b.logics.set(sig, node); } - const name = field ?? t("Result"); - if (!node.pillsOut.some((p) => p.name === name)) { - const at = b.row(node, name, {}, t("Outside")); - b.pill(node, "in", { name, cell: at, outside: true }); - b.pill(node, "out", { name, cell: at }); - } + const money = ["노무비", "재료비", "경비", "계"]; + for (const name of field && money.includes(field) ? money : [field ?? t("Result")]) + if (!node.pillsOut.some((p) => p.name === name)) { + const at = b.row(node, name, {}, t("Outside")); + b.pill(node, "in", { name, cell: at, outside: true }); + b.pill(node, "out", { name, cell: at }); + } return node; } @@ -374,7 +384,7 @@ function newTable(b: Build, n: Extract, sig: string): TableB for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k)); if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule)) throw new Fail(t("Rule", String(table.범위규칙))); - const node = b.node("table", table.이름 || n.head, 1, n.head); + const node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head); const s = b.sheet(node); // 조건 알약 — 받는 값은 넘긴 식 const cond = new Map(); @@ -383,7 +393,6 @@ function newTable(b: Build, n: Extract, sig: string): TableB // 표 E1 부터 const cols = tableCols(table); const rows = tableRows(table); - b.row(node, t("Table"), { 값: n.head }, t("Outside")); // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 b.pill(node, "out", { name: "표", cell: "E1", outside: true }); rows.forEach((r, i) => @@ -394,8 +403,13 @@ function newTable(b: Build, n: Extract, sig: string): TableB ); const letter = (key: string, part: "v" | "lo" | "hi"): string => colName(4 + cols.findIndex((c) => c.key === key && c.part === part)); - const N = table.줄.length; + // 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음 + const N = Math.max(20, table.줄.length); const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + const count = b.row(node, "표 줄 수", { + 식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`, + }); + const last = `$B$${count.slice(1)}`; let free = 4 + cols.length + 1; // 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로) const M = colName(free++); @@ -424,7 +438,8 @@ function newTable(b: Build, n: Extract, sig: string): TableB : `OR(${H}="",${x}<=${H})`; return `AND(OR(${L}="",${x}${low}${L}),${up})`; }); - s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" }; + const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1"; + s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` }; } // 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`) let hit = M; @@ -484,27 +499,14 @@ function inputBox(b: Build, spec: LogicInput): void { const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? ""); const r = Number(at.slice(1)) - 1; const whole = { r0: r, c0: 1, r1: r, c1: 1 }; - if (spec.고르기?.length) - b.sheet(node).유효성 = [ - { - id: "v1", - 범위: [whole], - 규칙: { 종류: "목록", 값들: spec.고르기.map(String) }, - 어기면: "막음", - }, - ]; - else if (spec.범위) - b.sheet(node).유효성 = [ - { - id: "v1", - 범위: [whole], - 규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] }, - 어기면: "막음", - }, - ]; + const rule: DataValidationKind | null = spec.고르기?.length + ? { 종류: "목록", 값들: spec.고르기.map(String) } + : spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore + if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }]; const pill = node.pillsOut[0]; if (spec.단위) pill.unit = spec.단위; b.inputs.set(spec.이름, node); + b.specs.set(spec.이름, spec); } /** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */ @@ -519,54 +521,90 @@ function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | numbe return [node, label]; } -/** 항목 상자(호표 줄) → 비목 출력 알약들 */ +/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */ +function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void { + const sheet = b.sheet(node); + const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore + const got = applyItemLayout(mini, node, ref, qty); + sheet.칸 = got.workbook.시트[0].칸; + const 유효성 = got.workbook.시트[0].유효성; + if (유효성) sheet.유효성 = 유효성; + Object.assign(node, got.nodes[0]); +} + +/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */ function itemBox(b: Build, item: HoLine, lines: Map): [SlNode, Cost][] { const name = item.이름 || String(item.요소); - const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소); - const call = - typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null; - const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore - const q = qtyFrom(b, item, name); - const qty = - typeof q === "number" - ? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "") - : b.link(node, q[0], q[1]); - if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 }); - const out: [SlNode, Cost][] = []; - if (call?.t === "logic") { - args(b, node, call.args); - const unit = COSTS.map((k) => { - const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside")); - b.pill(node, "in", { name: k, cell: at, outside: true }); - return at; - }); - const fixed = item.비목 as Cost | undefined; - const total = `${qty}*(${unit.join("+")})`; - if (fixed && (COSTS as readonly string[]).includes(fixed)) { - b.out(node, fixed, { 식: total }); - out.push([node, fixed]); - lines.set(name, [node, fixed]); - } else { - COSTS.forEach((k, i) => { - b.out(node, k, { 식: `${qty}*${unit[i]}` }); - out.push([node, k]); - }); - b.out(node, t("Amount"), { 식: total }); - lines.set(name, [node, t("Amount")]); + const el: unknown = item.요소; + const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null; + const logic = call?.t === "logic" ? call : null; + const what: ItemRef["what"] = logic + ? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가" + : item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore + // 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로 + const need: LogicInput[] = []; + const inputs: Record = {}; + const bad: string[] = []; + for (const [k, v] of logic?.args ?? []) { + if (v.t !== "name") { + if (v.t !== "num" && v.t !== "str") bad.push(k); + continue; + } + const spec = b.specs.get(v.v); + if (!spec) bad.push(k); + else { + need.push({ ...spec, 이름: k }); + inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값; } - return out; } - const price = b.row(node, t("Price"), {}, t("Outside")); - b.pill(node, "in", { name: t("Price"), cell: price, outside: true }); - const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {}); + const element = what === "재료" && typeof el === "string" ? { 키: el } : el; + const ref: ItemRef = { + what, + element, + ...(item.규격 ? { spec: item.규격 } : {}), + ...(item.단위 ? { unit: item.단위 } : {}), + ...(need.length ? { need, inputs } : {}), + }; + const node = b.node("item", name, 3, JSON.stringify(ref), false); + const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS]; + const q = qtyFrom(b, item, name); + layout(b, node, ref, typeof q === "number" ? q : undefined); + if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]); + const holes = typeof el === "string" ? [el] : Object.values(el ?? {}); + const priceAt = what === "재료" ? "B3" : "B2"; if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v))) - b.cant(node, price, t("Hole", element), element); + b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el)); + if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", ")); + lines.set(name, [node, "금액"]); + return outside.map((c): [SlNode, Cost] => [node, c]); +} + +/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */ +const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/; + +function extraBox( + b: Build, + extra: { 이름: string; 식: string; 비목?: string; 출처?: string }, + sc: Omit, +): [SlNode, Cost] { const cost = ( - (COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비" + (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" ) as Cost; - b.out(node, cost, { 식: `${qty}*${price}` }); - lines.set(name, [node, cost]); - return [[node, cost]]; + const m = RATE_RE.exec(extra.식.replace(/\s+/g, "")); + if (m) { + const base = m[1] as Cost; + const ref: ItemRef = { what: "비율", base, into: cost }; + const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false); + b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림 + layout(b, node, ref); + return [node, cost]; + } + const node = b.node("item", extra.이름, 3); + const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? ""); + b.out(node, "단가", { 식: at }); + b.out(node, cost, { 식: at }); + b.out(node, "사용 수량", { 값: 1 }); + return [node, cost]; } type Cut = { 대상?: string; 자리?: number; 방법?: string }; @@ -598,8 +636,10 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 }); } else { const lines = new Map(); + for (const h of row.호표 ?? []) + if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름); const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines)); - // 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합 + // 항목 합 — 식 덧줄이 「노무비」 등으로 부르는 앞 줄까지의 비목 합 const sum = b.node("calc", t("Sum"), 3); const costs = {} as Record; for (const k of COSTS) { @@ -608,27 +648,32 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl costs[k] = [[sum, k]]; } const extras = new Map(); + const into: [SlNode, Cost][] = [...outs]; for (const extra of row.덧줄 ?? []) { - const cost = ( - (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" - ) as Cost; - const node = b.node("item", extra.이름, 3, "비율"); - put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? ""); + const [node, cost] = extraBox(b, extra, { b, costs, extras, lines }); extras.set(extra.이름, [node, cost]); costs[cost] = [...costs[cost], [node, cost]]; + into.push([node, cost]); } - const total: string[] = []; + // 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계 for (const k of COSTS) { - const refs = costs[k].map(([n, c]) => b.link(result, n, c)); - const at = b.out(result, k, { - 식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]), - }); - total.push(at); + const at = b.row(result, k, {}); + b.pill(result, "in", { name: k, cell: at }); + const refs = into + .filter(([, c]) => c === k) + .map(([n]) => { + const src = n.pillsOut.find((p) => p.name === k)!; + const to = { node: result.id, pill: k }; + b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to }); + return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`; + }); + if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") }; } - b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위); + const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`)); + b.out(result, "계", { 식: parts.join("+") }, row.결과단위); } b.book.활성 = b.book.시트[0]?.id; - const sl: SheetLogic = { + const built: SheetLogic = { key: null, name: row.이름, resultUnit: row.결과단위, @@ -636,6 +681,8 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl nodes: b.nodes, edges: b.edges, }; + // 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께) + const sl = refreshBases(built); return { sl, problems: b.problems }; } @@ -644,48 +691,4 @@ export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLog return importLogicReport(row, opts).sl; } -/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ -function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { - const got = calc?.[sheetId]?.[cell]; - if (got && "수" in got) return Number(got.수); - if (got && "글" in got) return got.글; - if (got && "참" in got) return got.참; - return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; -} - -/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ -export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { - const out: SlOutsideAt[] = []; - for (const node of sl.nodes) { - const passed: Record = {}; - const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - for (const p of node.pillsIn) { - const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; - if (node.kind !== "table" && TEXT.Pass.includes(note as never)) - passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); - } - const ref = node.ref ?? ""; - for (const p of outside) { - const id = `${node.id}:${p.name}`; - const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) - ? (p.name as SlField) - : undefined; - let item: SlOutside; - if (node.kind === "table") item = { id, kind: "표", table: ref }; - else if ( - node.kind === "logic" || - ref.startsWith("GF") || - ref.startsWith("GC") || - ref.startsWith("GX") - ) - item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("로직(")) - item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; - else item = { id, kind: "자재", key: ref }; - out.push({ item, sheet: node.sheetId, cell: p.cell }); - } - } - return out; -} +export { outsideOf } from "./M01_SL_Import_Parse"; diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts index 0bdcffee6..99a8ee623 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -3,10 +3,15 @@ * 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로 * 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 · * - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null - * 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음 + * 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) · + * 바깥 값 자리(`outsideOf`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { SheetLogic, SlNode } from "./M01_SL_Model"; +import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { itemRefOf, type ItemRef } from "./M01_SL_Item"; export type Ast = | { t: "num"; v: string } @@ -184,3 +189,85 @@ export function tableRows(table: SlImportTable): (string | number | null)[][] { }; return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; } + +/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */ + +/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */ +const PASS = ["넘길 값", "Passed value"]; +const COST3 = ["노무비", "재료비", "경비"] as const; + +/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ +function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { + const got = calc?.[sheetId]?.[cell]; + if (got && "수" in got) return Number(got.수); + if (got && "글" in got) return got.글; + if (got && "참" in got) return got.참; + return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; +} + +/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ +export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { + const out: SlOutsideAt[] = []; + for (const node of sl.nodes) { + const passed: Record = {}; + const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + for (const p of node.pillsIn) { + const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; + if (node.kind !== "table" && PASS.includes(String(note))) + passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); + } + const ref = node.ref ?? ""; + const item = itemRefOf(node); + if (item) { + out.push(...itemOutside(node, item)); + continue; + } + for (const p of outside) { + const id = `${node.id}:${p.name}`; + const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) + ? (p.name as SlField) + : undefined; + let item: SlOutside; + if (node.kind === "table") item = { id, kind: "표", table: ref }; + else if ( + node.kind === "logic" || + ref.startsWith("GF") || + ref.startsWith("GC") || + ref.startsWith("GX") + ) + item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("로직(")) + item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; + else item = { id, kind: "자재", key: ref }; + out.push({ item, sheet: node.sheetId, cell: p.cell }); + } + } + return out; +} + +/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */ +function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] { + const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell }); + const id = (f: string): string => `${node.id}:${f}`; + const el = ref.element; + const inputs = ref.inputs ?? {}; + if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })]; + if (ref.what === "재료") { + const keyed = typeof el === "object" && el !== null && "키" in el; + const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el); + return [at("B3", { id: id("재료비"), kind: "자재", key })]; + } + if (ref.what === "비율") return []; + const text = String(el); + const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? ""; + return COST3.map((f, i) => + at( + `B${2 + i}`, + ref.what === "장비" + ? { id: id(f), kind: "장비", element: text, inputs, field: f } + : { id: id(f), kind: "로직", key, inputs, field: f }, + ), + ); +} diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py index 750efad84..6d62f9a11 100644 --- a/resources/tester/test_m01_sl_import.py +++ b/resources/tester/test_m01_sl_import.py @@ -29,7 +29,7 @@ HOOKS = r""" import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { stripTypeScriptTypes } from "node:module"; -const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/; +const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/; export async function resolve(spec, ctx, next) { if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { const u = new URL(spec + ".ts", ctx.parentURL); @@ -161,12 +161,25 @@ def _tables_of(row: dict, tables: dict) -> dict: return {k: tables[k] for k in keys if k in tables} +def _names_of(row: dict, master) -> dict: + """다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)""" + cm, files, _ = master + m = cm.mf.Master(files) + keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False))) + return {k: str(m.get(k).get("이름") or k) for k in keys} + + def _num(v) -> float: + """빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan""" + if v is None: + return 0.0 return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan") def _fill(sl: dict, places: list, py_lines: list, master) -> None: """바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`""" + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + cm, files, _ = master m = cm.mf.Master(files) nodes = {n["id"]: n for n in sl["nodes"]} @@ -180,14 +193,21 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None: elif kind == "자재": name = nodes[item["id"].split(":")[0]]["name"] value = next(x["단가"] for x in py_lines if x["이름"] == name) + elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목 + line = { + "종류": "로직", + "요소": item["element"], + "이름": "장비", + "단위": "시간", + "수량": "1", + } + inputs = item["inputs"] + one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "", + "입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip + value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0) else: - key = ( - item["key"] - if kind == "로직" - else re.match(r"로직\((\w+)", item["element"]).group(1) - ) - got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v - for k, v in item["inputs"].items()}) # fmt: skip + got = cm.mf.run(m, item["key"], {k: Decimal(str(v)) if isinstance(v, (int, float)) else v + for k, v in item["inputs"].items()}) # fmt: skip value = got["결과"] if "결과" in got else got[item.get("field") or "계"] if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0) continue @@ -197,7 +217,8 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None: def _result_cells(sl: dict) -> dict: node = next(n for n in sl["nodes"] if n["kind"] == "result") - return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]} + # 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약 + return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]} @pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES]) @@ -206,9 +227,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m from M01_MasterData.LogicBuilder import M01_LB_Calc as lb rep = _node( - "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });" + "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });" "out({ ...r, places: I.outsideOf(r.sl) });", - {"row": row, "tables": _tables_of(row, master[2]), "given": given}, + { + "row": row, + "tables": _tables_of(row, master[2]), + "given": given, + "names": _names_of(row, master), + }, ) assert rep["problems"] == [] sl = rep["sl"] @@ -219,6 +245,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴) if n["kind"] == "table": assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"] + # 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액) + assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])] + for n in sl["nodes"]: + if n["kind"] == "item" and n.get("ref", "").startswith("{"): + ref = json.loads(n["ref"]) + assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율") + pills = {p["name"]: p["cell"] for p in n["pillsOut"]} + assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6") for e in sl["edges"]: assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) From 0e72719a5d0271d68fd0b5f16cfd12f5a85c47af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:02:45 +0900 Subject: [PATCH 31/38] =?UTF-8?q?feat(m01-sl):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=9E=90=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=87=EA=B8=B0(renderLogicTools)=20=C2=B7=20?= =?UTF-8?q?=EB=84=98=EA=B8=B8=20=EA=B0=92=EC=9D=84=20=EC=8B=A4=EC=9D=80=20?= =?UTF-8?q?=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=EB=AC=BC=EC=9D=8C=EC=9D=84=20?= =?UTF-8?q?=EC=A7=90=EC=9E=91=ED=95=9C=20=EB=AC=BC=EC=9D=8C=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../SheetLogic/M01_SL_Entry_Paper.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index ca3b354a0..2b4d7bdb2 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -33,7 +33,8 @@ export const loadMods = () => import("./M01_SL_Result"), import("./M01_SL_Import"), import("../../A00_Common/spreadsheet/spreadsheet_recalc"), - ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({ + import("./M01_SL_LogicTools"), + ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc, logic]) => ({ model, ev, api, @@ -44,6 +45,7 @@ export const loadMods = () => result, imp, recalc, + logic, })); export type Mods = Awaited>; @@ -109,13 +111,19 @@ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { if (!at.has(k)) at.set(k, p); }; M.item.itemOutsides(sl).forEach(put); - for (const c of M.ev.outsideCells(sl)) { - const node = sl.nodes.find((n) => n.id === c.node); - if (node && c.source) - put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); - } + // 상자에 출처가 적힌 알약 → 넘길 값을 실은 물음(outsideOf) → 출처를 짐작한 나머지(넘길 값 없음) 차례 + const cells = M.ev.outsideCells(sl); + const fromCell = (explicit: boolean): void => { + for (const c of cells) { + const node = sl.nodes.find((n) => n.id === c.node); + if (node && c.source && !!node.source === explicit) + put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); + } + }; + fromCell(true); // 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0) M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put); + fromCell(false); return [...at.values()]; } @@ -294,10 +302,6 @@ export function showPaper( }); }; - /* ponytail: 「다른 로직」 상자 손잡이(sub5 renderLogicTools)가 올라오기 전엔 없음 — 올라오면 바로 M.item.renderLogicTools 로 부름 */ - const logicTools = (M.item as { renderLogicTools?: typeof M.item.renderItemTools }) - .renderLogicTools; - const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { @@ -323,8 +327,8 @@ export function showPaper( loadPrices(node.id); }, }); - else if (node.kind === "logic" && logicTools) - logicTools(toolsHost, sl, node.id, { + else if (node.kind === "logic") + M.logic.renderLogicTools(toolsHost, sl, node.id, { change: (next) => { edit(next, true); loadPrices(node.id); From 58cee6404e2026991886f21823542686df0a3483 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:03:38 +0900 Subject: [PATCH 32/38] =?UTF-8?q?chore(M01):=20=EC=8B=9C=ED=97=98=ED=8C=90?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D?= =?UTF-8?q?=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. From bb89baa557f65d4c0c9d895e8057f8fe7489ff8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:05:12 +0900 Subject: [PATCH 33/38] =?UTF-8?q?chore(M01):=20=EA=B3=B5=EC=9C=A0=20docs?= =?UTF-8?q?=20=EC=82=AC=EB=B3=B8=20=EB=8B=B4=EC=9D=8C(=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=EC=B0=BD=EC=9D=B4=20=EC=93=B4=20=EC=89=AC=EC=9A=B4=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B8=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. From 84c2b968ec74e3912c079d7ee7ccdccb09909d58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 15:58:33 +0900 Subject: [PATCH 34/38] =?UTF-8?q?chore(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=ED=8C=90=20=EC=A0=95=ED=95=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B8=B0=EC=A4=80=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 | From e0dfc5cbaa383053abca26e07d6e6cdd4ddf2329 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 15:59:36 +0900 Subject: [PATCH 35/38] auto: 2026-10-01 15:59 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 +++++++++++++++++++ docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 +++++++ 2 files changed, 150 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 | From 2e66b0a6ce68f04ed594d4747f4b2bf589b40b03 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 15:59:53 +0900 Subject: [PATCH 36/38] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20Model=20=C2=B7=20Eval=20=ED=83=80=EC=9E=85=20=C2=B7?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EB=A8=B8=EB=A6=AC=20=EC=98=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CfLogic · CfBox · CfInput · CfVariable · CfFunc · CfOutput · CfPort · CfLink · CfElementRef · CfUserElement · CfPill · CfKind · CfGroup · CfCost · CfValue · CfValues · CfEvalResult newLogic · addBox · removeBox · moveBox · setColor · setUnit · portsOut · portsIn · link · unlink · evalLogic · checkLogic (속은 준비 중) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Eval.ts | 44 ++++++ M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 164 ++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Eval.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Model.ts diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts new file mode 100644 index 000000000..1cd3f877b --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * M02_CF_Eval.ts + * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 + * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * ========================================================================== */ + +import type { CfLogic } from "./M02_CF_Model"; + +/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ +export type CfValue = number | string | boolean | null | (number | string | null)[][]; + +/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ +export type CfValues = Record>; + +export interface CfEvalResult { + /** 상자 id → 내보내는 알약 이름 → 값 */ + ports: Record>; + /** 출력 상자 id → 값 */ + outputs: Record; + cost: { + 노무비: number; + 재료비: number; + 경비: number; + /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ + 값들: Record; + 계: number; + }; + /** 쉬운 말 */ + errors: string[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +/** 화면 계산 */ +export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult { + return notReady(); +} + +/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] { + return notReady(); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts new file mode 100644 index 000000000..bc61a1ae1 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * M02_CF_Model.ts + * 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나) + * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 + * ========================================================================== */ + +import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +export type CfKind = "input" | "function" | "output"; +export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; +export type CfCost = "노무비" | "재료비" | "경비" | "값"; + +/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */ +export interface CfElementRef { + ref: string; + name: string; + spec: string; + unit: string; +} + +/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */ +export interface CfVariable { + name: string; + type: "number" | "choice" | "text"; + unit: string; + choices?: (string | number)[]; + range?: [number, number]; + example: number | string | null; + /** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */ + cell?: string; +} + +export interface CfInput { + mode: "group" | "variable"; + group?: CfGroup; + elements: CfElementRef[]; + /** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */ + table?: { key: string; row?: number }; + variables: CfVariable[]; +} + +export interface CfFunc { + /** 우리 스프레드시트 시트 하나 */ + sheet: Sheet; + /** 받는 알약 → 칸 */ + inputs: { port: string; cell: string }[]; + variables: CfVariable[]; + /** 내보내는 알약 하나 */ + output: { cell: string; name: string; unit: string }; +} + +export interface CfOutput { + cost: CfCost; + unit: string; +} + +export interface CfBox { + id: string; + kind: CfKind; + name: string; + color: string; + /** 오른쪽 위 단위 — 기본 「-」 */ + unit: string; + x: number; + y: number; + input?: CfInput; + func?: CfFunc; + output?: CfOutput; +} + +/** 알약 한쪽 끝 */ +export interface CfPort { + box: string; + name: string; +} + +export interface CfLink { + id: string; + from: CfPort; + to: CfPort; +} + +/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ +export interface CfUserElement { + id: string; + group: CfGroup; + name: string; + spec: string; + unit: string; + value: number | null; +} + +export interface CfLogic { + key: string | null; + name: string; + resultUnit: string; + boxes: CfBox[]; + links: CfLink[]; + userElements: CfUserElement[]; +} + +/** 알약 하나의 모습 */ +export interface CfPill { + name: string; + unit: string; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +export function newLogic(_name?: string, _resultUnit?: string): CfLogic { + return notReady(); +} + +/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */ +export function addBox( + _lg: CfLogic, + _kind: CfKind, + _x: number, + _y: number, +): { lg: CfLogic; box: CfBox } { + return notReady(); +} + +/** 상자 지우기 — 이어진 선도 지움 */ +export function removeBox(_lg: CfLogic, _id: string): CfLogic { + return notReady(); +} + +export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic { + return notReady(); +} + +export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic { + return notReady(); +} + +export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic { + return notReady(); +} + +/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */ +export function portsOut(_box: CfBox): CfPill[] { + return notReady(); +} + +/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */ +export function portsIn(_box: CfBox): CfPill[] { + return notReady(); +} + +/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */ +export function link( + _lg: CfLogic, + _from: CfPort, + _to: CfPort, +): { lg: CfLogic; why: string | null } { + return notReady(); +} + +export function unlink(_lg: CfLogic, _id: string): CfLogic { + return notReady(); +} From 0e200cf74a056954d5da5e1b03d4ffe3ce6493ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:00:12 +0900 Subject: [PATCH 37/38] =?UTF-8?q?chore(M01):=20=EC=9B=90=EA=B0=80=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=A0=95=ED=95=A8=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=B5=EC=9C=A0=20docs=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 | From 73fa2e612a184f30ab048ac07e98bb4a2c55e43e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:00:41 +0900 Subject: [PATCH 38/38] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20Api=20=ED=83=80=EC=9E=85=20=C2=B7=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EB=A8=B8=EB=A6=AC=20=C2=B7=20=5Fdata=20=EB=A5=BC?= =?UTF-8?q?=20git=20=EB=B0=96=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CfGroup · CfCost · CfQuery · CfValue · CfFill · CfLogic · CfListItem · CfSaved · CfUserElement - cfList · cfGet · cfSave · cfDelete · cfValues · cfUserElements · cfUserElementAdd · cfUserElementDelete · cfPlain 머리 - CostFlow/.gitignore — _data/ Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/CostFlow/.gitignore | 1 + M02_MasterTemplete/CostFlow/M02_CF_Api.ts | 136 ++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/.gitignore create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Api.ts diff --git a/M02_MasterTemplete/CostFlow/.gitignore b/M02_MasterTemplete/CostFlow/.gitignore new file mode 100644 index 000000000..c10c784e3 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/.gitignore @@ -0,0 +1 @@ +_data/ diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Api.ts b/M02_MasterTemplete/CostFlow/M02_CF_Api.ts new file mode 100644 index 000000000..4e26740a4 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Api.ts @@ -0,0 +1,136 @@ +/* ============================================================================= + * M02_CF_Api.ts + * 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 넣고 + * 같은 엔진으로 함수 상자 시트를 다시 셈) · 지우기 · 입력 값 한 번에 받기 · 사용자 요소 + * 서버 = `M02_CF_Router.py`(`/api/m02/cf/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` + * ========================================================================== */ + +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 입력 그룹 — M01 저장 그룹 + 사용자 요소 */ +export type CfGroup = + "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율" | "사용자"; + +/** 비용 종류 칸 */ +export type CfCost = "노무비" | "재료비" | "경비" | "계"; + +/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소는 `_data`)에서 새로 + * · 인력 · 재료 = 그 줄 단가(기존 계산과 같은 셈 · 준용 · 낮은 값) — 재료 key 는 키 또는 검색어 묶음 JSON 글, + * unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 + * · 유가전력 · 환율 · 요율 · 사용자 = 그 줄 값 + * · 기계 = key(EQ…) + field(값 칸 이름 — 가격 · 손료계수 …) 또는 element(기계 시간당 경비 로직 부름 글) + inputs → 한 시간 값 + * · 소요량 · 계수 = 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「이름·아래」 「이름·위」) · row 를 주면 그 줄 하나 */ +export type CfQuery = + | { id: string; group: "인력" | "유가전력" | "환율" | "요율" | "사용자"; key: string } + | { id: string; group: "재료"; key: string; unit?: string } + | { id: string; group: "기계"; key: string; field?: string } + | { + id: string; + group: "기계"; + element: string; + inputs: Record; + field?: CfCost; + } + | { id: string; group: "소요량" | "계수"; table: string; row?: number }; + +/** 입력 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ +export interface CfValue { + id: string; + ok: boolean; + value?: number | string | null; + rows?: (string | number | null)[][]; + name?: string; + unit?: string; + reason?: string; +} + +/** 저장 때 입력 값을 넣을 자리 — 함수 상자 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ +export interface CfFill { + item: CfQuery; + sheet: string; + cell: string; +} + +/** 다이어그램 한 벌 = 통합문서(함수 상자 = 시트) + 상자 · 선(다른 창 칸은 그대로 저장 · 돌려줌) */ +export interface CfLogic { + /** 새 것 null · 저장하면 서버가 `CF-0001` 꼴로 줌 · 견본은 그 이름(저장하면 새 번호) */ + key: string | null; + name: string; + resultUnit?: string; + workbook: Workbook; + /** 입력 값 자리 — 서버가 저장 때 마스터에서 다시 넣음 */ + fills?: CfFill[]; + [extra: string]: unknown; +} + +/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */ +export interface CfListItem { + key: string; + name: string; + resultUnit: string; + saved: string; + sample: boolean; +} + +/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 입력 값 */ +export interface CfSaved { + key: string; + logic: CfLogic; + values: CfValue[]; +} + +/** 사용자 요소 — 마스터에 없는 요소(시험 폴더 `_data` 에만) · 키 `UE-0001` 꼴 */ +export interface CfUserElement { + key: string; + group: CfGroup; + 이름: string; + 규격: string; + 단위: string; + 값: number | null; + 비고?: string; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +export async function cfList(): Promise { + return notReady(); +} + +export async function cfGet(_key: string): Promise { + return notReady(); +} + +export async function cfSave(_logic: CfLogic): Promise { + return notReady(); +} + +export async function cfDelete(_key: string): Promise { + return notReady(); +} + +export async function cfValues(_items: CfQuery[]): Promise { + return notReady(); +} + +/** 사용자 요소 목록 */ +export async function cfUserElements(): Promise { + return notReady(); +} + +/** 사용자 요소 만들기 — 키는 서버가 줌 */ +export async function cfUserElementAdd( + _element: Omit, +): Promise { + return notReady(); +} + +export async function cfUserElementDelete(_key: string): Promise { + return notReady(); +} + +/** 실패 → 쉬운 말 */ +export function cfPlain(_e: unknown): string { + return notReady(); +}