- toDoc · toRow: 호표 인력 · 재료 · 기계경비 로직(GC000267~276) · 그 밖 로직 카드 · 숫자 아닌 수량은 잠금 + 쉬운 말 까닭 - 덧줄 「바탕 * 숫자 / 100」(노무비 · 재료비 · 경비 · 사람 줄 노임 합) = 덧붙임 카드 · 그 밖 = 잠금 - 안 바뀐 줄은 원래 글자 그대로 — 견본 셋 · 마스터 로직 1,469개 왕복 같음 - addCard · addPercent · withInputs · updateCard · removeCard · moveCard(같은 묶음 안) · checkCard · checkName · EasyHistory - 시험 resources/tester/test_m01_easycalc_model.py 14개 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* 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 = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
|
|
const isObj = (v: unknown): v is Record<string, unknown> =>
|
|
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<string>();
|
|
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<string, string> = {
|
|
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<string, unknown>, 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<string, unknown> = 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<string, unknown> = 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<string, unknown>;
|
|
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<PickResult["kind"], CostKind | null> = {
|
|
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<EasyCard>): 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<string, number> = { 인: 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;
|
|
}
|
|
}
|