/* ============================================================================= * M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts * 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — * 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴) * · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`. * 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n) * 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n) * 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위) * 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다 * M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정) * 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`. * 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈). * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ export type NumCovers = Record>; export interface TextLine { 자리: string; 글: string; } export interface ConstDoc { 덮개?: Record>; 뺀줄?: string[]; 더한줄?: Record[]; 상수덮개?: NumCovers; 표값덮개?: NumCovers; 글줄?: TextLine[]; 까닭?: Record; [k: string]: unknown; } export type CoverKind = "품" | "단가" | "상수" | "표값" | "식" | "뺌"; const ADD = /^add:#(\d+)(\/.*)?$/; const PARTS = ["labor", "material", "expense"]; /** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */ function put(doc: T, name: string, value: unknown): T { const out: ConstDoc = { ...doc }; const empty = value == null || (Array.isArray(value) ? value.length === 0 : typeof value === "object" && Object.keys(value).length === 0); if (empty) delete out[name]; else out[name] = value; return out as T; } function setNum( doc: T, name: "상수덮개" | "표값덮개", spot: string, n: number, v: number | null, ): T { const all: NumCovers = { ...(doc[name] ?? {}) }; const one = { ...(all[spot] ?? {}) }; if (v == null) delete one[String(n)]; else one[String(n)] = v; if (Object.keys(one).length) all[spot] = one; else delete all[spot]; return put(doc, name, all); } /** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */ export const setConst = ( doc: T, spot: string, n: number, v: number | null, ): T => setNum(doc, "상수덮개", spot, n, v); /** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */ export const setTable = ( doc: T, spot: string, n: number, v: number | null, ): T => setNum(doc, "표값덮개", spot, n, v); /** 글 줄 넣기 — `at` 차례(없으면 끝). */ export function addText(doc: T, spot: string, text: string, at?: number): T { const lines = [...(doc.글줄 ?? [])]; lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text }); return put(doc, "글줄", lines); } export function editText(doc: T, at: number, text: string): T { const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t)); return put(doc, "글줄", lines); } export function dropText(doc: T, at: number): T { return put( doc, "글줄", (doc.글줄 ?? []).filter((_, i) => i !== at), ); } /** 까닭 — 빈 글 = 지움. */ export function setReason(doc: T, key: string, text: string): T { const why = { ...(doc.까닭 ?? {}) }; if (text.trim()) why[key] = text.trim(); else delete why[key]; return put(doc, "까닭", why); } const topAdd = (key: string): boolean => { const hit = ADD.exec(key); return !!hit && !hit[2]; }; /** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) · * 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */ export function restoreM01(doc: T, key?: string): T { let out: ConstDoc = { ...doc }; delete out.상수덮개; delete out.표값덮개; delete out.식덮개; // 식 칸(792-1) delete out.까닭; const why = { ...(doc.까닭 ?? {}) }; if (key == null) { out = { ...out, 덮개: {}, 뺀줄: [] }; const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k))); return put(out, "까닭", kept) as T; } const hit = ADD.exec(key); if (hit && hit[2]) { const k = Number(hit[1]); const sub = hit[2].slice(1); const adds = (doc.더한줄 ?? []).map((x) => ({ ...x })); if (k < adds.length) { const was = adds[k]; const one: Record = { ...was }; delete one.덮개; delete one.뺀줄; const covers = Object.fromEntries( Object.entries((was.덮개 as Record) ?? {}).filter(([c]) => c !== sub), ); const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub); if (Object.keys(covers).length) one.덮개 = covers; if (drop.length) one.뺀줄 = drop; adds[k] = one; } out.더한줄 = adds; } else { out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key)); out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key); for (const name of ["상수덮개", "표값덮개", "식덮개"] as const) { const all = (doc[name] ?? {}) as Record; const got = Object.fromEntries(Object.entries(all).filter(([s]) => s !== key)); out = put(out, name, got); } } delete why[key]; return put(out, "까닭", why) as T; } /** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */ export function dropAddKeys(doc: T, k: number): T { const move = (key: string): string | null => { const hit = ADD.exec(key); if (!hit) return key; const j = Number(hit[1]); return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`; }; let out: ConstDoc = { ...doc }; if (doc.까닭) { const why: Record = {}; for (const [x, v] of Object.entries(doc.까닭)) { const to = move(x); if (to) why[to] = v; } out = put(out, "까닭", why); } if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" })); return out as T; } /** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */ export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] { const cover = doc.덮개?.[spot] ?? {}; const out: CoverKind[] = []; if ("qty" in cover || "배율" in cover) out.push("품"); if (PARTS.some((k) => k in cover)) out.push("단가"); if (doc.상수덮개?.[spot]) out.push("상수"); if (doc.표값덮개?.[spot]) out.push("표값"); if ((doc.식덮개 as Record | undefined)?.[spot]) out.push("식"); if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌"); return out; }