- 항목 상자: 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
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M01_SL_Import_Parse.ts
|
|
* 시트 로직 가져오기 — 엔진 문법(`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 }
|
|
| { 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<string, string>;
|
|
값칸: Record<string, string>;
|
|
범위규칙?: string | null;
|
|
줄: Record<string, unknown>[];
|
|
}
|
|
|
|
/** 범위 칸 꼬리 — 한 · 영 */
|
|
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" }[] = [];
|
|
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" });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 품셈 표 → 시트 칸(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 (part !== "v") {
|
|
const x = Array.isArray(v) ? v[part === "lo" ? 0 : 1] : v;
|
|
return typeof x === "number" ? x : 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)))];
|
|
}
|
|
|
|
/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */
|
|
|
|
/** 넘길 값 칸 표시(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<string, unknown> = {};
|
|
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 },
|
|
),
|
|
);
|
|
}
|