/* ============================================================================= * M01_EasyCalc_Model.ts * 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기 * DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로 * * 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic * 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말) * 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked * 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림 * 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤 * ========================================================================== */ import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api"; export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; export type CostKind = "노무비" | "재료비" | "경비"; export interface EasyCard { /** 화면 번호 · 저장 안 함 */ id: string; kind: CardKind; /** 보이는 이름(호표 · 덧줄 이름) */ name: string; /** 크기(규격) · 없으면 "" */ spec: string; /** 인 · ㎥ · 시간 · % … */ unit: string; /** 숫자 수량 · 식이면 null */ qty: number | null; /** 잠금 식(원문 그대로) · 숫자면 null */ qtyFormula: string | null; /** 넣을 자리 · 다른 일위대가 · 기계는 null(엔진이 나눔) */ cost: CostKind | null; /** 덧붙임 바탕 · 덧붙임 아니면 null */ percentBase: CostKind | "사람줄합" | null; /** 호표 「요소」 칸 값 그대로(인력 LB 키 · 재료 검색어 묶음 · 로직 부름 글) */ element: unknown; /** 잠금 까닭 쉬운 말 · 아니면 null */ lockedWhy: string | null; /** 원래 자리 · 새 카드 null */ source: { part: "호표" | "덧줄"; index: number } | null; /** 원래 줄 통째 — 카드가 안 다룬 칸을 저장 때 그대로 살림 */ raw: unknown; } export interface EasyDoc { /** 새 로직 null */ key: string | null; /** 판본(409 대조) */ version: string | null; /** 품셈 기본 = 읽기만 */ isBase: boolean; name: string; spec: string; /** 결과단위 「원/㎡」 */ resultUnit: string; /** 설계 때 정하는 값 */ inputs: LogicInput[]; cards: EasyCard[]; /** 원래 로직 줄(새 로직은 최소 칸 줄) */ row: LogicRow; } /** 물음 셋 고르기 결과 — Pick 이 가져다 씀 */ export interface PickResult { kind: "person" | "material" | "machine" | "logic"; name: string; spec: string; unit: string; element: unknown; qty: number | null; needInputs?: LogicInput[]; } /* ── 글 ─────────────────────────────────────────────────────────────────── */ const TEXT = { WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"], WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"], WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"], WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"], WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"], NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"], Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"], Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"], NoName: ["이름이 비어 있음", "The name is empty"], SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"], SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"], /** {per} {qty} {unit} 을 채움 */ TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"], } as const; let lang = 0; /** 화면 말 고르기 — 0 한국어 · 1 영어 */ export function setEasyModelLanguage(index: number): void { lang = index === 1 ? 1 : 0; } const tx = (key: keyof typeof TEXT): string => TEXT[key][lang]; /* ── 작은 도움 ──────────────────────────────────────────────────────────── */ const COSTS: readonly string[] = ["노무비", "재료비", "경비"]; const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v); /** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */ const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/; const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/; const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/; let seq = 0; const newId = (): string => `c${++seq}`; const clone = (v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T)); const isObj = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); const norm = (s: string): string => s.replace(/\s+/g, ""); /** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */ function numText(n: number): string { const s = String(Number(n.toPrecision(12))); return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s; } /** 바깥을 통째 감싼 괄호 하나 벗김 */ function unwrap(s: string): string { const t = s.trim(); if (!t.startsWith("(") || !t.endsWith(")")) return t; let depth = 0; for (let i = 0; i < t.length; i++) { if (t[i] === "(") depth++; else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t; } return t.slice(1, -1).trim(); } /** 괄호 밖의 + 로 나눔 */ function splitPlus(s: string): string[] { const out: string[] = []; let depth = 0; let cur = ""; for (const ch of s) { if (ch === "(") depth++; if (ch === ")") depth--; if (ch === "+" && depth === 0) { out.push(cur.trim()); cur = ""; } else cur += ch; } out.push(cur.trim()); return out; } const hasName = (formula: string, name: string): boolean => { const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula); }; /** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */ function lockedWhy(formula: string, row: LogicRow): string { const seen = new Set(); const flags = { table: false, cond: false, input: false }; const walk = (f: string, depth: number): void => { if (/찾기\s*\(/.test(f)) flags.table = true; if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true; for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true; if (depth > 6) return; for (const m of row.중간 ?? []) { if (seen.has(m.이름) || !hasName(f, m.이름)) continue; seen.add(m.이름); walk(m.식, depth + 1); } }; walk(formula, 0); if (flags.table) return tx("WhyTable"); if (flags.cond) return tx("WhyCond"); if (flags.input) return tx("WhyInput"); return tx("WhyCalc"); } /* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */ /** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */ function personTerm(qtyText: string, element: unknown): string { const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`; return `${q} * ${String(element)}`; } /** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */ function parsePercent(formula: string): { base: string; rate: number } | null { const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim()); if (!m || !m[1].trim()) return null; return { base: unwrap(m[1]), rate: Number(m[2]) }; } /** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */ function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean { if (persons.length === 0) return false; const terms = splitPlus(base); if (terms.length !== persons.length) return false; return terms.every((t, i) => { const p = persons[i]; const want = personTerm(p.qtyText, p.element); return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`); }); } function baseOf( base: string, persons: { qtyText: string; element: unknown }[], ): EasyCard["percentBase"] { if (isCost(base)) return base; return isPersonSum(base, persons) ? "사람줄합" : null; } /* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */ const qtyTextOf = (line: HoLine): string => String(line.수량 ?? ""); function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard { const kind: CardKind = line.종류 === "인력" ? "person" : line.종류 === "재료" ? "material" : line.종류 === "로직" ? MACHINE_CALL.test(String(line.요소)) ? "machine" : "logic" : "locked"; const qtyText = qtyTextOf(line); const numeric = NUMBER_TEXT.test(qtyText); return { id: newId(), kind, name: line.이름 ?? "", spec: line.규격 ?? "", unit: line.단위 ?? "", qty: numeric ? Number(qtyText) : null, qtyFormula: numeric ? null : qtyText, cost: isCost(line.비목) ? line.비목 : null, percentBase: null, element: clone(line.요소), lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row), source: { part: "호표", index }, raw: clone(line), }; } function addCardOf( line: NamedFormula, index: number, row: LogicRow, persons: { qtyText: string; element: unknown }[], ): EasyCard { const p = parsePercent(line.식); const base = p ? baseOf(p.base, persons) : null; const percent = p !== null && base !== null; return { id: newId(), kind: percent ? "percent" : "locked", name: line.이름 ?? "", spec: "", unit: percent ? "%" : "", qty: percent ? p.rate : null, qtyFormula: percent ? null : line.식, cost: isCost(line.비목) ? line.비목 : null, percentBase: percent ? base : null, element: null, lockedWhy: percent ? null : lockedWhy(line.식, row), source: { part: "덧줄", index }, raw: clone(line), }; } export function toDoc( row: LogicRow, meta: { key: string | null; version: string | null; isBase: boolean }, ): EasyDoc { const ho = row.호표 ?? []; const persons = ho .filter((h) => h.종류 === "인력") .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); return { key: meta.key, version: meta.version, isBase: meta.isBase, name: row.이름 ?? "", spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "", resultUnit: row.결과단위 ?? "", inputs: clone(row.입력 ?? []), cards: [ ...ho.map((h, i) => hoCard(h, i, row)), ...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)), ], row: clone(row), }; } /** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ export function emptyRow(name = "", per = "㎡"): LogicRow { return { 키: "", 원문번호: "", 이름: name, 결과단위: `원/${per}`, 출처: "자체", 입력: [], 중간: [], 호표: [], 덧줄: [], 끝수: null, }; } /** 빈 종이로 새로 */ export function newDoc(name = "", per = "㎡"): EasyDoc { return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false }); } /* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */ const partOf = (c: EasyCard): "호표" | "덧줄" => c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; const TYPE_OF: Record = { person: "인력", material: "재료", machine: "로직", logic: "로직", }; /** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */ function qtyTextFor(c: EasyCard, rawText: unknown): string { if (c.qtyFormula !== null) return c.qtyFormula; if (c.qty === null || !Number.isFinite(c.qty)) return ""; const keep = typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty; return keep ? (rawText as string) : numText(c.qty); } /** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */ function setField(o: Record, k: string, v: string): void { if (v !== "" || k in o) o[k] = v; } function hoLineOf(c: EasyCard): HoLine { const raw = isObj(c.raw) ? clone(c.raw) : {}; const line: Record = raw; if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind]; line.요소 = clone(c.element); setField(line, "이름", c.name); setField(line, "규격", c.spec); setField(line, "단위", c.unit); line.수량 = qtyTextFor(c, raw.수량); if (c.cost !== null) line.비목 = c.cost; else if (isCost(raw.비목)) delete line.비목; return line as unknown as HoLine; } function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string { const rate = numText(c.qty ?? 0); if (c.percentBase === "사람줄합") { const terms = persons.map((p) => personTerm(p.qtyText, p.element)); return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`; } return `${c.percentBase ?? "노무비"} * ${rate} / 100`; } function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula { const raw = isObj(c.raw) ? clone(c.raw) : {}; const line: Record = raw; line.이름 = c.name; const rawText = typeof raw.식 === "string" ? raw.식 : ""; if (c.kind === "percent") { const p = parsePercent(rawText); const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase; line.식 = same ? rawText : percentText(c, persons); } else { line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText); } if (c.cost !== null) line.비목 = c.cost; else if (isCost(raw.비목)) delete line.비목; return line as unknown as NamedFormula; } export function toRow(doc: EasyDoc): LogicRow { const row = clone(doc.row) as LogicRow & Record; row.이름 = doc.name; setField(row, "규격", doc.spec); row.결과단위 = doc.resultUnit; row.입력 = clone(doc.inputs); const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf); const persons = ho .filter((h) => h.종류 === "인력") .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons)); if (ho.length || "호표" in row) row.호표 = ho; if (add.length || "덧줄" in row) row.덧줄 = add; return row; } /* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */ const COST_OF: Record = { person: "노무비", material: "재료비", machine: null, logic: null, }; /** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */ const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind); /** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */ function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] { const last = (hit: (c: EasyCard) => boolean): number => { let at = -1; cards.forEach((c, i) => { if (hit(c)) at = i; }); return at; }; let at = last((c) => groupOf(c) === groupOf(card)); if (at < 0) at = last((c) => partOf(c) === partOf(card)); if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1; const out = cards.slice(); out.splice(at + 1, 0, card); return out; } export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc { const card: EasyCard = { id: newId(), kind: pick.kind, name: pick.name, spec: pick.spec, unit: pick.unit, qty: pick.qty, qtyFormula: null, cost: COST_OF[pick.kind], percentBase: null, element: clone(pick.element), lockedWhy: null, source: null, raw: null, }; return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []); } /** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */ export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc { const inputs = doc.inputs.slice(); for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need)); return inputs.length === doc.inputs.length ? doc : { ...doc, inputs }; } /** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */ export function addPercent( doc: EasyDoc, name: string, base: CostKind | "사람줄합", rate: number | null, cost: CostKind | null, ): EasyDoc { const card: EasyCard = { id: newId(), kind: "percent", name, spec: "", unit: "%", qty: rate, qtyFormula: null, cost, percentBase: base, element: null, lockedWhy: null, source: null, raw: null, }; return { ...doc, cards: insertAt(doc.cards, card) }; } /** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */ export function updateCard(doc: EasyDoc, id: string, patch: Partial): EasyDoc { return { ...doc, cards: doc.cards.map((c) => { if (c.id !== id) return c; const next: EasyCard = { ...c, ...patch, id: c.id }; if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null; return next; }), }; } export function removeCard(doc: EasyDoc, id: string): EasyDoc { return { ...doc, cards: doc.cards.filter((c) => c.id !== id) }; } /** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */ export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc { const from = doc.cards.findIndex((c) => c.id === id); if (from < 0) return doc; const g = groupOf(doc.cards[from]); let to = from + step; while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step; if (to < 0 || to >= doc.cards.length) return doc; const cards = doc.cards.slice(); [cards[from], cards[to]] = [cards[to], cards[from]]; return { ...doc, cards }; } /* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */ /** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */ const TOO_BIG: Record = { 인: 10, 일: 10, 시간: 24, "%": 100 }; const TOO_BIG_ELSE = 10000; export function checkCard(card: EasyCard, doc: EasyDoc): string | null { if (!card.name.trim()) return tx("NoName"); if (card.qtyFormula !== null) return null; if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber"); if (card.qty === 0) return tx("Zero"); if (card.qty < 0) return tx("Negative"); const same = doc.cards.some( (c) => c.id !== card.id && c.kind === card.kind && c.name === card.name && c.spec === card.spec && JSON.stringify(c.element) === JSON.stringify(card.element) && (card.kind !== "percent" || c.percentBase === card.percentBase), ); if (same) return tx("SameLine"); if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) { const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`; return tx("TooBig") .replace("{per}", per) .replace("{qty}", numText(card.qty)) .replace("{unit}", card.unit); } return null; } /** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */ export function checkName(name: string, mine: string[]): string | null { if (!name.trim()) return tx("NoName"); return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null; } /* ── 되돌리기 ───────────────────────────────────────────────────────────── */ const HISTORY_MAX = 200; /** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */ export class EasyHistory { private stack: string[] = []; private saved: string | null = null; push(doc: EasyDoc): void { const s = JSON.stringify(doc); if (this.stack[this.stack.length - 1] === s) return; this.stack.push(s); if (this.saved === null) this.saved = s; if (this.stack.length > HISTORY_MAX) this.stack.shift(); } undo(): EasyDoc | null { if (this.stack.length < 2) return null; this.stack.pop(); return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc; } get canUndo(): boolean { return this.stack.length > 1; } /** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */ get dirty(): boolean { return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved; } markSaved(): void { this.saved = this.stack[this.stack.length - 1] ?? null; } }