Files
nocobase-mcp/src/crm-helpers.ts
T

325 lines
11 KiB
TypeScript

import type { ApiResponse, RecordRow } from "./types.js";
import { isRecord } from "./types.js";
export const MAX_PAGE_SIZE = 100;
export const MAX_SCAN_RECORDS = 5_000;
export const PROJECTION_FALLBACK_WARNING =
"NocoBase rejected relation field projection; check nameFields in the CRM config";
export const CUSTOMER_STATUS_LABELS: Record<string, string> = {
kh_p_not_start: "未开始",
kh_p_inprogress: "进行中",
kh_p_ordered: "已下单",
kh_p_audited: "已审核",
};
export const FOLLOWUP_CONTENT_KEYS = ["f_19iplxz0nds", "content", "description", "remark"];
export const FOLLOWUP_DATE_KEYS = ["f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt"];
export const FOLLOWUP_TYPE_LABELS: Record<string, string> = {
tdwfk5h1ohv: "邮件",
s48pnyhutwc: "电话",
mwyy8xwk66m: "视频会议",
m34aocz3ebo: "拜访/请客",
"0lmj78zpdnm": "审厂/来访",
o7rjlg4guzh: "论坛/大会",
"2csix8r3h0f": "代理跟进情况代填",
};
export const FOLLOWUP_STATUS_LABELS: Record<string, string> = {
"2rowk1bc8vc": "审批中",
"3ge6geal2u1": "OK",
"68vqz8raus6": "迟交",
b5pf5njqgp6: "无效",
};
export const INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6";
export interface LabelSpec {
idField: string;
nameFields: string[];
}
export function normalizePagination(limit: unknown, offset: unknown): [number, number] {
return [Math.min(Math.max(1, integer(limit, 20)), MAX_PAGE_SIZE), Math.max(0, integer(offset, 0))];
}
export function integer(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
export function dataRows(response: ApiResponse): RecordRow[] {
if (Array.isArray(response.data)) return response.data.filter(isRecord);
if (isRecord(response.data)) {
for (const key of ["data", "rows", "results"]) {
const nested = response.data[key];
if (Array.isArray(nested)) return nested.filter(isRecord);
}
}
return [];
}
export function singleRow(response: ApiResponse): RecordRow | undefined {
if (Array.isArray(response.data)) return response.data.find(isRecord);
return isRecord(response.data) ? response.data : undefined;
}
export function responseTotal(response: ApiResponse, fallback: number): number {
for (const container of [response.meta, response.data]) {
if (!isRecord(container)) continue;
for (const key of ["count", "total", "totalCount"]) {
const value = container[key];
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
}
}
return fallback;
}
export function paginatedRows(
rows: unknown[],
total: number,
limit: number,
offset: number,
): Record<string, unknown> {
const nextOffset = offset + rows.length;
return {
results: rows,
total,
returned: rows.length,
limit,
offset,
has_more: rows.length > 0 && nextOffset < total,
next_offset: nextOffset < total ? nextOffset : null,
};
}
export function paginationMetadata(result: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
["total", "returned", "limit", "offset", "has_more", "next_offset"].map((key) => [key, result[key]]),
);
}
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
export function compactFieldList(configured: string[] | null, candidates: string[]): string[] {
const available = new Set(configured ?? []);
return uniqueStrings(candidates).filter((candidate) => available.size === 0 || available.has(candidate));
}
export function fieldsWithProjections(scalars: string[], projections: string[]): string[] {
const roots = new Set(projections.map((path) => path.split(".", 1)[0]));
return [...uniqueStrings(scalars).filter((field) => !roots.has(field)), ...uniqueStrings(projections)];
}
export function firstValue(row: RecordRow, fields: string[]): unknown {
for (const field of uniqueStrings(fields)) {
const value = row[field];
if (value !== undefined && value !== null && value !== "") return value;
}
return undefined;
}
export function firstText(row: RecordRow, fields: string[]): string | undefined {
const value = firstValue(row, fields);
return typeof value === "object" ? undefined : value === undefined ? undefined : String(value);
}
export function firstNumeric(rows: RecordRow[], preferredKey: string): number {
for (const row of rows) {
const preferred = row[preferredKey];
if (typeof preferred === "number") return Math.trunc(preferred);
const nested = Object.values(row).find((value) => typeof value === "number");
if (typeof nested === "number") return Math.trunc(nested);
}
return 0;
}
export function prune(mapping: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(mapping).filter(([key, value]) => key === "id" || !isEmpty(value)),
);
}
export function relationRows(value: unknown): RecordRow[] {
if (Array.isArray(value)) return value.filter(isRecord);
if (isRecord(value)) {
if (Array.isArray(value.data)) return value.data.filter(isRecord);
return [value];
}
return [];
}
export function refFromRow(row: RecordRow, spec: LabelSpec): Record<string, unknown> {
const id = row[spec.idField];
const found = firstText(row, spec.nameFields);
return prune({ id, name: found ?? (id !== undefined && id !== null ? `#${String(id)}` : undefined) });
}
export function relationRefs(
row: RecordRow,
fields: string[],
spec: LabelSpec,
): Array<Record<string, unknown>> {
const result: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const field of uniqueStrings(fields)) {
for (const related of relationRows(row[field])) {
const ref = refFromRow(related, spec);
const key = JSON.stringify(ref.id ?? ref.name);
if (Object.keys(ref).length === 0 || seen.has(key)) continue;
seen.add(key);
result.push(ref);
}
}
return result;
}
export function relationIds(value: unknown, idField = "id"): unknown[] {
return relationRows(value)
.map((row) => row[idField])
.filter((value) => value !== undefined && value !== null);
}
export function refIds(refs: Array<Record<string, unknown>>): unknown[] {
return refs.map((ref) => ref.id).filter((value) => value !== undefined && value !== null);
}
export function refNames(refs: Array<Record<string, unknown>>): string[] {
return refs
.map((ref) => ref.name)
.filter((value): value is string => typeof value === "string" && value.length > 0);
}
export function formatUserCandidate(row: RecordRow, idField: string): Record<string, unknown> {
const label = firstText(row, ["nickname", "username", "email", "f_yh_english_name", "id"]) ?? "<unnamed>";
return prune({
id: row[idField],
label,
username: row.username,
nickname: row.nickname,
email: row.email,
f_yh_english_name: row.f_yh_english_name,
});
}
export function pickUserMatches(rows: RecordRow[], query: string, matchFields: string[]): RecordRow[] {
const normalized = query.toLocaleLowerCase();
const exact = rows.filter((row) =>
matchFields.some(
(field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase() === normalized,
),
);
if (exact.length > 0) return exact;
return rows.filter((row) =>
matchFields.some((field) =>
String(row[field] ?? "")
.trim()
.toLocaleLowerCase()
.includes(normalized),
),
);
}
export function decorateDimensionCounts(
rows: RecordRow[],
preferredKeys: string[],
labels: Record<string, string>,
): RecordRow[] {
const decorated = rows.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== "value" && key !== "label") delete item[key];
item.value = value;
item.label = labels[String(value)] ?? String(value);
}
return item;
});
return decorated.sort(
(left, right) =>
Number(isBlank(left.value)) - Number(isBlank(right.value)) ||
countValue(right) - countValue(left) ||
String(left.label ?? "").localeCompare(String(right.label ?? "")),
);
}
export function decorateIdCounts(rows: RecordRow[], preferredKeys: string[], outputKey: string): RecordRow[] {
return rows
.map((row) => {
const [value, key] = dimensionValue(row, preferredKeys);
const item = { ...row };
if (value !== undefined && value !== null) {
if (key && key !== outputKey) delete item[key];
item[outputKey] = value;
}
return item;
})
.sort(
(left, right) =>
countValue(right) - countValue(left) ||
String(left[outputKey] ?? "").localeCompare(String(right[outputKey] ?? "")),
);
}
export function toZonedIso(value: unknown, timezone: string): unknown {
if (typeof value !== "string" || !value.trim()) return value;
const normalized = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
try {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
const localUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMinutes = Math.round((localUtc - date.getTime()) / 60_000);
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
const offset = `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${offset}`;
} catch {
return date.toISOString();
}
}
export function parseDate(value: unknown): Date | undefined {
if (typeof value !== "string") return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
function dimensionValue(row: RecordRow, preferredKeys: string[]): [unknown, string | undefined] {
for (const key of preferredKeys) if (key in row) return [row[key], key];
const entry = Object.entries(row).find(([key, value]) => key !== "count" && typeof value !== "object");
return entry ? [entry[1], entry[0]] : [undefined, undefined];
}
function countValue(row: RecordRow): number {
return typeof row.count === "number" ? Math.trunc(row.count) : 0;
}
function isBlank(value: unknown): boolean {
return value === undefined || value === null || String(value).trim() === "";
}
function isEmpty(value: unknown): boolean {
return isBlank(value) || (typeof value === "object" && value !== null && Object.keys(value).length === 0);
}