Files
Aislo/common_util/common_util_line_stack.ts
T
eomsangdonandClaude Opus 5.5 eb64bf64ac feat(804-8): M02 단가산출 수식 탭 = M01 과 같은 단추 · 저장 한 번 · 머리 단추 줄바꿈 막음(요구 689)
- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로)
- M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움
- 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로
- 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈
- 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저
- 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:56:31 +09:00

443 lines
18 KiB
TypeScript

/* =============================================================================
* common_util_line_stack.ts
* 줄 쌓기 모델(PLAN 804-2 · 요구 670~677) — 수식 문서 ↔ 줄 쌓기 한 벌 왕복 · 화면 · DOM · fetch 없음(시험이 Node 로 돌림)
* 정본 = 수식 문서 그대로(변수 · 식 · 금액 · 끝수 …) · 줄 쌓기는 그 위의 차림 — 항목(제목 · 가나다) 안 줄:
* 금액 줄(문서 `금액` 차례) · 공식 묶음(기호 목록 · 틀) · 글 줄
* 차림 저장 = 문서 칸 `쌓기`(기본 차림과 다를 때만 · 계산기는 안 봄) — 문서 → 줄 → 문서 = 같은 문서(1,518 전수 시험)
* 기본 차림 = 금액 줄을 `묶음` 이 같은 이어진 줄끼리 항목으로 · 기호는 처음 쓰는 항목의 공식 묶음(쓰는 차례 = 입력 먼저)
* 옵션(선택 · 입력 열쇠) · 금액 줄 단가 하나만 쓰는 연결 기호(요소 · 단가산출)는 공식 묶음에 안 넣음(옵션 가로 · 그 줄이 보임)
* 금액 차례 = 줄 쌓기 차례(율 줄 「노무비」 = 앞 줄 누계라 차례가 셈을 바꿈)
* ========================================================================== */
import {
DocError,
names,
parse,
rename,
splitId,
symId,
textOf,
linkNames,
varId,
type DocVar,
type FormulaDoc,
type Link,
type MoneyLine,
type Node,
} from "./common_util_formula_doc";
import { evalNode } from "./common_util_formula_doc_view";
export type MoneyKind = "인력" | "재료" | "기계" | "단가산출" | "율";
export type StackLine =
| { 꼴: "금액"; i: number }
| { 꼴: "공식"; 기호: string[]; 틀?: string; 틀꼴?: string }
| { 꼴: "글"; 글: string };
export interface StackItem {
제목: string;
가나다?: boolean;
줄: StackLine[];
}
type SaveLine =
{ 금액: number; 이름: string } | { 공식: string[]; 틀?: string; 틀꼴?: string } | { 글: string };
export interface SaveItem {
제목?: string;
가나다?: boolean;
줄: SaveLine[];
}
export type StackDoc = FormulaDoc & { 쌓기?: SaveItem[] };
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
/** 식 글 → 나무(못 풀면 null) */
export function nodeOf(text: unknown): Node | null {
if (text === null || text === undefined || String(text).trim() === "") return null;
try {
return parse(text);
} catch (e) {
if (e instanceof DocError) return null;
throw e;
}
}
const namesIn = (text: unknown): string[] => {
const n = nodeOf(text);
return n ? names(n) : [];
};
/** 문서 기호 전부 — 변수 차례 · 변수 없는 식 줄 기호 */
export function symbols(doc: FormulaDoc): string[] {
const out = (doc.변수 ?? []).map(varId);
for (const l of doc.식 ?? []) {
const id = String(l.기호 ?? "");
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export const isOption = (v: DocVar | undefined): boolean =>
!!v?.열쇠 && (v.연결?.갈래 === "선택" || v.연결?.갈래 === "입력");
/** 기호가 부르는 기호 — 식 줄 · 연결(조건 · 넘길 입력 · 구멍) */
export function usesOf(doc: FormulaDoc): Map<string, string[]> {
const out = new Map<string, string[]>();
const add = (id: string, more: string[]): void => {
const now = out.get(id) ?? [];
for (const n of more) if (!now.includes(n)) now.push(n);
out.set(id, now);
};
for (const v of doc.변수 ?? []) add(varId(v), v.연결 ? linkNames(v.연결) : []);
for (const l of doc.식 ?? []) add(String(l.기호 ?? ""), namesIn(l.식));
return out;
}
/** 단가 식이 기호 하나(또는 그 칸 꺼냄)면 그 기호 */
export function priceSym(line: MoneyLine): string | null {
const n = nodeOf(line.단가);
if (!n) return null;
if (n[0] === "var") return n[1];
if (n[0] === "field" && n[1][0] === "var") return n[1][1];
return null;
}
/** 금액 줄 하나만 단가로 쓰는 연결 기호(요소 · 단가산출) — {줄 차례: 기호} */
export function ownedOf(doc: FormulaDoc): Map<number, string> {
const count = new Map<string, number>();
const bump = (ns: string[]): void => ns.forEach((n) => count.set(n, (count.get(n) ?? 0) + 1));
for (const l of doc.금액 ?? []) bump([...namesIn(l.수량), ...namesIn(l.단가)]);
for (const ns of usesOf(doc).values()) bump(ns);
if (doc.결과) bump([doc.결과]);
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")));
const out = new Map<number, string>();
(doc.금액 ?? []).forEach((l, i) => {
const s = priceSym(l);
const kind = s ? vars.get(s)?.연결?.갈래 : undefined;
if (s && (kind === "요소" || kind === "단가산출") && count.get(s) === 1 && !lines.has(s))
out.set(i, s);
});
return out;
}
/** 금액 줄 갈래 — 인력 · 재료(요소) · 기계(기계 시간당 경비 단가산출) · 단가산출 · 율(식) */
export function kindOf(
doc: FormulaDoc,
i: number,
look?: (key: string) => FormulaDoc | null,
): MoneyKind {
const line = (doc.금액 ?? [])[i] ?? {};
const s = priceSym(line);
const link = s ? (doc.변수 ?? []).find((v) => varId(v) === s)?.연결 : undefined;
if (link?.갈래 === "단가산출") {
const sub = link.키 && look ? look(link.키) : null;
const machine =
(link.입력 && "기계" in link.입력) ||
!!sub?.변수?.some((v) => v.열쇠 === "기계" && isOption(v));
return machine ? "기계" : "단가산출";
}
if (link?.갈래 === "요소") {
const lb = link.키 ? String(link.키).startsWith("LB") : line.비목 === "노무비"; // 고르기 전 = 비목으로
return lb ? "인력" : "재료";
}
return "율";
}
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결 */
export function hiddenOf(doc: FormulaDoc): Set<string> {
const out = new Set(ownedOf(doc).values());
for (const v of doc.변수 ?? []) if (isOption(v)) out.add(varId(v));
return out;
}
/** 쓰는 차례(입력 먼저) — 기호마다 부르는 기호를 먼저 */
function postOrder(doc: FormulaDoc, roots: string[], uses: Map<string, string[]>): string[] {
const out: string[] = [];
const seen = new Set<string>();
const go = (id: string): void => {
if (seen.has(id)) return;
seen.add(id);
for (const n of uses.get(id) ?? []) go(n);
out.push(id);
};
roots.forEach(go);
symbols(doc).forEach(go);
return out;
}
/** 금액 줄 i 가 (거쳐서) 부르는 기호 */
function reachOf(line: MoneyLine, uses: Map<string, string[]>): Set<string> {
const out = new Set<string>();
const go = (id: string): void => {
if (out.has(id)) return;
out.add(id);
(uses.get(id) ?? []).forEach(go);
};
[...namesIn(line.수량), ...namesIn(line.단가)].forEach(go);
return out;
}
/** 문서 → 줄 쌓기(저장 차림 + 안 놓인 것은 기본 자리) */
export function stackOf(doc: StackDoc): StackItem[] {
const money = doc.금액 ?? [];
const all = new Set(symbols(doc));
const hidden = hiddenOf(doc);
const items: StackItem[] = [];
const where = new Map<number, StackItem>();
const placed = new Set<string>();
const saved = Array.isArray(doc.쌓기) ? doc.쌓기 : [];
const taken = new Set<number>();
for (const s of saved) {
const it: StackItem = { 제목: String(s?.제목 ?? ""), 줄: [] };
if (s?.가나다) it.가나다 = true;
for (const l of Array.isArray(s?.줄) ? s.줄 : []) {
if (l && "금액" in l) {
let i = Number(l.금액);
if (!(money[i] && (money[i].이름 ?? "") === l.이름 && !taken.has(i)))
i = money.findIndex((m, k) => !taken.has(k) && (m.이름 ?? "") === l.이름);
if (i < 0 || taken.has(i)) continue;
taken.add(i);
where.set(i, it);
it.줄.push({ 꼴: "금액", i });
} else if (l && "공식" in l) {
const ids = (Array.isArray(l.공식) ? l.공식 : []).filter(
(x) => all.has(x) && !hidden.has(x) && !placed.has(x),
);
ids.forEach((x) => placed.add(x));
const one: StackLine = { 꼴: "공식", 기호: ids };
if (l.틀) Object.assign(one, { 틀: String(l.틀) }, l.틀꼴 ? { 틀꼴: String(l.틀꼴) } : {});
it.줄.push(one);
} else if (l && "글" in l) it.줄.push({ 꼴: "글", 글: String(l.글 ?? "") });
}
items.push(it);
}
// 안 놓인 금액 줄 — 앞 줄 항목(묶음 같으면) › 같은 제목 항목 › 새 항목
money.forEach((m, i) => {
if (taken.has(i)) return;
const title = String(m.묶음 ?? "");
const prev = where.get(i - 1);
let it = prev && prev.제목 === title ? prev : undefined;
if (it && prev) {
const at = prev.줄.findIndex((x) => x.꼴 === "금액" && x.i === i - 1);
prev.줄.splice(at + 1, 0, { 꼴: "금액", i });
} else {
it = saved.length ? items.find((x) => x.제목 === title) : undefined;
if (!it) items.push((it = { 제목: title, 줄: [] }));
it.줄.push({ 꼴: "금액", i });
}
taken.add(i);
where.set(i, it);
});
// 안 놓인 기호 — 처음 쓰는 금액 줄 항목의 첫 공식 묶음(없으면 맨 앞에 새로) · 아무도 안 쓰면 첫 항목
const uses = usesOf(doc);
const reach = money.map((m) => reachOf(m, uses));
const order = postOrder(doc, doc.결과 ? [doc.결과] : [], uses);
for (const id of order) {
if (!all.has(id) || hidden.has(id) || placed.has(id)) continue;
const i = reach.findIndex((r) => r.has(id));
let it = i >= 0 ? where.get(i) : items[0];
if (!it) items.push((it = { 제목: "", 줄: [] }));
let block = it.줄.find((x) => x.꼴 === "공식");
if (!block) it.줄.unshift((block = { 꼴: "공식", 기호: [] }));
(block as { 기호: string[] }).기호.push(id);
placed.add(id);
}
return items;
}
const saveOf = (items: StackItem[], at: (i: number) => number, money: MoneyLine[]): SaveItem[] =>
items.map((it) => ({
...(it.제목 ? { 제목: it.제목 } : {}),
...(it.가나다 ? { 가나다: true } : {}),
줄: it.줄.map((l): SaveLine => {
if (l.꼴 === "금액") return { 금액: at(l.i), 이름: money[l.i]?.이름 ?? "" };
if (l.꼴 === "글") return { 글: l.글 };
return {
공식: [...l.기호],
...(l.틀 ? { 틀: l.틀 } : {}),
...(l.틀꼴 ? { 틀꼴: l.틀꼴 } : {}),
};
}),
}));
/** 줄 쌓기 → 문서 — 금액 = 쌓은 차례(안 쌓인 금액 줄은 지움) · 차림이 기본과 다를 때만 `쌓기` */
export function docOf(doc: StackDoc, items: StackItem[]): StackDoc {
const money = doc.금액 ?? [];
const order: number[] = [];
for (const it of items)
for (const l of it.줄)
if (l.꼴 === "금액" && money[l.i] && !order.includes(l.i)) order.push(l.i);
const out = clone(doc);
delete out.쌓기;
if (doc.금액 !== undefined || order.length) out.금액 = order.map((i) => clone(money[i]));
const pos = new Map(order.map((i, k) => [i, k]));
const live = new Set(symbols(out));
const kept = items.map((it) => ({
...it,
줄: it.줄
.filter((l) => l.꼴 !== "금액" || pos.has(l.i))
.map((l) => (l.꼴 === "공식" ? { ...l, 기호: l.기호.filter((x) => live.has(x)) } : l)),
}));
const save = saveOf(kept, (i) => pos.get(i)!, money);
const plain = saveOf(stackOf(out), (i) => i, out.금액 ?? []);
if (JSON.stringify(save) !== JSON.stringify(plain)) out.쌓기 = save;
return out;
}
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 시험이 봄 · 화면 안내 줄은 804-8 에 걷음) */
export function notesOf(doc: FormulaDoc): string[] {
const out: string[] = [];
if (doc.단계?.length) out.push("단계");
if (doc.조정 && Object.keys(doc.조정).length) out.push("조정");
const money = doc.금액 ?? [];
if (money.some((l) => l.번지 && !/^복사\d+$/.test(l.번지))) out.push("번지"); // 복사N = 복사해 펼친 조각(804-4)
if (money.some((l) => l.합밖)) out.push("합밖");
if (money.some((l) => l.자리)) out.push("줄 자리");
if (money.some((l) => l.기호)) out.push("금액 줄 기호");
return out;
}
// ── 고침 ───────────────────────────────────────────────────────────────
/** 안 쓰는 새 기호 — `기호_아래N` */
export function freshId(doc: FormulaDoc, sign: string, low = ""): string {
const have = new Set(symbols(doc));
if (low && !have.has(symId(sign, low))) return symId(sign, low);
for (let n = 1; ; n++) {
const id = symId(sign, `${low}${n}`);
if (!have.has(id)) return id;
}
}
export function newVar(id: string, link: DocVar["연결"], name = ""): DocVar {
const [기호, 아래, 위] = splitId(id);
return { 기호, 아래, 위, 이름: name, 단위: "", 연결: link };
}
/** 기호 지움 — 변수 · 식 줄(다른 식이 부르면 「정의 필요」 표지 · 오류 없음) */
export function dropSyms(doc: FormulaDoc, ids: string[]): void {
const gone = new Set(ids);
doc.변수 = (doc.변수 ?? []).filter((v) => !gone.has(varId(v)));
doc.식 = (doc.식 ?? []).filter((l) => !gone.has(String(l.기호 ?? "")));
}
/** 끌어 옮김(804-5) — 줄 `[i, j]` → 항목 k 의 p 자리(빼기 전 차례 · 같은 항목 아래로면 하나 당김) */
export function moveLine(items: StackItem[], i: number, j: number, k: number, p: number): void {
const [line] = items[i].줄.splice(j, 1);
if (!line || !items[k]) return void (line && items[i].줄.splice(j, 0, line));
items[k].줄.splice(Math.min(i === k && p > j ? p - 1 : p, items[k].줄.length), 0, line);
}
/** 끌어 옮김(804-5) — 항목 i → p 자리(빼기 전 차례) */
export function moveItem<T>(items: T[], i: number, p: number): void {
const [it] = items.splice(i, 1);
if (it !== undefined) items.splice(Math.min(p > i ? p - 1 : p, items.length), 0, it);
}
/** 공식 줄 값 · 식 고침 — 수 = 숫자 연결 · 식 = 식 줄 */
export function setRow(doc: FormulaDoc, id: string, text: string): void {
const t = text.trim().replace(/,/g, "");
const n = nodeOf(t);
const plain = n && (n[0] === "num" || (n[0] === "neg" && n[1][0] === "num"));
let v = (doc.변수 ?? []).find((x) => varId(x) === id);
if (plain || !t) {
doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id);
if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "숫자" })));
const value = t ? Number(t) : null;
v.연결 = { 갈래: "숫자", 값: Number.isFinite(value) ? value : t };
return;
}
if (v && v.연결?.갈래 !== "식") v.연결 = { 갈래: "식" };
const line = (doc.식 ?? []).find((l) => l.기호 === id);
if (line) line.식 = text.trim();
else (doc.식 ??= []).push({ 기호: id, 식: text.trim() });
}
/** 기호 이름 바꿈 — 문서 전부(`rename`) + 차림 */
export function renameSym(doc: StackDoc, old: string, now: string): void {
const [s, lo, up] = splitId(now);
rename(doc, old, s, lo, up);
for (const it of doc.쌓기 ?? [])
for (const l of it.줄) if ("공식" in l) l.공식 = l.공식.map((x) => (x === old ? now : x));
}
/** 공식 묶음 꼴 — 틀과 견줌(이름 · 식 · 연결 갈래 · 숫자 값은 안 봄) */
export function shapeOf(doc: FormulaDoc, ids: string[]): string {
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
return JSON.stringify(
ids.map((id) => {
const t = lines.get(id);
const n = t === undefined ? null : nodeOf(t);
return [id, n ? textOf(n) : t !== undefined ? String(t) : (vars.get(id)?.연결?.갈래 ?? "식")];
}),
);
}
/** 틀 펼침 — 다른 문서(틀 · 단가산출 결과)의 기호 · 식을 이 문서로 옮김(겹치는 기호 = 아래첨자 끝 수)
* `target` = 결과를 받을 이 문서 기호(단가산출 연결 → 식) · 없으면 결과 기호 그대로 · 돌려줌 = 옮긴 기호(입력 먼저) */
export function unfold(doc: FormulaDoc, src: FormulaDoc, target?: string): string[] {
const sub = clone(src);
const result = sub.결과 ?? "";
const mine = new Set(symbols(doc).filter((x) => x !== target));
const own = new Set(symbols(sub));
const taken = new Set([...mine, ...own]);
for (const id of symbols(sub)) {
if (id === result && target) continue;
if (!mine.has(id) && id !== target) continue;
const [s, lo, up] = splitId(id);
let n = 2;
while (taken.has(symId(s, `${lo}${n}`, up))) n++;
rename(sub, id, s, `${lo}${n}`, up);
taken.add(symId(s, `${lo}${n}`, up));
}
const given = target ? ((doc.변수 ?? []).find((v) => varId(v) === target)?.연결?.입력 ?? {}) : {};
let resultId = sub.결과 ?? "";
if (target && resultId && resultId !== target) {
const [s, lo, up] = splitId(target);
rename(sub, resultId, s, lo, up);
resultId = target;
}
const keys = new Set((doc.변수 ?? []).map((v) => v.열쇠).filter(Boolean));
const moved: string[] = [];
for (const v of sub.변수 ?? []) {
const id = varId(v);
if (id === resultId && target) continue;
if (v.열쇠 && v.열쇠 in given) {
v.연결 = { 갈래: "식" };
(sub.식 ??= []).push({ 기호: id, 식: String(given[v.열쇠]) });
delete v.열쇠;
} else if (v.열쇠 && keys.has(v.열쇠)) delete v.열쇠;
(doc.변수 ??= []).push(v);
}
for (const l of sub.식 ?? []) (doc.식 ??= []).push(l);
if (target) {
const t = (doc.변수 ?? []).find((v) => varId(v) === target);
const r = (src.변수 ?? []).find((v) => varId(v) === src.결과);
if (t) {
t.연결 = { 갈래: "식" };
if (!t.단위) t.단위 = r?.단위 || src.결과단위 || "";
}
}
const uses = usesOf(sub);
for (const id of postOrder(sub, resultId ? [resultId] : [], uses))
if (
symbols(sub).includes(id) &&
!moved.includes(id) &&
!isOption(sub.변수?.find((v) => varId(v) === id))
)
moved.push(id);
return moved;
}
/** 단가산출 연결이 넘길 입력 값 — 부른 쪽 값으로 셈(펼침이 같은 계산기로 그 문서를 셈) */
export function subInputs(
link: Link | undefined,
vals: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [name, t] of Object.entries(link?.입력 ?? {})) {
const n = nodeOf(t);
const v = n ? evalNode(n, vals) : undefined;
if (v !== undefined) out[name] = v;
}
return out;
}