Files
Aislo/common_util/common_util_line_stack.ts
T
eomsangdonandClaude Opus 5.5 624fd3a555 feat(812-3): 가져온 단가산출 좌측 차림 — 경쟁사 머리 줄 = 항목 · 변수 대입 = 공식 묶음 · 근거 글 = 글 줄
- 새 M02_Doc_NativeLayout — 원본 글줄 차림 표지로 금액 줄 묶음(= 항목 제목) + 문서 쌓기(기본과 다를 때만) · EST 금액 차례 = 원본 차례(셈 같을 때만) · 합계 절 뺌 · 줄 쌓기 모델 stackOf · saveOf · hiddenOf 파이썬 거울
- M02_Doc_NativeEst.tags — 글줄 차림 머리(단 > 0 · 소계 구간 첫 글 · 번호 글) · 합계 · 글(분수 그림 위아래 줄 뺌)
- M02_San_Emit.stmate — 글 줄 표지 · 원본 차림 표지(계 줄마다 항목)
- M02_Doc_Import_Shape.shape — 차림 tag · apply 부름
- 줄 쌓기 hiddenOf — 가져온 값 옵션 칸도 공식 묶음에 보임(원파일 꼬리표 · 같은 값) · 옵션 가로 칸 고침 = 수 그대로(글이면 셈 「—」 흠 고침)
- 시험 test_812_3 — 견본 다섯(거창 1 · 2 · 41 · 15 · 봉화 14) · 전수 2,220 블록 금액 같음 · TS 왕복 · 거울 100 % · M01 1,518 차림 그대로
- 낡은 시험 고침 test_810_3 — 옵션 칸 ∩ 공식 묶음 = 빈 → 옵션 칸 ⊆ 공식 묶음(812-3 으로 가져온 값이 공식 묶음에도 보임)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:21:35 +09:00

455 lines
20 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 "율";
}
/** 가져온 식 값 옵션 칸 꼬리표(810-3 · 요구 705) — 경쟁사 파일에 값으로 드러난 수(q · K · E · Cm …)는 옵션 가로 칸 ·
* 수 하나 숫자 연결(엑셀 단가 기억 `엑셀` 칸 · 금액 줄 단가로 쓰는 수 빼고) · 원파일 = 「원파일」 · 엑셀 글 = 「엑셀」 · 그 밖 null */
export function sourceOf(doc: FormulaDoc, v: DocVar): string | null {
const head = (doc as { 가져온?: { 원파일?: boolean } }).가져온;
const link = (v.연결 ?? {}) as Link & Record<string, unknown>;
if (!head || link.갈래 !== "숫자" || "엑셀" in link || typeof link.값 === "object") return null;
if ((doc.금액 ?? []).some((l) => priceSym(l) === varId(v))) return null; // 손 단가 = 금액 줄 그대로
return String((v as { 출처?: string }).출처 ?? (head.원파일 ? "원파일" : "엑셀"));
}
/** 차림에 안 넣는 기호 — 옵션 · 금액 줄이 가진 연결(가져온 식 값 옵션 칸은 공식 묶음에도 보임 · 같은 값 · 812-3)
* 파이썬 거울 = `M02_Doc_NativeLayout.py`(stackOf · saveOf · hiddenOf · 가져온 단가산출 차림) — 고칠 때 같이 고침 */
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);
const from = v?.연결?.표 ? { 표: v.연결.표, 칸: v.연결.칸, 조건: v.연결.조건 } : {}; // 표에서 온 기호 = 별칭 · 꼬리표 남김(요구 692)
if (plain || !t) {
doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id);
if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "숫자" })));
const value = t ? Number(t) : null;
v.연결 = { ...from, 갈래: "숫자", 값: Number.isFinite(value) ? value : t };
return;
}
if (v && v.연결?.갈래 !== "식") v.연결 = { ...from, 갈래: "식" };
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;
}