/* ============================================================================= * 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")); // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 b.pill(node, "out", { name: "표", 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, ...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; }