feat(contracts): 合同管理功能 - 查询/下载/批量打包
新增功能: - 合同列表查询(支持省市、状态、时间筛选) - 单个合同 PDF 下载(支持断点续传) - 批量下载 ZIP 打包(异步任务处理) - 增量下载(基于上次下载时间) - 用户详情页合同 Tab 后端: - planting-service: 内部合同查询 API - admin-service: 合同管理控制器、服务、批量下载 Job - 新增 contract_batch_download_tasks 表 前端: - 新增独立合同管理页面 /contracts - 用户详情页新增合同信息 Tab - 侧边栏新增合同管理入口 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5bacd21840
commit
86461a052d
|
|
@ -41,6 +41,8 @@
|
|||
"@prisma/client": "^5.7.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"adbkit-apkreader": "^3.2.0",
|
||||
"archiver": "^6.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"bplist-parser": "^0.3.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
-- 合同批量下载任务表
|
||||
-- [2026-02-05] 新增:用于记录和追踪合同批量下载任务
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BatchDownloadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contract_batch_download_tasks" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"task_no" VARCHAR(50) NOT NULL,
|
||||
"status" "BatchDownloadStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"total_contracts" INTEGER NOT NULL DEFAULT 0,
|
||||
"downloaded_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_processed_order_no" VARCHAR(50),
|
||||
"result_file_url" VARCHAR(500),
|
||||
"result_file_size" BIGINT,
|
||||
"errors" JSONB,
|
||||
"operator_id" VARCHAR(50) NOT NULL,
|
||||
"filters" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "contract_batch_download_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "contract_batch_download_tasks_task_no_key" ON "contract_batch_download_tasks"("task_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_status_idx" ON "contract_batch_download_tasks"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_operator_id_idx" ON "contract_batch_download_tasks"("operator_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_created_at_idx" ON "contract_batch_download_tasks"("created_at");
|
||||
|
|
@ -1178,6 +1178,61 @@ model AppAsset {
|
|||
@@map("app_assets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Contract Batch Download Tasks (合同批量下载任务)
|
||||
// [2026-02-05] 新增:支持管理后台合同批量下载
|
||||
// 回滚方式:删除此 model 并运行 prisma migrate
|
||||
// =============================================================================
|
||||
|
||||
/// 批量下载任务状态
|
||||
enum BatchDownloadStatus {
|
||||
PENDING // 待处理
|
||||
PROCESSING // 处理中
|
||||
COMPLETED // 已完成
|
||||
FAILED // 失败
|
||||
CANCELLED // 已取消
|
||||
}
|
||||
|
||||
/// 合同批量下载任务 - 记录批量下载请求的执行状态
|
||||
model ContractBatchDownloadTask {
|
||||
id BigInt @id @default(autoincrement())
|
||||
taskNo String @unique @map("task_no") @db.VarChar(50)
|
||||
status BatchDownloadStatus @default(PENDING)
|
||||
|
||||
// 下载统计
|
||||
totalContracts Int @default(0) @map("total_contracts")
|
||||
downloadedCount Int @default(0) @map("downloaded_count")
|
||||
failedCount Int @default(0) @map("failed_count")
|
||||
progress Int @default(0) // 0-100
|
||||
|
||||
// 断点续传支持
|
||||
lastProcessedOrderNo String? @map("last_processed_order_no") @db.VarChar(50)
|
||||
|
||||
// 结果文件
|
||||
resultFileUrl String? @map("result_file_url") @db.VarChar(500)
|
||||
resultFileSize BigInt? @map("result_file_size")
|
||||
|
||||
// 错误信息
|
||||
errors Json? // 失败的合同列表
|
||||
|
||||
// 操作者
|
||||
operatorId String @map("operator_id") @db.VarChar(50)
|
||||
|
||||
// 筛选条件
|
||||
filters Json? // { signedAfter, signedBefore, provinceCode, cityCode }
|
||||
|
||||
// 时间戳
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
expiresAt DateTime? @map("expires_at") // 结果文件过期时间
|
||||
|
||||
@@index([status])
|
||||
@@index([operatorId])
|
||||
@@index([createdAt])
|
||||
@@map("contract_batch_download_tasks")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Customer Service Contacts (客服联系方式)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* 合同管理控制器
|
||||
* [2026-02-05] 新增:提供合同查询、下载、批量下载功能
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
Req,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { ContractService, ContractQueryParams } from '../../application/services/contract.service';
|
||||
|
||||
/**
|
||||
* 批量下载请求 DTO
|
||||
*/
|
||||
interface BatchDownloadRequestDto {
|
||||
filters?: {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
};
|
||||
orderNos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同管理控制器
|
||||
* 为管理后台提供合同查询和下载功能
|
||||
*/
|
||||
@ApiTags('Admin - Contracts')
|
||||
@Controller('v1/admin/contracts')
|
||||
export class ContractController {
|
||||
private readonly logger = new Logger(ContractController.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly contractService: ContractService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取合同列表
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取合同列表' })
|
||||
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始(ISO格式)' })
|
||||
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束(ISO格式)' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码,默认1' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认50' })
|
||||
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段:signedAt/createdAt' })
|
||||
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向:asc/desc' })
|
||||
@ApiResponse({ status: 200, description: '合同列表' })
|
||||
async getContracts(
|
||||
@Query('signedAfter') signedAfter?: string,
|
||||
@Query('signedBefore') signedBefore?: string,
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('orderBy') orderBy?: string,
|
||||
@Query('orderDir') orderDir?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts 请求 ==========`);
|
||||
|
||||
const params: ContractQueryParams = {
|
||||
signedAfter,
|
||||
signedBefore,
|
||||
provinceCode,
|
||||
cityCode,
|
||||
status,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
orderBy: orderBy as 'signedAt' | 'createdAt',
|
||||
orderDir: orderDir as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
return this.contractService.getContracts(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
@Get('statistics')
|
||||
@ApiOperation({ summary: '获取合同统计信息' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiResponse({ status: 200, description: '合同统计' })
|
||||
async getStatistics(
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/statistics 请求 ==========`);
|
||||
return this.contractService.getStatistics({ provinceCode, cityCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量下载任务
|
||||
*/
|
||||
@Post('batch-download')
|
||||
@ApiOperation({ summary: '创建合同批量下载任务' })
|
||||
@ApiBody({ description: '筛选条件' })
|
||||
@ApiResponse({ status: 201, description: '任务创建成功' })
|
||||
async createBatchDownload(
|
||||
@Body() body: BatchDownloadRequestDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
this.logger.log(`========== POST /v1/admin/contracts/batch-download 请求 ==========`);
|
||||
this.logger.log(`筛选条件: ${JSON.stringify(body.filters)}`);
|
||||
|
||||
// 生成任务号
|
||||
const taskNo = `BD${Date.now()}`;
|
||||
|
||||
// 获取操作者 ID(从请求头或默认值)
|
||||
const operatorId = (req.headers['x-operator-id'] as string) || 'system';
|
||||
|
||||
// 创建任务记录
|
||||
const task = await this.prisma.contractBatchDownloadTask.create({
|
||||
data: {
|
||||
taskNo,
|
||||
operatorId,
|
||||
filters: body.filters ? JSON.parse(JSON.stringify(body.filters)) : null,
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: 触发异步任务处理(后续实现)
|
||||
// 可以使用 Bull Queue 或 Kafka 消息
|
||||
|
||||
this.logger.log(`批量下载任务创建成功: ${taskNo}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId: task.id.toString(),
|
||||
taskNo: task.taskNo,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询批量下载任务状态
|
||||
*/
|
||||
@Get('batch-download/:taskNo')
|
||||
@ApiOperation({ summary: '查询批量下载任务状态' })
|
||||
@ApiParam({ name: 'taskNo', description: '任务号' })
|
||||
@ApiResponse({ status: 200, description: '任务状态' })
|
||||
@ApiResponse({ status: 404, description: '任务不存在' })
|
||||
async getBatchDownloadStatus(@Param('taskNo') taskNo: string) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/batch-download/${taskNo} 请求 ==========`);
|
||||
|
||||
const task = await this.prisma.contractBatchDownloadTask.findUnique({
|
||||
where: { taskNo },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`任务不存在: ${taskNo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: task.id.toString(),
|
||||
taskNo: task.taskNo,
|
||||
status: task.status,
|
||||
totalContracts: task.totalContracts,
|
||||
downloadedCount: task.downloadedCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: task.progress,
|
||||
resultFileUrl: task.resultFileUrl,
|
||||
resultFileSize: task.resultFileSize?.toString(),
|
||||
errors: task.errors,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
startedAt: task.startedAt?.toISOString(),
|
||||
completedAt: task.completedAt?.toISOString(),
|
||||
expiresAt: task.expiresAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的合同列表
|
||||
*/
|
||||
@Get('users/:accountSequence')
|
||||
@ApiOperation({ summary: '获取用户的合同列表' })
|
||||
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数' })
|
||||
@ApiResponse({ status: 200, description: '合同列表' })
|
||||
async getUserContracts(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/users/${accountSequence} 请求 ==========`);
|
||||
|
||||
return this.contractService.getUserContracts(accountSequence, {
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
@Get(':orderNo')
|
||||
@ApiOperation({ summary: '获取合同详情' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: '合同详情' })
|
||||
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||
async getContract(@Param('orderNo') orderNo: string) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/${orderNo} 请求 ==========`);
|
||||
|
||||
const contract = await this.contractService.getContract(orderNo);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载合同 PDF(支持断点续传)
|
||||
*/
|
||||
@Get(':orderNo/download')
|
||||
@ApiOperation({ summary: '下载合同 PDF(支持断点续传)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: 'PDF 文件' })
|
||||
@ApiResponse({ status: 206, description: '部分内容(断点续传)' })
|
||||
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||
async downloadContract(
|
||||
@Param('orderNo') orderNo: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/${orderNo}/download 请求 ==========`);
|
||||
|
||||
// 获取合同详情
|
||||
const contract = await this.contractService.getContract(orderNo);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
if (!contract.signedPdfUrl) {
|
||||
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${contract.status}`);
|
||||
}
|
||||
|
||||
// 下载 PDF
|
||||
const pdfBuffer = await this.contractService.downloadContractPdf(orderNo);
|
||||
const fileSize = pdfBuffer.length;
|
||||
|
||||
// 生成文件名
|
||||
const safeRealName = contract.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
|
||||
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵_${contract.provinceName}${contract.cityName}.pdf`;
|
||||
const encodedFileName = encodeURIComponent(fileName);
|
||||
|
||||
// 检查 Range 请求头
|
||||
const range = req.headers.range;
|
||||
|
||||
// 设置通用响应头
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (range) {
|
||||
// 断点续传
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
|
||||
if (start >= fileSize || end >= fileSize || start > end) {
|
||||
res.status(416);
|
||||
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1;
|
||||
const chunk = pdfBuffer.slice(start, end + 1);
|
||||
|
||||
this.logger.log(`Range 请求: ${fileName}, bytes ${start}-${end}/${fileSize}`);
|
||||
|
||||
res.status(206);
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader('Content-Length', chunkSize);
|
||||
res.end(chunk);
|
||||
} else {
|
||||
// 完整文件
|
||||
this.logger.log(`完整下载: ${fileName}, size=${fileSize}`);
|
||||
|
||||
res.setHeader('Content-Length', fileSize);
|
||||
res.end(pdfBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ import { UserTagController } from './api/controllers/user-tag.controller';
|
|||
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||
import { ContractBatchDownloadJob } from './infrastructure/jobs/contract-batch-download.job';
|
||||
// Co-Managed Wallet imports
|
||||
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
|
||||
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
|
||||
|
|
@ -80,6 +81,9 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
|||
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
|
||||
// Customer Service Contact imports
|
||||
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
|
||||
// [2026-02-05] 新增:合同管理模块
|
||||
import { ContractController } from './api/controllers/contract.controller';
|
||||
import { ContractService } from './application/services/contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -121,6 +125,8 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
|
|||
// Customer Service Contact Controllers
|
||||
AdminCustomerServiceContactController,
|
||||
PublicCustomerServiceContactController,
|
||||
// [2026-02-05] 新增:合同管理控制器
|
||||
ContractController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -186,6 +192,7 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
|
|||
AudienceSegmentService,
|
||||
// Scheduled Jobs
|
||||
AutoTagSyncJob,
|
||||
ContractBatchDownloadJob,
|
||||
// Co-Managed Wallet
|
||||
CoManagedWalletMapper,
|
||||
CoManagedWalletService,
|
||||
|
|
@ -207,6 +214,8 @@ import { AdminCustomerServiceContactController, PublicCustomerServiceContactCont
|
|||
provide: APP_INTERCEPTOR,
|
||||
useClass: MaintenanceInterceptor,
|
||||
},
|
||||
// [2026-02-05] 新增:合同管理服务
|
||||
ContractService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* 合同管理服务
|
||||
* [2026-02-05] 新增:调用 planting-service 内部 API 获取合同数据
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
/**
|
||||
* 合同 DTO
|
||||
*/
|
||||
export interface ContractDto {
|
||||
orderNo: string;
|
||||
contractNo: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
userRealName: string | null;
|
||||
userPhoneNumber: string | null;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
status: string;
|
||||
signedAt: string | null;
|
||||
signedPdfUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同列表响应
|
||||
*/
|
||||
export interface ContractsListResponse {
|
||||
items: ContractDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同统计响应
|
||||
*/
|
||||
export interface ContractStatisticsResponse {
|
||||
totalContracts: number;
|
||||
signedContracts: number;
|
||||
pendingContracts: number;
|
||||
expiredContracts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同查询参数
|
||||
*/
|
||||
export interface ContractQueryParams {
|
||||
accountSequences?: string[];
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
orderBy?: 'signedAt' | 'createdAt';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractService {
|
||||
private readonly logger = new Logger(ContractService.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly plantingServiceUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.plantingServiceUrl = this.configService.get<string>(
|
||||
'PLANTING_SERVICE_URL',
|
||||
'http://rwa-planting-service:3002',
|
||||
);
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.plantingServiceUrl,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.logger.log(`ContractService initialized, planting-service URL: ${this.plantingServiceUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同列表
|
||||
*/
|
||||
async getContracts(params: ContractQueryParams): Promise<ContractsListResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.accountSequences?.length) {
|
||||
queryParams.append('accountSequences', params.accountSequences.join(','));
|
||||
}
|
||||
if (params.signedAfter) queryParams.append('signedAfter', params.signedAfter);
|
||||
if (params.signedBefore) queryParams.append('signedBefore', params.signedBefore);
|
||||
if (params.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||
if (params.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||
if (params.status) queryParams.append('status', params.status);
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||
if (params.orderBy) queryParams.append('orderBy', params.orderBy);
|
||||
if (params.orderDir) queryParams.append('orderDir', params.orderDir);
|
||||
|
||||
const url = `/planting/internal/contracts?${queryParams.toString()}`;
|
||||
this.logger.debug(`[getContracts] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractsListResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getContracts] 失败: ${error.message}`);
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 50,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的合同列表
|
||||
*/
|
||||
async getUserContracts(accountSequence: string, params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<ContractsListResponse> {
|
||||
return this.getContracts({
|
||||
accountSequences: [accountSequence],
|
||||
page: params?.page,
|
||||
pageSize: params?.pageSize,
|
||||
status: undefined, // 查询所有状态
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
async getContract(orderNo: string): Promise<ContractDto | null> {
|
||||
try {
|
||||
const url = `/planting/internal/contracts/${orderNo}`;
|
||||
this.logger.debug(`[getContract] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractDto>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
this.logger.error(`[getContract] 失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载合同 PDF
|
||||
* @returns PDF Buffer
|
||||
*/
|
||||
async downloadContractPdf(orderNo: string): Promise<Buffer> {
|
||||
const url = `/planting/internal/contracts/${orderNo}/pdf`;
|
||||
this.logger.debug(`[downloadContractPdf] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
async getStatistics(params?: {
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
}): Promise<ContractStatisticsResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||
if (params?.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||
|
||||
const url = `/planting/internal/contracts/statistics?${queryParams.toString()}`;
|
||||
this.logger.debug(`[getStatistics] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractStatisticsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getStatistics] 失败: ${error.message}`);
|
||||
return {
|
||||
totalContracts: 0,
|
||||
signedContracts: 0,
|
||||
pendingContracts: 0,
|
||||
expiredContracts: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* 合同批量下载任务处理器
|
||||
* [2026-02-05] 新增:定时处理批量下载任务
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as archiver from 'archiver';
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
import { ContractService, ContractDto } from '../../application/services/contract.service';
|
||||
|
||||
/**
|
||||
* 筛选条件类型
|
||||
*/
|
||||
interface BatchDownloadFilters {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同批量下载任务处理 Job
|
||||
* 每分钟检查是否有待处理的批量下载任务
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBatchDownloadJob implements OnModuleInit {
|
||||
private readonly logger = new Logger(ContractBatchDownloadJob.name);
|
||||
private isRunning = false;
|
||||
private readonly downloadDir: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly contractService: ContractService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
|
||||
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log('ContractBatchDownloadJob initialized');
|
||||
// 确保下载目录存在
|
||||
const contractsDir = path.join(this.downloadDir, 'contracts');
|
||||
if (!existsSync(contractsDir)) {
|
||||
mkdirSync(contractsDir, { recursive: true });
|
||||
this.logger.log(`Created contracts download directory: ${contractsDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查并处理待处理的批量下载任务
|
||||
*/
|
||||
@Cron('0 * * * * *') // 每分钟的第0秒
|
||||
async processPendingTasks(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.debug('Batch download job is already running, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
try {
|
||||
// 查找待处理的任务
|
||||
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (!pendingTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
|
||||
|
||||
// 更新状态为处理中
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: pendingTask.id },
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
|
||||
} catch (error) {
|
||||
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: pendingTask.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errors: { message: error.message, stack: error.stack },
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('批量下载任务检查失败', error);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个批量下载任务
|
||||
*/
|
||||
private async processTask(
|
||||
taskId: bigint,
|
||||
taskNo: string,
|
||||
filters: BatchDownloadFilters | null,
|
||||
): Promise<void> {
|
||||
const errors: Array<{ orderNo: string; error: string }> = [];
|
||||
let downloadedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 1. 获取符合条件的合同列表(只获取已签署的)
|
||||
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
|
||||
|
||||
const contractsResult = await this.contractService.getContracts({
|
||||
signedAfter: filters?.signedAfter,
|
||||
signedBefore: filters?.signedBefore,
|
||||
provinceCode: filters?.provinceCode,
|
||||
cityCode: filters?.cityCode,
|
||||
status: 'SIGNED',
|
||||
pageSize: 10000, // 最大获取1万份
|
||||
orderBy: 'signedAt',
|
||||
orderDir: 'asc',
|
||||
});
|
||||
|
||||
const contracts = contractsResult.items;
|
||||
const totalContracts = contracts.length;
|
||||
|
||||
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
|
||||
|
||||
if (totalContracts === 0) {
|
||||
// 没有合同需要下载
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
totalContracts: 0,
|
||||
downloadedCount: 0,
|
||||
failedCount: 0,
|
||||
progress: 100,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新总数
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: { totalContracts },
|
||||
});
|
||||
|
||||
// 2. 创建临时目录
|
||||
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 3. 逐个下载合同 PDF
|
||||
for (let i = 0; i < contracts.length; i++) {
|
||||
const contract = contracts[i];
|
||||
|
||||
try {
|
||||
// 下载 PDF
|
||||
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
|
||||
|
||||
// 生成文件路径(按省市分组)
|
||||
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
|
||||
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
|
||||
const subDir = path.join(tempDir, safeProvince, safeCity);
|
||||
|
||||
if (!existsSync(subDir)) {
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
|
||||
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
|
||||
const filePath = path.join(subDir, fileName);
|
||||
|
||||
// 保存文件
|
||||
await fs.writeFile(filePath, pdfBuffer);
|
||||
|
||||
downloadedCount++;
|
||||
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
errors.push({ orderNo: contract.orderNo, error: error.message });
|
||||
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const progress = Math.floor(((i + 1) / totalContracts) * 100);
|
||||
if (progress % 10 === 0 || i === totalContracts - 1) {
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
downloadedCount,
|
||||
failedCount,
|
||||
progress,
|
||||
lastProcessedOrderNo: contract.orderNo,
|
||||
errors: errors.length > 0 ? errors : null,
|
||||
},
|
||||
});
|
||||
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 打包成 ZIP
|
||||
this.logger.log('开始打包 ZIP...');
|
||||
|
||||
const zipFileName = this.generateZipFileName(filters, downloadedCount);
|
||||
const zipDir = path.join(this.downloadDir, 'contracts');
|
||||
const zipPath = path.join(zipDir, zipFileName);
|
||||
|
||||
await this.createZipArchive(tempDir, zipPath);
|
||||
|
||||
// 获取 ZIP 文件大小
|
||||
const zipStats = await fs.stat(zipPath);
|
||||
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
|
||||
|
||||
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
|
||||
|
||||
// 5. 清理临时文件
|
||||
await this.cleanupTempDir(tempDir);
|
||||
|
||||
// 6. 更新任务状态为完成
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
downloadedCount,
|
||||
failedCount,
|
||||
progress: 100,
|
||||
resultFileUrl,
|
||||
resultFileSize: BigInt(zipStats.size),
|
||||
errors: errors.length > 0 ? errors : null,
|
||||
completedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 ZIP 文件名
|
||||
*/
|
||||
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
|
||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
let rangeStr = '';
|
||||
|
||||
if (filters?.signedAfter || filters?.signedBefore) {
|
||||
const start = filters.signedAfter
|
||||
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
|
||||
: 'all';
|
||||
const end = filters.signedBefore
|
||||
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
|
||||
: 'now';
|
||||
rangeStr = `_${start}-${end}`;
|
||||
}
|
||||
|
||||
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ZIP 压缩包
|
||||
*/
|
||||
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 6 }, // 压缩级别
|
||||
});
|
||||
|
||||
output.on('close', () => {
|
||||
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
// 添加目录下所有文件
|
||||
archive.directory(sourceDir, false);
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时目录
|
||||
*/
|
||||
private async cleanupTempDir(tempDir: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
this.logger.debug(`清理临时目录: ${tempDir}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
private sanitizeFileName(name: string): string {
|
||||
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发任务处理(供 API 调用)
|
||||
*/
|
||||
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
|
||||
if (this.isRunning) {
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
await this.processPendingTasks();
|
||||
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取处理状态
|
||||
*/
|
||||
getProcessingStatus(): { isRunning: boolean } {
|
||||
return { isRunning: this.isRunning };
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
ContractSigningController,
|
||||
ContractSigningConfigController,
|
||||
} from './controllers/contract-signing.controller';
|
||||
// [2026-02-05] 新增:合同管理内部 API
|
||||
import { ContractAdminController } from './controllers/contract-admin.controller';
|
||||
import { ApplicationModule } from '../application/application.module';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
|
|
@ -19,6 +21,8 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|||
HealthController,
|
||||
ContractSigningController,
|
||||
ContractSigningConfigController,
|
||||
// [2026-02-05] 新增:合同管理内部 API
|
||||
ContractAdminController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
/**
|
||||
* 合同管理内部 API 控制器
|
||||
* [2026-02-05] 新增:用于管理后台合同管理功能
|
||||
* 回滚方式:删除此文件并从 api.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Logger,
|
||||
Res,
|
||||
StreamableFile,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { MinioStorageService } from '../../infrastructure/storage/minio-storage.service';
|
||||
import { ContractSigningStatus } from '../../domain/value-objects/contract-signing-status.enum';
|
||||
|
||||
/**
|
||||
* 合同查询 DTO
|
||||
*/
|
||||
interface ContractDto {
|
||||
orderNo: string;
|
||||
contractNo: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
userRealName: string | null;
|
||||
userPhoneNumber: string | null;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
status: string;
|
||||
signedAt: string | null;
|
||||
signedPdfUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同列表响应
|
||||
*/
|
||||
interface ContractsListResponse {
|
||||
items: ContractDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同统计响应
|
||||
*/
|
||||
interface ContractStatisticsResponse {
|
||||
totalContracts: number;
|
||||
signedContracts: number;
|
||||
pendingContracts: number;
|
||||
expiredContracts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同管理内部 API 控制器
|
||||
* 用于服务间通信,不需要 JWT 认证
|
||||
*/
|
||||
@ApiTags('Internal - Contract Admin')
|
||||
@Controller('planting/internal/contracts')
|
||||
export class ContractAdminController {
|
||||
private readonly logger = new Logger(ContractAdminController.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly minioStorage: MinioStorageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询已签署合同列表
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: '查询合同列表(内部接口)' })
|
||||
@ApiQuery({ name: 'accountSequences', required: false, description: '账户序列号列表(逗号分隔)' })
|
||||
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始(ISO格式)' })
|
||||
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束(ISO格式)' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码,默认1' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认50' })
|
||||
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段:signedAt/createdAt,默认signedAt' })
|
||||
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向:asc/desc,默认desc' })
|
||||
@ApiResponse({ status: 200, description: '合同列表' })
|
||||
async getContracts(
|
||||
@Query('accountSequences') accountSequences?: string,
|
||||
@Query('signedAfter') signedAfter?: string,
|
||||
@Query('signedBefore') signedBefore?: string,
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('orderBy') orderBy?: string,
|
||||
@Query('orderDir') orderDir?: string,
|
||||
): Promise<ContractsListResponse> {
|
||||
this.logger.log(`========== internal/contracts 查询请求 ==========`);
|
||||
|
||||
const pageNum = page ? parseInt(page, 10) : 1;
|
||||
const pageSizeNum = pageSize ? parseInt(pageSize, 10) : 50;
|
||||
const skip = (pageNum - 1) * pageSizeNum;
|
||||
|
||||
// 构建查询条件
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
// 账户序列号过滤
|
||||
if (accountSequences) {
|
||||
const sequences = accountSequences.split(',').map(s => s.trim());
|
||||
where.accountSequence = { in: sequences };
|
||||
}
|
||||
|
||||
// 签署时间过滤
|
||||
if (signedAfter || signedBefore) {
|
||||
where.signedAt = {};
|
||||
if (signedAfter) {
|
||||
(where.signedAt as Record<string, Date>).gte = new Date(signedAfter);
|
||||
}
|
||||
if (signedBefore) {
|
||||
(where.signedAt as Record<string, Date>).lte = new Date(signedBefore);
|
||||
}
|
||||
}
|
||||
|
||||
// 省份过滤
|
||||
if (provinceCode) {
|
||||
where.provinceCode = provinceCode;
|
||||
}
|
||||
|
||||
// 城市过滤
|
||||
if (cityCode) {
|
||||
where.cityCode = cityCode;
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (status) {
|
||||
where.status = status;
|
||||
} else {
|
||||
// 默认只查询已签署的合同
|
||||
where.status = ContractSigningStatus.SIGNED;
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortField = orderBy === 'createdAt' ? 'createdAt' : 'signedAt';
|
||||
const sortDir = orderDir === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
this.logger.log(`查询条件: ${JSON.stringify(where)}`);
|
||||
|
||||
// 查询总数
|
||||
const total = await this.prisma.contractSigningTask.count({ where });
|
||||
|
||||
// 查询列表
|
||||
const tasks = await this.prisma.contractSigningTask.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSizeNum,
|
||||
orderBy: { [sortField]: sortDir },
|
||||
select: {
|
||||
orderNo: true,
|
||||
contractNo: true,
|
||||
userId: true,
|
||||
accountSequence: true,
|
||||
userRealName: true,
|
||||
userPhoneNumber: true,
|
||||
treeCount: true,
|
||||
totalAmount: true,
|
||||
provinceCode: true,
|
||||
provinceName: true,
|
||||
cityCode: true,
|
||||
cityName: true,
|
||||
status: true,
|
||||
signedAt: true,
|
||||
signedPdfUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const items: ContractDto[] = tasks.map(task => ({
|
||||
orderNo: task.orderNo,
|
||||
contractNo: task.contractNo,
|
||||
userId: task.userId.toString(),
|
||||
accountSequence: task.accountSequence,
|
||||
userRealName: task.userRealName,
|
||||
userPhoneNumber: task.userPhoneNumber,
|
||||
treeCount: task.treeCount,
|
||||
totalAmount: Number(task.totalAmount),
|
||||
provinceCode: task.provinceCode,
|
||||
provinceName: task.provinceName,
|
||||
cityCode: task.cityCode,
|
||||
cityName: task.cityName,
|
||||
status: task.status,
|
||||
signedAt: task.signedAt?.toISOString() ?? null,
|
||||
signedPdfUrl: task.signedPdfUrl,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
this.logger.log(`查询结果: total=${total}, page=${pageNum}, pageSize=${pageSizeNum}`);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: pageNum,
|
||||
pageSize: pageSizeNum,
|
||||
totalPages: Math.ceil(total / pageSizeNum),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
@Get('statistics')
|
||||
@ApiOperation({ summary: '获取合同统计信息(内部接口)' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiResponse({ status: 200, description: '合同统计' })
|
||||
async getStatistics(
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
): Promise<ContractStatisticsResponse> {
|
||||
this.logger.log(`========== internal/contracts/statistics 请求 ==========`);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (provinceCode) where.provinceCode = provinceCode;
|
||||
if (cityCode) where.cityCode = cityCode;
|
||||
|
||||
// 并行查询各状态数量
|
||||
const [totalContracts, signedContracts, pendingContracts, expiredContracts] = await Promise.all([
|
||||
this.prisma.contractSigningTask.count({ where }),
|
||||
this.prisma.contractSigningTask.count({ where: { ...where, status: ContractSigningStatus.SIGNED } }),
|
||||
this.prisma.contractSigningTask.count({
|
||||
where: {
|
||||
...where,
|
||||
status: { in: [ContractSigningStatus.PENDING, ContractSigningStatus.SCROLLED, ContractSigningStatus.ACKNOWLEDGED] },
|
||||
},
|
||||
}),
|
||||
this.prisma.contractSigningTask.count({ where: { ...where, status: ContractSigningStatus.EXPIRED } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalContracts,
|
||||
signedContracts,
|
||||
pendingContracts,
|
||||
expiredContracts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
@Get(':orderNo')
|
||||
@ApiOperation({ summary: '获取合同详情(内部接口)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: '合同详情' })
|
||||
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||
async getContract(@Param('orderNo') orderNo: string): Promise<ContractDto> {
|
||||
this.logger.log(`========== internal/contracts/${orderNo} 请求 ==========`);
|
||||
|
||||
const task = await this.prisma.contractSigningTask.findUnique({
|
||||
where: { orderNo },
|
||||
select: {
|
||||
orderNo: true,
|
||||
contractNo: true,
|
||||
userId: true,
|
||||
accountSequence: true,
|
||||
userRealName: true,
|
||||
userPhoneNumber: true,
|
||||
treeCount: true,
|
||||
totalAmount: true,
|
||||
provinceCode: true,
|
||||
provinceName: true,
|
||||
cityCode: true,
|
||||
cityName: true,
|
||||
status: true,
|
||||
signedAt: true,
|
||||
signedPdfUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
orderNo: task.orderNo,
|
||||
contractNo: task.contractNo,
|
||||
userId: task.userId.toString(),
|
||||
accountSequence: task.accountSequence,
|
||||
userRealName: task.userRealName,
|
||||
userPhoneNumber: task.userPhoneNumber,
|
||||
treeCount: task.treeCount,
|
||||
totalAmount: Number(task.totalAmount),
|
||||
provinceCode: task.provinceCode,
|
||||
provinceName: task.provinceName,
|
||||
cityCode: task.cityCode,
|
||||
cityName: task.cityName,
|
||||
status: task.status,
|
||||
signedAt: task.signedAt?.toISOString() ?? null,
|
||||
signedPdfUrl: task.signedPdfUrl,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载合同 PDF
|
||||
*/
|
||||
@Get(':orderNo/pdf')
|
||||
@ApiOperation({ summary: '下载合同 PDF(内部接口)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: 'PDF 文件流' })
|
||||
@ApiResponse({ status: 404, description: '合同或PDF不存在' })
|
||||
async downloadPdf(
|
||||
@Param('orderNo') orderNo: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
this.logger.log(`========== internal/contracts/${orderNo}/pdf 下载请求 ==========`);
|
||||
|
||||
// 查询合同任务
|
||||
const task = await this.prisma.contractSigningTask.findUnique({
|
||||
where: { orderNo },
|
||||
select: {
|
||||
contractNo: true,
|
||||
userRealName: true,
|
||||
treeCount: true,
|
||||
provinceName: true,
|
||||
cityName: true,
|
||||
signedPdfUrl: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
if (!task.signedPdfUrl) {
|
||||
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${task.status}`);
|
||||
}
|
||||
|
||||
// 从 MinIO 下载 PDF
|
||||
const pdfBuffer = await this.minioStorage.downloadSignedPdf(task.signedPdfUrl);
|
||||
|
||||
// 生成文件名
|
||||
const safeRealName = task.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
|
||||
const fileName = `${task.contractNo}_${safeRealName}_${task.treeCount}棵_${task.provinceName}${task.cityName}.pdf`;
|
||||
const encodedFileName = encodeURIComponent(fileName);
|
||||
|
||||
// 设置响应头
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodedFileName}`,
|
||||
'Content-Length': pdfBuffer.length,
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
|
||||
this.logger.log(`下载合同 PDF: ${fileName}, size=${pdfBuffer.length}`);
|
||||
|
||||
return new StreamableFile(pdfBuffer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* 合同管理页面样式
|
||||
* [2026-02-05] 新增
|
||||
* 回滚方式:删除此文件
|
||||
*/
|
||||
|
||||
.contracts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
&__statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__statCard {
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__statLabel {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
&__statValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #212529);
|
||||
}
|
||||
|
||||
&__filters {
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__filterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__filterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
min-width: 150px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #0d6efd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__table {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__tableHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.2fr 0.8fr 0.8fr 1fr 0.8fr 1fr 0.8fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
border-bottom: 1px solid var(--border-color, #dee2e6);
|
||||
}
|
||||
|
||||
&__tableRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.2fr 0.8fr 0.8fr 1fr 0.8fr 1fr 0.8fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid var(--border-color, #dee2e6);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f8f9fa);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__tableCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__tableSubtext {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__empty {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
&__pageInfo {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #6c757d);
|
||||
}
|
||||
|
||||
// 状态颜色
|
||||
&__statusGreen {
|
||||
color: #16a34a !important;
|
||||
}
|
||||
|
||||
&__statusYellow {
|
||||
color: #ca8a04 !important;
|
||||
}
|
||||
|
||||
&__statusRed {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
&__statusGray {
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 合同管理页面
|
||||
* [2026-02-05] 新增:全局合同查询、筛选、批量下载功能
|
||||
* 回滚方式:删除此目录即可
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button, toast } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { formatNumber } from '@/utils/formatters';
|
||||
import {
|
||||
contractService,
|
||||
CONTRACT_STATUS_LABELS,
|
||||
type ContractsListResponse,
|
||||
type ContractStatisticsResponse,
|
||||
type ContractQueryParams,
|
||||
} from '@/services/contractService';
|
||||
import { PROVINCE_CODE_NAMES } from '@/types';
|
||||
import styles from './contracts.module.scss';
|
||||
|
||||
// 获取合同状态对应的样式类
|
||||
const getStatusStyleClass = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'SIGNED':
|
||||
return styles.contracts__statusGreen;
|
||||
case 'PENDING':
|
||||
case 'SCROLLED':
|
||||
case 'ACKNOWLEDGED':
|
||||
return styles.contracts__statusYellow;
|
||||
case 'EXPIRED':
|
||||
case 'TIMEOUT':
|
||||
return styles.contracts__statusRed;
|
||||
default:
|
||||
return styles.contracts__statusGray;
|
||||
}
|
||||
};
|
||||
|
||||
// 省份列表(用于筛选)
|
||||
const PROVINCE_OPTIONS = Object.entries(PROVINCE_CODE_NAMES).map(([code, name]) => ({
|
||||
value: code,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 合同管理页面
|
||||
*/
|
||||
export default function ContractsPage() {
|
||||
// 筛选状态
|
||||
const [filters, setFilters] = useState<ContractQueryParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
provinceCode: '',
|
||||
cityCode: '',
|
||||
status: '',
|
||||
signedAfter: '',
|
||||
signedBefore: '',
|
||||
orderBy: 'signedAt',
|
||||
orderDir: 'desc',
|
||||
});
|
||||
|
||||
// 数据状态
|
||||
const [data, setData] = useState<ContractsListResponse | null>(null);
|
||||
const [statistics, setStatistics] = useState<ContractStatisticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
// 增量下载状态
|
||||
const [lastDownloadTime, setLastDownloadTime] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('contract_last_download_time');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 加载合同列表
|
||||
const loadContracts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await contractService.getContracts({
|
||||
...filters,
|
||||
provinceCode: filters.provinceCode || undefined,
|
||||
cityCode: filters.cityCode || undefined,
|
||||
status: filters.status || undefined,
|
||||
signedAfter: filters.signedAfter || undefined,
|
||||
signedBefore: filters.signedBefore || undefined,
|
||||
});
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error('获取合同列表失败:', error);
|
||||
toast.error('获取合同列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// 加载统计信息
|
||||
const loadStatistics = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const result = await contractService.getStatistics({
|
||||
provinceCode: filters.provinceCode || undefined,
|
||||
cityCode: filters.cityCode || undefined,
|
||||
});
|
||||
setStatistics(result);
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [filters.provinceCode, filters.cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContracts();
|
||||
}, [loadContracts]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatistics();
|
||||
}, [loadStatistics]);
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// 下载单个合同
|
||||
const handleDownloadContract = (orderNo: string) => {
|
||||
const downloadUrl = contractService.getDownloadUrl(orderNo);
|
||||
window.open(downloadUrl, '_blank');
|
||||
};
|
||||
|
||||
// 批量下载(创建下载任务)
|
||||
const handleBatchDownload = async () => {
|
||||
try {
|
||||
const result = await contractService.createBatchDownload({
|
||||
filters: {
|
||||
signedAfter: filters.signedAfter || undefined,
|
||||
signedBefore: filters.signedBefore || undefined,
|
||||
provinceCode: filters.provinceCode || undefined,
|
||||
cityCode: filters.cityCode || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(`批量下载任务已创建,任务号: ${result.taskNo}`);
|
||||
|
||||
// 保存当前时间作为最后下载时间
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem('contract_last_download_time', now);
|
||||
setLastDownloadTime(now);
|
||||
} catch (error) {
|
||||
console.error('创建批量下载任务失败:', error);
|
||||
toast.error('创建批量下载任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 增量下载(只下载上次之后签署的合同)
|
||||
const handleIncrementalDownload = async () => {
|
||||
if (!lastDownloadTime) {
|
||||
toast.error('未找到上次下载记录,请先执行全量下载');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await contractService.createBatchDownload({
|
||||
filters: {
|
||||
signedAfter: lastDownloadTime,
|
||||
provinceCode: filters.provinceCode || undefined,
|
||||
cityCode: filters.cityCode || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(`增量下载任务已创建,任务号: ${result.taskNo}`);
|
||||
|
||||
// 更新最后下载时间
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem('contract_last_download_time', now);
|
||||
setLastDownloadTime(now);
|
||||
} catch (error) {
|
||||
console.error('创建增量下载任务失败:', error);
|
||||
toast.error('创建增量下载任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const handleResetFilters = () => {
|
||||
setFilters({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
provinceCode: '',
|
||||
cityCode: '',
|
||||
status: '',
|
||||
signedAfter: '',
|
||||
signedBefore: '',
|
||||
orderBy: 'signedAt',
|
||||
orderDir: 'desc',
|
||||
});
|
||||
};
|
||||
|
||||
// 翻页
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="合同管理">
|
||||
<div className={styles.contracts}>
|
||||
{/* 统计卡片 */}
|
||||
<div className={styles.contracts__statsGrid}>
|
||||
<div className={styles.contracts__statCard}>
|
||||
<span className={styles.contracts__statLabel}>合同总数</span>
|
||||
<span className={styles.contracts__statValue}>
|
||||
{statsLoading ? '...' : formatNumber(statistics?.totalContracts || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.contracts__statCard}>
|
||||
<span className={styles.contracts__statLabel}>已签署</span>
|
||||
<span className={cn(styles.contracts__statValue, styles.contracts__statusGreen)}>
|
||||
{statsLoading ? '...' : formatNumber(statistics?.signedContracts || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.contracts__statCard}>
|
||||
<span className={styles.contracts__statLabel}>待签署</span>
|
||||
<span className={cn(styles.contracts__statValue, styles.contracts__statusYellow)}>
|
||||
{statsLoading ? '...' : formatNumber(statistics?.pendingContracts || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.contracts__statCard}>
|
||||
<span className={styles.contracts__statLabel}>已超时</span>
|
||||
<span className={cn(styles.contracts__statValue, styles.contracts__statusRed)}>
|
||||
{statsLoading ? '...' : formatNumber(statistics?.expiredContracts || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className={styles.contracts__filters}>
|
||||
<div className={styles.contracts__filterRow}>
|
||||
<div className={styles.contracts__filterItem}>
|
||||
<label>省份:</label>
|
||||
<select
|
||||
value={filters.provinceCode}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, provinceCode: e.target.value, cityCode: '', page: 1 }))}
|
||||
>
|
||||
<option value="">全部省份</option>
|
||||
{PROVINCE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.contracts__filterItem}>
|
||||
<label>状态:</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }))}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="SIGNED">已签署</option>
|
||||
<option value="PENDING">待签署</option>
|
||||
<option value="EXPIRED">已超时</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.contracts__filterItem}>
|
||||
<label>签署时间起:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.signedAfter?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, signedAfter: e.target.value ? `${e.target.value}T00:00:00.000Z` : '', page: 1 }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.contracts__filterItem}>
|
||||
<label>签署时间止:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.signedBefore?.split('T')[0] || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, signedBefore: e.target.value ? `${e.target.value}T23:59:59.999Z` : '', page: 1 }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleResetFilters}>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.contracts__actions}>
|
||||
<Button variant="primary" onClick={handleBatchDownload}>
|
||||
批量下载 ZIP
|
||||
</Button>
|
||||
{lastDownloadTime && (
|
||||
<Button variant="outline" onClick={handleIncrementalDownload}>
|
||||
增量下载(上次: {new Date(lastDownloadTime).toLocaleString('zh-CN')})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合同列表 */}
|
||||
<div className={styles.contracts__table}>
|
||||
<div className={styles.contracts__tableHeader}>
|
||||
<div className={styles.contracts__tableCell}>合同编号</div>
|
||||
<div className={styles.contracts__tableCell}>订单号</div>
|
||||
<div className={styles.contracts__tableCell}>用户</div>
|
||||
<div className={styles.contracts__tableCell}>认种数量</div>
|
||||
<div className={styles.contracts__tableCell}>金额</div>
|
||||
<div className={styles.contracts__tableCell}>省市</div>
|
||||
<div className={styles.contracts__tableCell}>状态</div>
|
||||
<div className={styles.contracts__tableCell}>签署时间</div>
|
||||
<div className={styles.contracts__tableCell}>操作</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.contracts__loading}>加载中...</div>
|
||||
) : data && data.items.length > 0 ? (
|
||||
data.items.map((contract) => (
|
||||
<div key={contract.orderNo} className={styles.contracts__tableRow}>
|
||||
<div className={styles.contracts__tableCell}>{contract.contractNo}</div>
|
||||
<div className={styles.contracts__tableCell}>{contract.orderNo}</div>
|
||||
<div className={styles.contracts__tableCell}>
|
||||
<div>{contract.userRealName || '未实名'}</div>
|
||||
<div className={styles.contracts__tableSubtext}>{contract.accountSequence}</div>
|
||||
</div>
|
||||
<div className={styles.contracts__tableCell}>{formatNumber(contract.treeCount)}</div>
|
||||
<div className={styles.contracts__tableCell}>{formatAmount(contract.totalAmount)}</div>
|
||||
<div className={styles.contracts__tableCell}>
|
||||
{contract.provinceName} / {contract.cityName}
|
||||
</div>
|
||||
<div className={cn(styles.contracts__tableCell, getStatusStyleClass(contract.status))}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status] || contract.status}
|
||||
</div>
|
||||
<div className={styles.contracts__tableCell}>
|
||||
{contract.signedAt ? formatDate(contract.signedAt) : '-'}
|
||||
</div>
|
||||
<div className={styles.contracts__tableCell}>
|
||||
{contract.status === 'SIGNED' && contract.signedPdfUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadContract(contract.orderNo)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.contracts__empty}>暂无合同数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className={styles.contracts__pagination}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page === 1}
|
||||
onClick={() => handlePageChange(filters.page! - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className={styles.contracts__pageInfo}>
|
||||
第 {filters.page} / {data.totalPages} 页,共 {data.total} 条
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page === data.totalPages}
|
||||
onClick={() => handlePageChange(filters.page! + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Button, toast } from '@/components/common';
|
||||
import { Button } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { formatNumber, formatRanking } from '@/utils/formatters';
|
||||
|
|
@ -16,28 +15,40 @@ import {
|
|||
useAuthorizationDetail,
|
||||
} from '@/hooks/useUserDetailPage';
|
||||
import { userDetailService } from '@/services/userDetailService';
|
||||
import type {
|
||||
ReferralNode,
|
||||
PlantingLedgerItem,
|
||||
WalletLedgerItem,
|
||||
WALLET_ENTRY_TYPE_LABELS,
|
||||
ASSET_TYPE_LABELS,
|
||||
PLANTING_STATUS_LABELS,
|
||||
AUTHORIZATION_ROLE_LABELS,
|
||||
AUTHORIZATION_STATUS_LABELS,
|
||||
ASSESSMENT_RESULT_LABELS,
|
||||
} from '@/types/userDetail.types';
|
||||
// [2026-02-05] 新增:合同服务
|
||||
import { contractService, CONTRACT_STATUS_LABELS, type ContractsListResponse } from '@/services/contractService';
|
||||
|
||||
// 获取合同状态颜色(内联样式)
|
||||
const getContractStatusStyle = (status: string): React.CSSProperties => {
|
||||
switch (status) {
|
||||
case 'SIGNED':
|
||||
return { color: '#16a34a' };
|
||||
case 'PENDING':
|
||||
case 'SCROLLED':
|
||||
case 'ACKNOWLEDGED':
|
||||
return { color: '#ca8a04' };
|
||||
case 'EXPIRED':
|
||||
case 'TIMEOUT':
|
||||
return { color: '#dc2626' };
|
||||
default:
|
||||
return { color: '#4b5563' };
|
||||
}
|
||||
};
|
||||
import type { ReferralNode } from '@/types/userDetail.types';
|
||||
import { PROVINCE_CODE_NAMES, CITY_CODE_NAMES } from '@/types';
|
||||
import styles from './user-detail.module.scss';
|
||||
|
||||
// Tab 类型
|
||||
type TabType = 'referral' | 'planting' | 'wallet' | 'authorization';
|
||||
// [2026-02-05] 更新:新增 contracts Tab
|
||||
type TabType = 'referral' | 'planting' | 'wallet' | 'authorization' | 'contracts';
|
||||
|
||||
const tabs: { key: TabType; label: string }[] = [
|
||||
{ key: 'referral', label: '引荐关系' },
|
||||
{ key: 'planting', label: '认种信息' },
|
||||
{ key: 'wallet', label: '钱包信息' },
|
||||
{ key: 'authorization', label: '授权信息' },
|
||||
// [2026-02-05] 新增:合同信息 Tab
|
||||
{ key: 'contracts', label: '合同信息' },
|
||||
];
|
||||
|
||||
// 流水类型标签
|
||||
|
|
@ -168,6 +179,10 @@ export default function UserDetailPage() {
|
|||
const [walletPage, setWalletPage] = useState(1);
|
||||
// 存储已展开节点的状态:key 是节点 accountSequence,value 是其子节点数组(null 表示未加载)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Record<string, ReferralNode[] | null>>({});
|
||||
// [2026-02-05] 新增:合同信息状态
|
||||
const [contractsPage, setContractsPage] = useState(1);
|
||||
const [contractsData, setContractsData] = useState<ContractsListResponse | null>(null);
|
||||
const [contractsLoading, setContractsLoading] = useState(false);
|
||||
|
||||
// 获取用户完整信息
|
||||
const { data: userDetail, isLoading: detailLoading, error: detailError } = useUserFullDetail(accountSequence);
|
||||
|
|
@ -200,6 +215,27 @@ export default function UserDetailPage() {
|
|||
}
|
||||
}, [referralTree]);
|
||||
|
||||
// [2026-02-05] 新增:加载合同数据
|
||||
useEffect(() => {
|
||||
if (activeTab === 'contracts') {
|
||||
setContractsLoading(true);
|
||||
contractService.getUserContracts(accountSequence, {
|
||||
page: contractsPage,
|
||||
pageSize: 10,
|
||||
})
|
||||
.then((data) => {
|
||||
setContractsData(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('获取合同列表失败:', error);
|
||||
setContractsData(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setContractsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [activeTab, accountSequence, contractsPage]);
|
||||
|
||||
// 切换推荐关系树的根节点
|
||||
const handleTreeNodeClick = useCallback((node: ReferralNode) => {
|
||||
setTreeRootUser(node.accountSequence);
|
||||
|
|
@ -241,6 +277,12 @@ export default function UserDetailPage() {
|
|||
router.push('/users');
|
||||
}, [router]);
|
||||
|
||||
// [2026-02-05] 新增:下载合同 PDF
|
||||
const handleDownloadContract = useCallback((orderNo: string) => {
|
||||
const downloadUrl = contractService.getDownloadUrl(orderNo);
|
||||
window.open(downloadUrl, '_blank');
|
||||
}, []);
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
|
|
@ -927,6 +969,112 @@ export default function UserDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [2026-02-05] 新增:合同信息 Tab */}
|
||||
{activeTab === 'contracts' && (
|
||||
<div className={styles.plantingTab}>
|
||||
{contractsLoading ? (
|
||||
<div className={styles.plantingTab__loading}>加载中...</div>
|
||||
) : contractsData ? (
|
||||
<>
|
||||
{/* 合同汇总 */}
|
||||
<div className={styles.plantingTab__summary}>
|
||||
<h3>合同汇总</h3>
|
||||
<div className={styles.plantingTab__summaryGrid}>
|
||||
<div className={styles.plantingTab__summaryItem}>
|
||||
<span className={styles.plantingTab__summaryLabel}>合同总数</span>
|
||||
<span className={styles.plantingTab__summaryValue}>
|
||||
{formatNumber(contractsData.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.plantingTab__summaryItem}>
|
||||
<span className={styles.plantingTab__summaryLabel}>已签署</span>
|
||||
<span className={styles.plantingTab__summaryValue}>
|
||||
{formatNumber(contractsData.items.filter(c => c.status === 'SIGNED').length)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.plantingTab__summaryItem}>
|
||||
<span className={styles.plantingTab__summaryLabel}>待签署</span>
|
||||
<span className={styles.plantingTab__summaryValue}>
|
||||
{formatNumber(contractsData.items.filter(c => ['PENDING', 'SCROLLED', 'ACKNOWLEDGED'].includes(c.status)).length)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合同列表 */}
|
||||
<div className={styles.plantingTab__ledger}>
|
||||
<h3>合同列表</h3>
|
||||
<div className={styles.ledgerTable}>
|
||||
<div className={styles.ledgerTable__header}>
|
||||
<div className={styles.ledgerTable__cell}>合同编号</div>
|
||||
<div className={styles.ledgerTable__cell}>订单号</div>
|
||||
<div className={styles.ledgerTable__cell}>认种数量</div>
|
||||
<div className={styles.ledgerTable__cell}>金额</div>
|
||||
<div className={styles.ledgerTable__cell}>省市</div>
|
||||
<div className={styles.ledgerTable__cell}>状态</div>
|
||||
<div className={styles.ledgerTable__cell}>签署时间</div>
|
||||
<div className={styles.ledgerTable__cell}>操作</div>
|
||||
</div>
|
||||
{contractsData.items.length === 0 ? (
|
||||
<div className={styles.ledgerTable__empty}>暂无合同记录</div>
|
||||
) : (
|
||||
contractsData.items.map((contract) => (
|
||||
<div key={contract.orderNo} className={styles.ledgerTable__row}>
|
||||
<div className={styles.ledgerTable__cell}>{contract.contractNo}</div>
|
||||
<div className={styles.ledgerTable__cell}>{contract.orderNo}</div>
|
||||
<div className={styles.ledgerTable__cell}>{formatNumber(contract.treeCount)}</div>
|
||||
<div className={styles.ledgerTable__cell}>{formatAmount(contract.totalAmount.toString())}</div>
|
||||
<div className={styles.ledgerTable__cell}>
|
||||
{contract.provinceName} / {contract.cityName}
|
||||
</div>
|
||||
<div className={styles.ledgerTable__cell} style={getContractStatusStyle(contract.status)}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status] || contract.status}
|
||||
</div>
|
||||
<div className={styles.ledgerTable__cell}>
|
||||
{contract.signedAt ? formatDate(contract.signedAt) : '-'}
|
||||
</div>
|
||||
<div className={styles.ledgerTable__cell}>
|
||||
{contract.status === 'SIGNED' && contract.signedPdfUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadContract(contract.orderNo)}
|
||||
>
|
||||
下载PDF
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{contractsData.totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
disabled={contractsPage === 1}
|
||||
onClick={() => setContractsPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {contractsPage} / {contractsData.totalPages} 页</span>
|
||||
<button
|
||||
disabled={contractsPage === contractsData.totalPages}
|
||||
onClick={() => setContractsPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.plantingTab__empty}>暂无合同数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface MenuItem {
|
|||
const topMenuItems: MenuItem[] = [
|
||||
{ key: 'dashboard', icon: '/images/Container1.svg', label: '仪表板', path: '/dashboard' },
|
||||
{ key: 'users', icon: '/images/Container2.svg', label: '用户管理', path: '/users' },
|
||||
// [2026-02-05] 新增:合同管理
|
||||
{ key: 'contracts', icon: '/images/Container2.svg', label: '合同管理', path: '/contracts' },
|
||||
{ key: 'leaderboard', icon: '/images/Container3.svg', label: '龙虎榜', path: '/leaderboard' },
|
||||
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
|
||||
{ key: 'co-managed-wallet', icon: '/images/Container4.svg', label: '共管钱包', path: '/co-managed-wallet' },
|
||||
|
|
|
|||
|
|
@ -269,4 +269,23 @@ export const API_ENDPOINTS = {
|
|||
UPDATE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
|
||||
DELETE: (id: string) => `/v1/admin/customer-service-contacts/${id}`,
|
||||
},
|
||||
|
||||
// [2026-02-05] 新增:合同管理 (admin-service)
|
||||
// 回滚方式:删除此部分即可
|
||||
CONTRACTS: {
|
||||
// 合同列表查询
|
||||
LIST: '/v1/admin/contracts',
|
||||
// 合同统计信息
|
||||
STATISTICS: '/v1/admin/contracts/statistics',
|
||||
// 合同详情
|
||||
DETAIL: (orderNo: string) => `/v1/admin/contracts/${orderNo}`,
|
||||
// 下载合同 PDF(支持断点续传)
|
||||
DOWNLOAD: (orderNo: string) => `/v1/admin/contracts/${orderNo}/download`,
|
||||
// 用户合同列表
|
||||
USER_CONTRACTS: (accountSequence: string) => `/v1/admin/contracts/users/${accountSequence}`,
|
||||
// 批量下载任务
|
||||
BATCH_DOWNLOAD: '/v1/admin/contracts/batch-download',
|
||||
// 批量下载任务状态
|
||||
BATCH_DOWNLOAD_STATUS: (taskNo: string) => `/v1/admin/contracts/batch-download/${taskNo}`,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* 合同管理服务
|
||||
* [2026-02-05] 新增:负责合同管理相关的 API 调用
|
||||
* 回滚方式:删除此文件
|
||||
*/
|
||||
|
||||
import apiClient from '@/infrastructure/api/client';
|
||||
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||
|
||||
/**
|
||||
* 合同 DTO
|
||||
*/
|
||||
export interface ContractDto {
|
||||
orderNo: string;
|
||||
contractNo: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
userRealName: string | null;
|
||||
userPhoneNumber: string | null;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
status: string;
|
||||
signedAt: string | null;
|
||||
signedPdfUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同列表响应
|
||||
*/
|
||||
export interface ContractsListResponse {
|
||||
items: ContractDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同统计响应
|
||||
*/
|
||||
export interface ContractStatisticsResponse {
|
||||
totalContracts: number;
|
||||
signedContracts: number;
|
||||
pendingContracts: number;
|
||||
expiredContracts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同查询参数
|
||||
*/
|
||||
export interface ContractQueryParams {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
orderBy?: 'signedAt' | 'createdAt';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载请求
|
||||
*/
|
||||
export interface BatchDownloadRequest {
|
||||
filters?: {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
};
|
||||
orderNos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载任务响应
|
||||
*/
|
||||
export interface BatchDownloadTaskResponse {
|
||||
taskId: string;
|
||||
taskNo: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
|
||||
totalContracts: number;
|
||||
downloadedCount: number;
|
||||
failedCount: number;
|
||||
progress: number;
|
||||
resultFileUrl: string | null;
|
||||
resultFileSize: string | null;
|
||||
errors: unknown | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同状态标签
|
||||
*/
|
||||
export const CONTRACT_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待签署',
|
||||
SCROLLED: '已阅读',
|
||||
ACKNOWLEDGED: '已确认',
|
||||
SIGNED: '已签署',
|
||||
EXPIRED: '已超时',
|
||||
TIMEOUT: '已超时',
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取合同状态对应的颜色类名
|
||||
*/
|
||||
export const getContractStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'SIGNED':
|
||||
return 'text-green-600';
|
||||
case 'PENDING':
|
||||
case 'SCROLLED':
|
||||
case 'ACKNOWLEDGED':
|
||||
return 'text-yellow-600';
|
||||
case 'EXPIRED':
|
||||
case 'TIMEOUT':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 合同管理服务
|
||||
*/
|
||||
export const contractService = {
|
||||
/**
|
||||
* 获取合同列表
|
||||
*/
|
||||
async getContracts(params?: ContractQueryParams): Promise<ContractsListResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.CONTRACTS.LIST, { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
async getStatistics(params?: {
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
}): Promise<ContractStatisticsResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.CONTRACTS.STATISTICS, { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
async getContract(orderNo: string): Promise<ContractDto> {
|
||||
return apiClient.get(API_ENDPOINTS.CONTRACTS.DETAIL(orderNo));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户的合同列表
|
||||
*/
|
||||
async getUserContracts(
|
||||
accountSequence: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
): Promise<ContractsListResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.CONTRACTS.USER_CONTRACTS(accountSequence), { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载合同 PDF
|
||||
* 返回下载 URL,前端直接打开进行下载
|
||||
*/
|
||||
getDownloadUrl(orderNo: string): string {
|
||||
// 获取 API 基础 URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${baseUrl}${API_ENDPOINTS.CONTRACTS.DOWNLOAD(orderNo)}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建批量下载任务
|
||||
*/
|
||||
async createBatchDownload(request: BatchDownloadRequest): Promise<{
|
||||
success: boolean;
|
||||
taskId: string;
|
||||
taskNo: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}> {
|
||||
return apiClient.post(API_ENDPOINTS.CONTRACTS.BATCH_DOWNLOAD, request);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取批量下载任务状态
|
||||
*/
|
||||
async getBatchDownloadStatus(taskNo: string): Promise<BatchDownloadTaskResponse> {
|
||||
return apiClient.get(API_ENDPOINTS.CONTRACTS.BATCH_DOWNLOAD_STATUS(taskNo));
|
||||
},
|
||||
};
|
||||
|
||||
export default contractService;
|
||||
Loading…
Reference in New Issue