feat: refine report material workflow states

This commit is contained in:
hectorzhao
2026-09-03 12:50:33 +08:00
parent 55915ed826
commit 4ff6ed0786
12 changed files with 237 additions and 31 deletions
@@ -103,8 +103,15 @@ export class AdminSmsConfigController {
} }
@Put('enterprise-signatures/:id') @Put('enterprise-signatures/:id')
updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto) { updateSignature(
return this.smsConfig.updateSignature(signatureId, body); @Param('id') signatureId: string,
@Body() body: UpdateSmsSignatureDto,
@CurrentSessionUserId() reviewerId?: string,
) {
return this.smsConfig.updateSignature(signatureId, body, undefined, {
initialAuditStatus: 'approved',
reviewerId,
});
} }
@Get('drainage-infos') @Get('drainage-infos')
+43 -10
View File
@@ -440,15 +440,22 @@ export class SmsSignatureService {
] ]
: undefined, : undefined,
}; };
const [items, total, pendingReportDetailTotal] = await Promise.all([ const [items, total, pendingReportSummary] = await Promise.all([
this.listSignatures({ ...query, page, pageSize }, true), this.listSignatures({ ...query, page, pageSize }, true),
this.prisma.smsSignature.count({ where }), this.prisma.smsSignature.count({ where }),
this.countPendingReportDetails(where), this.countPendingReportSummary(where),
]); ]);
return { items, total, page, pageSize, pendingReportDetailTotal }; return {
items,
total,
page,
pageSize,
pendingReportMaterialTotal: pendingReportSummary.materialTotal,
pendingReportDetailTotal: pendingReportSummary.detailTotal,
};
} }
private async countPendingReportDetails(where: Prisma.SmsSignatureWhereInput) { private async countPendingReportSummary(where: Prisma.SmsSignatureWhereInput) {
const signatures = await this.prisma.smsSignature.findMany({ const signatures = await this.prisma.smsSignature.findMany({
where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] }, where: { AND: [where, { auditStatus: 'approved', pendingReport: true }] },
select: { select: {
@@ -487,9 +494,11 @@ export class SmsSignatureService {
}, },
}) })
: []; : [];
let total = 0; let materialTotal = 0;
let detailTotal = 0;
for (const signature of signatures) { for (const signature of signatures) {
if (!signature.applicationId || signature.application?.status !== 'active') continue; if (!signature.applicationId || signature.application?.status !== 'active') continue;
let hasPendingTarget = false;
const generatedTargets = new Set<string>(); const generatedTargets = new Set<string>();
for (const item of signature.reportBatchItems.filter( for (const item of signature.reportBatchItems.filter(
(entry) => entry.materialVersion === signature.materialVersion, (entry) => entry.materialVersion === signature.materialVersion,
@@ -526,11 +535,13 @@ export class SmsSignatureService {
); );
if (task?.status === 'abandoned') continue; if (task?.status === 'abandoned') continue;
if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue; if (generatedTargets.has(`${channel.id}:${carrier}`) || generatedTargets.has(`${channel.id}:legacy`)) continue;
total += 1; detailTotal += 1;
hasPendingTarget = true;
} }
} }
if (hasPendingTarget) materialTotal += 1;
} }
return total; return { materialTotal, detailTotal };
} }
async getSignature(id: string) { async getSignature(id: string) {
@@ -817,7 +828,12 @@ export class SmsSignatureService {
}; };
} }
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { async updateSignature(
signatureId: string,
data: UpdateSmsSignatureDto,
tenantId?: string,
options: CreateSmsSignatureOptions = {},
) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || (tenantId && signature.tenantId !== tenantId)) { if (!signature || (tenantId && signature.tenantId !== tenantId)) {
throw new NotFoundException('Signature not found'); throw new NotFoundException('Signature not found');
@@ -837,7 +853,9 @@ export class SmsSignatureService {
(data.purpose !== undefined && data.purpose !== signature.purpose) || (data.purpose !== undefined && data.purpose !== signature.purpose) ||
(data.drainageInfo !== undefined && (data.drainageInfo !== undefined &&
JSON.stringify(drainageInfo ?? null) !== JSON.stringify(signature.drainageInfo ?? null)); JSON.stringify(drainageInfo ?? null) !== JSON.stringify(signature.drainageInfo ?? null));
const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; const auditStatus =
options.initialAuditStatus ??
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
const updated = await this.prisma.smsSignature.update({ const updated = await this.prisma.smsSignature.update({
where: { id: signatureId }, where: { id: signatureId },
data: { data: {
@@ -845,7 +863,7 @@ export class SmsSignatureService {
name, name,
purpose: data.purpose, purpose: data.purpose,
auditStatus, auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined, rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: materialChanged ? { increment: 1 } : undefined, materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined, pendingReport: materialChanged ? true : undefined,
@@ -858,6 +876,21 @@ export class SmsSignatureService {
updated.applicationId ?? undefined, updated.applicationId ?? undefined,
drainageInfo, drainageInfo,
); );
if (
options.initialAuditStatus === 'approved' &&
(materialChanged || signature.auditStatus !== 'approved')
) {
await this.audit.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signatureId,
action: 'admin_update_approved',
statusBefore: signature.auditStatus,
statusAfter: 'approved',
reason: '运营端修改签名并自动审核通过',
reviewerId: options.reviewerId,
});
}
return { return {
...updated, ...updated,
reportMaterialChanged: materialChanged, reportMaterialChanged: materialChanged,
@@ -47,6 +47,7 @@ export interface CreateSmsSignatureDto {
export interface CreateSmsSignatureOptions { export interface CreateSmsSignatureOptions {
initialAuditStatus?: string; initialAuditStatus?: string;
reviewerId?: string;
} }
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & { export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
@@ -1106,6 +1106,44 @@ describe('SmsConfigService', () => {
})); }));
}); });
it('keeps an operator-edited signature approved and records the operator audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】',
purpose: '通知', drainageInfo: {}, auditStatus: 'approved',
});
const service = new SmsConfigService(prisma as never);
const result = await service.updateSignature(
'sig-1',
{ name: '【新签名】' },
undefined,
{ initialAuditStatus: 'approved', reviewerId: 'operator-1' },
);
expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
auditStatus: 'approved',
rejectReason: null,
materialVersion: { increment: 1 },
pendingReport: true,
}),
}));
expect(prisma.auditRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_update_approved',
reviewerId: 'operator-1',
statusBefore: 'approved',
statusAfter: 'approved',
}),
});
expect(result).toEqual(expect.objectContaining({
auditStatus: 'approved',
reportMaterialChanged: true,
reportPoolAvailableAfter: 'immediate',
}));
});
it('resets an approved template to pending when key content is changed', async () => { it('resets an approved template to pending when key content is changed', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsTemplate.findUnique.mockResolvedValue({ prisma.smsTemplate.findUnique.mockResolvedValue({
@@ -1223,6 +1261,7 @@ describe('SmsConfigService', () => {
const result = await service.listSignaturesPage({ page: 1, pageSize: 10 }); const result = await service.listSignaturesPage({ page: 1, pageSize: 10 });
expect(result.total).toBe(1); expect(result.total).toBe(1);
expect(result.pendingReportMaterialTotal).toBe(0);
expect(result.pendingReportDetailTotal).toBe(0); expect(result.pendingReportDetailTotal).toBe(0);
expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' })); expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' }));
expect(result.items[0]).not.toHaveProperty('materials'); expect(result.items[0]).not.toHaveProperty('materials');
@@ -1280,6 +1319,7 @@ describe('SmsConfigService', () => {
const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 }); const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 });
expect(result.pendingReportMaterialTotal).toBe(1);
expect(result.pendingReportDetailTotal).toBe(expectedTotal); expect(result.pendingReportDetailTotal).toBe(expectedTotal);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith( expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
+7 -2
View File
@@ -163,8 +163,13 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
return this.signatures.createSignature(data, options); return this.signatures.createSignature(data, options);
} }
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { async updateSignature(
return this.signatures.updateSignature(signatureId, data, tenantId); signatureId: string,
data: UpdateSmsSignatureDto,
tenantId?: string,
options: CreateSmsSignatureOptions = {},
) {
return this.signatures.updateSignature(signatureId, data, tenantId, options);
} }
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) { async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
@@ -112,7 +112,7 @@
"importOpen", "importOpen",
"message", "message",
"materialChangedSignature", "materialChangedSignature",
"pendingReportDetailTotal", "pendingReportMaterialTotal",
"listRequestSequence" "listRequestSequence"
], ],
"files": [ "files": [
+3 -1
View File
@@ -5009,7 +5009,7 @@ npm run verify:phase8
| 用例ID | 场景 | 预期 | | 用例ID | 场景 | 预期 |
| --- | --- | --- | | --- | --- | --- |
| TC-REPORT-WORKBENCH-001 | 打开运营端导航及四个报备页面 | 一级菜单为“报备工作台”,二级菜单依次为“报备资料池、报备批次、通道报备明细、状态记录”;资料池和批次不再共用页签 | | TC-REPORT-WORKBENCH-001 | 打开运营端导航及四个报备页面 | 一级菜单为“报备工作台”,二级菜单依次为“报备资料池、报备批次、通道报备明细、状态记录”;资料池和批次不再共用页签 |
| TC-REPORT-WORKBENCH-002 | 新增审核通过签名或修改报备相关资料 | 后端返回资料变化标识;页面提示到报备资料池生成批次;搜索条件下方集中展示当前条件内最新版本尚未生成的通道×运营商明细总数并可下钻,签名列表不展示该字段 | | TC-REPORT-WORKBENCH-002 | 运营端新增签名或修改签名报备资料 | 保存后签名自动审核通过并写`admin_create_approved/admin_update_approved`审核记录,客户端编辑和导入审核流程不变;后端返回资料变化标识提示到报备资料池生成批次 |
| TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 | | TC-REPORT-WORKBENCH-003 | 查看存在有效应用路由但尚未生成任务的签名 | 通道报备明细按企业应用×签名×通道×运营商显示虚拟“未报备”行,可单选或多选后通过真实状态接口创建/更新任务并写状态记录 |
| TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 | | TC-REPORT-WORKBENCH-004 | 将一条通道运营商明细设为放弃报备后预检批次 | 仅该通道运营商组合被排除,其他有效组合仍可生成;不得发送、补发、重投或重新入队短信 |
| TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表不直接铺开文件,仅显示通道数、生成状态及报备总数/报备中/成功/失败四项明细数;“打开明细”从右侧滑出该批次通道明细列表 | | TC-REPORT-WORKBENCH-005 | 从资料池选择资料生成批次并打开批次明细 | 批次列表不直接铺开文件,仅显示通道数、生成状态及报备总数/报备中/成功/失败四项明细数;“打开明细”从右侧滑出该批次通道明细列表 |
@@ -5030,6 +5030,8 @@ npm run verify:phase8
| TC-REPORT-WORKBENCH-020 | 打开“报备文件导出”弹窗并下载单个通道文件 | 每个通道展示一份简报、复制按钮和报备文件下载按钮;XLSX文件名为`YYYY-MM-DD_通道名_批次号.xlsx`,文件来自该批次真实MinIO对象 | | TC-REPORT-WORKBENCH-020 | 打开“报备文件导出”弹窗并下载单个通道文件 | 每个通道展示一份简报、复制按钮和报备文件下载按钮;XLSX文件名为`YYYY-MM-DD_通道名_批次号.xlsx`,文件来自该批次真实MinIO对象 |
| TC-REPORT-WORKBENCH-021 | 点击报备文件弹窗“全部下载” | 一次下载ZIP,内含每个通道一份XLSX和一份TXT简报;所有条目均按`YYYY-MM-DD_通道名_批次号`命名;任一通道文件缺失、超过100个通道或总文件超过200MB时返回明确错误,不生成不完整压缩包 | | TC-REPORT-WORKBENCH-021 | 点击报备文件弹窗“全部下载” | 一次下载ZIP,内含每个通道一份XLSX和一份TXT简报;所有条目均按`YYYY-MM-DD_通道名_批次号`命名;任一通道文件缺失、超过100个通道或总文件超过200MB时返回明确错误,不生成不完整压缩包 |
| TC-REPORT-WORKBENCH-022 | 对尚未生成当前材料版本批次的签名,分别从通道报备明细和企业签名的报备状态弹窗将一个通道×运营商组合设为放弃报备,再恢复为其他状态并刷新企业签名页 | 两个入口调用同一真实状态接口并写入各自来源记录;设为放弃后待生成明细总数减少1,恢复后增加1。当前材料版本已经生成成功批次的组合不得因人工改状态重新计入,避免重复生成;企业签名页右上角两个按钮使用统一操作区间距,窄屏可换行且不重叠 | | TC-REPORT-WORKBENCH-022 | 对尚未生成当前材料版本批次的签名,分别从通道报备明细和企业签名的报备状态弹窗将一个通道×运营商组合设为放弃报备,再恢复为其他状态并刷新企业签名页 | 两个入口调用同一真实状态接口并写入各自来源记录;设为放弃后待生成明细总数减少1,恢复后增加1。当前材料版本已经生成成功批次的组合不得因人工改状态重新计入,避免重复生成;企业签名页右上角两个按钮使用统一操作区间距,窄屏可换行且不重叠 |
| TC-REPORT-WORKBENCH-023 | 企业签名页按企业、应用或签名筛选后查看待生成报备资料汇总并点击“查看详情” | 数量按当前筛选范围内仍有至少一个待生成通道目标的签名资料去重,以“份”展示;按钮进入报备资料池,不再进入通道报备明细;明细数仍保留在接口中用于兼容但不在页面展示 |
| TC-REPORT-WORKBENCH-024 | 报备资料池分别准备全部可生成、部分可生成、缺资料、全部放弃、无有效通道及已有同版本批次的资料 | 页面同时显示文字状态;待生成和部分可生成为黄色、资料不完整为红色、已生成为绿色、全部放弃和无有效通道为灰色;部分可生成的阻断原因使用红色提示,不得只依靠颜色表达状态 |
## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情 ## TC-HIGH-FREQUENCY-QUERY-20260902 高频查询与按需详情
+9
View File
@@ -4390,3 +4390,12 @@ git diff --check
- 增加两个状态入口共用事务逻辑、各状态下待生成数量和页面按钮间距的回归用例。定向API 2套122项、API全量52套601项、前端全量12文件55项、前后端TypeScript及Vite生产构建均通过;Vite仅保留既有Chart分块超过500kB提示。 - 增加两个状态入口共用事务逻辑、各状态下待生成数量和页面按钮间距的回归用例。定向API 2套122项、API全量52套601项、前端全量12文件55项、前后端TypeScript及Vite生产构建均通过;Vite仅保留既有Chart分块超过500kB提示。
- Browser插件不在本会话技能列表,按前端调试流程使用本机Playwright Chrome检查本地生产构建:1600×1000与390×844下均显示两个目标按钮,计算样式间距为8px、无重叠和页面横向溢出,控制台及页面错误均为0。接口响应使用隔离布局数据,仅验证样式和响应式交互,不冒充真实API/PostgreSQL验收。 - Browser插件不在本会话技能列表,按前端调试流程使用本机Playwright Chrome检查本地生产构建:1600×1000与390×844下均显示两个目标按钮,计算样式间距为8px、无重叠和页面横向溢出,控制台及页面错误均为0。接口响应使用隔离布局数据,仅验证样式和响应式交互,不冒充真实API/PostgreSQL验收。
- 本轮不提交、不推送、不部署,不访问预生产,不修改测试环境业务数据,不触发短信发送、补发、重投或重新入队。 - 本轮不提交、不推送、不部署,不访问预生产,不修改测试环境业务数据,不触发短信发送、补发、重投或重新入队。
## 2026-09-03 运营修改自动通过、资料池状态颜色与待生成资料数(测试环境发布准备)
- 运营端新增签名沿用既有自动通过规则;运营端修改签名或资料后也直接保持/变为审核通过,并写入`admin_update_approved`审核记录及操作人。客户端修改和导入审核流程不变,仍按原规则进入待审核;没有新增数据库字段或migration。
- 报备资料池按真实预检结果展示明确状态颜色:待生成、部分可生成为黄色,资料不完整及阻断原因为红色,已生成为绿色,全部放弃、无有效通道和资格检查中为灰色。状态只作用于现有资料池查询范围,不扩大为全部签名或历史资料查询。
- 企业签名管理搜索区下方汇总改为“待生成报备资料”份数,按一条企业应用下的签名资料计数;只有至少一个当前通道×运营商组合可生成时才计入。同一接口继续返回原“待生成明细数”兼容字段。入口文案改为“查看详情”,点击跳转报备资料池。
- 定向API 1套76项、定向报备工作台组件1文件5项通过;全量API 52套602项、前端12文件56项通过。前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、R4页面契约、包体积及`git diff --check`通过;Vite仅保留既有Chart分块超过500kB提示,入口gzip约107.63KiB。
- Chrome本地生产预览在1600×1000及390×844下验证汇总文案、7份示例计数、“查看详情”跳转和六类状态颜色,无控制台或页面错误;首行滚动到可视区后待生成黄色状态可见。隔离页面数据只用于布局、响应式和交互覆盖,不冒充真实API/PostgreSQL验收。
- 本轮发布范围仅为测试环境`100.93.204.60`,不访问预生产,不修改余额、客户、通道或签名业务资料,不发送、补发、重投或重新入队短信。发布恢复资产、最终部署标记、真实服务和只读数据核验结果待发布完成后追加。
+1 -1
View File
@@ -41,7 +41,7 @@ export const adminGovernanceApi = {
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) => listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature> & { pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)), request<PagedResult<ClientSmsSignature> & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) => listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)), request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`), getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`),
@@ -45,7 +45,7 @@ export function AdminEnterpriseSignaturesPage() {
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null); const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]); const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [pendingReportDetailTotal, setPendingReportDetailTotal] = useState(0); const [pendingReportMaterialTotal, setPendingReportMaterialTotal] = useState(0);
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@@ -70,7 +70,7 @@ export function AdminEnterpriseSignaturesPage() {
if (sequence !== listRequestSequence.current) return; if (sequence !== listRequestSequence.current) return;
setSignatures(signatureResult.items); setSignatures(signatureResult.items);
setTotal(signatureResult.total); setTotal(signatureResult.total);
setPendingReportDetailTotal(signatureResult.pendingReportDetailTotal); setPendingReportMaterialTotal(signatureResult.pendingReportMaterialTotal);
setError(''); setError('');
} catch (failure) { } catch (failure) {
if (sequence !== listRequestSequence.current) return; if (sequence !== listRequestSequence.current) return;
@@ -317,11 +317,11 @@ export function AdminEnterpriseSignaturesPage() {
</div> </div>
<div className="enterprise-signature-pending-summary" role="status"> <div className="enterprise-signature-pending-summary" role="status">
<span></span> <span></span>
<strong>{pendingReportDetailTotal}</strong> <strong>{pendingReportMaterialTotal}</strong>
<span></span> <span></span>
<Button onClick={() => navigate('/admin/report-tasks?scope=pending')} size="sm" variant="ghost"> <Button onClick={() => navigate('/admin/report-materials')} size="sm" variant="ghost">
</Button> </Button>
</div> </div>
+63 -7
View File
@@ -24,6 +24,61 @@ import {
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId'; import { createUuid } from '@/utils/randomId';
type PoolVisualState = {
label: string;
tone: 'neutral' | 'success' | 'warning' | 'danger';
detail?: string;
detailDanger?: boolean;
};
function poolVisualState(
materialVersion: number,
inspection?: ReportMaterialBatchPreflight['items'][number],
): PoolVisualState {
if (!inspection) return { label: `V${materialVersion}资格检查中`, tone: 'neutral' };
const targets = inspection.targets;
const eligibleCount = targets.filter((target) => target.eligible).length;
const blockedTargets = targets.filter((target) => !target.eligible);
const blockedReasons = [...new Set(blockedTargets.flatMap((target) => target.blockedReasons ?? []))];
const allTargetsMatch = (fragment: string) =>
targets.length > 0 &&
targets.every((target) => (target.blockedReasons ?? []).some((reason) => reason.includes(fragment)));
if (allTargetsMatch('已放弃报备')) return { label: '全部放弃', tone: 'neutral' };
if (
targets.length === 0 ||
inspection.blockedReasons.some((reason) =>
['未绑定短信应用', '短信应用未启用', '当前应用没有启用且可路由的通道'].some((fragment) =>
reason.includes(fragment),
),
)
) {
return {
label: '无有效通道',
tone: 'neutral',
detail: inspection.blockedReasons[0],
};
}
if (allTargetsMatch('同一资料版本已在批次')) {
return { label: `V${materialVersion}已生成`, tone: 'success' };
}
if (eligibleCount > 0 && blockedTargets.length > 0) {
return {
label: `V${materialVersion}部分可生成`,
tone: 'warning',
detail: blockedReasons[0],
detailDanger: true,
};
}
if (eligibleCount > 0) return { label: `V${materialVersion}待生成`, tone: 'warning' };
return {
label: `V${materialVersion}资料不完整`,
tone: 'danger',
detail: blockedReasons[0] ?? inspection.blockedReasons[0],
detailDanger: true,
};
}
export function AdminReportMaterialsPage() { export function AdminReportMaterialsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({
@@ -187,17 +242,18 @@ export function AdminReportMaterialsPage() {
}, },
{ {
key: 'eligibility', key: 'eligibility',
title: '版本/资格', title: '版本/状态',
render: (item) => { render: (item) => {
const eligibility = poolEligibility.get(item.id); const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible; const visualState = poolVisualState(item.materialVersion, eligibility);
return ( return (
<div> <div>
<Tag tone={eligible ? 'success' : 'warning'}> <Tag tone={visualState.tone}>{visualState.label}</Tag>
V{item.materialVersion} ·{' '} {visualState.detail ? (
{eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'} <div className={visualState.detailDanger ? 'muted status-danger' : 'muted'}>
</Tag> {visualState.detail}
{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null} </div>
) : null}
</div> </div>
); );
}, },
@@ -118,6 +118,59 @@ describe('report workbench pages', () => {
expect(screen.queryByText('选择本页全部可生成资料')).not.toBeInTheDocument(); expect(screen.queryByText('选择本页全部可生成资料')).not.toBeInTheDocument();
}); });
it('uses explicit colors and text for material pool work states', async () => {
const materials = [
['pending', '待生成'],
['partial', '部分可生成'],
['incomplete', '资料不完整'],
['abandoned', '全部放弃'],
['no-route', '无有效通道'],
['generated', '已生成'],
].map(([id]) => ({
id: `signature:${id}`,
tenantId: 'tenant-1',
signatureId: `signature-${id}`,
applicationId: 'app-1',
reportType: 'signature' as const,
name: `资料${id}`,
materialVersion: 2,
changedAt: '2026-09-03T01:00:00.000Z',
tenant: { id: 'tenant-1', name: '测试企业' },
application: { id: 'app-1', name: '测试应用' },
}));
const target = (eligible: boolean, blockedReasons: string[] = []) => ({ eligible, blockedReasons });
adminApi.listPendingReportMaterials.mockResolvedValue({ items: materials, total: materials.length, page: 1, pageSize: 20 });
adminApi.preflightReportMaterialBatch.mockResolvedValue({
eligible: true,
eligibleTargetCount: 2,
skippedTargetCount: 5,
items: [
{ id: 'signature:pending', eligible: true, blockedReasons: [], targets: [target(true)] },
{ id: 'signature:partial', eligible: true, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(true), target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:incomplete', eligible: false, blockedReasons: ['缺少必填字段:营业执照'], targets: [target(false, ['缺少必填字段:营业执照'])] },
{ id: 'signature:abandoned', eligible: false, blockedReasons: ['该通道报备明细已放弃报备'], targets: [target(false, ['该通道报备明细已放弃报备'])] },
{ id: 'signature:no-route', eligible: false, blockedReasons: ['当前应用没有启用且可路由的通道'], targets: [] },
{ id: 'signature:generated', eligible: false, blockedReasons: ['同一资料版本已在批次 RB-1 生成'], targets: [target(false, ['同一资料版本已在批次 RB-1 生成'])] },
],
});
render(
<MemoryRouter>
<AdminReportMaterialsPage />
</MemoryRouter>,
);
expect(await screen.findByText('V2待生成')).toHaveClass('ui-tag--warning');
expect(screen.getByText('V2部分可生成')).toHaveClass('ui-tag--warning');
expect(screen.getByText('V2资料不完整')).toHaveClass('ui-tag--danger');
expect(screen.getByText('全部放弃')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('无有效通道')).toHaveClass('ui-tag--neutral');
expect(screen.getByText('V2已生成')).toHaveClass('ui-tag--success');
screen.getAllByText('缺少必填字段:营业执照').forEach((message) =>
expect(message).toHaveClass('status-danger'),
);
});
it('shows and copies the channel brief returned by the batch detail API', async () => { it('shows and copies the channel brief returned by the batch detail API', async () => {
const batch = { const batch = {
id: 'batch-1', id: 'batch-1',