- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음 - 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기 - 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음 - 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함) - 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리 - 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠) - 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
9.7 KiB
TypeScript
224 lines
9.7 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|