/* ============================================================================= * M01_EasyCalc_Cond.ts * 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고 * 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null * * 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음 * 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리) * 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로 * 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로) * 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄 * 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { runCalc, type CalcAnswer, type LogicInput, type LogicRow, } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { calcLater } from "./M01_EasyCalc_Api"; import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; import "./M01_EasyCalc_Cond.css"; const TEXT = { Title: ["조건 보고 고치기", "Change by conditions"], Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"], Part: ["조건", "Condition"], If: ["만약", "If"], ElseIf: ["아니고 만약", "Else if"], Then: ["→", "→"], Else: ["아니면 →", "Otherwise →"], Subject: ["가", "is"], Big: ["가운데 큰 값", "whichever is larger"], Small: ["가운데 작은 값", "whichever is smaller"], And: ["와", "and"], RoundA: ["을 소수", "to"], RoundB: ["자리까지", "decimal places,"], Up: ["올림", "round up"], Down: ["버림", "round down"], Half: ["반올림", "round off"], OpEq: ["이면", "equal to"], OpNe: ["이 아니면", "not equal to"], OpGt: ["보다 크면", "greater than"], OpGe: ["이상이면", "at least"], OpLt: ["보다 작으면", "less than"], OpLe: ["이하이면", "at most"], ChipTable: ["표에서 찾은 값", "Value from table"], ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"], ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"], ChipCalc: ["계산한 값", "Calculated value"], ToNumber: ["숫자로", "Use a number"], Line: ["이 줄", "This line"], Total: ["합계", "Total"], Was: ["전", "before"], Waiting: ["계산 중 …", "Calculating …"], Use: ["이걸로", "Use this"], Cancel: ["취소", "Cancel"], NotNumber: ["숫자만 넣음", "Numbers only"], } as const; const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; /* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */ /** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */ type CNode = { src: string; p: [number, number] } & ( | { t: "num" | "str" | "name" | "ref"; v: string } | { t: "call"; f: string; items: CNode[] } | { t: "bin"; op: string; a: CNode; b: CNode } | { t: "neg" | "paren"; a: CNode } | { t: "field"; a: CNode; f: string } ); const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"]; const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"]; const NAME_RE = /[\p{L}\p{N}_]+/uy; const NUM_RE = /\d+(?:\.\d+)?/y; /** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */ function parseCond(s: string): CNode | 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 mk = (start: number, body: T): CNode => ({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode; const sticky = (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(), sticky(NAME_RE) ?? fail()); const quoted = (): string => { const end = s.indexOf("'", i); if (end < 0) fail(); const v = s.slice(i, end); i = end + 1; return v; }; /** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */ const skipGroup = (): void => { let depth = 0; let q = false; for (; i < s.length; i++) { if (s[i] === "'") q = !q; else if (!q && s[i] === "(") depth++; else if (!q && s[i] === ")" && --depth === 0) return void i++; } fail(); }; /** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */ const skipRef = (): void => { for (let depth = 0; i < s.length; i++) { const ch = s[i]; if ("({".includes(ch)) depth++; else if (")}".includes(ch) && depth-- === 0) return; else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return; else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return; } }; const fields = (start: number, node: CNode): CNode => { while (take(".")) { const f = take("'") ? quoted() : word(); node = mk(start, { t: "field", a: node, f }); } return node; }; const primary = (): CNode => { ws(); const start = i; if (take("(")) { const a = expr(); need(")"); return fields(start, mk(start, { t: "paren", a })); } if (take("'")) return mk(start, { t: "str", v: quoted() }); const num = sticky(NUM_RE); if (num) return mk(start, { t: "num", v: num }); const w = word(); if (w === "찾기" || w === "로직") { ws(); if (s[i] !== "(") fail(); skipGroup(); return fields(start, mk(start, { t: "ref", v: w })); } if (COND_FUNCS.includes(w)) { need("("); const items = [expr()]; while (take(",")) items.push(expr()); need(")"); return fields(start, mk(start, { t: "call", f: w, items })); } if (s[i] === ":") { i++; skipRef(); return fields(start, mk(start, { t: "ref", v: w })); } return fields(start, mk(start, { t: "name", v: w })); }; const unary = (): CNode => { ws(); const start = i; return take("-") ? mk(start, { t: "neg", a: unary() }) : primary(); }; const chain = (next: () => CNode, ops: string[], once = false): CNode => { ws(); const start = i; let node = next(); for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) node = mk(start, { t: "bin", op, a: node, b: next() }); return node; }; const mul = (): CNode => chain(unary, ["*", "/"]); const add = (): CNode => chain(mul, ["+", "-"]); const compare = (): CNode => chain(add, COMPARE_OPS, true); const both = (): CNode => chain(compare, ["그리고"]); const expr = (): CNode => chain(both, ["또는"]); try { const node = expr(); ws(); return i === s.length ? node : null; } catch { return null; } } /** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */ export type CondSlot = | { kind: "num"; value: number } | { kind: "text"; value: string } | { kind: "input"; name: string } | { kind: "table" | "calc"; src: string }; export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<="; export interface CondTest { left: CondSlot; op: CondOp; right: CondSlot; } export type CondShape = | { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot } | { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] } | { type: "round"; fn: "올림" | "버림" | "반올림"; value: CondSlot; places: number; hadPlaces: boolean; }; /** 덩어리 하나 — p = 읽은 글(text) 안 자리 */ export interface CondBlock { shape: CondShape; p: [number, number]; } /** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */ export interface CondRead { text: string; via: string | null; blocks: CondBlock[]; } const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n); function slotOf(node: CNode, inputs: LogicInput[]): CondSlot { const n = unparen(node); if (n.t === "num") return { kind: "num", value: Number(n.v) }; if (n.t === "neg" && unparen(n.a).t === "num") return { kind: "num", value: -Number(unparen(n.a).src) }; if (n.t === "str") return { kind: "text", value: n.v }; if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v }; if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기")) return { kind: "table", src: n.src }; return { kind: "calc", src: node.src.trim() }; } function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null { if (n.t !== "call") return null; const slot = (x: CNode): CondSlot => slotOf(x, inputs); if (n.f === "만약") { const t = unparen(n.items[0]); if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null; const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) }; const branch = { test, value: slot(n.items[1]) }; const rest = shapeOf(unparen(n.items[2]), inputs); return rest?.type === "if" ? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise } : { type: "if", branches: [branch], otherwise: slot(n.items[2]) }; } if (n.f === "최소" || n.f === "최대") return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null; const places = n.items[1] ? unparen(n.items[1]) : null; if (n.items.length > 2 || (places && places.t !== "num")) return null; const fn = n.f as "올림" | "버림" | "반올림"; const value = slot(n.items[0]); return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places }; } const condNum = (n: number): string => { const t = String(Number(n.toPrecision(12))); return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; }; function slotText(s: CondSlot): string { if (s.kind === "num") return condNum(s.value); if (s.kind === "text") return `'${s.value}'`; return s.kind === "input" ? s.name : s.src; } /** 덩어리 → 식 글 */ export function writeCond(shape: CondShape): string { if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`; if (shape.type === "round") { const v = slotText(shape.value); return shape.places === 0 && !shape.hadPlaces ? `${shape.fn}(${v})` : `${shape.fn}(${v}, ${condNum(shape.places)})`; } return shape.branches.reduceRight((acc, b) => { const t = b.test; return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`; }, slotText(shape.otherwise)); } const flat = (s: string): string => s.replace(/[\s()]+/g, ""); /** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */ function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] { if (n.t === "call") { const shape = shapeOf(n, inputs); if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }]; return n.items.flatMap((x) => blocksOf(x, inputs)); } if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)]; if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs); return []; } /** 식 글 → 덩어리들 · 하나도 못 읽으면 null */ export function readCond(formula: string, row: Pick): CondRead | null { let text = formula.trim(); let via: string | null = null; for (let hop = 0; hop < 5; hop++) { const mid = (row.중간 ?? []).find((m) => m.이름 === text); if (!mid) break; via = mid.이름; text = mid.식.trim(); } const node = parseCond(text); const blocks = node ? blocksOf(node, row.입력 ?? []) : []; return blocks.length ? { text, via, blocks } : null; } /** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */ export function writeRead(read: CondRead): string { let out = read.text; for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0])) out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]); return out; } /* ── 화면 ───────────────────────────────────────────────────────────────── */ const OPS: [CondOp, keyof typeof TEXT][] = [ ["==", "OpEq"], ["!=", "OpNe"], [">", "OpGt"], [">=", "OpGe"], ["<", "OpLt"], ["<=", "OpLe"], ]; /** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */ const TABLE_MODULE = import.meta.glob<{ openEasyTable: ( card: EasyCard, doc: EasyDoc, trial?: Record, ) => Promise; }>("./M01_EasyCalc_Table.ts"); /** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */ const put = (slot: CondSlot, next: CondSlot): void => { for (const k of Object.keys(slot)) delete (slot as Record)[k]; Object.assign(slot, next); }; function select( options: { value: string; text: string }[], value: string, onChange: (v: string) => void, ): HTMLSelectElement { const sel = el("select", { className: "m01-easycond__select" }); const all = options.some((o) => o.value === value) ? options : [...options, { value, text: value }]; for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } })); sel.value = value; sel.addEventListener("change", () => onChange(sel.value)); return sel; } function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement { const box = el("input", { className: "m01-easycond__num", attrs: { type: "text", inputmode: "decimal", value: condNum(value) }, }); box.addEventListener("input", () => { const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value); box.classList.toggle("is-bad", !ok); box.title = ok ? "" : te("NotNumber"); if (ok) onChange(Number(box.value)); }); return box; } function chipLabel(slot: CondSlot, inputs: LogicInput[]): string { if (slot.kind === "table") return te("ChipTable"); if (slot.kind !== "calc") return ""; if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc"); const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? []; return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc"); } /** 문장 짓기 — 고칠 때마다 changed() */ class Sentence { constructor( private card: EasyCard, private doc: EasyDoc, private changed: () => void, private trial: Record, ) {} /** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */ slot(s: CondSlot): HTMLElement { const host = el("span", { className: "m01-easycond__slot" }); const draw = (): void => { host.replaceChildren(); if (s.kind === "num") { host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed()))); return; } if (s.kind === "text" || s.kind === "input") { const text = s.kind === "text" ? s.value : s.name; host.append(el("span", { className: "m01-easycond__word", text })); return; } const chip = el("button", { className: "m01-easycond__chip", text: chipLabel(s, this.doc.inputs), attrs: { type: "button" }, }); const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"]; if (load && s.kind === "table") chip.addEventListener("click", async () => { const got = await ( await load() ).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial); if (got) (put(s, { kind: "table", src: got }), draw(), this.changed()); }); else chip.disabled = true; const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed()); host.append( chip, createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }), ); }; draw(); return host; } /** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */ test(t: CondTest): HTMLElement[] { const inputs = this.doc.inputs; const right = el("span", { className: "m01-easycond__slot" }); const drawRight = (): void => { const name = t.left.kind === "input" ? t.left.name : null; const inp = inputs.find((x) => x.이름 === name); const choices = inp?.고르기 ?? []; const r = t.right; if (!choices.length || (r.kind !== "text" && r.kind !== "num")) return right.replaceChildren(this.slot(r)); const pickOne = (v: string): void => { const asNum = choices.some((c) => typeof c === "number" && String(c) === v); put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v }); this.changed(); }; const opts = choices.map((c) => ({ value: String(c), text: String(c) })); right.replaceChildren(select(opts, String(r.value), pickOne)); }; drawRight(); const l = t.left; const left = l.kind === "input" ? select( inputs.map((x) => ({ value: x.이름, text: x.이름 })), l.name, (v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()), ) : this.slot(l); const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) })); const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed())); return [left, el("span", { text: te("Subject") }), right, op]; } /** 덩어리 하나 → 줄들 */ block(shape: CondShape): HTMLElement[] { if (shape.type === "if") return [ ...shape.branches.map((b, i) => line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)), ), line(te("Else"), this.slot(shape.otherwise)), ]; if (shape.type === "pick") { const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)])); const opts = [ { value: "최대", text: te("Big") }, { value: "최소", text: te("Small") }, ]; const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed())); return [line(...kids, fn)]; } const ways = [ { value: "올림", text: te("Up") }, { value: "버림", text: te("Down") }, { value: "반올림", text: te("Half") }, ]; const setPlaces = (v: number): void => ( (shape.places = Math.max(0, Math.round(v))), this.changed() ); const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed())); const places = numberBox(shape.places, setPlaces); return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)]; } } const line = (...kids: (HTMLElement | string)[]): HTMLElement => el("div", { className: "m01-easycond__line", children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)), }); /** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */ function moneyView(card: EasyCard, doc: EasyDoc, trial: Record) { const box = el("div", { className: "m01-easycond__money", text: te("Waiting") }); const ho = doc.cards.filter( (c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"), ); const at = ho.findIndex((c) => c.id === card.id); let first: [number | null, number | null] | null = null; let seq = 0; let timer = 0; const show = (ans: CalcAnswer | null, plain: string | null): void => { box.classList.remove("is-waiting"); if (!ans || !ans.ok) return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : "")); const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null; const sums = ans.sums ?? {}; const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0); first ??= [lineSum, total]; const part = (label: string, v: number | null, was: number | null): string => v === null ? "" : `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`; const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])]; box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t }))); }; const update = (text: string): void => { box.classList.add("is-waiting"); const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null })); const mine = ++seq; const done = (ans: CalcAnswer | null, plain: string | null = null): void => void (mine === seq && show(ans, plain)); try { calcLater(row, trial, done); } catch { // Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만) window.clearTimeout(timer); timer = window.setTimeout(() => { runCalc({ key: "", inputs: trial, row }).then( (a) => done(a), () => done(null), ); }, 300); } }; return { box, update }; } /** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */ export function openEasyCond( card: EasyCard, doc: EasyDoc, trial: Record = {}, ): Promise { const read = readCond(card.qtyFormula ?? "", doc.row); return new Promise((resolve) => { let done = false; const finish = (v: string | null): void => { if (!done) ((done = true), resolve(v)); }; openModal({ title: te("Title"), closeLabel: te("Cancel"), dialogClass: "m01-easycond", beforeClose: () => (finish(null), true), mount: (body, close) => { if (!read) return void body.append( el("p", { className: "m01-easycond__cannot", text: te("Cannot") }), ); const base = flat(writeRead(read)); const money = moneyView(card, doc, trial); const use = createButton({ label: te("Use"), variant: "filled", disabled: true, onClick: () => (finish(writeRead(read)), close()), }); const changed = (): void => { const text = writeRead(read); use.disabled = flat(text) === base; money.update(text); }; const say = new Sentence(card, doc, changed, trial); const lines = el("div", { className: "m01-easycond__lines" }); read.blocks.forEach((b, i) => { if (read.blocks.length > 1) lines.append( el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }), ); lines.append(...say.block(b.shape)); }); const cancel = createButton({ label: te("Cancel"), variant: "ghost", onClick: () => (finish(null), close()), }); const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] }); body.append(lines, money.box, foot); money.update(writeRead(read)); }, }); }); }