325 lines
11 KiB
TypeScript
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);
|
|
}
|