/* ============================================================================= * spreadsheet_functions_misc.ts (주인 B) * 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는 * `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27). * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. * ========================================================================== */ import { ZERO, cmp, frac, mul, parseDecimal, roundAt, toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types"; import { formatValue } from "./spreadsheet_numfmt"; import { bool, fracToDouble, fromDouble, isErr, isFrac, isRange, mkErr, num, str, toBool, toScalar, toText, } from "./spreadsheet_functions_math"; const ONE = frac(1n); /** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */ function wildcardToSearchRegex(text: string): RegExp { const escaped = text .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); return new RegExp(escaped, "i"); } // ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── function groupThousands(digits: string): string { return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function formatFixed(n: Frac, decimals: number, commas: boolean): string { const d = Math.max(0, decimals); const rounded = roundAt(n, d, "round"); const negative = cmp(rounded, ZERO) < 0; const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded; const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round"); const digits = scaled.toString().padStart(d + 1, "0"); let whole = d > 0 ? digits.slice(0, -d) : digits; const fraction = d > 0 ? digits.slice(-d) : ""; if (commas) whole = groupThousands(whole); const body = fraction ? `${whole}.${fraction}` : whole; return negative ? `-${body}` : body; } function collectBools(args: LazyArg[]): boolean[] | ErrorValue { const out: boolean[] = []; for (const a of args) { const v = a(); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const cell = v.at(r, c); if (isErr(cell)) return cell; if (typeof cell === "boolean") out.push(cell); else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0); } } } else if (isErr(v)) { return v; } else if (v !== null) { const b = toBool(v); if (isErr(b)) return b; out.push(b); } } return out; } // ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ─────────────────────── // 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) — // 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖). const MS_PER_DAY = 86400000; const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; function serialToDate(serial: number): Date { return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); } function dateToSerial(d: Date): number { return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; } /** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */ function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue { const start = serialToDate(startSerial); const end = serialToDate(endSerial); if (end.getTime() < start.getTime()) return mkErr("#NUM!"); const u = unit.toUpperCase(); const sy = start.getUTCFullYear(); const sm = start.getUTCMonth(); const sd = start.getUTCDate(); const ey = end.getUTCFullYear(); const em = end.getUTCMonth(); const ed = end.getUTCDate(); if (u === "Y") { let y = ey - sy; if (em < sm || (em === sm && ed < sd)) y--; return y; } if (u === "M") { let m = (ey - sy) * 12 + (em - sm); if (ed < sd) m--; return m; } if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY); if (u === "YM") { let m = em - sm; if (ed < sd) m--; return ((m % 12) + 12) % 12; } if (u === "MD") { let d = ed - sd; if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate(); return d; } if (u === "YD") { let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY); if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY); return days; } return mkErr("#NUM!"); } /** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */ function timeParts(serial: number): { h: number; mnt: number; s: number } { const dayFrac = serial - Math.floor(serial); const totalSeconds = Math.round(dayFrac * 86400); return { h: Math.floor(totalSeconds / 3600) % 24, mnt: Math.floor(totalSeconds / 60) % 60, s: totalSeconds % 60, }; } // ── 함수 표 ────────────────────────────────────────────────────────────── export const MISC_FUNCTIONS: Record = { IF: { min: 2, max: 3, call: (args) => { const c = bool(args[0]()); if (isErr(c)) return c; if (c) return args[1](); return args.length > 2 ? args[2]() : false; }, }, AND: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.every(Boolean); }, }, OR: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.some(Boolean); }, }, NOT: { min: 1, max: 1, call: (args) => { const b = bool(args[0]()); return isErr(b) ? b : !b; }, }, IFERROR: { min: 2, max: 2, call: (args) => { const v = args[0](); if (!isRange(v) && isErr(v)) return args[1](); return v; }, }, CONCATENATE: { min: 1, call: (args) => { let out = ""; for (const a of args) { const v = a(); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const t = toText(v.at(r, c)); if (isErr(t)) return t; out += t; } } } else { const t = str(v); if (isErr(t)) return t; out += t; } } return out; }, }, FIXED: { min: 1, max: 3, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const decArg = args.length > 1 ? num(args[1]()) : frac(2n); if (isErr(decArg)) return decArg; const decimals = Math.trunc(fracToDouble(decArg)); const noCommaArg = args.length > 2 ? bool(args[2]()) : false; if (isErr(noCommaArg)) return noCommaArg; return formatFixed(n, decimals, !noCommaArg); }, }, TEXT: { min: 2, max: 2, call: (args) => { const v = toScalar(args[0]()); if (isErr(v)) return v; const code = str(args[1]()); if (isErr(code)) return code; return formatValue(v, code).글; }, }, LEN: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : frac(BigInt(s.length)); }, }, LEFT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return s.slice(0, n); }, }, RIGHT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return n === 0 ? "" : s.slice(-n); }, }, MID: { min: 3, max: 3, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const startN = num(args[1]()); if (isErr(startN)) return startN; const lenN = num(args[2]()); if (isErr(lenN)) return lenN; const start = Math.trunc(fracToDouble(startN)); const len = Math.max(0, Math.trunc(fracToDouble(lenN))); if (start < 1) return mkErr("#VALUE!"); return s.slice(start - 1, start - 1 + len); }, }, VALUE: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const trimmed = s.trim(); const pct = trimmed.endsWith("%"); const body = pct ? trimmed.slice(0, -1) : trimmed; try { const v = parseDecimal(body); return pct ? { n: v.n, d: v.d * 100n } : v; } catch { return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`); } }, }, TEXTJOIN: { min: 3, call: (args) => { const delim = str(args[0]()); if (isErr(delim)) return delim; const ignoreEmpty = bool(args[1]()); if (isErr(ignoreEmpty)) return ignoreEmpty; const parts: string[] = []; for (let i = 2; i < args.length; i++) { const v = args[i](); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { for (let c = 0; c < v.cols; c++) { const t = toText(v.at(r, c)); if (isErr(t)) return t; if (!(ignoreEmpty && t === "")) parts.push(t); } } } else { const t = str(v); if (isErr(t)) return t; if (!(ignoreEmpty && t === "")) parts.push(t); } } return parts.join(delim); }, }, TRIM: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.split(" ").filter(Boolean).join(" "); }, }, UPPER: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.toUpperCase(); }, }, LOWER: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : s.toLowerCase(); }, }, SUBSTITUTE: { min: 3, max: 4, call: (args) => { const text = str(args[0]()); if (isErr(text)) return text; const oldT = str(args[1]()); if (isErr(oldT)) return oldT; const newT = str(args[2]()); if (isErr(newT)) return newT; if (oldT === "") return text; if (args.length <= 3) return text.split(oldT).join(newT); const nthArg = num(args[3]()); if (isErr(nthArg)) return nthArg; const nth = Math.trunc(fracToDouble(nthArg)); let idx = -1; let count = 0; let pos = 0; for (;;) { const found = text.indexOf(oldT, pos); if (found < 0) break; count++; if (count === nth) { idx = found; break; } pos = found + oldT.length; } return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length); }, }, REPLACE: { min: 4, max: 4, call: (args) => { const text = str(args[0]()); if (isErr(text)) return text; const startN = num(args[1]()); if (isErr(startN)) return startN; const lenN = num(args[2]()); if (isErr(lenN)) return lenN; const newText = str(args[3]()); if (isErr(newText)) return newText; const start = Math.trunc(fracToDouble(startN)); const len = Math.max(0, Math.trunc(fracToDouble(lenN))); if (start < 1) return mkErr("#VALUE!"); return text.slice(0, start - 1) + newText + text.slice(start - 1 + len); }, }, FIND: { min: 2, max: 3, call: (args) => { const findT = str(args[0]()); if (isErr(findT)) return findT; const within = str(args[1]()); if (isErr(within)) return within; const startArg = args.length > 2 ? num(args[2]()) : ONE; if (isErr(startArg)) return startArg; const start = Math.trunc(fracToDouble(startArg)); if (start < 1) return mkErr("#VALUE!"); const idx = within.indexOf(findT, start - 1); return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1)); }, }, SEARCH: { min: 2, max: 3, call: (args) => { const findT = str(args[0]()); if (isErr(findT)) return findT; const within = str(args[1]()); if (isErr(within)) return within; const startArg = args.length > 2 ? num(args[2]()) : ONE; if (isErr(startArg)) return startArg; const start = Math.trunc(fracToDouble(startArg)); if (start < 1) return mkErr("#VALUE!"); const pat = wildcardToSearchRegex(findT); const m = pat.exec(within.slice(start - 1)); return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!"); }, }, REPT: { min: 2, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = num(args[1]()); if (isErr(nArg)) return nArg; const n = Math.trunc(fracToDouble(nArg)); return n < 0 ? mkErr("#VALUE!") : s.repeat(n); }, }, ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) }, ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" }, ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null }, ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) }, NA: { min: 0, max: 0, call: () => mkErr("#N/A") }, TODAY: { min: 0, max: 0, call: () => { const now = new Date(); const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); return fromDouble(dateToSerial(new Date(midnight))); }, }, DATE: { min: 3, max: 3, call: (args) => { const y = num(args[0]()); if (isErr(y)) return y; const m = num(args[1]()); if (isErr(m)) return m; const d = num(args[2]()); if (isErr(d)) return d; const date = new Date( Date.UTC( Math.trunc(fracToDouble(y)), Math.trunc(fracToDouble(m)) - 1, Math.trunc(fracToDouble(d)), ), ); return fromDouble(dateToSerial(date)); }, }, YEAR: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear())); }, }, MONTH: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1)); }, }, DAY: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) }, WEEKDAY: { min: 1, max: 2, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const typeArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(typeArg)) return typeArg; const type = Math.trunc(fracToDouble(typeArg)); const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토 if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1)); if (type === 3) return frac(BigInt((dow + 6) % 7)); return frac(BigInt(dow + 1)); }, }, EDATE: { min: 2, max: 2, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const mArg = num(args[1]()); if (isErr(mArg)) return mArg; const months = Math.trunc(fracToDouble(mArg)); const d = serialToDate(fracToDouble(n)); const targetMonth = d.getUTCMonth() + months; // 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음). const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate(); const day = Math.min(d.getUTCDate(), lastDay); const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day)); return fromDouble(dateToSerial(date)); }, }, EOMONTH: { min: 2, max: 2, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const mArg = num(args[1]()); if (isErr(mArg)) return mArg; const months = Math.trunc(fracToDouble(mArg)); const d = serialToDate(fracToDouble(n)); const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0)); return fromDouble(dateToSerial(date)); }, }, DATEDIF: { min: 3, max: 3, call: (args) => { const s = num(args[0]()); if (isErr(s)) return s; const e = num(args[1]()); if (isErr(e)) return e; const unit = str(args[2]()); if (isErr(unit)) return unit; const result = dateDiff(fracToDouble(s), fracToDouble(e), unit); return isErr(result) ? result : frac(BigInt(result)); }, }, NETWORKDAYS: { min: 2, max: 3, call: (args) => { const s = num(args[0]()); if (isErr(s)) return s; const e = num(args[1]()); if (isErr(e)) return e; let startSerial = Math.round(fracToDouble(s)); let endSerial = Math.round(fracToDouble(e)); let sign = 1; if (startSerial > endSerial) { [startSerial, endSerial] = [endSerial, startSerial]; sign = -1; } const holidays = new Set(); if (args.length > 2) { const holidayArg = args[2](); if (isRange(holidayArg)) { for (let r = 0; r < holidayArg.rows; r++) { for (let c = 0; c < holidayArg.cols; c++) { const cell = holidayArg.at(r, c); if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell))); } } } else if (isFrac(holidayArg)) { holidays.add(Math.round(fracToDouble(holidayArg))); } } let count = 0; for (let day = startSerial; day <= endSerial; day++) { const dow = serialToDate(day).getUTCDay(); if (dow === 0 || dow === 6) continue; if (holidays.has(day)) continue; count++; } return frac(BigInt(sign * count)); }, }, HOUR: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h)); }, }, MINUTE: { min: 1, max: 1, call: (args) => { const n = num(args[0]()); return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt)); }, }, TIME: { min: 3, max: 3, call: (args) => { const hArg = num(args[0]()); if (isErr(hArg)) return hArg; const mArg = num(args[1]()); if (isErr(mArg)) return mArg; const sArg = num(args[2]()); if (isErr(sArg)) return sArg; const totalSeconds = Math.trunc(fracToDouble(hArg)) * 3600 + Math.trunc(fracToDouble(mArg)) * 60 + Math.trunc(fracToDouble(sArg)); const dayFraction = ((totalSeconds % 86400) + 86400) % 86400; return fromDouble(dayFraction / 86400); }, }, };