/* ============================================================================= * 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; 이름?: string } | { 갈래: "인력" | "재료" | "기계"; 고른: Picked; 수량: string } | { 갈래: "율" | "손"; 줄: MoneyLine & { 규격?: string } } | { 갈래: "글"; 글: string }; export interface Taken { 이름: string; 출처: string; 조각: TakePiece[]; 계?: number | null; } const COSTS = ["노무비", "재료비", "경비"]; const clone = (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((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): 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 = {}, 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 { const money = doc.금액 ?? []; const out: Record = {}; 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; }