Files
Aislo/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts
T
eomsangdonandClaude Opus 5.5 63235dc203 feat(m01-easycalc): 쉬운 단가산출 Model 속 채움 · 시험
- 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
2026-10-01 09:37:05 +09:00

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;
}
}