- readLogic: 식 자리(중간 · 수량 · 요소 · 비율 · 결과)를 엔진 문법으로 읽어 ① 입력값 ② 표 조회(Table readFinds) ③ 계산(값 · 곱나눔 · 할증 · 더하기 · 조건목록 · 최소최대 · 올림 · 단계 · Cond readCond) ④ 항목(Model EasyDoc) ⑤ 결과값 - writeLogic = Model toRow · setCond · setLookupTable · setCalc 는 그 자리 글만 갈아끼운 뒤 다시 읽음 · newLogic - 못 읽는 꼴은 blocked 에 쉬운 말(표 조회 조건에 계산 · 그리고/또는 · 넘길 값에 계산) - 마스터 1,469 왕복 다른 것 0 · 블록으로 다 읽힘 1,396(95%) - 시험 resources/tester/test_m01_lb_blocks.py 14개(견본 여섯 · Node 고리로 EasyCalc 그대로 부름) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
666 lines
24 KiB
TypeScript
666 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* 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<LbPart, "요소">; 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, "src">): 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<string>;
|
|
mids: Set<string>;
|
|
/** 찾기 글(빈칸 뺌) → 표 조회 번호 */
|
|
finds: Map<string, string>;
|
|
/** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */
|
|
personSum: string | null;
|
|
bad: Set<string>;
|
|
}
|
|
|
|
/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */
|
|
const SRC = new WeakMap<object, { text: string; snap: string }>();
|
|
const keep = <T extends object>(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<LbLookup, FindArg[]>();
|
|
|
|
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<string, typeof edits>();
|
|
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<LbCalc>): 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;
|
|
}
|
|
}
|