/* ============================================================================= * M01_LB_Blocks.ts * 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기 * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로 * * 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나 * 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어 * ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서 * 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond"; import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model"; import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table"; /** 값이 오는 곳 */ export type LbRef = | { k: "input"; name: string } // ① 입력값 | { k: "fixed"; value: number | string } // 고정값 | { k: "lookup"; id: string } // ② 표 조회 결과 | { k: "calc"; name: string } // ③ 이름 붙은 계산값(중간) | { k: "logic"; key: string; field: string | null } // 다른 로직 결과값(.노무비 등) | { k: "cost"; name: "노무비" | "재료비" | "경비" | "사람줄합" }; /** 블록으로 못 나눈 조각 — 원래 글 그대로 */ export interface LbExpr { k: "expr"; text: string; } /** 식 자리 — 중간 · 호표 수량 · 덧줄 · 결과 · 장비/일위대가 요소 */ export type LbPart = "중간" | "수량" | "비율" | "결과" | "요소"; export interface LbLookup { /** 화면 번호 · 저장 안 함 */ id: string; /** QF000421 · 현장 사본이면 QX… */ table: string; conds: { col: string; from: LbRef | LbExpr }[]; /** 결과 칸 */ col: string; at: { part: LbPart; index: number; start: number; end: number }[]; } export type LbCalcKind = "값" | "곱나눔" | "할증" | "더하기" | "조건목록" | "최소최대" | "올림" | "단계"; export interface LbCalc { /** 중간 이름 · 수량 자리면 null */ name: string | null; kind: LbCalcKind; /** 곱나눔 · 더하기 · 할증(바탕 · 율) */ parts: (LbRef | LbExpr)[]; ops?: ("*" | "/" | "+" | "-")[]; /** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */ cond?: CondRead; at: { part: Exclude; index: number }; /** 원래 글(안 고치면 그대로 씀) */ text: string; } export interface LbLogic { /** ④ 항목 · ⑤ 금액 — Model 것 그대로 */ doc: EasyDoc; /** ① */ inputs: LogicInput[]; /** ② */ lookups: LbLookup[]; /** ③ */ calcs: LbCalc[]; /** ⑤ */ result: { kind: "금액"; unit: string } | { kind: "값"; unit: string; calc: LbCalc }; /** 못 읽은 까닭(쉬운 말) — 비면 다 읽힘 */ blocked: string[]; } /** 원문 표 칸 고치기(메모리 사본) */ export interface TableEdit { table: string; row: number; col: string; value: number | string | [number | null, number | null]; } const TEXT = { BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"], CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"], AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"], PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], Unknown: ["모르는 이름이 있음", "Has an unknown name"], Result: ["결과값", "Result value"], Qty: ["수량", "quantity"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */ type Node = { t: "num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin"; v?: string; f?: string; op?: string; a?: Node; b?: Node; items?: Node[]; head?: string; args?: [string, Node][]; src: string; }; const CMPS = ["<=", ">=", "==", "!=", "<", ">"]; const ROUND = ["올림", "버림", "반올림"]; function parse(s: string): Node | 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 stick = (re: RegExp): string | null => { re.lastIndex = i; const m = re.exec(s); if (m) i = re.lastIndex; return m ? m[0] : null; }; const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail()); const quoted = (): string => { const e = s.indexOf("'", i); if (e < 0) fail(); const v = s.slice(i, e); i = e + 1; return v; }; /** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */ const ref = (head: boolean): string => { 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; else if (head) continue; else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break; else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break; } return s.slice(st, i).trim(); }; const mk = (st: number, o: Omit): Node => ({ ...o, src: s.slice(st, i).trim() }); const fields = (st: number, n: Node): Node => { while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() }); return n; }; const args = (): [string, Node][] => { const out: [string, Node][] = []; while (take(",")) { const k = take("'") ? quoted() : word(); need("="); out.push([k, expr()]); } need(")"); return out; }; const primary = (): Node => { ws(); const st = i; if (take("(")) { const a = expr(); need(")"); return fields(st, mk(st, { t: "paren", a })); } if (take("'")) return mk(st, { t: "str", v: quoted() }); const n = stick(/\d+(?:\.\d+)?/y); if (n) return mk(st, { t: "num", v: n }); const w = word(); if (w === "찾기" || w === "로직") { need("("); ws(); const head = ref(true); return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() })); } if (["만약", "최소", "최대", ...ROUND].includes(w)) { need("("); const items = [expr()]; while (take(",")) items.push(expr()); need(")"); return fields(st, mk(st, { t: "call", f: w, items })); } if (s[i] === ":") { i++; ref(false); return fields(st, mk(st, { t: "eref" })); } return fields(st, mk(st, { t: "name", v: w })); }; const unary = (): Node => { ws(); const st = i; return take("-") ? mk(st, { t: "neg", a: unary() }) : primary(); }; const chain = (next: () => Node, ops: string[], once = false): Node => { ws(); const st = i; let n = next(); for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) n = mk(st, { t: "bin", op, a: n, b: next() }); return n; }; const mul = (): Node => chain(unary, ["*", "/"]); const add = (): Node => chain(mul, ["+", "-"]); const cmp = (): Node => chain(add, CMPS, true); const both = (): Node => chain(cmp, ["그리고"]); const expr = (): Node => chain(both, ["또는"]); try { const node = expr(); ws(); return i === s.length ? node : null; } catch { return null; } } const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n); const flat = (s: string): string => s.replace(/\s+/g, ""); const KEY = /^[A-Z]{2}\d{6}$/; /* ── 읽기 ───────────────────────────────────────────────────────────────── */ interface Ctx { row: LogicRow; inputs: Set; mids: Set; /** 찾기 글(빈칸 뺌) → 표 조회 번호 */ finds: Map; /** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */ personSum: string | null; bad: Set; } /** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */ const SRC = new WeakMap(); const keep = (ref: T, text: string): T => { SRC.set(ref, { text, snap: JSON.stringify(ref) }); return ref; }; const isSimple = (n: Node, c: Ctx): boolean => { const u = un(n); if (u.t === "num" || u.t === "str") return true; if (u.t === "neg") return un(u.a!).t === "num"; return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!)); }; /** 식 나무를 훑어 못 읽는 까닭을 모음 */ function check(n: Node, c: Ctx): void { const u = un(n); if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr")); if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup")); if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc")); if (u.t === "find" || u.t === "logic") return; if (u.t === "name") { const v = u.v!; const known = c.inputs.has(v) || c.mids.has(v) || KEY.test(v) || ["노무비", "재료비", "경비", "계", "줄"].includes(v); const item = (c.row.호표 ?? []).some((h) => h.이름 === v); if (!known && !item) c.bad.add(tl("Unknown")); } for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c); } /** 잎 하나 → 값이 오는 곳 */ function refOf(node: Node, c: Ctx): LbRef | LbExpr { const u = un(node); const text = node.src; if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text); if (u.t === "neg" && un(u.a!).t === "num") return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text); if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text); if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text); if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text); if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!)) return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text); if (c.personSum !== null && flat(text) === c.personSum) return keep({ k: "cost", name: "사람줄합" }, text); if (u.t === "field" && u.a!.t === "find") { const id = c.finds.get(flat(u.src)); if (id) return keep({ k: "lookup", id }, text); } if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) { const call = u.t === "logic" ? u : u.a!; return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text); } return { k: "expr", text }; } const isLeaf = (n: Node): boolean => { const u = un(n); return ( ["num", "str", "name", "field", "logic", "eref"].includes(u.t) || (u.t === "neg" && un(u.a!).t === "num") ); }; /** 같은 연산 사슬 — a op b op c … */ function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } { const u = un(n); if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] }; const left = chainOf(u.a!, ops); return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] }; } function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc { const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] }; const node = parse(text); if (!node) { c.bad.add(tl("BadText")); return { ...base, kind: "단계" }; } check(node, c); const u = un(node); const inner = (): CondRead | undefined => { const r = readCond(text, c.row); return r && r.via === null ? r : undefined; }; if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] }; if (u.t === "call") { const kind: LbCalcKind = u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대"; const cond = inner(); const whole = cond && cond.blocks.length === 1 && cond.blocks[0].p[0] === 0 && cond.blocks[0].p[1] >= cond.text.length; return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond }; } if (u.t === "bin" && u.op === "*") { const r = un(u.b!); if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") { const rate = un(r.b!); if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100") return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() }; } } for (const [kind, ops] of [ ["곱나눔", ["*", "/"]], ["더하기", ["+", "-"]], ] as const) { const ch = chainOf(u === node ? node : u, [...ops]); if (ch.items.length > 1) return { ...base, kind, parts: ch.items.map((x) => refOf(x, c)), ops: ch.ops as LbCalc["ops"], cond: inner(), }; } return { ...base, kind: "단계", cond: inner() }; } /** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */ function personSumText(row: LogicRow): string | null { const terms = (row.호표 ?? []) .filter((h) => h.종류 === "인력") .map((h) => { const q = String(h.수량 ?? "").trim(); return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`; }); return terms.length ? `(${terms.join(" + ")})` : null; } /** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */ function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] { const ho = row.호표 ?? []; return [ ...(row.중간 ?? []).map((m, i) => ({ part: "중간" as const, index: i, text: m.식, label: m.이름, })), ...ho.map((h, i) => ({ part: "수량" as const, index: i, text: String(h.수량 ?? ""), label: `${h.이름 ?? ""} ${tl("Qty")}`, })), ...ho .map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" })) .filter( (p): p is { part: "요소"; index: number; text: string; label: string } => typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text), ), ...(row.덧줄 ?? []).map((d, i) => ({ part: "비율" as const, index: i, text: d.식, label: d.이름, })), ...(row.결과 ? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }] : []), ]; } /** 로직 줄 → 블록 */ export function readLogic( row: LogicRow, meta: { key: string | null; version: string | null; isBase: boolean }, ): LbLogic { const doc = toDoc(row, meta); const ps = personSumText(row); const c: Ctx = { row, inputs: new Set((row.입력 ?? []).map((x) => x.이름)), mids: new Set((row.중간 ?? []).map((x) => x.이름)), finds: new Map(), personSum: ps === null ? null : flat(ps), bad: new Set(), }; const lookups: LbLookup[] = []; const blocked: string[] = []; const places = placesOf(row); for (const p of places) { const calls = readFinds(p.text); if (!calls) { blocked.push(`${p.label} — ${tl("BadText")}`); continue; } for (const call of calls) { const key = flat(p.text.slice(call.start, call.end)); let id = c.finds.get(key); if (!id) { id = `L${lookups.length + 1}`; c.finds.set(key, id); const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] }; look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) })); ARGS.set(look, call.args); lookups.push(look); } lookups .find((x) => x.id === id)! .at.push({ part: p.part, index: p.index, start: call.start, end: call.end }); } } const calcs: LbCalc[] = []; for (const p of places) { if (p.part === "요소") { const n = parse(p.text); c.bad.clear(); if (n) check(n, c); else c.bad.add(tl("BadText")); for (const b of c.bad) blocked.push(`${p.label} — ${b}`); continue; } c.bad.clear(); const name = p.part === "중간" ? p.label : null; calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c)); for (const b of c.bad) blocked.push(`${p.label} — ${b}`); } const resultCalc = calcs.find((x) => x.at.part === "결과"); return { doc, inputs: row.입력 ?? [], lookups, calcs, result: resultCalc ? { kind: "값", unit: row.결과단위, calc: resultCalc } : { kind: "금액", unit: row.결과단위 }, blocked: [...new Set(blocked)], }; } /** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */ const ARGS = new WeakMap(); function argRef(a: FindArg, c: Ctx): LbRef | LbExpr { const n = parse(a.rhs); return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs }; } /* ── 쓰기 ───────────────────────────────────────────────────────────────── */ /** 블록 → 로직 줄 */ export function writeLogic(lb: LbLogic): LogicRow { return toRow(lb.doc); } /** 단계 안내가 채울 빈 로직 */ export function newLogic(name = "", per = "㎡"): LbLogic { return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false }); } const metaOf = (lb: LbLogic) => ({ key: lb.doc.key, version: lb.doc.version, isBase: lb.doc.isBase, }); /** 한 자리 글 읽기 · 쓰기 */ function textAt(row: LogicRow, part: LbPart, index: number): string { if (part === "중간") return row.중간[index].식; if (part === "비율") return row.덧줄![index].식; if (part === "결과") return row.결과!.식; const h = row.호표![index]; return String(part === "수량" ? h.수량 : h.요소); } function putText(row: LogicRow, part: LbPart, index: number, text: string): void { if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text }; else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text }; else if (part === "결과") row.결과 = { ...row.결과!, 식: text }; else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text }; else row.호표![index] = { ...row.호표![index], 요소: text }; } /** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */ function splice( lb: LbLogic, edits: { part: LbPart; index: number; start: number; end: number; text: string }[], ): LbLogic { const row = writeLogic(lb); const groups = new Map(); for (const e of edits) groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]); for (const list of groups.values()) { const { part, index } = list[0]; let text = textAt(row, part, index); for (const e of [...list].sort((x, y) => y.start - x.start)) text = text.slice(0, e.start) + e.text + text.slice(e.end); putText(row, part, index, text); } return readLogic(row, metaOf(lb)); } const numText = (n: number): string => { const t = String(Number(n.toPrecision(12))); return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; }; /** 조각 → 글 — 안 바뀐 조각은 원래 글 */ function refText(ref: LbRef | LbExpr, lb: LbLogic): string { const src = SRC.get(ref); if (src && src.snap === JSON.stringify(ref)) return src.text; switch (ref.k) { case "expr": return ref.text; case "input": case "calc": return ref.name; case "fixed": return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`; case "cost": return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name; case "logic": return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`; case "lookup": { const look = lb.lookups.find((x) => x.id === ref.id); return look ? findText(look, look.conds, look.table) : "0"; } } } /** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */ function wrap(text: string, strong: boolean): string { const n = parse(text); const low = n && n.t === "bin" && ["+", "-"].includes(n.op!); const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!); return low || (strong && mid) ? `(${text})` : text; } function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string { const raw = ARGS.get(look) ?? []; const args: FindArg[] = conds.map((cd) => { const old = raw.find((a) => a.key === cd.col); const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from; return { key: cd.col, rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic), }; }); return writeFind(table, args, look.col); } /** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */ export function setCond( lb: LbLogic, lookupId: string, col: string, from: LbRef | LbExpr | null, ): LbLogic { const look = lb.lookups.find((x) => x.id === lookupId); if (!look) return lb; const has = look.conds.some((x) => x.col === col); const conds = from === null ? look.conds.filter((x) => x.col !== col) : has ? look.conds.map((x) => (x.col === col ? { col, from } : x)) : [...look.conds, { col, from }]; const text = findText(look, conds, look.table); return splice( lb, look.at.map((a) => ({ ...a, text })), ); } /** 표 조회가 보는 표 바꾸기(현장 사본 등) */ export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic { const look = lb.lookups.find((x) => x.id === lookupId); if (!look) return lb; const text = findText(look, look.conds, table); return splice( lb, look.at.map((a) => ({ ...a, text })), ); } /** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */ export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial): LbLogic { const next: LbCalc = { ...calc, ...patch }; const old = textAt(writeLogic(lb), calc.at.part, calc.at.index); const text = patch.text !== undefined ? patch.text : buildText(next, lb, old); return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]); } function buildText(c: LbCalc, lb: LbLogic, old: string): string { const parts = c.parts.map((p) => refText(p, lb)); switch (c.kind) { case "값": return parts[0] ?? old; case "할증": return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`; case "곱나눔": case "더하기": { const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+")); const strong = c.kind === "곱나눔"; return parts.reduce( (acc, p, i) => i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false), "", ); } default: return c.cond ? writeRead(c.cond) : old; } }