fix: close sms scheduling and billing gaps
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
||||
|
||||
@ApiTags('client-certification')
|
||||
@Controller('client/enterprise-certification')
|
||||
export class ClientCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@TenantId() tenantId?: string) {
|
||||
return this.certifications.list(tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
submit(@Body() body: SubmitCertificationDto) {
|
||||
return this.certifications.submit(body);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin-certification')
|
||||
@Controller('admin/enterprise-certifications')
|
||||
export class AdminCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.certifications.list(tenantId, status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.certifications.get(id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.approve(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: ReviewCertificationDto) {
|
||||
return this.certifications.reject(id, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminCertificationController, ClientCertificationController } from './certification.controller';
|
||||
import { CertificationService } from './certification.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientCertificationController, AdminCertificationController],
|
||||
providers: [CertificationService],
|
||||
exports: [CertificationService],
|
||||
})
|
||||
export class CertificationModule {}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CertificationService } from './certification.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'tenant-1', certificationStatus: 'pending' }),
|
||||
},
|
||||
enterpriseCertification: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'cert-1', tenantId: 'tenant-1', status: 'pending' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'cert-1', ...data })),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'reviewer-1' }),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CertificationService', () => {
|
||||
it('submits certification and marks tenant pending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await service.submit({ tenantId: 'tenant-1', companyName: '测试企业', licenseNo: 'LIC-1' });
|
||||
|
||||
expect(prisma.enterpriseCertification.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', companyName: '测试企业', status: 'pending' }),
|
||||
});
|
||||
expect(prisma.tenant.update).toHaveBeenCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'pending' },
|
||||
});
|
||||
});
|
||||
|
||||
it('approves and rejects certification while syncing tenant status', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new CertificationService(prisma as never);
|
||||
|
||||
await service.approve('cert-1', { reviewerId: 'reviewer-1' });
|
||||
expect(prisma.tenant.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await service.reject('cert-1', { reviewerId: 'reviewer-1', reason: '资料不清晰' });
|
||||
expect(prisma.tenant.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'tenant-1' },
|
||||
data: { certificationStatus: 'rejected' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface SubmitCertificationDto {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
licenseNo?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
materials?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReviewCertificationDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, status?: string) {
|
||||
return this.prisma.enterpriseCertification.findMany({
|
||||
where: { tenantId, status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async submit(data: SubmitCertificationDto) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: data.tenantId } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
const certification = await this.prisma.enterpriseCertification.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
companyName: data.companyName,
|
||||
licenseNo: data.licenseNo,
|
||||
contactName: data.contactName,
|
||||
contactPhone: data.contactPhone,
|
||||
materials: data.materials as Prisma.InputJsonValue | undefined,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: data.tenantId },
|
||||
data: { certificationStatus: 'pending' },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: 'enterprise_certification.submit',
|
||||
resource: 'enterprise_certification',
|
||||
resourceId: certification.id,
|
||||
detail: { companyName: data.companyName } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return certification;
|
||||
}
|
||||
|
||||
approve(id: string, data: ReviewCertificationDto) {
|
||||
return this.review(id, 'approved', data);
|
||||
}
|
||||
|
||||
reject(id: string, data: ReviewCertificationDto) {
|
||||
if (!data.reason) {
|
||||
throw new BadRequestException('Reject reason is required');
|
||||
}
|
||||
return this.review(id, 'rejected', data);
|
||||
}
|
||||
|
||||
private async review(id: string, status: 'approved' | 'rejected', data: ReviewCertificationDto) {
|
||||
const certification = await this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
if (data.reviewerId) {
|
||||
const reviewer = await this.prisma.user.findUnique({ where: { id: data.reviewerId }, select: { id: true } });
|
||||
if (!reviewer) {
|
||||
throw new BadRequestException('reviewerId does not reference an existing user');
|
||||
}
|
||||
}
|
||||
const updated = await this.prisma.enterpriseCertification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
rejectReason: status === 'rejected' ? data.reason : null,
|
||||
reviewerId: data.reviewerId,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.prisma.tenant.update({
|
||||
where: { id: certification.tenantId },
|
||||
data: { certificationStatus: status },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: certification.tenantId,
|
||||
userId: data.reviewerId,
|
||||
action: `enterprise_certification.${status}`,
|
||||
resource: 'enterprise_certification',
|
||||
resourceId: id,
|
||||
detail: { reason: data.reason } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user