fix: close sms scheduling and billing gaps

This commit is contained in:
hectorzhao
2026-07-01 18:56:05 +08:00
parent 8ba4ef8a13
commit f8c9b78c21
28 changed files with 1480 additions and 26 deletions
@@ -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;
}
}