fix: correct operational statistics and form interactions
This commit is contained in:
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
|
||||
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
|
||||
import type { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts';
|
||||
|
||||
export function summarizeReportStatuses(statuses: string[]) {
|
||||
return summarizeCommonReportStatuses(statuses);
|
||||
@@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
|
||||
desiredConnections: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'desiredConnections'),
|
||||
1,
|
||||
'desiredConnections',
|
||||
),
|
||||
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
|
||||
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
|
||||
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
|
||||
@@ -254,23 +258,22 @@ export function getRuntimeConfigInteger(
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function channelConnectionSettingsChanged(
|
||||
before: ChannelConnectionSettings,
|
||||
after: ChannelConnectionSettings,
|
||||
) {
|
||||
return before.gatewayHost !== after.gatewayHost
|
||||
|| before.gatewayPort !== after.gatewayPort
|
||||
|| before.account !== after.account
|
||||
|| before.passwordCipher !== after.passwordCipher
|
||||
|| before.cmppVersion !== after.cmppVersion
|
||||
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
|
||||
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|
||||
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
|
||||
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|
||||
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
|
||||
export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) {
|
||||
return (
|
||||
before.gatewayHost !== after.gatewayHost ||
|
||||
before.gatewayPort !== after.gatewayPort ||
|
||||
before.account !== after.account ||
|
||||
before.passwordCipher !== after.passwordCipher ||
|
||||
before.cmppVersion !== after.cmppVersion ||
|
||||
getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !==
|
||||
getRuntimeConfigInteger(after.config, 'desiredConnections', 1) ||
|
||||
getRuntimeConfigInteger(before.config, 'windowSize', 16) !==
|
||||
getRuntimeConfigInteger(after.config, 'windowSize', 16) ||
|
||||
getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !==
|
||||
getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) ||
|
||||
getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !==
|
||||
getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
|
||||
);
|
||||
}
|
||||
|
||||
export function channelGroupAuditSnapshot(group: {
|
||||
@@ -320,19 +323,49 @@ export function normalizeChannelRuntimeConfig(
|
||||
heartbeatIntervalSeconds?: number,
|
||||
heartbeatMissThreshold?: number,
|
||||
) {
|
||||
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? existingConfig as Record<string, unknown>
|
||||
: {};
|
||||
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
|
||||
? incomingConfig
|
||||
: {};
|
||||
const existing =
|
||||
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
|
||||
? (existingConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const incoming =
|
||||
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
|
||||
const base = { ...existing, ...incoming };
|
||||
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
|
||||
base.desiredConnections = boundedRuntimeInteger(
|
||||
desiredConnections ?? base.desiredConnections,
|
||||
1,
|
||||
8,
|
||||
1,
|
||||
'desiredConnections',
|
||||
);
|
||||
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
|
||||
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds');
|
||||
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds');
|
||||
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds');
|
||||
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds');
|
||||
base.connectionWarmupSeconds = boundedRuntimeInteger(
|
||||
base.connectionWarmupSeconds,
|
||||
0,
|
||||
300,
|
||||
30,
|
||||
'connectionWarmupSeconds',
|
||||
);
|
||||
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(
|
||||
base.connectionDrainTimeoutSeconds,
|
||||
1,
|
||||
600,
|
||||
60,
|
||||
'connectionDrainTimeoutSeconds',
|
||||
);
|
||||
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(
|
||||
base.submitResponseTimeoutSeconds,
|
||||
1,
|
||||
300,
|
||||
60,
|
||||
'submitResponseTimeoutSeconds',
|
||||
);
|
||||
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(
|
||||
base.connectionFailureCooldownSeconds,
|
||||
1,
|
||||
300,
|
||||
30,
|
||||
'connectionFailureCooldownSeconds',
|
||||
);
|
||||
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
|
||||
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
|
||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
||||
@@ -423,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) {
|
||||
}
|
||||
|
||||
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const lines = content
|
||||
.replace(/^\uFEFF/, '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
throw new BadRequestException('Receipt file is empty');
|
||||
}
|
||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||
const firstCells = splitReceiptLine(lines[0], separator);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
const hasHeader = firstCells.some((cell) =>
|
||||
['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()),
|
||||
);
|
||||
const header = hasHeader ? firstCells : [];
|
||||
const rows = hasHeader ? lines.slice(1) : lines;
|
||||
const statusIndex = findReceiptStatusIndex(header);
|
||||
@@ -445,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
failedCount += 1;
|
||||
}
|
||||
return {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
rowNumber: hasHeader ? index + 2 : index + 1,
|
||||
phone: cells[0] ?? '',
|
||||
status: normalizedStatus,
|
||||
rawStatus,
|
||||
@@ -504,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) {
|
||||
|
||||
export function normalizeReceiptStatus(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
|
||||
if (
|
||||
[
|
||||
'success',
|
||||
'succeeded',
|
||||
'approved',
|
||||
'completed',
|
||||
'ok',
|
||||
'pass',
|
||||
'passed',
|
||||
'通过',
|
||||
'成功',
|
||||
'已完成',
|
||||
'报备成功',
|
||||
].includes(normalized)
|
||||
) {
|
||||
return 'success';
|
||||
}
|
||||
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
|
||||
if (
|
||||
[
|
||||
'failed',
|
||||
'fail',
|
||||
'rejected',
|
||||
'reject',
|
||||
'error',
|
||||
'no',
|
||||
'denied',
|
||||
'驳回',
|
||||
'失败',
|
||||
'不通过',
|
||||
'拒绝',
|
||||
'报备失败',
|
||||
].includes(normalized)
|
||||
) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'failed';
|
||||
@@ -524,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
|
||||
}
|
||||
|
||||
export type ChannelReportDeliveryRow = {
|
||||
carrier: string | null;
|
||||
channelId: string;
|
||||
signatureId: string;
|
||||
drainageInfoId: string | null;
|
||||
@@ -557,10 +626,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[])
|
||||
};
|
||||
}
|
||||
|
||||
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
|
||||
ChannelReportDeliveryRow,
|
||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||
>) {
|
||||
export function sumReportDelivery(
|
||||
rows: ChannelReportDeliveryRow[],
|
||||
key: keyof Pick<
|
||||
ChannelReportDeliveryRow,
|
||||
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
|
||||
>,
|
||||
) {
|
||||
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
||||
}
|
||||
|
||||
@@ -580,7 +652,11 @@ export function currentShanghaiDayRange(now = new Date()) {
|
||||
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
|
||||
}
|
||||
|
||||
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
||||
export function normalizeRetryTimeLimitMinutes(
|
||||
minutes: number | undefined,
|
||||
hours: number | undefined,
|
||||
fallbackMinutes: number,
|
||||
) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||
@@ -588,7 +664,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
|
||||
export function normalizeSpreadsheetSize(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
) {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||
}
|
||||
@@ -602,7 +683,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
}
|
||||
|
||||
export function normalizeChannelCarrier(carrier?: string | null) {
|
||||
const value = String(carrier ?? '').trim().toLowerCase();
|
||||
const value = String(carrier ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||
@@ -631,12 +714,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) {
|
||||
return 'multi';
|
||||
}
|
||||
|
||||
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
|
||||
export function isChannelCarrierCompatible(
|
||||
channelCarrier: string | null | undefined,
|
||||
groupCarrier: string,
|
||||
carriers?: string[] | null,
|
||||
) {
|
||||
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
return String(region ?? '')
|
||||
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
|
||||
@@ -646,7 +735,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
|
||||
export function validateGroupItems(
|
||||
groupCarrier: string,
|
||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
|
||||
channels: Map<
|
||||
string,
|
||||
{ id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }
|
||||
>,
|
||||
) {
|
||||
const channelIds = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user