/* ============================================================================= * M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts * 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 · 724-1 — SK 상세 · 일위대가 줄 모드 같이 씀) — * 서버 셈 답(`/layers/{층}/sangeun/calc`) → 공용 상세(`M01_MasterData_UI_LogicLab_Ho`) 줄 · 계산서 줄 · * 고친 칸(`LineCover` · 호표 차례 열쇠) → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). * 금액은 늘 서버가 셈 — 여기는 줄 모양 · 문서 칸 옮김만. * 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음 * (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만 * 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴) * 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` · * 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만 * SK 단위 환산(728-2) = 서버가 화면 줄을 SK 1 단위 몫으로(`화면배`) — 문서 품 = 화면 품 × 화면배(율 빼고) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ import type { CalcLine, TextAnswer } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { bornOfCid } from "./M02_Born_Tag"; import type { SheetGroup } from "../../A00_Common/calc_sheet"; import { rounddown } from "../../B09_Estimation/B09_Estimation_Calc_Base"; import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo"; 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; /** 둘째 기초 세부 고침(727-2) — 열쇠 = 세부 cid */ 덮개?: Record; 뺀줄?: string[]; 바탕판?: string; /** 넣을 때 수량(744-1) — [되돌림] 값 */ 처음수량?: number; [k: string]: unknown; } /** SK 문서 중 고침이 만지는 칸 */ export interface SkDoc { 키?: string; 이름?: string; 단위?: string; 바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null; 입력값?: Record; 열린입력?: string[]; 덮개?: Record; 더한줄?: SkAdded[]; 뺀줄?: string[]; /** 뺀 더한줄 기억(744-1) — [되살림] = 그 차례에 다시 끼움 */ 뺀더한줄?: { 차례: number; 줄: SkAdded }[]; 고름?: Record; 자재표시?: Record; 바탕판?: string; [k: string]: unknown; } export interface SkLine { 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; 비목?: Record; 묶음?: string | null; cid: string; 율?: number; /** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */ 까닭?: string; /** 둘째 기초(727-2) — 세부 줄 · 뺀 세부 · 그 기초 지문 · 원문번호 */ 펼침?: SkLine[]; 뺀세부?: { cid: string; 이름: string }[]; 바탕판?: string; 꼬리표?: string; /** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */ 바탕값?: { qty?: number; 단가?: number } | null; /** 둘째 기초 세부 줄(744-1) — 그 기초 1 기준단위 몫(M01 원문 · 머리 수량 안 곱함) · 화면 품 칸 */ 원문?: { 수량: number | null; 금액: number | null; 비목?: Record }; /** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */ 품목?: CalcLine["품목"]; /** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */ 옵션꺼짐?: boolean; } export interface SkMat { 차례: number; 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; } export interface SkAnswer { 기준: Record | null; 줄: SkLine[]; 재료?: SkMat[]; 까닭?: string | null; /** 못 셈 줄(725 · 입력 없음 · 수량 없음) — 그 줄만 빠지고 나머지 셈 */ 빠진줄?: { cid: string; 이름: string; 까닭: string }[]; 정체: string[]; 짝잃음: { cid: string; 까닭: string; 바탕?: string }[]; 바탕판: string; 머리?: string; /** 바탕 원문번호(참조 바탕 = 그 키) */ 꼬리표?: string | null; /** 바탕 기준단위 → SK 단위 배(728-2) · 화면 줄을 나눈 배(없으면 1 · 문서 품 = 화면 품 × 화면배) */ 배?: number; 화면배?: number; /** 보기 덧칸(750-1) — 텍스트 수식 글(줄마다 cid · 금액 = SK 줄) · 시험 계산 중간값 · 몸 `보기: false` 면 없음 */ 글?: TextAnswer; 중간?: Record; } 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; /** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */ sub: { k: number; cid: string; scale: number } | null; /** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */ t: number; /** (753-2) 옵션으로 빠진 줄 — 계산서에서 안 그림 */ off: boolean; } /* ── 순수 셈 ── */ const BUNDLE_OF: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; const ADD_RE = /^add:#(\d+)$/; const SUB_RE = /^add:#(\d+)\/(.+)$/; /** 세부 열쇠 `add:#k/세부 cid` → {k, cid} */ export function subOf(key: string): { k: number; cid: string } | null { const m = SUB_RE.exec(key); return m ? { k: Number(m[1]), cid: m[2] } : null; } /** 셈 답 줄 — 둘째 기초 줄은 세부 줄로 바꿔 놓음(정체도 같은 차례) */ export function flatLines(ans: SkAnswer): SkLine[] { return ans.줄.flatMap((l) => l.펼침 ?? [l]); } function flatIds(ans: SkAnswer): string[] { const by = new Map(ans.줄.map((l) => [l.cid, l])); return ans.정체.flatMap((c) => by.get(c)?.펼침?.map((x) => x.cid) ?? [c]); } /** 기초 수 — 로직 바탕 · 더한줄 로직 */ const baseCount = (doc: SkDoc): number => (doc.바탕?.로직 ? 1 : 0) + (doc.더한줄 ?? []).filter((x) => x.종류 === "로직").length; /** 줄 꼬리표(기초 둘 이상일 때만) — 바탕 줄 = 바탕 원문번호 · 세부 줄 = 그 기초 원문번호 · 그 밖 = "" */ export function tagsOf(ans: SkAnswer, doc: SkDoc): Map { const out = new Map(); if (baseCount(doc) < 2) return out; for (const l of ans.줄) { if (l.펼침) for (const x of l.펼침) out.set(x.cid, l.꼬리표 ?? ""); else if (!ADD_RE.test(l.cid) && ans.꼬리표) out.set(l.cid, ans.꼬리표); } return out; } /** 뺀 세부 줄(되살림 띠) */ export const removedSubs = (ans: SkAnswer | null): { cid: string; 이름: string }[] => (ans?.줄 ?? []).flatMap((l) => l.뺀세부 ?? []); /** M01 개정된 둘째 기초 — 저장 때 지문 ≠ 지금 지문 */ export function revisedAdds(ans: SkAnswer | null, doc: SkDoc): number[] { return (ans?.줄 ?? []).flatMap((l) => { const hit = ADD_RE.exec(l.cid); const one = hit ? doc.더한줄?.[Number(hit[1])] : undefined; return one?.바탕판 && l.바탕판 && one.바탕판 !== l.바탕판 ? [Number(hit![1])] : []; }); } 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 t = ans.화면배 ?? 1; const rows: CoverRow[] = []; const row = ( cid: string, l: { 이름: string; 단위?: string; 수량: number | null; 단가: number | null; 금액: number | null; }, parts: Record, extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"]; 옵션꺼짐?: boolean; }, ): CoverRow => { const hit = ADD_RE.exec(cid); const added = hit ? Number(hit[1]) : null; const at = subOf(cid); const one = added === null ? undefined : doc.더한줄?.[added]; const owner = at ? doc.더한줄?.[at.k] : undefined; const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined; const pct = (at?.cid ?? cid).startsWith("pct:"); const keys = Object.keys(parts); const shown = added === null && !at ? (was.get(cid) ?? null) : null; const base = at ? subWas(extra.바탕값) : shown && !pct && shown.qty != null ? { ...shown, qty: shown.qty * t } : shown; 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?.종류 === "재료", sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함 off: !!extra.옵션꺼짐, }; }; for (const l of flatLines(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 subWas = (v: SkLine["바탕값"]): CoverRow["was"] => v ? { qty: v.qty ?? null, 단가: v.단가 ?? null } : null; 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 owner = r.sub ? doc.더한줄?.[r.sub.k] : doc; if (!owner) return; const key = r.sub?.cid ?? r.cid; const covers = { ...(owner.덮개 ?? {}) }; if (COVER_KEYS.some((k) => cover[k] != null)) { const old = cover.원값 ?? memo(r.was); covers[key] = { ...cover, ...(old ? { 원값: old } : {}) }; } else delete covers[key]; if (r.sub && !Object.keys(covers).length) delete owner.덮개; else owner.덮개 = covers; } /** 공용 상세 고침 칸(`LogicLab_Cover.LineCover` 와 같은 꼴) — 품 · 비목 단가(로직 줄 = 1 단위 노 · 재 · 경) */ export type LineCover = Pick; /** 공용 상세 고침 한 줄 → 문서 — 더한줄 = 그 줄 수량 · 단가(요소 줄만) · 바탕 줄 = 덮개 * (열린입력 SK = 품 대신 배율 = 고친 품 ÷ 바탕 품 · 바탕 값과 같은 품 = 덮개 안 둠) · `c` null = 고침 풂 */ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opened: boolean): void { const price = r.priceEdit ? PARTS.map((k) => c?.[k]).find((v) => v != null) : undefined; if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (!one) return; if (c?.qty != null) setQty(one, c.qty * r.t); else if (!c) resetQty(one); // [되돌림] = 넣을 때 수량(744-1) if (price == null) delete one.단가; else one.단가 = price; return; } const cover: SkCover = {}; // 세부 줄 품 = 화면 품 그대로(M01 원문 = 1 기준단위 몫 · 744-1) — 화면배 · 머리 수량 안 곱함 const qty = c?.qty == null ? c?.qty : c.qty * r.t; const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로 // ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함) if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty; if (qty != null && !open && !near(qty, r.was?.qty)) cover.qty = qty; if (r.priceEdit) for (const k of PARTS) if (c?.[k] != null) cover[k] = c[k]; if (cover.배율 != null && near(cover.배율, 1)) delete cover.배율; putCover(doc, r, cover); } /** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */ export function dropLine(doc: SkDoc, r: CoverRow): void { const one = r.sub ? doc.더한줄?.[r.sub.k] : undefined; if (one && r.sub) { one.뺀줄 = [...(one.뺀줄 ?? []).filter((c) => c !== r.sub!.cid), r.sub.cid]; if (one.덮개) delete one.덮개[r.sub.cid]; return; } if (r.added !== null) { forgetAdd(doc, r.added, r.이름); // 뺀더한줄 기억 — 744-1 [되살림] 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 { const gone = /^gone:(\d+)$/.exec(cid); // 뺀 더한줄(744-1) — 그 차례에 다시 끼움 if (gone) return reviveAdd(doc, Number(gone[1])); const at = subOf(cid); const one = at ? doc.더한줄?.[at.k] : undefined; if (one && at) { one.뺀줄 = (one.뺀줄 ?? []).filter((c) => c !== at.cid); if (!one.뺀줄.length) delete one.뺀줄; return; } doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); } /** 둘째 기초 통째 빼기 — 그 더한줄을 뺌(기억 744-1 · 되살림 띠 글 = `name`) */ export function dropBase(doc: SkDoc, k: number, name = ""): void { forgetAdd(doc, k, name); } /** 뺀 더한줄 되살림 띠 항목 — 열쇠 `gone:n` */ export const goneRows = (doc: SkDoc): { cid: string; 이름: string }[] => (doc.뺀더한줄 ?? []).map((g, n) => ({ cid: `gone:${n}`, 이름: goneName(g.줄) })); 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 || (!entries.length && r.금액 == null) ? 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.sub?.cid ?? r.cid).startsWith("logic:"), ...(r.off ? { 옵션꺼짐: true } : {}), }); } } const order = Object.keys(PART_OF); return [...groups.values()] // 751-3 소계 = 줄 합 1 원 버림(품셈 1-2-2 · 서버 `master_cut` 와 같은 값) .map((g) => ({ ...g, 소계: rounddown(g.소계 ?? 0, 0) })) .sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); } /* ── 공용 상세(Ho) 줄 ── */ /** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */ export interface HoItem { 종류: string; 요소: string; 이름?: string; 규격?: string; 단위?: string; 수량: string; 비목?: string; /** 출처 꼬리표(727-2 · 기초 둘 이상) */ 꼬리표?: string; } export interface HoExtra { 이름: string; 식: string; 비목?: string; 꼬리표?: string; } export interface HoView { 호표: HoItem[]; 덧줄: HoExtra[]; /** Ho 줄 차례(호표 → 덧줄)의 셈 줄 — 셈 전 · 못 셈 = null · 별도계상 빠진 줄 = 빈 칸 */ lines: (SkLine | undefined)[] | null; /** Ho 줄 차례 → cid(고침 열쇠 옮김) */ cids: string[]; /** Ho 줄 차례 → 지금 고침(덮개 · 더한줄) */ covers: Record; } const KIND_OF: Record = { labor: "인력", material: "재료", machine: "기계" }; const ELEMENT_KINDS = ["인력", "재료", "기계"]; /** SK 셈 답 → 공용 상세 줄 — 줄 차례 = 서버 `정체`(바탕 남은 호표 · 더한줄 · 바탕 남은 덧줄) · * 바탕 로직 줄을 알면(`base` + 바탕 셈 `bare.정체` 차례) 그 줄 그대로(식 · 요소 · 규격) · * 더한줄 = 문서 줄 · 그 밖(참조 바탕) = 셈 줄로 지음 */ export function hoView( ans: SkAnswer, bare: SkAnswer | null, base: { 호표?: HoItem[]; 덧줄?: HoExtra[] } | null, doc: SkDoc, ): HoView { const head = base?.호표 ?? []; const known = new Map(); if (base && bare) bare.정체.forEach((cid, i) => { const it = i < head.length ? head[i] : base.덧줄?.[i - head.length]; if (it) known.set(cid, it); }); const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l])); const tags = tagsOf(ans, doc); const t = ans.화면배 ?? 1; const single = (l?: SkLine): string | undefined => { const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v); return parts.length === 1 ? parts[0][0] : undefined; }; /** 세부 줄 = 수량만 M01 원문(금액 · 비목은 상자 합 몫 그대로) */ const shown = (l?: SkLine): SkLine | undefined => l?.원문 ? { ...l, 수량: l.원문.수량 ?? l.수량 } : l; const ho: [string, HoItem][] = []; const extra: [string, HoExtra][] = []; for (const cid of flatIds(ans)) { const l = lineBy.get(cid); const name = l?.이름 ?? cid; const at = subOf(cid); const born = bornOfCid(cid, doc.더한줄, tags.get(cid) ?? ""); // 넣은 줄 출처(740-2) — 기초 번호 뒤에 붙임 const tag = born ? { 꼬리표: born } : {}; if ((at?.cid ?? cid).startsWith("pct:")) { extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]); continue; } if (at) { const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직"; const qty = String(l?.율 ?? l?.원문?.수량 ?? l?.수량 ?? ""); // 세부 품 = M01 원문(744-1) const item = { 종류: kind, 요소: "", 이름: name, 단위: l?.단위 ?? "", 수량: qty }; ho.push([cid, { ...item, 비목: single(l), ...tag }]); continue; } const hit = ADD_RE.exec(cid); const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined; const kind = a ? ELEMENT_KINDS.includes(String(a.종류)) ? String(a.종류) : "로직" : (KIND_OF[cid.split(":")[0]] ?? "로직"); const made: HoItem = { 종류: kind, 요소: typeof a?.요소 === "string" ? a.요소 : "", 이름: name, 단위: l?.단위 ?? String(a?.단위 ?? ""), 수량: String(a ? a.수량 / t : (l?.율 ?? l?.수량 ?? "")), 비목: typeof a?.비목 === "string" ? a.비목 : single(l), }; ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]); } const cids = [...ho, ...extra].map(([cid]) => cid); const covers: Record = {}; cids.forEach((cid, i) => { const hit = ADD_RE.exec(cid); const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined; const part = PART_OF[String(a?.비목 ?? "")]; if (a) covers[i] = { qty: a.수량 / t, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) }; const at = subOf(cid); const owner = at ? doc.더한줄?.[at.k] : undefined; const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid]; if (!c) return; const l = lineBy.get(cid); const out: LineCover = {}; const per = at || cid.startsWith("pct:") ? 1 : t; // 율 · 세부 품 = 그대로 if (c.qty != null) out.qty = c.qty / per; else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined; for (const k of PARTS) if (c[k] != null) out[k] = c[k]; covers[i] = out; }); return { 호표: ho.map(([, x]) => x), 덧줄: extra.map(([, x]) => x), lines: ans.줄.length ? cids.map((c) => shown(lineBy.get(c))) : null, cids, covers, }; } /* ── 일위대가 줄 → SK 문서(717-3 · 723-3 줄 모드) ── */ export interface LinePatch { 덮개?: Record; 뺀줄?: string[]; 더한줄?: unknown[]; /** 뺀 더한줄 기억(744-1) */ 뺀더한줄?: SkDoc["뺀더한줄"]; } /** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */ 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 = 그 일위대가 고침 없는 원래 값 · * 고름 · 자재표시(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 */ export function lineDoc( row: PatchRow, ids: string[] | null, patch: LinePatch | null, ): Record { const head = { 키: "SK-000000", 이름: "줄", 단위: "식", 덮개: patch?.덮개 ?? {}, 뺀줄: patch?.뺀줄 ?? [], 더한줄: patch?.더한줄 ?? [], 줄모드: true, // 셈 = 일위대가 그 줄 셈(별도계상 기본 · 750-1 · 서버 `calc_doc`) }; if (row.참조) return { ...head, 바탕: { 참조: row.참조 } }; return { ...head, ...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}), 바탕: { 로직: row.로직 ?? "" }, 입력값: { ...(row.입력값 ?? {}) }, 고름: ids ? keyed(row.고름, ids) : {}, 자재표시: ids ? keyed(row.자재표시, ids) : {}, }; } /** 빈 칸 · 원값 뺀 패치 — 다 비면 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.더한줄] } : {}), ...(patch?.뺀더한줄?.length ? { 뺀더한줄: [...patch.뺀더한줄] } : {}), }; return Object.keys(out).length ? out : null; } /* ── 순수 셈 끝 ── */