Files
Aislo/A00_Common/spreadsheet/spreadsheet_functions_misc.ts
T
eomsangdonandClaude Sonnet 5 4804caa9bc feat(spreadsheet): 통계 함수 13개 · 날짜 함수 9개 · numfmt 날짜 형식
MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE·
COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE·
EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는
월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게).
numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용
이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침).
경계값 시험 180건 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 21:18:23 +09:00

657 lines
19 KiB
TypeScript

/* =============================================================================
* 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<string, SheetFunction> = {
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<number>();
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);
},
},
};