Files
Aislo/common_util/common_util_line_stack_copy.ts
T
eomsangdonandClaude Opus 5.5 9c43296145 feat(804-4): 줄 쌓기 다른 단가산출 · 일위대가 복사해 펼침 — M01 은 M01 전체만
- M02 단가산출 줄 쌓기 [+ 줄] 「다른 단가산출 · 일위대가 복사」 = 찾기 창 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 막음)
- 고름 = 그 문서 전체를 복사해 이 단가산출 항목 · 줄로 펼침(참조 줄 없음 · 원본 연결 없음 · 머리 글 「○○에서 복사」 · 옵션 = 고른 값으로 굳힘 · 기호 겹침 = 새 이름)
- 일위대가 = 로직 줄 → M01 문서 × 수량 ÷ 배 · 참조 SK → 그 SK 문서 × 수량 ÷ 배 · 고정 노임 · 자재 · 기계 · 손 단가 · 율(비목 기준) · 못 옮김 = 「⚠」 글 줄
- 율 줄 밑 = 그 조각 줄만(금액번지 복사N) — 앞 줄 · 다른 조각 안 섞임
- 서버 조각 길 GET …/sangeun/{키}/doc/take?src= (M02_Doc_Take) · 화면 넣기 common_util_line_stack_copy.ts
- 빈 SK(바탕 · 줄 없음)에 수식 길로 저장한 문서가 빈 씨로 덮이던 것 고침(M02_Doc_Sk.settle 차례)
- [단가 갱신] = 사본 값 줄 자원 단가만 지금 M01(구조 · 품 · 식 그대로)
- 시험 test_804_4_line_stack_take 6 · helper_804_2 「복사」 할일

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

198 lines
8.3 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 });
}
}