/* ============================================================================= * M02_Sangeun_Cover_Model.ts * 단가산출 고친 칸(덮개) 순수 함수(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24) — 화면 · fetch 없음. * · `lineDoc` = 일위대가 줄 → 이름 없는 단가산출(SK) 문서(서버 `M02_Labor_Patch.as_doc` 꼴 · 셈 길 몸) · * 줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 바꿈 * · `setCover` · `cleanPatch` = 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` 고침(숫자만 · `원값`은 서버가 저장 때 셈) * · `sheetGroups` = 셈 답 줄 → 공용 계산서(`A00_Common/calc_sheet`) 묶음(값 = 서버 셈 그대로 · 다시 안 셈) * 계산 자리 = 서버(`M02_Sangeun_Calc`) 단독 — 여기는 문서 모양만. * ========================================================================== */ export const PARTS = ["노무비", "재료비", "경비"] as const; export type Part = (typeof PARTS)[number]; /** 덮개 칸(서버 `M02_Sangeun_Doc.COVER_KEYS`) — qty = 품 · labor · material · expense = 줄 단가 */ export const PART_KEY = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const; export type CoverKey = "qty" | "배율" | (typeof PART_KEY)[Part]; export type Cover = Partial>; export interface LinePatch { 덮개?: Record; 뺀줄?: string[]; 더한줄?: unknown[]; } export type Money = Record; /** 셈 답 한 줄(M01 시험 계산 줄 + cid · 고친 칸 이름) */ export interface SkLine { cid: string; 이름: string; 단위?: string; 수량: number; 단가?: number; 금액?: number; 비목?: Partial>; 율?: number | null; 출처?: string; 덮개?: string[]; } export interface SkCalc { 기준: Money | null; 줄: SkLine[]; 까닭?: string | null; "1단위"?: Money | null; 배?: number; 정체?: string[]; 짝잃음?: { cid: string; 까닭: string }[]; 머리?: string | null; } /** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */ export interface PatchRow { 로직?: string; 참조?: { 층: string; 주인?: string | null; 키: string }; 입력값?: Record; 켜쌓기율?: number | null; 고름?: Record; 자재표시?: Record; 패치?: LinePatch; } const keyed = (value: Record | undefined, ids: string[]) => Object.fromEntries( Object.entries(value ?? {}).flatMap(([n, v]) => /^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [], ), ); /** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */ export const needsIds = (row: PatchRow): boolean => !row.참조 && (Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0); /** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 */ export function lineDoc( row: PatchRow, ids: string[] | null, patch: LinePatch | null, ): Record { const head = { 키: "SK-000000", 이름: "줄", 단위: "식", 덮개: patch?.덮개 ?? {}, 뺀줄: patch?.뺀줄 ?? [], 더한줄: patch?.더한줄 ?? [], }; if (row.참조) return { ...head, 바탕: { 참조: row.참조 } }; return { ...head, ...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}), 바탕: { 로직: row.로직 ?? "" }, 입력값: { ...(row.입력값 ?? {}) }, 고름: ids ? keyed(row.고름, ids) : {}, 자재표시: ids ? keyed(row.자재표시, ids) : {}, }; } /** 단가를 고칠 수 있는 비목 — 금액이 한 비목에만 있는 줄만(덧줄 % 줄 = 율만 · 없음 = null) */ export function priceKey(line: SkLine): CoverKey | null { if (line.율 != null || line.cid.startsWith("pct:")) return null; const parts = PARTS.filter((p) => (line.비목?.[p] ?? 0) !== 0); return parts.length === 1 ? PART_KEY[parts[0]] : null; } /** 품 칸 값 — 덧줄 = 율 · 그 밖 = 수량 */ export const qtyOf = (line: SkLine): number => line.율 ?? line.수량; /** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */ export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null { const covers = Object.fromEntries( Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => { const keep = Object.fromEntries( Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"), ); return Object.keys(keep).length ? [[cid, keep]] : []; }), ); const out: LinePatch = { ...(Object.keys(covers).length ? { 덮개: covers } : {}), ...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}), ...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}), }; return Object.keys(out).length ? out : null; } /** 칸 하나 고침 — `value` null = 그 칸 덮개 풂(원래 값) · 같은 값도 덮개로 둠(사용자가 적은 값) */ export function setCover( patch: LinePatch | null, cid: string, key: CoverKey, value: number | null, ): LinePatch | null { const covers = { ...(patch?.덮개 ?? {}) }; const one: Cover = { ...(covers[cid] ?? {}) }; if (value === null) delete one[key]; else one[key] = value; if (key === "qty") delete one.배율; // 품은 qty · 배율 중 하나(서버 400) covers[cid] = one; return cleanPatch({ ...patch, 덮개: covers }); } const fmt = (v: number | null | undefined): string => v == null ? "-" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); /** 계산서 묶음 — 비목마다 그 비목 금액이 있는 줄 · 식 = 「단가 * 품 = 금액」(한 비목 줄) · 소계 = 서버 기준 */ export function sheetGroups(calc: SkCalc): { 비목: string; 소계: number | null; 줄: { 이름: string; 글: string; 금액: number | null }[]; }[] { return PARTS.flatMap((p) => { const lines = calc.줄.filter((x) => (x.비목?.[p] ?? 0) !== 0); if (!lines.length) return []; return [ { 비목: p, 소계: calc.기준?.[p] ?? null, 줄: lines.map((x) => { const amount = x.비목?.[p] ?? null; const single = priceKey(x) !== null && x.단가 != null; const pct = x.율 != null ? " %" : ""; // 덧줄 = 율 % const text = single ? `${fmt(x.단가)} * ${fmt(qtyOf(x))}` : `${fmt(qtyOf(x))}${pct}`; return { 이름: x.이름, 글: `${text} = ${fmt(amount)}`, 금액: amount }; }), }, ]; }); }