/* ============================================================================= * M01_MasterData/logic/M01_Logic_Sheet_Model.ts * M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴 * 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 개정 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) · * 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」) * 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계) * 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로 * 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음 * ========================================================================== */ import type { CalcAnswer, CalcLine, LogicInput, LogicRow, NamedFormula, } from "../M01_MasterData_UI_Logic_Api"; export type LabPart = "중간" | "호표" | "덧줄"; export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계"; /** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */ export const FIELD: Record = { 중간: "식", 호표: "수량", 덧줄: "식" }; export interface LabNames { 중간: string[]; 호표: string[]; 덧줄: string[]; 이름표?: { 글: string; 갈래: string }[]; } export interface LabItem { kind: LabKind; 이름: string; /** 이름 꼴 식 글 · 빈 글 = 식 없음 */ 식: string; 값: number | string | null; /** 비목 줄임(노 · 재 · 경) */ 비목: string; /** 식 칸 자리(중간 · 호표 · 덧줄) */ part?: LabPart; at?: number; input?: LogicInput; /** 금액번지(791-1) */ 번지?: string; /** 저장본과 식이 다름 */ changed: boolean; } const SHORT: Record = { 노무비: "노", 재료비: "재", 경비: "경" }; const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : ""); /** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */ export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × "); export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`; /** 줄 식 칸 값(엔진 식) */ export function formulaAt(row: LogicRow, part: LabPart, at: number): string { const list = (row[part] ?? []) as unknown as Record[]; return String(list[at]?.[FIELD[part]] ?? ""); } /** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */ export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean { const list = row[part] as unknown as Record[] | undefined; if (!list || at < 0 || at >= list.length) return false; list[at][FIELD[part]] = engine; return true; } /** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */ export function cutText(rule: unknown): string { if (rule == null || rule === "") return ""; if (typeof rule === "string") return rule; const r = rule as Record; return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""] .filter((x) => x !== undefined && x !== "") .join(" · "); } const valueOf = (v: unknown): number | string | null => typeof v === "number" || typeof v === "string" ? v : null; /** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(794-2b · 없으면 빈 글) */ export const revisionText = (row: LogicRow): string => (row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · "); /** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */ export function labItems(o: { row: LogicRow; /** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */ original: LogicRow | null; values: Record; names: LabNames | null; answer: CalcAnswer | null; }): LabItem[] { const { row, original } = o; const out: LabItem[] = []; const item = (x: Partial & { kind: LabKind; 이름: string }): void => void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x }); if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 }); if (row.개정?.length) item({ kind: "근거", 이름: "개정", 식: revisionText(row) }); for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim())) item({ kind: "근거", 이름: "", 식: line.trim() }); for (const input of row.입력 ?? []) item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input }); const ok = o.answer?.ok ? o.answer : null; const mid = ok?.middle ?? {}; const shown = (part: LabPart, at: number): string => o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at)); const changed = (part: LabPart, at: number): boolean => !!original && formulaAt(original, part, at) !== formulaAt(row, part, at); const spot = (part: LabPart, at: number) => ({ part, at, 식: shown(part, at), changed: changed(part, at), }); (row.중간 ?? []).forEach((f: NamedFormula, at) => item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }), ); const ho = row.호표 ?? []; const extra = row.덧줄 ?? []; const lines: (CalcLine | undefined)[] = ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : []; ho.forEach((h, at) => { const b = (h as { 번지?: string }).번지; item({ kind: "자원", 이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""), 값: lines[at]?.금액 ?? null, 비목: short(h.비목), ...(b ? { 번지: b } : {}), ...spot("호표", at), }); }); extra.forEach((x, at) => { const b = (x as { 번지?: string }).번지; item({ kind: "율", 이름: x.이름, 값: lines[ho.length + at]?.금액 ?? null, 비목: short(x.비목), ...(b ? { 번지: b } : {}), ...spot("덧줄", at), }); }); const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[]; for (const s of Array.isArray(steps) ? steps : []) item({ kind: "단계", 이름: String(s.이름 ?? ""), 식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "", }); const cut = cutText(row.끝수); if (cut) item({ kind: "근거", 이름: "끝수", 식: cut }); const sums = ok?.sums ?? null; for (const k of Object.keys(SHORT)) if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] }); if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 }); return out; }