feat: inherit channel report requirements
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
ALTER TABLE "ChannelReportField"
|
||||||
|
ADD COLUMN "drainageFieldId" TEXT,
|
||||||
|
ADD COLUMN "reportType" TEXT NOT NULL DEFAULT 'both';
|
||||||
|
|
||||||
|
CREATE INDEX "ChannelReportField_drainageFieldId_reportType_idx"
|
||||||
|
ON "ChannelReportField"("drainageFieldId", "reportType");
|
||||||
|
|
||||||
|
ALTER TABLE "ChannelReportField"
|
||||||
|
ADD CONSTRAINT "ChannelReportField_drainageFieldId_fkey"
|
||||||
|
FOREIGN KEY ("drainageFieldId") REFERENCES "DrainageField"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "DrainageReportMaterial" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"signatureId" TEXT NOT NULL,
|
||||||
|
"drainageItemId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"fieldCode" TEXT NOT NULL,
|
||||||
|
"fieldValue" TEXT,
|
||||||
|
"fileObjectId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "DrainageReportMaterial_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "DrainageReportMaterial_signatureId_drainageItemId_channelId_fieldCode_key"
|
||||||
|
ON "DrainageReportMaterial"("signatureId", "drainageItemId", "channelId", "fieldCode");
|
||||||
|
CREATE INDEX "DrainageReportMaterial_channelId_fieldCode_idx"
|
||||||
|
ON "DrainageReportMaterial"("channelId", "fieldCode");
|
||||||
|
|
||||||
|
ALTER TABLE "DrainageReportMaterial" ADD CONSTRAINT "DrainageReportMaterial_signatureId_fkey"
|
||||||
|
FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DrainageReportMaterial" ADD CONSTRAINT "DrainageReportMaterial_channelId_fkey"
|
||||||
|
FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -235,6 +235,8 @@ model DrainageField {
|
|||||||
description String?
|
description String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
channelReportFields ChannelReportField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model BillingPlan {
|
model BillingPlan {
|
||||||
@@ -410,6 +412,7 @@ model SmsSignature {
|
|||||||
materials SignatureMaterial[]
|
materials SignatureMaterial[]
|
||||||
templates SmsTemplate[]
|
templates SmsTemplate[]
|
||||||
reportMaterials SignatureReportMaterial[]
|
reportMaterials SignatureReportMaterial[]
|
||||||
|
drainageReportMaterials DrainageReportMaterial[]
|
||||||
reportTasks ChannelSignatureReportTask[]
|
reportTasks ChannelSignatureReportTask[]
|
||||||
messageRecords SmsMessageRecord[]
|
messageRecords SmsMessageRecord[]
|
||||||
|
|
||||||
@@ -512,6 +515,7 @@ model SmsChannel {
|
|||||||
routeRules ChannelRouteRule[]
|
routeRules ChannelRouteRule[]
|
||||||
healthMetrics ChannelHealthMetric[]
|
healthMetrics ChannelHealthMetric[]
|
||||||
reportFields ChannelReportField[]
|
reportFields ChannelReportField[]
|
||||||
|
drainageReportMaterials DrainageReportMaterial[]
|
||||||
reportTasks ChannelSignatureReportTask[]
|
reportTasks ChannelSignatureReportTask[]
|
||||||
reportRecords ChannelSignatureReportRecord[]
|
reportRecords ChannelSignatureReportRecord[]
|
||||||
messageRecords SmsMessageRecord[]
|
messageRecords SmsMessageRecord[]
|
||||||
@@ -658,6 +662,8 @@ model ChannelHealthMetric {
|
|||||||
model ChannelReportField {
|
model ChannelReportField {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
channelId String
|
channelId String
|
||||||
|
drainageFieldId String?
|
||||||
|
reportType String @default("both")
|
||||||
code String
|
code String
|
||||||
name String
|
name String
|
||||||
fieldType String
|
fieldType String
|
||||||
@@ -669,8 +675,10 @@ model ChannelReportField {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
|
drainageField DrainageField? @relation(fields: [drainageFieldId], references: [id])
|
||||||
|
|
||||||
@@unique([channelId, code])
|
@@unique([channelId, code])
|
||||||
|
@@index([drainageFieldId, reportType])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SignatureReportMaterial {
|
model SignatureReportMaterial {
|
||||||
@@ -689,6 +697,24 @@ model SignatureReportMaterial {
|
|||||||
@@index([channelId])
|
@@index([channelId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DrainageReportMaterial {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
signatureId String
|
||||||
|
drainageItemId String
|
||||||
|
channelId String
|
||||||
|
fieldCode String
|
||||||
|
fieldValue String?
|
||||||
|
fileObjectId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
|
||||||
|
channel SmsChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([signatureId, drainageItemId, channelId, fieldCode])
|
||||||
|
@@index([channelId, fieldCode])
|
||||||
|
}
|
||||||
|
|
||||||
model ChannelSignatureReportTask {
|
model ChannelSignatureReportTask {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ function createPrismaMock() {
|
|||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||||
},
|
},
|
||||||
|
drainageField: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
|
||||||
|
},
|
||||||
signatureReportMaterial: {
|
signatureReportMaterial: {
|
||||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
@@ -152,6 +155,25 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ChannelsService', () => {
|
describe('ChannelsService', () => {
|
||||||
|
it('creates channel report requirements only from the report field library', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|
||||||
|
await service.createReportField({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'ignored', name: 'ignored', fieldType: 'string' });
|
||||||
|
|
||||||
|
expect(prisma.channelReportField.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
channelId: 'channel-1',
|
||||||
|
drainageFieldId: 'library-1',
|
||||||
|
reportType: 'signature',
|
||||||
|
code: 'license',
|
||||||
|
name: '营业执照',
|
||||||
|
fieldType: 'file',
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockQueueAdd.mockClear();
|
mockQueueAdd.mockClear();
|
||||||
mockQueueClose.mockClear();
|
mockQueueClose.mockClear();
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ export interface CreateRouteRuleDto {
|
|||||||
|
|
||||||
export interface CreateReportFieldDto {
|
export interface CreateReportFieldDto {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
code: string;
|
drainageFieldId: string;
|
||||||
name: string;
|
reportType: 'signature' | 'drainage' | 'both';
|
||||||
fieldType: string;
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
fieldType?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
@@ -363,6 +365,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
config: source.config as Prisma.InputJsonValue | undefined,
|
config: source.config as Prisma.InputJsonValue | undefined,
|
||||||
reportFields: {
|
reportFields: {
|
||||||
create: source.reportFields.map((field) => ({
|
create: source.reportFields.map((field) => ({
|
||||||
|
drainageFieldId: field.drainageFieldId,
|
||||||
|
reportType: field.reportType,
|
||||||
code: field.code,
|
code: field.code,
|
||||||
name: field.name,
|
name: field.name,
|
||||||
fieldType: field.fieldType,
|
fieldType: field.fieldType,
|
||||||
@@ -894,19 +898,27 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
listReportFields(channelId?: string) {
|
listReportFields(channelId?: string) {
|
||||||
return this.prisma.channelReportField.findMany({
|
return this.prisma.channelReportField.findMany({
|
||||||
where: channelId ? { channelId } : undefined,
|
where: channelId ? { channelId } : undefined,
|
||||||
|
include: { drainageField: true },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createReportField(data: CreateReportFieldDto) {
|
async createReportField(data: CreateReportFieldDto) {
|
||||||
|
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||||
|
if (!field || field.status !== 'active') {
|
||||||
|
throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||||
|
}
|
||||||
|
const reportType = normalizeReportType(data.reportType);
|
||||||
return this.prisma.channelReportField.create({
|
return this.prisma.channelReportField.create({
|
||||||
data: {
|
data: {
|
||||||
channelId: data.channelId,
|
channelId: data.channelId,
|
||||||
code: data.code,
|
drainageFieldId: field.id,
|
||||||
name: data.name,
|
reportType,
|
||||||
fieldType: data.fieldType,
|
code: field.code,
|
||||||
required: data.required ?? false,
|
name: field.name,
|
||||||
description: data.description,
|
fieldType: field.fieldType,
|
||||||
|
required: data.required ?? field.required,
|
||||||
|
description: data.description ?? field.description,
|
||||||
sortOrder: data.sortOrder ?? 100,
|
sortOrder: data.sortOrder ?? 100,
|
||||||
status: data.status ?? 'active',
|
status: data.status ?? 'active',
|
||||||
},
|
},
|
||||||
@@ -1627,6 +1639,11 @@ function validateGroupItems(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReportType(value?: string) {
|
||||||
|
if (value === 'signature' || value === 'drainage' || value === 'both') return value;
|
||||||
|
throw new BadRequestException('reportType must be signature, drainage or both');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLinkEvent(action: string) {
|
function normalizeLinkEvent(action: string) {
|
||||||
if (action.includes('connect_requested')) {
|
if (action.includes('connect_requested')) {
|
||||||
return '连接请求';
|
return '连接请求';
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export class AdminSmsConfigController {
|
|||||||
return this.smsConfig.getApplication(applicationId);
|
return this.smsConfig.getApplication(applicationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('enterprise-applications/:id/report-fields')
|
||||||
|
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType?: 'signature' | 'drainage') {
|
||||||
|
return this.smsConfig.getApplicationReportFields(applicationId, reportType);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('enterprise-applications')
|
@Post('enterprise-applications')
|
||||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||||
return this.smsConfig.createApplication(body);
|
return this.smsConfig.createApplication(body);
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ function createPrismaMock() {
|
|||||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
|
||||||
},
|
},
|
||||||
|
signatureReportMaterial: {
|
||||||
|
upsert: jest.fn().mockResolvedValue({ id: 'signature-report-value-1' }),
|
||||||
|
},
|
||||||
|
drainageReportMaterial: {
|
||||||
|
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
|
||||||
|
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
smsTemplate: {
|
smsTemplate: {
|
||||||
findMany: jest.fn().mockResolvedValue([{
|
findMany: jest.fn().mockResolvedValue([{
|
||||||
id: 'tpl-1',
|
id: 'tpl-1',
|
||||||
@@ -427,6 +434,76 @@ describe('SmsConfigService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges report fields from every channel in the application channel groups', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'route-1', priority: 10,
|
||||||
|
group: {
|
||||||
|
id: 'group-1', name: '默认通道组',
|
||||||
|
items: [
|
||||||
|
{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: false, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } },
|
||||||
|
{ channel: { id: 'channel-2', code: 'CH-2', name: '通道二', reportFields: [{ status: 'active', required: true, reportType: 'both', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'field-1',
|
||||||
|
code: 'license',
|
||||||
|
required: true,
|
||||||
|
reportTypes: ['signature', 'both'],
|
||||||
|
channels: [
|
||||||
|
expect.objectContaining({ id: 'channel-1', groupId: 'group-1' }),
|
||||||
|
expect.objectContaining({ id: 'channel-2', groupId: 'group-1' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates and persists dynamic signature and drainage report values by channel', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
|
||||||
|
prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }));
|
||||||
|
prisma.channelRouteRule.findMany.mockResolvedValue([{
|
||||||
|
id: 'route-1', priority: 10,
|
||||||
|
group: {
|
||||||
|
id: 'group-1', name: '默认通道组',
|
||||||
|
items: [{
|
||||||
|
channel: {
|
||||||
|
id: 'channel-1', code: 'CH-1', name: '通道一',
|
||||||
|
reportFields: [
|
||||||
|
{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } },
|
||||||
|
{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'text', description: null, status: 'active' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}] as never);
|
||||||
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|
||||||
|
await service.updateSignature('sig-1', {
|
||||||
|
applicationId: 'app-1',
|
||||||
|
drainageInfo: {
|
||||||
|
signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } },
|
||||||
|
links: [{ id: 'drain-1', reportValues: { site_owner: '企业A' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }),
|
||||||
|
}));
|
||||||
|
expect(prisma.drainageReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
create: expect.objectContaining({ signatureId: 'sig-1', drainageItemId: 'drain-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }),
|
||||||
|
}));
|
||||||
|
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updates enterprise signature drainage info through the admin API path', async () => {
|
it('updates enterprise signature drainage info through the admin API path', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new SmsConfigService(prisma as never);
|
const service = new SmsConfigService(prisma as never);
|
||||||
|
|||||||
@@ -181,6 +181,75 @@ export class SmsConfigService {
|
|||||||
return application;
|
return application;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getApplicationReportFields(applicationId: string, reportType?: 'signature' | 'drainage') {
|
||||||
|
await this.getApplication(applicationId);
|
||||||
|
const routes = await this.prisma.channelRouteRule.findMany({
|
||||||
|
where: { applicationId, status: 'active' },
|
||||||
|
include: {
|
||||||
|
group: {
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
channel: {
|
||||||
|
include: {
|
||||||
|
reportFields: { include: { drainageField: true }, orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { priority: 'asc' },
|
||||||
|
});
|
||||||
|
type MergedReportField = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
fieldType: string;
|
||||||
|
required: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
reportTypes: string[];
|
||||||
|
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: string }>;
|
||||||
|
};
|
||||||
|
const merged = new Map<string, MergedReportField>();
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!route.group) continue;
|
||||||
|
for (const item of route.group.items) {
|
||||||
|
for (const configured of item.channel.reportFields) {
|
||||||
|
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
||||||
|
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
||||||
|
const key = configured.drainageField.id;
|
||||||
|
const current: MergedReportField = merged.get(key) ?? {
|
||||||
|
id: configured.drainageField.id,
|
||||||
|
code: configured.drainageField.code,
|
||||||
|
name: configured.drainageField.name,
|
||||||
|
fieldType: configured.drainageField.fieldType,
|
||||||
|
required: false,
|
||||||
|
description: configured.drainageField.description,
|
||||||
|
reportTypes: [],
|
||||||
|
channels: [],
|
||||||
|
};
|
||||||
|
current.required = current.required || configured.required;
|
||||||
|
if (!current.reportTypes.includes(configured.reportType)) current.reportTypes.push(configured.reportType);
|
||||||
|
if (!current.channels.some((channel) => channel.id === item.channel.id)) {
|
||||||
|
current.channels.push({
|
||||||
|
id: item.channel.id,
|
||||||
|
code: item.channel.code,
|
||||||
|
name: item.channel.name,
|
||||||
|
groupId: route.group.id,
|
||||||
|
groupName: route.group.name,
|
||||||
|
required: configured.required,
|
||||||
|
reportType: configured.reportType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
merged.set(key, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
async createApplication(data: CreateSmsApplicationDto) {
|
async createApplication(data: CreateSmsApplicationDto) {
|
||||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||||
@@ -519,16 +588,20 @@ export class SmsConfigService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createSignature(data: CreateSmsSignatureDto) {
|
async createSignature(data: CreateSmsSignatureDto) {
|
||||||
return this.prisma.smsSignature.create({
|
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||||
|
const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
|
||||||
|
const signature = await this.prisma.smsSignature.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
purpose: data.purpose,
|
purpose: data.purpose,
|
||||||
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
|
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||||
|
return signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
||||||
@@ -536,17 +609,114 @@ export class SmsConfigService {
|
|||||||
if (!signature) {
|
if (!signature) {
|
||||||
throw new NotFoundException('Signature not found');
|
throw new NotFoundException('Signature not found');
|
||||||
}
|
}
|
||||||
return this.prisma.smsSignature.update({
|
await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||||
|
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
||||||
|
const drainageInfo = data.drainageInfo
|
||||||
|
? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||||
|
: undefined;
|
||||||
|
const updated = await this.prisma.smsSignature.update({
|
||||||
where: { id: signatureId },
|
where: { id: signatureId },
|
||||||
data: {
|
data: {
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
purpose: data.purpose,
|
purpose: data.purpose,
|
||||||
auditStatus: data.auditStatus,
|
auditStatus: data.auditStatus,
|
||||||
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
|
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||||
},
|
},
|
||||||
include: { materials: true, tenant: true, application: true },
|
include: { materials: true, tenant: true, application: true },
|
||||||
});
|
});
|
||||||
|
await this.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
|
if (!drainageInfo || !applicationId) return drainageInfo;
|
||||||
|
const fields = await this.getApplicationReportFields(applicationId);
|
||||||
|
return {
|
||||||
|
...drainageInfo,
|
||||||
|
reportRequirementSnapshot: {
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
applicationId,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
id: field.id,
|
||||||
|
code: field.code,
|
||||||
|
name: field.name,
|
||||||
|
fieldType: field.fieldType,
|
||||||
|
required: field.required,
|
||||||
|
reportTypes: field.reportTypes,
|
||||||
|
channels: field.channels,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncSignatureReportValues(signatureId: string, applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
|
if (!applicationId || !drainageInfo) return;
|
||||||
|
const fields = await this.getApplicationReportFields(applicationId);
|
||||||
|
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||||
|
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
|
||||||
|
const drainageItemIds = links.map((link) => String(link.id ?? '')).filter(Boolean);
|
||||||
|
await this.prisma.drainageReportMaterial.deleteMany({
|
||||||
|
where: {
|
||||||
|
signatureId,
|
||||||
|
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
|
||||||
|
const value = reportValueParts(signatureValues[field.code]);
|
||||||
|
for (const channel of field.channels) {
|
||||||
|
await this.prisma.signatureReportMaterial.upsert({
|
||||||
|
where: { signatureId_channelId_fieldCode: { signatureId, channelId: channel.id, fieldCode: field.code } },
|
||||||
|
update: value,
|
||||||
|
create: { signatureId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const link of links) {
|
||||||
|
const drainageItemId = String(link.id ?? '');
|
||||||
|
const values = isRecord(link.reportValues) ? link.reportValues : {};
|
||||||
|
if (!drainageItemId) continue;
|
||||||
|
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'))) {
|
||||||
|
const value = reportValueParts(values[field.code]);
|
||||||
|
for (const channel of field.channels) {
|
||||||
|
await this.prisma.drainageReportMaterial.upsert({
|
||||||
|
where: {
|
||||||
|
signatureId_drainageItemId_channelId_fieldCode: {
|
||||||
|
signatureId,
|
||||||
|
drainageItemId,
|
||||||
|
channelId: channel.id,
|
||||||
|
fieldCode: field.code,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: value,
|
||||||
|
create: { signatureId, drainageItemId, channelId: channel.id, fieldCode: field.code, ...value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
|
||||||
|
if (!applicationId || !drainageInfo) return;
|
||||||
|
const fields = await this.getApplicationReportFields(applicationId);
|
||||||
|
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
|
||||||
|
const missingSignature = fields
|
||||||
|
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'))
|
||||||
|
.filter((field) => !hasReportValue(signatureValues[field.code]));
|
||||||
|
if (missingSignature.length > 0) {
|
||||||
|
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
|
||||||
|
}
|
||||||
|
const drainageFields = fields.filter(
|
||||||
|
(field) => field.required && field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
|
||||||
|
);
|
||||||
|
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
|
||||||
|
for (const link of links) {
|
||||||
|
const values = isRecord(link.reportValues) ? link.reportValues : {};
|
||||||
|
const missing = drainageFields.filter((field) => !hasReportValue(values[field.code]));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
||||||
@@ -934,3 +1104,21 @@ function parseGatewayDate(value?: string) {
|
|||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportValueParts(value: unknown) {
|
||||||
|
if (isRecord(value) && typeof value.fileObjectId === 'string') {
|
||||||
|
return { fieldValue: typeof value.fileName === 'string' ? value.fileName : undefined, fileObjectId: value.fileObjectId };
|
||||||
|
}
|
||||||
|
return { fieldValue: value === undefined || value === null ? undefined : String(value), fileObjectId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReportValue(value: unknown) {
|
||||||
|
if (isRecord(value)) {
|
||||||
|
return Boolean(value.fileObjectId || value.fieldValue || value.value);
|
||||||
|
}
|
||||||
|
return value !== undefined && value !== null && String(value).trim().length > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,15 +180,16 @@
|
|||||||
|
|
||||||
### 4.7 通道签名报备
|
### 4.7 通道签名报备
|
||||||
|
|
||||||
1. 运营端在通道配置中维护签名报备字段。
|
1. 运营端先在“报备字段库”维护字段编码、名称、类型、是否必填等标准定义;通道报备详情只能从字段库选择字段,并指定用途为签名报备、引流信息报备或两者共用,不得在通道内另建同名孤立字段。
|
||||||
2. 客户端上传签名资料。
|
2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。
|
||||||
3. 运营端审核企业签名资料。
|
3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。
|
||||||
4. 运营端在通道资料更新后生成通道签名报备任务。
|
4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。
|
||||||
5. 运营端在报备任务中导出通道报备资料。
|
5. 企业资料保存后,原始动态值随签名 JSON 保存,同时按实际目标通道分别写入签名报备材料和引流报备材料表,供通道报备任务导出使用;删除引流项时同步清理其规范化材料记录。
|
||||||
6. 运营端在报备任务或通道报备详情页导入通道回执。
|
6. 客户端上传签名资料,运营端审核企业签名资料。
|
||||||
7. 系统根据回执同步签名在各通道的报备状态。
|
7. 运营端在通道资料更新后生成通道签名报备任务,并在报备任务中导出通道报备资料。
|
||||||
8. 报备记录保留每次导出、导入、状态变更和操作人。
|
8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。
|
||||||
9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
|
9. 报备记录保留每次导出、导入、状态变更和操作人。
|
||||||
|
10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
|
||||||
|
|
||||||
### 4.8 CMPP Gateway 与外部接入
|
### 4.8 CMPP Gateway 与外部接入
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,22 @@
|
|||||||
- 生成导出文件记录。
|
- 生成导出文件记录。
|
||||||
- 报备记录包含 create/export 两个动作。
|
- 报备记录包含 create/export 两个动作。
|
||||||
|
|
||||||
|
### TC-ADMIN-005A 报备字段库到企业资料动态继承
|
||||||
|
|
||||||
|
- 优先级:P0
|
||||||
|
- 前置条件:存在两个 active 通道、一个包含这两个通道的通道组,以及绑定该通道组的企业应用。
|
||||||
|
- 步骤:
|
||||||
|
1. 在报备字段库创建文件字段“营业执照”和文本字段“网站主体”。
|
||||||
|
2. 在通道一将营业执照配置为签名报备必填,在通道二将同一字段配置为两者共用非必填,并将网站主体配置为引流信息报备必填。
|
||||||
|
3. 打开该企业应用下的企业签名编辑弹窗和引流信息编辑弹窗。
|
||||||
|
4. 分别尝试缺少必填值保存,再补齐文件和值后保存。
|
||||||
|
5. 查询 PostgreSQL 中签名、签名报备材料和引流报备材料记录;删除该引流项后再次查询。
|
||||||
|
- 预期结果:
|
||||||
|
- 营业执照按字段库 ID 合并为一个字段,且因任一目标通道必填而整体必填;签名弹窗展示营业执照,引流弹窗展示网站主体及两者共用字段。
|
||||||
|
- 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。
|
||||||
|
- 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。
|
||||||
|
- 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料。
|
||||||
|
|
||||||
### TC-ADMIN-006 报备回执导入通过
|
### TC-ADMIN-006 报备回执导入通过
|
||||||
|
|
||||||
- 优先级:P0
|
- 优先级:P0
|
||||||
|
|||||||
@@ -1618,3 +1618,13 @@ git diff --check
|
|||||||
- 聚合窗口关闭前,任务不返回到待审列表且审核接口拒绝提前操作,避免窗口内后到短信加入已完成任务。
|
- 聚合窗口关闭前,任务不返回到待审列表且审核接口拒绝提前操作,避免窗口内后到短信加入已完成任务。
|
||||||
- Prisma migration:`20260712113000_add_cmpp_review_aggregation`。已执行 API 全量测试(13 suites、129 项通过)、API build、前端 build、Prisma validate 和 `git diff --check`。
|
- Prisma migration:`20260712113000_add_cmpp_review_aggregation`。已执行 API 全量测试(13 suites、129 项通过)、API build、前端 build、Prisma validate 和 `git diff --check`。
|
||||||
- 已将 `8b6ec92f` 部署生产,部署前完成 PostgreSQL 和发布源码备份,migration 已成功应用。`SmsSendTask` 5 个聚合字段、`SmsMessageRecord.reviewTaskId/signatureId` 均已存在;生产应用中 `manual_review` 3 个、`reject` 201 个。审核 API 返回 200,当前无待审样本,未为验收人工注入短信或修改业务数据。`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听、API/Gateway health 和外部 `12026` HTTP 均通过。
|
- 已将 `8b6ec92f` 部署生产,部署前完成 PostgreSQL 和发布源码备份,migration 已成功应用。`SmsSendTask` 5 个聚合字段、`SmsMessageRecord.reviewTaskId/signatureId` 均已存在;生产应用中 `manual_review` 3 个、`reject` 201 个。审核 API 返回 200,当前无待审样本,未为验收人工注入短信或修改业务数据。`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090/3000` 监听、API/Gateway health 和外部 `12026` HTTP 均通过。
|
||||||
|
|
||||||
|
## 2026-07-12 报备字段库到企业签名/引流资料完整链路
|
||||||
|
|
||||||
|
- `ChannelReportField` 通过 `drainageFieldId` 真实关联报备字段库,并增加 `reportType=signature/drainage/both`;通道报备配置页改为选择字段库字段和报备用途,不再在通道内手工复制字段编码、名称和类型。
|
||||||
|
- 新增企业应用报备字段解析 API,按生效的应用路由规则遍历通道组及组内通道,以字段库 ID 求合集;同字段任一通道必填即整体必填,并返回全部来源通道。
|
||||||
|
- 企业签名与引流信息弹窗根据所选企业应用动态加载字段合集。签名只展示签名/共用字段,引流项只展示引流/共用字段;文件字段继续走真实 MinIO/对象存储上传,文本值和文件对象 ID 均提交 NestJS API。
|
||||||
|
- API 在写签名前执行必填校验,防止绕过前端;保存后同步写入各目标通道的 `SignatureReportMaterial` 和新增的 `DrainageReportMaterial`,删除引流项时同步清理旧材料。
|
||||||
|
- 为避免运营人员面对动态资料时无法理解来源,签名和引流编辑页增加“通道组数/通道数/字段数/必填数”摘要、字段级来源说明和“为什么需要这些资料”解释弹窗;弹窗按企业应用、通道组、通道逐级展示字段用途及必填口径。每次保存同时在签名 JSON 中固化 `reportRequirementSnapshot`,记录当时的字段与来源通道,供配置变化后的历史追溯。
|
||||||
|
- Prisma migration:`20260712150000_link_report_field_library`。已执行 Prisma generate/validate、`channels.service.spec.ts + sms-config.service.spec.ts`(2 suites、44 项通过)、API build 和前端 build;前端仅有既有 chunk size warning。
|
||||||
|
- 本轮按用户要求仅完成本地代码与验证,尚未提交、push 或部署生产。
|
||||||
|
|||||||
@@ -509,12 +509,26 @@ export type ChannelGroupItem = DictionaryItem & {
|
|||||||
|
|
||||||
export type ChannelReportField = DictionaryItem & {
|
export type ChannelReportField = DictionaryItem & {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
drainageFieldId?: string | null;
|
||||||
|
reportType?: 'signature' | 'drainage' | 'both';
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
fieldType: string;
|
fieldType: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
drainageField?: DictionaryItem | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplicationReportField = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
fieldType: string;
|
||||||
|
required: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
reportTypes: string[];
|
||||||
|
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both' }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReportTask = DictionaryItem & {
|
export type ReportTask = DictionaryItem & {
|
||||||
@@ -876,6 +890,8 @@ export const adminApi = {
|
|||||||
}),
|
}),
|
||||||
listApplicationConnections: (applicationId: string) =>
|
listApplicationConnections: (applicationId: string) =>
|
||||||
request<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
|
request<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
|
||||||
|
listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') =>
|
||||||
|
request<ApplicationReportField[]>(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })),
|
||||||
getApplicationCmppParams: (applicationId: string) =>
|
getApplicationCmppParams: (applicationId: string) =>
|
||||||
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
|
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
|
||||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ArrowLeft, Plus, Search } from 'lucide-react';
|
import { ArrowLeft, Plus, Search } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
|
import { adminApi, type AdminChannel, type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
export function AdminChannelReportPage() {
|
export function AdminChannelReportPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||||
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
const [fields, setFields] = useState<ChannelReportField[]>([]);
|
||||||
|
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||||
const [channelId, setChannelId] = useState('');
|
const [channelId, setChannelId] = useState('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [drainageFieldId, setDrainageFieldId] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [reportType, setReportType] = useState<'signature' | 'drainage' | 'both'>('signature');
|
||||||
const [fieldType, setFieldType] = useState('string');
|
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(nextChannelId = channelId) {
|
||||||
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined)])
|
Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(nextChannelId || undefined), adminApi.listDrainageFields()])
|
||||||
.then(([channelItems, fieldItems]) => {
|
.then(([channelItems, fieldItems, libraryItems]) => {
|
||||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||||
setFields(fieldItems);
|
setFields(fieldItems);
|
||||||
|
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
|
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
|
||||||
@@ -34,12 +36,14 @@ export function AdminChannelReportPage() {
|
|||||||
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]);
|
const filteredFields = useMemo(() => fields.filter((field) => !keyword || [field.code, field.name, field.fieldType, field.description].join(' ').includes(keyword)), [fields, keyword]);
|
||||||
|
|
||||||
function createField() {
|
function createField() {
|
||||||
adminApi.createChannelReportField({ channelId, code, name, fieldType, description, status: 'active' })
|
const selected = libraryFields.find((item) => item.id === drainageFieldId);
|
||||||
|
if (!selected) return;
|
||||||
|
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType, required, description, status: 'active' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setCode('');
|
setDrainageFieldId('');
|
||||||
setName('');
|
setReportType('signature');
|
||||||
setFieldType('string');
|
setRequired(false);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
loadData();
|
loadData();
|
||||||
})
|
})
|
||||||
@@ -51,6 +55,7 @@ export function AdminChannelReportPage() {
|
|||||||
{ 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: '160px', render: (record) => record.name },
|
{ key: 'name', title: '字段名称', width: '160px', render: (record) => record.name },
|
||||||
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType },
|
{ key: 'type', title: '字段类型', width: '120px', render: (record) => record.fieldType },
|
||||||
|
{ 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: 'required', title: '必填', width: '90px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '是' : '否'}</Tag> },
|
||||||
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
|
{ key: 'description', title: '说明', render: (record) => record.description ?? '-' },
|
||||||
];
|
];
|
||||||
@@ -87,26 +92,20 @@ export function AdminChannelReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button><Button disabled={!channelId || !code || !name} onClick={createField}>保存</Button></>}
|
footer={<><Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button><Button disabled={!channelId || !drainageFieldId} onClick={createField}>保存</Button></>}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
title="新增通道报备字段"
|
title="新增通道报备字段"
|
||||||
>
|
>
|
||||||
<div className="admin-system-modal-form">
|
<div className="admin-system-modal-form">
|
||||||
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} />
|
|
||||||
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
|
||||||
<Select
|
<Select
|
||||||
label="字段类型"
|
label="报备字段库字段"
|
||||||
onChange={(event) => setFieldType(event.target.value)}
|
onChange={(event) => setDrainageFieldId(event.target.value)}
|
||||||
options={[
|
options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}(${field.code})`, value: field.id }))]}
|
||||||
{ label: '字符串', value: 'string' },
|
value={drainageFieldId}
|
||||||
{ label: '数字', value: 'number' },
|
|
||||||
{ label: '文件', value: 'file' },
|
|
||||||
{ label: '图片', value: 'image' },
|
|
||||||
{ label: '网址', value: 'url' },
|
|
||||||
]}
|
|
||||||
value={fieldType}
|
|
||||||
/>
|
/>
|
||||||
|
<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)} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||||
import { displayFileName } from '@/utils/fileName';
|
import { displayFileName } from '@/utils/fileName';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
@@ -26,9 +26,11 @@ type DrainageInfo = {
|
|||||||
telecom: CarrierStatus;
|
telecom: CarrierStatus;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
reportValues: ReportValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UploadedFileRef = FileRef;
|
type UploadedFileRef = FileRef;
|
||||||
|
type ReportValues = Record<string, string | UploadedFileRef | null>;
|
||||||
|
|
||||||
type SignatureProfile = {
|
type SignatureProfile = {
|
||||||
basis: string;
|
basis: string;
|
||||||
@@ -55,6 +57,7 @@ type SignatureFormState = {
|
|||||||
mobile: CarrierStatus;
|
mobile: CarrierStatus;
|
||||||
unicom: CarrierStatus;
|
unicom: CarrierStatus;
|
||||||
telecom: CarrierStatus;
|
telecom: CarrierStatus;
|
||||||
|
reportValues: ReportValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabelMap: Record<CarrierStatus, string> = {
|
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||||
@@ -88,6 +91,7 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
|||||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||||
},
|
},
|
||||||
signatureProfile: normalizeSignatureProfile(profile, signature),
|
signatureProfile: normalizeSignatureProfile(profile, signature),
|
||||||
|
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
|
||||||
links: links.map((item) => ({
|
links: links.map((item) => ({
|
||||||
id: String(item.id ?? `drain-${Date.now()}`),
|
id: String(item.id ?? `drain-${Date.now()}`),
|
||||||
siteName: String(item.siteName ?? ''),
|
siteName: String(item.siteName ?? ''),
|
||||||
@@ -107,12 +111,22 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
|||||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||||
submittedAt: String(item.submittedAt ?? ''),
|
submittedAt: String(item.submittedAt ?? ''),
|
||||||
remark: String(item.remark ?? ''),
|
remark: String(item.remark ?? ''),
|
||||||
|
reportValues: normalizeReportValues(item.reportValues),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile) {
|
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile, signatureReportValues?: ReportValues) {
|
||||||
return { carrierStatus, links, signatureProfile };
|
return { carrierStatus, links, signatureProfile, signatureReportValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReportValues(value: unknown): ReportValues {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeUploadedFile(item) ?? String(item ?? '')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMissingRequiredReportValue(fields: ApplicationReportField[], values: ReportValues) {
|
||||||
|
return fields.some((field) => field.required && !values[field.code]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||||
@@ -224,6 +238,67 @@ function SignatureUploadBox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DynamicReportFields({ fields, onChange, title, values }: { fields: ApplicationReportField[]; onChange: (code: string, value: string | UploadedFileRef | null) => void; title: string; values: ReportValues }) {
|
||||||
|
const [explanationOpen, setExplanationOpen] = useState(false);
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
const channels = Array.from(new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])).values());
|
||||||
|
const groups = Array.from(new Map(channels.map((channel) => [channel.groupId, channel.groupName])).entries());
|
||||||
|
const requiredCount = fields.filter((field) => field.required).length;
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="report-requirement-heading">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<Button icon={<Info size={15} />} onClick={() => setExplanationOpen(true)} size="sm" variant="ghost">为什么需要这些资料?</Button>
|
||||||
|
</div>
|
||||||
|
<div className="signature-alert">
|
||||||
|
<Info size={18} />
|
||||||
|
<span>当前要求由 {groups.length} 个通道组、{channels.length} 个通道合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||||
|
</div>
|
||||||
|
<div className="signature-form-grid">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const channelHint = field.channels.map((channel) => channel.name).join('、');
|
||||||
|
const requiredChannels = field.required ? field.channels.filter((channel) => channel.required).map((channel) => channel.name).join('、') : '';
|
||||||
|
const label = `${field.required ? '* ' : ''}${field.name}`;
|
||||||
|
const hint = field.required
|
||||||
|
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||||
|
: `适用通道:${channelHint}`;
|
||||||
|
return field.fieldType === 'file' || field.fieldType === 'image' ? (
|
||||||
|
<div key={field.id}>
|
||||||
|
<SignatureUploadBox compact file={typeof values[field.code] === 'object' ? values[field.code] as UploadedFileRef : null} label={label} onUploaded={(file) => onChange(field.code, file)} />
|
||||||
|
<small className="report-field-source">{hint}</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={field.id}>
|
||||||
|
<Input label={label} onChange={(event) => onChange(field.code, event.target.value)} placeholder={field.description ?? `请输入${field.name}`} required={field.required} value={typeof values[field.code] === 'string' ? values[field.code] as string : ''} />
|
||||||
|
<small className="report-field-source">{hint}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Modal footer={<Button onClick={() => setExplanationOpen(false)}>我知道了</Button>} onClose={() => setExplanationOpen(false)} open={explanationOpen} size="xl" title="这些资料从哪里来?">
|
||||||
|
<div className="report-requirement-explanation">
|
||||||
|
<p>资料要求按“企业应用 → 通道组 → 通道 → 通道报备字段”实时计算;相同字段只填写一次,但会按来源通道分别用于报备。</p>
|
||||||
|
{groups.map(([groupId, groupName]) => (
|
||||||
|
<section className="report-source-group" key={groupId}>
|
||||||
|
<h4>通道组:{groupName}</h4>
|
||||||
|
{channels.filter((channel) => channel.groupId === groupId).map((channel) => (
|
||||||
|
<div className="report-source-channel" key={channel.id}>
|
||||||
|
<strong>{channel.name}({channel.code})</strong>
|
||||||
|
<ul>
|
||||||
|
{fields.filter((field) => field.channels.some((source) => source.id === channel.id)).map((field) => (
|
||||||
|
<li key={field.id}>{field.name} · {field.channels.find((source) => source.id === channel.id)?.reportType === 'both' ? '签名和引流共用' : field.channels.find((source) => source.id === channel.id)?.reportType === 'signature' ? '签名报备' : '引流信息报备'} · {field.channels.find((source) => source.id === channel.id)?.required ? '必填' : '选填'}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SignatureFormModal({
|
function SignatureFormModal({
|
||||||
applications,
|
applications,
|
||||||
item,
|
item,
|
||||||
@@ -247,9 +322,19 @@ function SignatureFormModal({
|
|||||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||||
|
reportValues: payload?.signatureReportValues ?? {},
|
||||||
});
|
});
|
||||||
|
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.applicationId) {
|
||||||
|
setReportFields([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminApi.listApplicationReportFields(form.applicationId, 'signature').then(setReportFields).catch(() => setReportFields([]));
|
||||||
|
}, [form.applicationId]);
|
||||||
|
|
||||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
@@ -258,12 +343,16 @@ function SignatureFormModal({
|
|||||||
setForm((current) => ({ ...current, profile: { ...current.profile, [key]: value } }));
|
setForm((current) => ({ ...current, profile: { ...current.profile, [key]: value } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||||
|
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={!form.tenantId || !form.name} onClick={() => onSubmit(form)}>保存</Button>
|
<Button disabled={!form.tenantId || !form.name || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -347,12 +436,15 @@ function SignatureFormModal({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道签名报备资料" values={form.reportValues} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applicationId?: string | null; item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
||||||
|
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||||
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||||
id: `drain-${Date.now()}`,
|
id: `drain-${Date.now()}`,
|
||||||
siteName: '',
|
siteName: '',
|
||||||
@@ -372,18 +464,28 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
|||||||
telecom: 'filing',
|
telecom: 'filing',
|
||||||
submittedAt: formatDateTime(new Date()),
|
submittedAt: formatDateTime(new Date()),
|
||||||
remark: '',
|
remark: '',
|
||||||
|
reportValues: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!applicationId) return;
|
||||||
|
adminApi.listApplicationReportFields(applicationId, 'drainage').then(setReportFields).catch(() => setReportFields([]));
|
||||||
|
}, [applicationId]);
|
||||||
|
|
||||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||||
|
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||||
<Button disabled={!form.url} onClick={() => onSubmit(form)}>保存</Button>
|
<Button disabled={!form.url || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}>保存</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -410,19 +512,11 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-form-grid">
|
<div className="signature-form-grid">
|
||||||
<SignatureUploadBox compact file={form.field1File} label="* 字段名称1" onUploaded={(file) => update('field1File', file)} />
|
<Input label="* 站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站名称" value={form.siteName} />
|
||||||
<Input label="* 字段名称2" onChange={(event) => update('field2', event.target.value)} placeholder="请输入字段2内容" value={form.field2 ?? ''} />
|
|
||||||
<Input label="* 字段名称3" onChange={(event) => { update('field3', event.target.value); update('siteName', event.target.value); }} placeholder="请输入公司名称" value={form.field3 ?? form.siteName} />
|
|
||||||
<Input label="字段名称4" onChange={(event) => update('field4', event.target.value)} placeholder="请输入统一社会信用代码" value={form.field4 ?? ''} />
|
|
||||||
<Input label="* 字段名称5" onChange={(event) => update('field5', event.target.value)} placeholder="请输入法人姓名" value={form.field5 ?? ''} />
|
|
||||||
<Input label="字段名称6" onChange={(event) => update('field6', event.target.value)} placeholder="请输入法人身份证号" value={form.field6 ?? ''} />
|
|
||||||
<SignatureUploadBox compact file={form.field7File} label="字段名称7" onUploaded={(file) => update('field7File', file)} />
|
|
||||||
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
|
|
||||||
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
|
|
||||||
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
|
|
||||||
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
||||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||||
</div>
|
</div>
|
||||||
|
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道引流信息报备资料" values={form.reportValues} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -544,7 +638,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
mobile: state.mobile,
|
mobile: state.mobile,
|
||||||
unicom: state.unicom,
|
unicom: state.unicom,
|
||||||
telecom: state.telecom,
|
telecom: state.telecom,
|
||||||
}, existingPayload.links, state.profile);
|
}, existingPayload.links, state.profile, state.reportValues);
|
||||||
try {
|
try {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||||
@@ -579,7 +673,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
? payload.links.map((current) => current.id === item.id ? item : current)
|
? payload.links.map((current) => current.id === item.id ? item : current)
|
||||||
: [item, ...payload.links];
|
: [item, ...payload.links];
|
||||||
await adminApi.updateEnterpriseSignature(signatureId, {
|
await adminApi.updateEnterpriseSignature(signatureId, {
|
||||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile),
|
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile, payload.signatureReportValues),
|
||||||
});
|
});
|
||||||
setDrainageModal(null);
|
setDrainageModal(null);
|
||||||
setExpandedSignatureId(signatureId);
|
setExpandedSignatureId(signatureId);
|
||||||
@@ -597,7 +691,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
if (signature) {
|
if (signature) {
|
||||||
const payload = readDrainagePayload(signature);
|
const payload = readDrainagePayload(signature);
|
||||||
await adminApi.updateEnterpriseSignature(signature.id, {
|
await adminApi.updateEnterpriseSignature(signature.id, {
|
||||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile),
|
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile, payload.signatureReportValues),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,6 +834,7 @@ export function AdminEnterpriseSignaturesPage() {
|
|||||||
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
|
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
|
||||||
{drainageModal ? (
|
{drainageModal ? (
|
||||||
<DrainageFormModal
|
<DrainageFormModal
|
||||||
|
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
|
||||||
item={drainageModal.item}
|
item={drainageModal.item}
|
||||||
onClose={() => setDrainageModal(null)}
|
onClose={() => setDrainageModal(null)}
|
||||||
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
|
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
|
||||||
|
|||||||
@@ -3060,6 +3060,48 @@ h3 {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-requirement-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-requirement-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-field-source {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-requirement-explanation > p {
|
||||||
|
background: var(--color-selected-soft);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-group {
|
||||||
|
border-left: 3px solid var(--color-selected);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-channel {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-source-channel ul {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.signature-upload {
|
.signature-upload {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 2px dashed var(--color-border);
|
border: 2px dashed var(--color-border);
|
||||||
|
|||||||
Reference in New Issue
Block a user