feat: unify signature channel reporting status

This commit is contained in:
hectorzhao
2026-07-12 20:38:20 +08:00
parent 3f3ff8a793
commit eab059583f
16 changed files with 439 additions and 110 deletions
@@ -0,0 +1,9 @@
UPDATE "DrainageField"
SET "fieldType" = 'string'
WHERE "fieldType" NOT IN ('string', 'image', 'file');
UPDATE "ChannelReportField"
SET "fieldType" = CASE
WHEN "fieldType" IN ('string', 'image', 'file') THEN "fieldType"
ELSE 'string'
END;
+8 -2
View File
@@ -12,6 +12,7 @@ import {
CreateReportFieldDto, CreateReportFieldDto,
CreateReportMaterialDto, CreateReportMaterialDto,
CreateReportTaskDto, CreateReportTaskDto,
ChangeReportTaskStatusesDto,
CreateRouteRuleDto, CreateRouteRuleDto,
TestChannelDto, TestChannelDto,
UpsertConnectionStateDto, UpsertConnectionStateDto,
@@ -145,8 +146,8 @@ export class ChannelsController {
} }
@Get('report-tasks') @Get('report-tasks')
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) { listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportTasks(tenantId, status); return this.channels.listReportTasks(tenantId, status, channelId);
} }
@Post('report-tasks/generate') @Post('report-tasks/generate')
@@ -154,6 +155,11 @@ export class ChannelsController {
return this.channels.createReportTask(body); return this.channels.createReportTask(body);
} }
@Post('report-tasks/status-change')
changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) {
return this.channels.changeReportTaskStatuses(body);
}
@Post('report-tasks/:id/export') @Post('report-tasks/:id/export')
createReportExport(@Param('id') taskId: string, @Body() body: CreateReportExportDto) { createReportExport(@Param('id') taskId: string, @Body() body: CreateReportExportDto) {
return this.channels.createReportExport(taskId, body); return this.channels.createReportExport(taskId, body);
+46 -4
View File
@@ -86,7 +86,7 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
}, },
channelRouteRule: { channelRouteRule: {
findMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
}, },
@@ -103,7 +103,7 @@ function createPrismaMock() {
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })), upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
}, },
channelSignatureReportTask: { channelSignatureReportTask: {
findMany: jest.fn(), findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]),
create: jest.fn().mockResolvedValue(reportTask), create: jest.fn().mockResolvedValue(reportTask),
findUnique: jest.fn().mockResolvedValue(reportTask), findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
@@ -119,6 +119,7 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })),
}, },
smsSignature: { smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null }),
update: jest.fn(), update: jest.fn(),
}, },
smsApplication: { smsApplication: {
@@ -174,6 +175,47 @@ describe('ChannelsService', () => {
}); });
}); });
it('lists report tasks for one real channel', async () => {
const prisma = createPrismaMock();
prisma.channelSignatureReportTask.findMany.mockResolvedValue([]);
const service = new ChannelsService(prisma as never);
await service.listReportTasks(undefined, undefined, 'channel-1');
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
where: { tenantId: undefined, status: undefined, channelId: 'channel-1' },
include: { signature: true, channel: true },
orderBy: { createdAt: 'desc' },
});
});
it('changes channel report status and recomputes the signature summary atomically', async () => {
const prisma = createPrismaMock();
const tx = {
smsSignature: {
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]),
},
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) },
};
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
]);
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
beforeEach(() => { beforeEach(() => {
mockQueueAdd.mockClear(); mockQueueAdd.mockClear();
mockQueueClose.mockClear(); mockQueueClose.mockClear();
@@ -548,7 +590,7 @@ describe('ChannelsService', () => {
}); });
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' }, where: { id: 'sig-1' },
data: { reportStatus: 'partial' }, data: { reportStatus: 'reporting' },
}); });
}); });
@@ -579,7 +621,7 @@ describe('ChannelsService', () => {
}); });
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' }, where: { id: 'sig-1' },
data: { reportStatus: 'partial' }, data: { reportStatus: 'reporting' },
}); });
}); });
+67 -6
View File
@@ -101,6 +101,12 @@ export interface CreateReportTaskDto {
createdById?: string; createdById?: string;
} }
export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string }>;
reason?: string;
operatorId?: string;
}
export interface CreateReportExportDto { export interface CreateReportExportDto {
fileObjectId?: string; fileObjectId?: string;
fileName: string; fileName: string;
@@ -958,9 +964,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}); });
} }
listReportTasks(tenantId?: string, status?: string) { listReportTasks(tenantId?: string, status?: string, channelId?: string) {
return this.prisma.channelSignatureReportTask.findMany({ return this.prisma.channelSignatureReportTask.findMany({
where: { tenantId, status }, where: { tenantId, status, channelId },
include: { signature: true, channel: true }, include: { signature: true, channel: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
@@ -980,6 +986,53 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
return task; return task;
} }
async changeReportTaskStatuses(data: ChangeReportTaskStatusesDto) {
if (!data.items.length) throw new BadRequestException('items is required');
const allowed = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.map((item) => item.signatureId))];
for (const item of data.items) {
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId } });
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
}
const summaries = [];
for (const signatureId of signatureIds) summaries.push(await this.recomputeSignatureReportSummary(tx, signatureId));
return summaries;
});
}
private async recomputeSignatureReportSummary(tx: Prisma.TransactionClient, signatureId: string) {
const signature = await tx.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
const routes = signature.applicationId ? await tx.channelRouteRule.findMany({
where: { applicationId: signature.applicationId, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId }, include: { channel: true } });
const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel);
const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map(tasks.map((task) => [task.channelId, task]));
const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
return [carrier, summarizeReportStatuses(statuses)];
}));
const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const reportStatus = summarizeReportStatuses(allStatuses).status;
await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } });
return { signatureId, reportStatus, carrierReportSummary };
}
async createReportExport(taskId: string, data: CreateReportExportDto) { async createReportExport(taskId: string, data: CreateReportExportDto) {
const task = await this.getReportTaskOrThrow(taskId); const task = await this.getReportTaskOrThrow(taskId);
const exported = await this.prisma.reportExportFile.create({ const exported = await this.prisma.reportExportFile.create({
@@ -1014,10 +1067,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}, },
}); });
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
await this.prisma.smsSignature.update({ await this.recomputeSignatureReportSummary(this.prisma as unknown as Prisma.TransactionClient, task.signatureId);
where: { id: task.signatureId },
data: { reportStatus: statusAfter },
});
return imported; return imported;
} }
@@ -1644,6 +1694,17 @@ function normalizeReportType(value?: string) {
throw new BadRequestException('reportType must be signature, drainage or both'); throw new BadRequestException('reportType must be signature, drainage or both');
} }
function summarizeReportStatuses(statuses: string[]) {
if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 };
const approved = statuses.filter((status) => status === 'approved').length;
let status = 'pending';
if (approved === statuses.length) status = 'approved';
else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed';
else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting';
else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material';
return { status, approved, total: statuses.length };
}
function normalizeLinkEvent(action: string) { function normalizeLinkEvent(action: string) {
if (action.includes('connect_requested')) { if (action.includes('connect_requested')) {
return '连接请求'; return '连接请求';
@@ -25,6 +25,9 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
}, },
drainageField: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
},
smsApplication: { smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
}, },
@@ -112,4 +115,17 @@ describe('DictionariesService', () => {
}); });
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } }); expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
}); });
it('only accepts string, image and file report field types', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
await expect(service.createDrainageField({ code: 'license', name: '营业执照', fieldType: 'image' })).resolves.toEqual(
expect.objectContaining({ fieldType: 'image' }),
);
expect(() => service.createDrainageField({ code: 'amount', name: '数量', fieldType: 'number' as never })).toThrow(
'fieldType must be string, image or file',
);
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
});
}); });
+4 -1
View File
@@ -47,7 +47,7 @@ export interface CreateBlacklistDto {
export interface CreateDrainageFieldDto { export interface CreateDrainageFieldDto {
code: string; code: string;
name: string; name: string;
fieldType: string; fieldType: 'string' | 'image' | 'file';
required?: boolean; required?: boolean;
status?: string; status?: string;
description?: string; description?: string;
@@ -255,6 +255,9 @@ export class DictionariesService {
} }
createDrainageField(data: CreateDrainageFieldDto) { createDrainageField(data: CreateDrainageFieldDto) {
if (!['string', 'image', 'file'].includes(data.fieldType)) {
throw new BadRequestException('fieldType must be string, image or file');
}
return this.prisma.drainageField.create({ return this.prisma.drainageField.create({
data: { data: {
code: data.code, code: data.code,
@@ -430,7 +430,7 @@ describe('SmsConfigService', () => {
auditStatus: { not: 'deleted' }, auditStatus: { not: 'deleted' },
OR: expect.any(Array), OR: expect.any(Array),
}), }),
include: { materials: true, tenant: true, application: true }, include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
})); }));
}); });
+25 -3
View File
@@ -570,9 +570,9 @@ export class SmsConfigService {
}); });
} }
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
return this.prisma.smsSignature.findMany({ const signatures = await this.prisma.smsSignature.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
@@ -583,9 +583,31 @@ export class SmsConfigService {
{ application: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } },
] : undefined, ] : undefined,
}, },
include: { materials: true, tenant: true, application: true }, include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: true } } } } },
}) : [];
return signatures.map((signature) => ({
...signature,
reportTargets: (() => {
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(),
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
const taskByChannel = new Map((signature.reportTasks ?? []).map((task) => [task.channelId, task]));
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const approved = statuses.filter((status) => status === 'approved').length;
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
})),
}));
} }
async createSignature(data: CreateSmsSignatureDto) { async createSignature(data: CreateSmsSignatureDto) {
@@ -180,7 +180,7 @@
### 4.7 通道签名报备 ### 4.7 通道签名报备
1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。 1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;字段类型只允许字符串、图片、文件三种。通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。
2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。 2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。
3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。 3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。
4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。 4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。
@@ -190,6 +190,8 @@
8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。 8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。
9. 报备记录保留每次导出、导入、状态变更和操作人。 9. 报备记录保留每次导出、导入、状态变更和操作人。
10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
11. 企业签名页、通道报备详情页和报备任务页均允许人工修正报备状态,但三个入口必须操作同一份 `ChannelSignatureReportTask` 通道级事实并写 `ChannelSignatureReportRecord`;企业签名页修改时必须展示应用当前通道组内的具体通道矩阵,不允许直接修改移动/联通/电信汇总标签。
12. 每次人工状态变更或回执导入后,系统必须按应用当前生效路由规则重新汇总各运营商目标通道状态和签名全局 `reportStatus`。新增目标通道但尚无任务时按未报备计入分母;移出当前配置的历史通道不参与当前汇总,但任务和记录继续保留。
### 4.8 CMPP Gateway 与外部接入 ### 4.8 CMPP Gateway 与外部接入
+19 -1
View File
@@ -301,17 +301,35 @@
- 优先级:P0 - 优先级:P0
- 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。 - 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。
- 步骤: - 步骤:
1. 在报备字段库创建文件字段“营业执照”和文本字段“网站主体”。 1. 在报备字段库创建图片字段“营业执照”和字符串字段“网站主体”,并直接调用 API 尝试创建整数、网址、电话、日期等其他类型
2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。 2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。
3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。 3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。
4. 分别尝试缺少必填值保存,再补齐文件和值后保存。 4. 分别尝试缺少必填值保存,再补齐文件和值后保存。
5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。 5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。
- 预期结果: - 预期结果:
- 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。 - 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。
- 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。
- 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。 - 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。
- 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。 - 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。
- 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料。 - 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料。
### TC-ADMIN-005B 企业签名、通道详情和报备任务状态一致性
- 优先级:P0
- 前置条件:企业应用绑定移动、联通通道组,移动组含两个通道,联通组含一个通道;企业签名已存在。
- 步骤:
1. 在企业签名页打开“报备状态”,确认显示三个具体目标通道,将移动通道一标记通过。
2. 在移动通道二的通道报备详情中标记报备通过。
3. 在报备任务页将联通任务标记报备中,再通过回执导入改为通过。
4. 每步后分别刷新企业签名、通道详情和报备任务页面,并查询数据库任务、记录和签名状态。
5. 向移动通道组新增一个通道但不生成任务,再刷新企业签名列表。
- 预期结果:
- 三个入口操作同一条 `ChannelSignatureReportTask`;不存在的目标通道任务由统一接口真实创建。
- 每次变化写入 `ChannelSignatureReportRecord`,包含前后状态、原因、操作人和时间。
- 两个移动通道均通过后移动汇总为通过;联通处理中时签名全局状态不是通过;联通回执通过后三网目标通道全部通过,签名全局状态为 approved。
- 新增移动通道后移动汇总立即变为部分通过/报备中,分母包含新增通道,不能继续误显示全部通过。
- 发送时仍校验最终路由通道对应任务为 approved,不以企业签名列表汇总标签代替通道级校验。
### TC-ADMIN-006 报备回执导入通过 ### TC-ADMIN-006 报备回执导入通过
- 优先级:P0 - 优先级:P0
+5
View File
@@ -1630,3 +1630,8 @@ git diff --check
- 已提交并 push `87ae4a20`,随后以该提交生成发布快照并部署生产;部署前备份 PostgreSQL 和运行源码,migration `20260712150000_link_report_field_library` 已成功应用。生产 `.deployed-commit=87ae4a20``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 均通过。 - 已提交并 push `87ae4a20`,随后以该提交生成发布快照并部署生产;部署前备份 PostgreSQL 和运行源码,migration `20260712150000_link_report_field_library` 已成功应用。生产 `.deployed-commit=87ae4a20``cmpp-api``cmpp-gateway`、Nginx、MinIO 均为 active`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 均通过。
- 生产数据库已确认 `ChannelReportField.drainageFieldId/reportType``DrainageReportMaterial` 存在。当前生产 `DrainageField=0``ChannelReportField=0`,因此不会凭空展示动态资料区;需要先按真实业务配置创建字段库和通道字段后再做页面来源弹窗的有数据验收。 - 生产数据库已确认 `ChannelReportField.drainageFieldId/reportType``DrainageReportMaterial` 存在。当前生产 `DrainageField=0``ChannelReportField=0`,因此不会凭空展示动态资料区;需要先按真实业务配置创建字段库和通道字段后再做页面来源弹窗的有数据验收。
- 浏览器可打开生产登录路由并识别页面标题“CMPP 短信平台”,但读取 DOM/控制台时浏览器连接连续超时,未将解释弹窗点击交互标记为已通过;待生产产生真实字段配置后补测。 - 浏览器可打开生产登录路由并识别页面标题“CMPP 短信平台”,但读取 DOM/控制台时浏览器连接连续超时,未将解释弹窗点击交互标记为已通过;待生产产生真实字段配置后补测。
- 2026-07-12 追加:报备字段库字段类型收窄为字符串、图片、文件三种;前端筛选和新增弹窗移除整数、网址、电话、日期,API 严格拒绝三种之外的类型。migration `20260712170000_normalize_report_field_types` 将历史其他类型及其通道字段副本统一归并为字符串。
- 2026-07-12 追加:按设计基线 `131f344a^` 恢复“通道列表 → 报备详情”页面结构,不再把报备详情错误简化为字段配置表。页面按当前通道查询真实 `ChannelSignatureReportTask/ChannelSignatureReportRecord/SmsSignature.drainageInfo`,展示签名任务、报备状态和时间,签名下引流信息默认收起并可展开;查看详情使用真实企业、应用和动态资料。发送统计没有数据库事实时明确显示“暂无统计”,不复用基线演示百分比。签名报备字段和引流信息字段配置保留为页面顶部两个入口,均从真实报备字段库选择。
- 本地验证:通道/字典定向测试 2 suites、31 项通过,API 全量 13 suites、134 项通过,API build、前端 build、`git diff --check` 通过。浏览器确认本地构建可加载且无框架错误覆盖,但本地未启动真实 API,认证验证码请求返回 502 并停留登录页,因此未把目标报备页面的登录后视觉交互标记为通过;没有绕过认证或注入 mock 数据。
- 2026-07-12 追加:报备状态改为通道任务唯一事实来源。新增统一批量状态变更 API,企业签名按应用当前通道组展示具体通道矩阵,通道详情修改当前任务,报备任务页人工修正任务;三个入口统一更新/创建 `ChannelSignatureReportTask`、写 `ChannelSignatureReportRecord`,并重算三网汇总和 `SmsSignature.reportStatus`。回执导入不再直接覆盖全局状态,同样调用汇总算法;新增但无任务的目标通道按未报备计入汇总分母。
- 已执行相关定向测试 2 suites、46 项及 API 全量测试 13 suites、135 项,API build、前端 build、`git diff --check` 均通过;前端仅有既有 Vite chunk size warning。
+8 -2
View File
@@ -281,6 +281,10 @@ export type ClientSmsSignature = {
materials?: Array<Record<string, unknown>>; materials?: Array<Record<string, unknown>>;
tenant?: TenantOption; tenant?: TenantOption;
application?: ClientSmsApplication | null; application?: ClientSmsApplication | null;
reportStatus?: string;
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>;
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
}; };
export type ClientSmsTemplate = { export type ClientSmsTemplate = {
@@ -976,9 +980,11 @@ export const adminApi = {
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })), listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) => createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) => createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string }>; reason?: string; operatorId?: string }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) => importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
@@ -1040,7 +1046,7 @@ export const adminApi = {
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'), listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) => createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
const form = new FormData(); const form = new FormData();
+162 -73
View File
@@ -1,112 +1,201 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Plus, Search } from 'lucide-react'; import { ArrowLeft, ChevronDown, ChevronRight, Eye, FileSliders, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
type ReportType = 'signature' | 'drainage' | 'both';
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string };
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
approved: { label: '报备成功', tone: 'success' },
success: { label: '报备成功', tone: 'success' },
failed: { label: '报备失败', tone: 'danger' },
rejected: { label: '报备失败', tone: 'danger' },
pending: { label: '未报备', tone: 'neutral' },
waiting_material: { label: '资料待补充', tone: 'warning' },
exporting: { label: '报备中', tone: 'warning' },
partial_success: { label: '部分成功', tone: 'warning' },
filing: { label: '报备中', tone: 'warning' },
};
const fieldTypeLabel: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo);
return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : [];
}
function DateTime({ value }: { value?: unknown }) {
return value ? <span className="channel-report-date">{formatDateTime(String(value))}</span> : <span className="muted">-</span>;
}
function ReportStatus({ value }: { value?: string }) {
const meta = statusMeta[value ?? ''] ?? { label: value || '未报备', tone: 'neutral' as const };
return <Tag tone={meta.tone}>{meta.label}</Tag>;
}
function DetailModal({ drainage, signature, task, onClose }: { drainage?: DrainageItem; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
const payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
<div className="channel-report-detail">
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : `${signature?.name ?? task.signature?.name ?? '-'}`}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
{drainage ? <><p><span></span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
</div>
</Modal>
);
}
export function AdminChannelReportPage() { export function AdminChannelReportPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [channels, setChannels] = useState<AdminChannel[]>([]); const { channelId = '' } = useParams();
const [channel, setChannel] = useState<AdminChannel>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [records, setRecords] = useState<ReportRecord[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]); const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]); const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [channelId, setChannelId] = useState(''); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
const [configType, setConfigType] = useState<ReportType>();
const [drainageFieldId, setDrainageFieldId] = useState(''); const [drainageFieldId, setDrainageFieldId] = useState('');
const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature');
const [required, setRequired] = useState(false); const [required, setRequired] = useState(false);
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData(nextChannelId = channelId) { function loadData() {
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()]) Promise.all([
.then(([channelItems, fieldItems, libraryItems]) => { adminApi.listChannels(),
setChannels(channelItems.filter((item) => item.status !== 'deleted')); adminApi.listReportTasks({ channelId }),
adminApi.listReportRecords({ channelId }),
adminApi.listEnterpriseSignatures(),
adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(),
]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskItems);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems); setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active')); setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError(''); setError('');
}) }).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
} }
useEffect(() => { useEffect(loadData, [channelId]);
loadData('');
}, []);
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]); const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
const visibleTasks = useMemo(() => tasks.filter((task) => {
const signature = signatureMap.get(task.signatureId);
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name].some((value) => String(value ?? '').includes(keyword.trim()));
return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]);
function lastRecord(taskId: string, action: string) {
return records.find((record) => record.taskId === taskId && record.action === action);
}
function createField() { function createField() {
const selected = libraryFields.find((item) => item.id === drainageFieldId); if (!configType || !drainageFieldId) return;
if (!selected) return; adminApi.createChannelReportField({ channelId, drainageFieldId, reportType: configType, required, description, status: 'active' })
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' }) .then(() => { setConfigType(undefined); setDrainageFieldId(''); setRequired(false); setDescription(''); loadData(); })
.then(() => {
setModalOpen(false);
setDrainageFieldId('');
setReportType('signature');
setRequired(false);
setDescription('');
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段保存失败')); .catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
} }
const columns: Array<TableColumn<ChannelReportField>> = [ function toggle(signatureId: string) {
{ key: 'channel', title: '通道', width: '220px', render: (record) => channels.find((item) => item.id === record.channelId)?.name ?? record.channelId }, setExpanded((current) => {
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> }, const next = new Set(current);
{ key: 'name', title: '字段名称', width: '160px', render: (record) => record.name }, next.has(signatureId) ? next.delete(signatureId) : next.add(signatureId);
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType }, return next;
{ key: 'reportType', title: '报备用途', width: '140px', render: (record) => record.reportType === 'signature' ? '签名报备' : record.reportType === 'drainage' ? '引流信息报备' : '签名+引流' }, });
{ key: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> }, }
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
]; function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
return ( return (
<section className="page-stack admin-system-page admin-drainage-page"> <section className="page-stack channel-report-page">
<div className="page-heading"> <div className="surface channel-report-hero">
<div> <Breadcrumb items={['通道管理', '短信通道', '报备详情']} />
<Breadcrumb items={['报备管理', '通道报备配置']} /> <div className="channel-report-heading">
<h1></h1> <Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<h1>{channel?.name ?? '通道报备详情'}</h1>
<div className="channel-report-config-actions">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost"></Button>
</div> </div>
<div className="page-actions">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div> </div>
<div className="muted">{channel?.code ?? channelId} · {fields.length} </div>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-drainage-toolbar"> <div className="surface channel-report-filter">
<Select <div className="channel-report-filter-grid">
onChange={(event) => { <Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} />
setChannelId(event.target.value); <Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
loadData(event.target.value); <div><strong></strong><p className="muted"></p></div>
}} </div>
options={[{ label: '全部通道', value: '' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} <div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div>
value={channelId}
/>
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段代码、名称或说明" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
</div> </div>
<div className="surface admin-system-table-card admin-drainage-table-card"> <div className="surface channel-report-table">
<Table columns={columns} data={filteredFields} emptyText="暂无通道报备字段" rowKey="id" /> <div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const links = drainageItems(signature);
const isExpanded = expanded.has(task.signatureId);
const exported = lastRecord(task.id, 'export');
const imported = lastRecord(task.id, 'receipt_import');
return <div key={task.id}>
<div className="channel-report-row channel-report-row--signature">
<span />
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{signature?.name ?? task.signature?.name ?? '-'}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} />
<DateTime value={task.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>
{isExpanded ? links.map((link, index) => <div className="channel-report-row channel-report-row--drainage" key={String(link.id ?? index)}>
<span />
<div className="channel-report-name channel-report-name--flow"><i /><span><strong>{String(link.siteName || link.url || `引流信息 ${index + 1}`)}</strong><small>{String(link.url ?? '')}</small></span></div>
<ReportStatus value={task.status} /><DateTime value={link.submittedAt ?? signature?.updatedAt} /><DateTime value={imported?.createdAt} /><DateTime value={signature?.updatedAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div>
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage: link })} type="button"><Eye size={16} /></button></div>
</div>) : null}
</div>;
})}
</div> </div>
<Modal {detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost"></Button><Button disabled={!channelId || !drainageFieldId} onClick={createField}></Button></>} <Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
onClose={() => setModalOpen(false)} <Modal footer={<><Button onClick={() => setConfigType(undefined)} variant="ghost"></Button><Button disabled={!drainageFieldId} onClick={createField}></Button></>} onClose={() => setConfigType(undefined)} open={Boolean(configType)} title={configType === 'drainage' ? '配置引流信息报备字段' : '配置签名报备字段'}>
open={modalOpen}
title="新增通道报备字段"
>
<div className="admin-system-modal-form"> <div className="admin-system-modal-form">
<Select <Select label="报备字段库字段" onChange={(event) => setDrainageFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}${fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}`, value: field.id }))]} value={drainageFieldId} />
label="报备字段库字段"
onChange={(event) => setDrainageFieldId(event.target.value)}
options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}${field.code}`, value: field.id }))]}
value={drainageFieldId}
/>
<Select label="报备用途" onChange={(event) => setReportType(event.target.value as typeof reportType)} options={[{ label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }, { label: '签名+引流', value: 'both' }]} value={reportType} />
<Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} /> <Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} />
<Textarea label="说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} /> <Textarea label="通道报备说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
</div> </div>
</Modal> </Modal>
</section> </section>
+8 -7
View File
@@ -11,16 +11,17 @@ type DrainageField = DictionaryItem & {
description?: string | null; description?: string | null;
}; };
type ReportFieldType = 'string' | 'image' | 'file';
const typeOptions = [ const typeOptions = [
{ label: '全部类型', value: 'all' }, { label: '全部类型', value: 'all' },
{ label: '字符串', value: 'string' }, { label: '字符串', value: 'string' },
{ label: '整数', value: 'number' }, { label: '图片', value: 'image' },
{ label: '文件', value: 'file' }, { label: '文件', value: 'file' },
{ label: '网址', value: 'url' },
{ label: '电话', value: 'phone' },
{ label: '日期', value: 'date' },
]; ];
const typeLabels: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
export function AdminDrainageFieldsPage() { export function AdminDrainageFieldsPage() {
const [fields, setFields] = useState<DrainageField[]>([]); const [fields, setFields] = useState<DrainageField[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
@@ -30,7 +31,7 @@ export function AdminDrainageFieldsPage() {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [fieldType, setFieldType] = useState('string'); const [fieldType, setFieldType] = useState<ReportFieldType>('string');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -72,7 +73,7 @@ export function AdminDrainageFieldsPage() {
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [ const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> }, { key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' }, { key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{record.fieldType}</span> }, { key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' }, { key: 'description', title: '描述', render: (record) => record.description ?? '-' },
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> }, { key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
], []); ], []);
@@ -117,7 +118,7 @@ export function AdminDrainageFieldsPage() {
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} /> <Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select <Select
label="字段类型" label="字段类型"
onChange={(event) => setFieldType(event.target.value)} onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
options={typeOptions.filter((option) => option.value !== 'all')} options={typeOptions.filter((option) => option.value !== 'all')}
value={fieldType} value={fieldType}
/> />
@@ -544,6 +544,39 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
); );
} }
const reportStatusOptions = [
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
];
function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
const targets = item.reportTargets ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 220px', padding: 16 }}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
}
function summaryStatus(signature: ClientSmsSignature, carrier: 'mobile' | 'unicom' | 'telecom'): CarrierStatus {
const status = signature.carrierReportSummary?.[carrier]?.status;
return status === 'approved' ? 'approved' : status === 'failed' ? 'rejected' : status === 'reporting' ? 'pending' : 'filing';
}
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) { function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
return ( return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情"> <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情">
@@ -592,6 +625,7 @@ export function AdminEnterpriseSignaturesPage() {
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState(''); const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null); const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null); const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -703,9 +737,10 @@ export function AdminEnterpriseSignaturesPage() {
<div className="signature-list admin-enterprise-signature-list"> <div className="signature-list admin-enterprise-signature-list">
{visibleSignatures.map((signature) => { {visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature); const payload = readDrainagePayload(signature);
const effectiveCarrierStatus = { mobile: summaryStatus(signature, 'mobile'), unicom: summaryStatus(signature, 'unicom'), telecom: summaryStatus(signature, 'telecom') };
const expanded = expandedSignatureId === signature.id; const expanded = expandedSignatureId === signature.id;
return ( return (
<article className={`signature-card signature-card--${signatureCardTone(payload.carrierStatus)}`} key={signature.id}> <article className={`signature-card signature-card--${signatureCardTone(effectiveCarrierStatus)}`} key={signature.id}>
<div className="signature-summary"> <div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button"> <button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />} {expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
@@ -713,12 +748,13 @@ export function AdminEnterpriseSignaturesPage() {
<div><span></span><strong>{signature.name}</strong></div> <div><span></span><strong>{signature.name}</strong></div>
<div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div> <div><span></span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
<div><span></span><strong>{signature.application?.name ?? '-'}</strong></div> <div><span></span><strong>{signature.application?.name ?? '-'}</strong></div>
<div><span></span><StatusTag status={payload.carrierStatus.mobile} /></div> <div><span></span><StatusTag status={effectiveCarrierStatus.mobile} /></div>
<div><span></span><StatusTag status={payload.carrierStatus.unicom} /></div> <div><span></span><StatusTag status={effectiveCarrierStatus.unicom} /></div>
<div><span></span><StatusTag status={payload.carrierStatus.telecom} /></div> <div><span></span><StatusTag status={effectiveCarrierStatus.telecom} /></div>
<div><span></span><strong>{payload.links.length} </strong></div> <div><span></span><strong>{payload.links.length} </strong></div>
<div className="signature-actions"> <div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button> <Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button> <Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button> <Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button>
</div> </div>
@@ -832,6 +868,7 @@ export function AdminEnterpriseSignaturesPage() {
/> />
) : null} ) : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null} {signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{drainageModal ? ( {drainageModal ? (
<DrainageFormModal <DrainageFormModal
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId} applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
+13 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Download, Eye, FileUp, Search } from 'lucide-react'; import { Download, Eye, FileUp, Search } from 'lucide-react';
import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi'; import { adminApi, type FileObject, type FileRef, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = { const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
pending: { label: '待处理', tone: 'neutral' }, pending: { label: '待处理', tone: 'neutral' },
@@ -112,6 +112,9 @@ export function AdminReportTasksPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null); const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null); const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData() { function loadData() {
@@ -157,6 +160,13 @@ export function AdminReportTasksPage() {
.catch((failure: Error) => setError(failure.message || '报备回执导入失败')); .catch((failure: Error) => setError(failure.message || '报备回执导入失败'));
} }
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, status: nextStatus }], reason: statusReason })
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
const columns: Array<TableColumn<ReportTask>> = [ const columns: Array<TableColumn<ReportTask>> = [
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> }, { key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> }, { key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> },
@@ -170,6 +180,7 @@ export function AdminReportTasksPage() {
render: (record) => ( render: (record) => (
<div className="admin-task-actions"> <div className="admin-task-actions">
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button> <Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => { setStatusTask(record); setNextStatus(record.status); }} size="sm" variant="ghost"></Button>
<Button icon={<Download size={14} />} onClick={() => exportTask(record)} size="sm" variant="ghost"></Button> <Button icon={<Download size={14} />} onClick={() => exportTask(record)} size="sm" variant="ghost"></Button>
<Button icon={<FileUp size={14} />} onClick={() => setReceiptTask(record)} size="sm" variant="ghost"></Button> <Button icon={<FileUp size={14} />} onClick={() => setReceiptTask(record)} size="sm" variant="ghost"></Button>
</div> </div>
@@ -202,6 +213,7 @@ export function AdminReportTasksPage() {
{receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null} {receiptTask ? <ReceiptImportModal onClose={() => setReceiptTask(null)} onSubmit={importReceipt} task={receiptTask} /> : null}
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null} {detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button onClick={() => setStatusTask(null)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="人工修正报备任务状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
</section> </section>
); );
} }