Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Import.ts
T
eomsangdonandClaude Opus 5.5 8b07250aa0 fix(m01-sl): 가져오기 이음매 — 항목 상자 = Item 틀(applyItemLayout) · 다른 로직 = LogicTools 틀 · 표 줄 수 따라감 · 자재 묶음 · 상자 이름
- 항목 상자: 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 14:01:08 +09:00

695 lines
31 KiB
TypeScript

/* =============================================================================
* 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 {
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 { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item";
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"],
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);
export type { SlImportTable };
export interface SlImportOptions {
/** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */
tables?: Record<string, SlImportTable>;
/** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */
examples?: Record<string, number | string>;
/** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */
names?: Record<string, string>;
}
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<string, { x: string; hi?: string }>;
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<string, number>();
private readonly sheets = new Map<string, Sheet>();
private readonly linked = new Map<string, string>();
readonly inputs = new Map<string, SlNode>();
readonly mids = new Map<string, SlNode>();
readonly tables = new Map<string, TableBox>();
readonly logics = new Map<string, SlNode>();
readonly consts = new Map<string, SlNode>();
readonly specs = new Map<string, LogicInput>();
/** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */
readonly names = new Map<string, string>();
constructor(readonly opts: SlImportOptions) {
for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v);
}
/** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */
node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode {
const n = this.nodes.length + 1;
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,
칸: head ? { 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<Cost, [SlNode, string][]>;
/** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */
extras?: Map<string, [SlNode, string]>;
lines?: Map<string, [SlNode, string]>;
}
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", 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 });
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<Ast, { t: "logic" }>, 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", b.names.get(n.head) ?? n.head, 1, n.head);
args(b, node, n.args);
b.logics.set(sig, node);
}
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;
}
/* ── 표 상자 ─────────────────────────────────────────────────────────────── */
/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */
function findBox(b: Build, n: Extract<Ast, { t: "find" }>, 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<Ast, { t: "find" }>, 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.이름 || b.names.get(n.head) || n.head, 1, n.head);
const s = b.sheet(node);
// 조건 알약 — 받는 값은 넘긴 식
const cond = new Map<string, string>();
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);
// 바깥 값 「표」 알약 — 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));
// 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 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++);
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})`;
});
const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1";
s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` };
}
// 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `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<string, { x: string; hi?: string }>();
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 ·
* 범위 값 칸은 아래 = 위(숫자 하나)일 때만 값 · 아니면 #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: 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)`,
};
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())` });
}
/* ── 상자들 ─────────────────────────────────────────────────────────────── */
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 };
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);
}
/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */
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];
}
/** 항목 틀 — 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<string, [SlNode, string]>): [SlNode, Cost][] {
const name = item.이름 || String(item.요소);
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<string, unknown> = {};
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.이름]).값;
}
}
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, 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<Scope, "here">,
): [SlNode, Cost] {
const cost = (
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
) as 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 };
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<string, [SlNode, string]>();
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<Cost, [SlNode, string][]>;
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<string, [SlNode, string]>();
const into: [SlNode, Cost][] = [...outs];
for (const extra of row.덧줄 ?? []) {
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]);
}
// 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계
for (const k of COSTS) {
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("+") };
}
const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`));
b.out(result, "계", { 식: parts.join("+") }, row.결과단위);
}
b.book.활성 = b.book.시트[0]?.id;
const built: SheetLogic = {
key: null,
name: row.이름,
resultUnit: row.결과단위,
workbook: b.book,
nodes: b.nodes,
edges: b.edges,
};
// 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께)
const sl = refreshBases(built);
return { sl, problems: b.problems };
}
/** 기존 로직 → 시트 로직(정함 9) */
export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic {
return importLogicReport(row, opts).sl;
}
export { outsideOf } from "./M01_SL_Import_Parse";