/* ============================================================================= * M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts * 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 · * 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 — * 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만). * 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음 * (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만 * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ import type { SheetGroup } from "../../A00_Common/calc_sheet"; export type CoverPart = "labor" | "material" | "expense"; export const PART_OF: Record = { 노무비: "labor", 재료비: "material", 경비: "expense", }; const PARTS: CoverPart[] = ["labor", "material", "expense"]; export interface SkCover { qty?: number; 배율?: number; labor?: number; material?: number; expense?: number; 원값?: { qty?: number; 단가?: number }; } export interface SkAdded { 종류?: string; 수량: number; 단가?: number; [k: string]: unknown; } /** SK 문서 중 고침이 만지는 칸 */ export interface SkDoc { 키?: string; 이름?: string; 단위?: string; 바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null; 입력값?: Record; 열린입력?: string[]; 덮개?: Record; 더한줄?: SkAdded[]; 뺀줄?: string[]; 고름?: Record; 자재표시?: Record; 바탕판?: string; [k: string]: unknown; } export interface SkLine { 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; 비목?: Record; 묶음?: string | null; cid: string; 율?: number; } export interface SkMat { 차례: number; 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; } export interface SkAnswer { 기준: Record | null; 줄: SkLine[]; 재료?: SkMat[]; 까닭?: string | null; 정체: string[]; 짝잃음: { cid: string; 까닭: string; 바탕?: string }[]; 바탕판: string; 머리?: string; } export interface CoverRow { cid: string; 묶음: string; 이름: string; 단위: string; 수량: number | null; 단가: number | null; 금액: number | null; /** 비목 몫 금액 */ parts: Record; /** % 덧줄 — 수량 = 율 · 단가 못 고침 */ pct: boolean; /** 더한줄 차례 · 바탕 줄 = null */ added: number | null; /** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */ apart: boolean; covered: { qty: boolean; price: boolean }; /** 덮개 배율(열린입력 SK) */ 배율: number | null; /** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */ was: { qty: number | null; 단가: number | null } | null; /** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */ revised: boolean; priceEdit: boolean; /** 더한 재료 줄 — 별도계상 고름(기본 계 안) */ apartPick: boolean; } /* ── 순수 셈 ── */ const BUNDLE_OF: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; const OWN_PART: Record = { 인력: "노무비", 재료: "재료비" }; const ADD_RE = /^add:#(\d+)$/; const COVER_KEYS = ["qty", "배율", ...PARTS] as const; const partsOf = (l: { 비목?: Record }): Record => Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0])); /** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */ export function baseValues( bare: SkAnswer | null, ): Map { const out = new Map(); if (!bare) return out; for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 }); for (const m of bare.재료 ?? []) { const cid = bare.정체[m.차례]; if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 }); } return out; } const near = (a: number | null | undefined, b: number | null | undefined): boolean => a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); /** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] { const was = baseValues(bare); const rows: CoverRow[] = []; const row = ( cid: string, l: { 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; }, parts: Record, extra: { 묶음?: string | null; 율?: number; apart?: boolean }, ): CoverRow => { const hit = ADD_RE.exec(cid); const added = hit ? Number(hit[1]) : null; const one = added === null ? undefined : doc.더한줄?.[added]; const cover = added === null ? doc.덮개?.[cid] : undefined; const pct = cid.startsWith("pct:"); const keys = Object.keys(parts); const base = added === null ? (was.get(cid) ?? null) : null; const old = cover?.원값; return { cid, 묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위", 이름: l.이름, 단위: pct ? "%" : (l.단위 ?? ""), 수량: pct ? (extra.율 ?? l.수량) : l.수량, 단가: pct ? null : l.단가, 금액: l.금액, parts, pct, added, apart: !!extra.apart, covered: { qty: !!cover && ("qty" in cover || "배율" in cover), price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null, }, 배율: cover?.배율 ?? null, was: base, revised: !!old && !!base && ((old.qty != null && !near(old.qty, base.qty)) || (old.단가 != null && !near(old.단가, base.단가))), priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), apartPick: one?.종류 === "재료", }; }; for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l)); const seen = new Set(rows.map((r) => r.cid)); for (const m of ans.재료 ?? []) { const cid = ans.정체[m.차례]; if (!cid || seen.has(cid)) continue; rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true })); } return rows; } const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => { if (!base) return undefined; const out: { qty?: number; 단가?: number } = {}; if (base.qty != null) out.qty = base.qty; if (base.단가 != null) out.단가 = base.단가; return Object.keys(out).length ? out : undefined; }; function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void { const covers = { ...(doc.덮개 ?? {}) }; if (COVER_KEYS.some((k) => cover[k] != null)) { const old = cover.원값 ?? memo(r.was); covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) }; } else delete covers[r.cid]; doc.덮개 = covers; } /** 품 고침 — 더한줄 = 그 줄 수량 · 바탕 줄 = 덮개 qty(열린입력 SK = 배율 · 1 = 지움) · 바탕 값과 같거나 빈 값 = 덮개 지움 */ export function setQty(doc: SkDoc, r: CoverRow, v: number | null, opened: boolean): void { if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (one && v !== null) one.수량 = v; return; } const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) }; delete cover.qty; delete cover.배율; if (v !== null && opened && v !== 1) cover.배율 = v; if (v !== null && !opened && !near(v, r.was?.qty)) cover.qty = v; putCover(doc, r, cover); } /** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */ export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void { if (!r.priceEdit) return; if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (!one) return; if (v === null) delete one.단가; else one.단가 = v; return; } const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) }; for (const k of PARTS) delete cover[k]; if (v !== null && !near(v, r.was?.단가)) { const keys = Object.keys(r.parts).filter((k) => PART_OF[k]); const sum = keys.reduce((s, k) => s + r.parts[k], 0); const own = PART_OF[keys[0] ?? ""] ?? PART_OF[OWN_PART[r.묶음] ?? "경비"]; if (keys.length === 1 || !sum) cover[own] = v; else for (const k of keys) cover[PART_OF[k]] = Math.round(((v * r.parts[k]) / sum) * 1e4) / 1e4; } putCover(doc, r, cover); } /** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */ export function dropLine(doc: SkDoc, r: CoverRow): void { if (r.added !== null) { doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added); return; } doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid]; for (const k of ["덮개", "고름", "자재표시"] as const) { const map = { ...((doc[k] as Record | undefined) ?? {}) }; delete map[r.cid]; (doc as Record)[k] = map; } } /** 더한 재료 줄 별도계상 — 켬 = 계 밖(재료 목록만 · 서버 자재표시) · 끔 = 칸 지움(기본 계 안) */ export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void { const one = r.added === null ? undefined : doc.더한줄?.[r.added]; if (!one || one.종류 !== "재료") return; if (on) one.별도계상 = true; else delete one.별도계상; } /** 뺀 줄 되살림 */ export function restoreLine(doc: SkDoc, cid: string): void { doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); } const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); const num = (v: number | null): string => (v == null ? "—" : NUM.format(v)); /** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { const groups = new Map(); const take = (k: string): SheetGroup => { let g = groups.get(k); if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] })); return g; }; for (const r of rows) { const entries = Object.entries(r.parts); for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) { const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가; const amount = r.apart ? null : v; const g = take(k); g.소계 = (g.소계 ?? 0) + (amount ?? 0); g.줄.push({ 이름: r.이름, 글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`, 금액: amount, 묶음: r.묶음, 장비: entries.length > 1 && r.cid.startsWith("logic:"), }); } } const order = Object.keys(PART_OF); return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); } /* ── 순수 셈 끝 ── */