/* ============================================================================= * B09_Estimation_Sangeun_Cover.ts * B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움. * * ⚠ 짝 = `B09_Estimation_Sangeun_Cover.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_719_3_sangeun_cover.py`. * 조작 중 = 이 파일이 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 파이썬 짝으로 정본. * * 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} — * 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만) * 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율 * 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) · * 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면 * 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이 * · 율 식 밖 덧줄(`of` 없음) = 서버 값 그대로 * 고친 줄 = `base`(덮기 전 값) · `covered`(고친 칸) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값 * (두 번 덮어도 같음 — 서버 답이 덮인 채 오면 벗기고 다시 덮음) * ========================================================================== */ import { gap, num, rounddown, total, type Num, type Row } from "./B09_Estimation_Calc_Base"; const PARTS = ["labor", "material", "expense"] as const; export const SG_COVER_KEYS = ["qty", ...PARTS] as const; const DEPTH = 8; /** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */ export const lineId = (n: number, line: Row): string => `${n}:${line.name || ""}`; /** 고칠 수 있는 칸 — 품(qty) + 단가 비목 */ export function fields(line: Row): string[] { const kind = line.kind; if (kind === "sangeun") return ["qty"]; if (kind === "extra") return line.of ? ["qty"] : []; if (kind === "machine") return ["qty", ...PARTS]; if (kind === "labor") return ["qty", "labor"]; if (kind === "material") return ["qty", ...PARTS.filter((p) => p in (line.price || {}))]; if (kind === "blank") return ["qty", ...PARTS.filter((p) => (line[p] ?? null) === null)]; return []; } const money = (price: Num | undefined, qty: Num | undefined): Num => price === null || price === undefined || qty === null || qty === undefined ? null : rounddown(price * qty, 1); function baseOf(lines: Row[], n: number, of: string): Num { if (of === "people") return total( lines .slice(0, n) .filter((l) => l.kind === "labor") .map((l) => l.labor ?? null), ); return total(lines.slice(0, n).map((l) => l[of] ?? null)); } function settle(block: Row, mine: Row, by: Map): void { const first: Row[] = JSON.parse(JSON.stringify(block.lines)); (block.lines as Row[]).forEach((ln, n) => { const cell: Row = mine[lineId(n, ln)] || {}; const can = fields(ln); const keys = can.filter((k) => num(cell[k]) !== null); const qty0: Num = ln.qty ?? null; const price0: Row = { ...(ln.price || {}) }; if (keys.length) { ln.base = { qty: qty0, ...Object.fromEntries(can.filter((p) => p !== "qty").map((p) => [p, price0[p] ?? null])), }; ln.covered = keys; } const qty: Num = keys.includes("qty") ? num(cell.qty) : qty0; if (ln.kind === "sangeun") { const sub: Row = by.get((ln.ref || {}).key) || {}; const price = Object.fromEntries(PARTS.map((p) => [p, sub[p] ?? null])); Object.assign(ln, { qty, price }, ...PARTS.map((p) => ({ [p]: money(price[p], qty) }))); ln.why = (sub.sum ?? null) !== null ? null : `하위 산근 ${sub.no ?? ""} 빈칸`; } else if (ln.kind === "extra") { const of: string | undefined = ln.of; const part = Object.keys(price0)[0] ?? PARTS.find((p) => (ln[p] ?? null) === null) ?? undefined; if (!of || part === undefined) return; const now = baseOf(block.lines, n, of); const then = baseOf(first, n, of); const value0: Num = price0[part] ?? null; let value: Num; if (now === null || qty === null) value = null; else if (value0 !== null && then && qty0) value = value0 * (now / then) * ((qty as number) / qty0); else value = (now * (qty as number)) / 100; Object.assign(ln, { qty, price: { [part]: value }, [part]: money(value, 1) }); ln.why = value !== null ? null : "밑수 빈칸"; } else { const price: Row = { ...price0, ...Object.fromEntries(keys.filter((p) => p !== "qty").map((p) => [p, num(cell[p])])), }; for (const p of can) if (p !== "qty") ln[p] = money(price[p] ?? null, qty); Object.assign(ln, { qty, price }); if (PARTS.every((p) => (ln[p] ?? null) !== null)) ln.why = null; } }); for (const part of PARTS) { const s = total((block.lines as Row[]).map((l) => l[part] ?? null)); block[part] = s === null ? null : rounddown(s, 0); } block.sum = total(PARTS.map((p) => block[p])); block.why = (block.lines as Row[]).find((l) => l.why)?.why ?? null; } function orphansOf(by: Map, cover: Row): Row[] { const out: Row[] = []; for (const [key, lines] of Object.entries(cover)) { const block = by.get(key); const ids = lines && typeof lines === "object" ? Object.keys(lines) : []; for (const lid of ids) { const at = lid.indexOf(":"); const n = at < 0 ? lid : lid.slice(0, at); const name = at < 0 ? "" : lid.slice(at + 1); const ok = /^\d+$/.test(n) && block && Number(n) < block.lines.length; const ln: Row | null = ok ? block!.lines[Number(n)] : null; if (!ln || (ln.name || "") !== name || !fields(ln).length) out.push({ key, line: lid }); } } return out; } /** 산근 답(sg · sg_list · sg_gaps …) + 덮개 → 새 답(받은 답은 안 고침) · 덮개 없으면 받은 값 그대로 */ const bare = (row: Row): Row => { if (!("raw" in row)) return row; const { raw, covered: _covered, ...rest } = row; return { ...rest, ...raw }; }; const gapsOf = (blocks: Row[]): Row[] => blocks .filter((b) => b.sum === null) .map((b) => gap("sg", b.key, `산근 ${b.no} ${b.name}`, b.why || "단가 빈칸")); /** 이미 덮인 답(서버 [저장] · 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음 */ export function coverSangeun(sg: Row, cover: unknown): Row { const covers: Row = cover && typeof cover === "object" ? (cover as Row) : {}; const worn: Row[] = sg.sg || []; const blocks: Row[] = worn.map((b) => ("raw" in b ? b.raw : b)); const orphans = orphansOf(new Map(blocks.map((b) => [b.key, b])), covers); if (!blocks.some((b) => covers[b.key] && Object.keys(covers[b.key]).length)) { if (!worn.some((b) => "raw" in b)) return { ...sg, sg_orphans: orphans }; const rows = ((sg.sg_list || []) as Row[]).map(bare); return { ...sg, sg: blocks, sg_list: rows, sg_gaps: gapsOf(blocks), sg_orphans: orphans }; } const out: Row[] = JSON.parse(JSON.stringify(blocks)); const by = new Map(out.map((b) => [b.key as string, b])); const hit = new Map(); const touch = (key: string, depth: number): boolean => { if (hit.has(key)) return hit.get(key)!; const block = by.get(key); if (!block || depth > DEPTH) return false; hit.set(key, false); // 돎 막음 const subs = (block.lines as Row[]) .filter((l) => l.kind === "sangeun" && (l.ref || {}).key) .map((l) => touch(l.ref.key, depth + 1)); const mine: Row = covers[key] || {}; const got = Object.keys(mine).length > 0 || subs.some(Boolean); hit.set(key, got); if (got) { const raw = JSON.parse(JSON.stringify(block)); settle(block, mine, by); block.raw = raw; } return got; }; for (const b of out) touch(b.key, 0); const listing = ((sg.sg_list || []) as Row[]).map(bare).map((row) => { const b = by.get(row.key); if (!b || !hit.get(row.key)) return row; const n = (b.lines as Row[]).filter((l) => l.covered).length; return { ...row, ...Object.fromEntries([...PARTS, "sum"].map((k) => [k, b[k]])), ...(n ? { covered: n } : {}), raw: Object.fromEntries([...PARTS, "sum"].map((k) => [k, row[k] ?? null])), }; }); return { ...sg, sg: out, sg_list: listing, sg_gaps: gapsOf(out), sg_orphans: orphans }; }