Files
Aislo/common_util/common_util_line_stack_copy.ts
T
eomsangdonandClaude Opus 5.5 af576e5a7a feat(804-5): 줄 쌓기 왼쪽 손질 — 자원 이름 단추 · 항목 띠 · 끌어 옮기기 · X 지움 되돌리기 · 율 번지 읽는 이름
- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음
- 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기
- 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음
- 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함)
- 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리
- 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠)
- 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함

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

224 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_line_stack_copy.ts
* 줄 쌓기 복사해 펼침(PLAN 804-4 · 요구 680-1 · 681) — 화면 · DOM · fetch 없음(시험이 Node 로 돌림)
* M02 단가산출 줄 쌓기에서 M01 단가산출 · M02 단가산출 · M02 일위대가를 고름 = 서버가 준 조각
* (`M02_Doc_Take.take`)을 이 문서의 항목 · 줄로 넣음 — 원본과 연결 없음 · 머리 글 「○○에서 복사」
* 문서 조각 = 그 문서 변수 · 식 · 금액 · 차림 전체(기호 겹침 = 아래첨자 끝 수 · 옵션 = 고른 값으로 굳힘 ·
* 곱 = 금액 줄 수량 × 곱(율 줄 = 그대로 — 밑이 곱해짐))
* 율 줄 밑(`노무비` 등 앞 줄 누계)은 그 조각 줄만 — 조각마다 금액번지 `복사N` 에 쌓고 `번지.복사N.노무비` 로 바꿈
* (앞에 있던 줄 · 다른 조각이 섞이지 않음) · 일위대가 율 줄 밑 = 그 일위대가 조각 번지 합
* 줄 조각 = 인력 · 재료 · 기계(`addPicked` + 수량) · 손 단가 · 율 = 금액 줄 · 글 = 글 줄(못 옮김 「⚠」)
* ========================================================================== */
import {
names,
parse,
textOf,
varId,
type DocVar,
type FormulaDoc,
type MoneyLine,
type Node,
} from "./common_util_formula_doc";
import {
isOption,
nodeOf,
renameSym,
stackOf,
symbols,
type MoneyKind,
type StackDoc,
type StackItem,
type StackLine,
} from "./common_util_line_stack";
import { addPicked, type Picked } from "./common_util_line_stack_pick";
import { splitId, symId } from "./common_util_formula_doc";
export type TakePiece =
| { 갈래: "문서"; 문서: FormulaDoc; 입력?: Record<string, unknown>; 곱?: string; 이름?: string }
| { 갈래: "인력" | "재료" | "기계"; 고른: Picked; 수량: string }
| { 갈래: "율" | "손"; 줄: MoneyLine & { 규격?: string } }
| { 갈래: "글"; 글: string };
export interface Taken {
이름: string;
출처: string;
조각: TakePiece[];
계?: number | null;
}
const COSTS = ["노무비", "재료비", "경비"];
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
const PLACE = "복사";
/** 앞 줄 누계(노무비 · 재료비 · 경비) → 그 번지들 합 */
function scoped(n: Node, places: string[]): Node {
if (n[0] === "var" && COSTS.includes(n[1])) {
const one = (p: string): Node => ["field", ["field", ["var", "번지"], p], n[1]] as Node;
return places.slice(1).reduce<Node>((a, p) => ["bin", "+", a, one(p)] as Node, one(places[0]));
}
if (!Array.isArray(n)) return n;
return n.map((x) => (Array.isArray(x) ? scoped(x as Node, places) : x)) as unknown as Node;
}
const usesRunning = (t: unknown): boolean => {
const n = nodeOf(t);
return !!n && names(n).some((x) => COSTS.includes(x));
};
const rescope = (t: string | null | undefined, places: string[]): string | null | undefined => {
if (!places.length || !usesRunning(t)) return t;
return textOf(scoped(parse(t), places));
};
function freshPlace(doc: FormulaDoc): string {
const have = new Set((doc.금액 ?? []).map((l) => String(l.번지 ?? "")));
for (let n = 1; ; n++) if (!have.has(`${PLACE}${n}`)) return `${PLACE}${n}`;
}
/** 옵션 → 고른 값으로 굳힘(열쇠 지움 · 수 = 숫자 · 글 = 식 「'글'」) */
function freeze(s: FormulaDoc, given: Record<string, unknown>): void {
for (const v of s.변수 ?? []) {
if (!isOption(v)) continue;
const link = v.연결 ?? {};
const key = String(v.열쇠);
let val: unknown = key in given ? given[key] : link.값;
const list = (link.목록 ?? []) as unknown[];
if (link.갈래 === "선택") {
const hit = list.find((x) => String(x) === String(val));
val = hit !== undefined ? hit : val === undefined || val === null ? list[0] : val;
}
delete (v as DocVar & { 열쇠?: string }).열쇠;
if (typeof val === "number") v.연결 = { 갈래: "숫자", 값: val };
else if (val === undefined || val === null || val === "") v.연결 = { 갈래: "숫자", 값: "" };
else {
v.연결 = { 갈래: "식" };
(s.식 ??= []).push({ 기호: varId(v), 식: `'${String(val).replace(/'/g, "")}'` });
}
}
}
/** 문서 조각 하나 → 이 문서에 붙임 · 돌려줌 = 그 조각 항목들(금액 차례 = 이 문서) · 번지 이름 */
export function copyDoc(
doc: StackDoc,
src: FormulaDoc,
given: Record<string, unknown> = {},
factor?: string,
): { items: StackItem[]; place: string | null } {
const s = clone(src) as StackDoc;
const mine = new Set([
...symbols(doc),
...(doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean),
]);
const taken = new Set([
...mine,
...symbols(s),
...(s.금액 ?? []).map((l) => String(l.기호 ?? "")),
]);
const ids = [...symbols(s), ...(s.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)];
for (const id of ids) {
if (!mine.has(id)) continue;
const [sg, lo, up] = splitId(id);
let n = 2;
while (taken.has(symId(sg, `${lo}${n}`, up))) n++;
renameSym(s, id, symId(sg, `${lo}${n}`, up));
taken.add(symId(sg, `${lo}${n}`, up));
}
freeze(s, given);
const money = s.금액 ?? [];
const place = money.length ? freshPlace(doc) : null;
for (const l of money) {
const running = usesRunning(l.단가) || usesRunning(l.수량);
if (place) {
l.단가 = rescope(l.단가, [place]) as string;
l.수량 = rescope(l.수량, [place]);
l.번지 = place;
}
if (factor && !running) l.수량 = l.수량 ? `(${l.수량}) × (${factor})` : factor;
}
const items = stackOf({ ...s, 금액: money }); // 원본 차림(쌓기 · 묶음) 그대로 — 금액 차례 = s
const at = (doc.금액 ??= []).length;
doc.금액.push(...money);
(doc.변수 ??= []).push(...(s.변수 ?? []));
(doc.식 ??= []).push(...(s.식 ?? []));
const shift = (l: StackLine): StackLine => (l.꼴 === "금액" ? { 꼴: "금액", i: l.i + at } : l);
return { items: items.map((it) => ({ ...it, 줄: it.줄.map(shift) })), place };
}
/** 받은 조각 전부 → 새 항목(제목 = 이름 · 첫 줄 = 「출처에서 복사」) · 제목 있는 조각 항목 = 가나다 */
export function addTaken(doc: StackDoc, items: StackItem[], t: Taken): void {
const head: StackItem = {
제목: t.이름,
줄: [{ 꼴: "글", 글: `${t.출처} 「${t.이름}」에서 복사` }],
};
items.push(head);
let cur = head;
let inDoc = false; // 지금 항목 = 문서 조각 항목(뒤 줄 조각은 새 항목)
const places: string[] = [];
let lone: string | null = null; // 줄 조각(인력 · 재료 · 기계 · 손) 번지 — 율 밑에 듦
const pushMoney = (line: MoneyLine): void => {
const n = (doc.금액 ??= []).length;
doc.금액.push(line);
cur.줄.push({ 꼴: "금액", i: n });
};
const rates: MoneyLine[] = [];
for (const p of t.조각) {
if (p.갈래 === "문서") {
const got = copyDoc(doc, p.문서, p.입력 ?? {}, p.곱);
if (got.place) places.push(got.place);
const many = got.items.length > 1 || got.items.some((it) => it.제목);
if (t.조각.filter((x) => x.갈래 === "문서").length > 1 || many) {
const name = p.이름 ?? "";
got.items.forEach((it, k) => {
const title = k || !name ? it.제목 : it.제목 ? `${name} · ${it.제목}` : name;
items.push({ ...it, 제목: title, 가나다: true });
});
cur = items[items.length - 1];
inDoc = true;
} else got.items.forEach((it) => cur.줄.push(...it.줄));
} else if (p.갈래 === "글") cur.줄.push({ 꼴: "글", 글: p.글 });
else if (p.갈래 === "율") rates.push({ ...p.줄 });
else {
if (inDoc) items.push((cur = { 제목: "", 가나다: true, 줄: [] }));
inDoc = false;
lone ??= freshPlace(doc);
if (!places.includes(lone)) places.push(lone);
if (p.갈래 === "손") pushMoney({ ...p.줄, 번지: lone });
else if ("고른" in p) {
const kind = p.갈래 as MoneyKind;
addPicked(doc, cur, kind, p.고른);
const line = doc.금액![doc.금액!.length - 1];
line.수량 = p.수량;
line.번지 = lone;
}
}
}
if (rates.length) {
cur = items[items.length - 1];
for (const r of rates) pushMoney({ ...r, 단가: rescope(r.단가, places) as string });
}
}
/** 번지 읽는 이름(804-5) — 산출근거 「번지.복사N.노무비」 대신 · 그 번지 줄만 든 항목 제목 · 없으면 줄 이름(둘까지 · 「외 n」) */
export function placeNames(doc: StackDoc, items: StackItem[]): Record<string, string> {
const money = doc.금액 ?? [];
const out: Record<string, string> = {};
for (const p of new Set(money.map((l) => String(l.번지 ?? "")).filter(Boolean))) {
const mine = (l: StackLine): boolean => l.꼴 === "금액" && money[l.i]?.번지 === p;
const holds = items.filter((it) => it.줄.some(mine));
const only = holds.every((it) =>
it.줄.every((l) => l.꼴 !== "금액" || mine(l) || !money[l.i]?.번지),
);
let title = only ? (holds.find((it) => it.제목.trim())?.제목.trim() ?? "") : "";
// 여러 항목에 걸친 조각 = 첫 항목 「조각 이름 · 항목 제목」의 조각 이름(addTaken 꼴)
// ponytail: 항목 제목에 「 · 」가 있으면 덜 자름 — 조각 이름을 문서에 따로 적게 되면 그것으로
if (title && holds.length > 1 && title.lastIndexOf(" · ") > 0)
title = title.slice(0, title.lastIndexOf(" · "));
const lines = money.filter((l) => l.번지 === p).map((l) => String(l.이름 ?? "").trim());
out[p] =
title ||
(lines.length > 2
? `${lines[0]} 외 ${lines.length - 1}`
: lines.filter(Boolean).join(" · ")) ||
p;
}
return out;
}