feat(admin): 实现预种管理页面完整API端点
planting-service: InternalPrePlantingController 新增4个管理员查询端点 - GET /internal/pre-planting/admin/orders (分页订单列表) - GET /internal/pre-planting/admin/positions (分页持仓列表) - GET /internal/pre-planting/admin/merges (分页合并记录) - GET /internal/pre-planting/admin/stats (统计汇总) admin-service: HTTP代理层新增5个端点 - PUT config/toggle (开关切换) - GET orders/positions/merges/stats (代理转发到planting-service) - 新建 PrePlantingProxyService (复用ContractService的axios代理模式) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
63a169abb0
commit
37a5610d74
|
|
@ -87,6 +87,8 @@ import { ContractService } from './application/services/contract.service';
|
|||
// [2026-02-17] 新增:预种计划开关管理
|
||||
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
|
||||
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
|
||||
// [2026-02-27] 新增:预种计划数据代理(admin-service → planting-service 内部 HTTP)
|
||||
import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service';
|
||||
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
|
||||
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
|
||||
import { TreePricingService } from './pricing/tree-pricing.service';
|
||||
|
|
@ -231,6 +233,8 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
|
|||
ContractService,
|
||||
// [2026-02-17] 新增:预种计划开关管理
|
||||
PrePlantingConfigService,
|
||||
// [2026-02-27] 新增:预种计划数据代理
|
||||
PrePlantingProxyService,
|
||||
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
|
||||
TreePricingService,
|
||||
AutoPriceIncreaseJob,
|
||||
|
|
|
|||
|
|
@ -2,23 +2,31 @@ import {
|
|||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { PrePlantingConfigService } from './pre-planting-config.service';
|
||||
import { PrePlantingProxyService } from './pre-planting-proxy.service';
|
||||
|
||||
class UpdatePrePlantingConfigDto {
|
||||
isActive: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
class TogglePrePlantingConfigDto {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@ApiTags('预种计划配置')
|
||||
@Controller('admin/pre-planting')
|
||||
export class PrePlantingConfigController {
|
||||
constructor(
|
||||
private readonly configService: PrePlantingConfigService,
|
||||
private readonly proxyService: PrePlantingProxyService,
|
||||
) {}
|
||||
|
||||
@Get('config')
|
||||
|
|
@ -35,6 +43,81 @@ export class PrePlantingConfigController {
|
|||
async updateConfig(@Body() dto: UpdatePrePlantingConfigDto) {
|
||||
return this.configService.updateConfig(dto.isActive, dto.updatedBy);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// [2026-02-27] 新增:预种管理端点(toggle + 数据查询代理)
|
||||
// ============================================
|
||||
|
||||
@Put('config/toggle')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '切换预种计划开关' })
|
||||
@ApiResponse({ status: HttpStatus.OK, description: '切换成功' })
|
||||
async toggleConfig(@Body() dto: TogglePrePlantingConfigDto) {
|
||||
return this.configService.updateConfig(dto.isActive);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
async getOrders(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.proxyService.getOrders({
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
keyword: keyword || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
async getPositions(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.proxyService.getPositions({
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
keyword: keyword || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('merges')
|
||||
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
async getMerges(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.proxyService.getMerges({
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
keyword: keyword || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: '预种统计汇总' })
|
||||
async getStats() {
|
||||
return this.proxyService.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* 预种计划数据代理服务
|
||||
* [2026-02-27] 新增:通过内部 HTTP 调用 planting-service 获取预种管理数据
|
||||
*
|
||||
* === 架构 ===
|
||||
* admin-web → admin-service (本服务) → planting-service /internal/pre-planting/admin/*
|
||||
* 复用现有 ContractService 的 axios 代理模式
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class PrePlantingProxyService {
|
||||
private readonly logger = new Logger(PrePlantingProxyService.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const plantingServiceUrl = this.configService.get<string>(
|
||||
'PLANTING_SERVICE_URL',
|
||||
'http://rwa-planting-service:3003',
|
||||
);
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: plantingServiceUrl,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`PrePlantingProxyService initialized, planting-service URL: ${plantingServiceUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getOrders(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
if (params.page) qp.append('page', params.page.toString());
|
||||
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||
if (params.keyword) qp.append('keyword', params.keyword);
|
||||
if (params.status) qp.append('status', params.status);
|
||||
|
||||
const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`;
|
||||
this.logger.debug(`[getOrders] 请求: ${url}`);
|
||||
const response = await this.httpClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getOrders] 失败: ${error.message}`);
|
||||
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||
}
|
||||
}
|
||||
|
||||
async getPositions(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
if (params.page) qp.append('page', params.page.toString());
|
||||
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||
if (params.keyword) qp.append('keyword', params.keyword);
|
||||
|
||||
const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`;
|
||||
this.logger.debug(`[getPositions] 请求: ${url}`);
|
||||
const response = await this.httpClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getPositions] 失败: ${error.message}`);
|
||||
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||
}
|
||||
}
|
||||
|
||||
async getMerges(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
if (params.page) qp.append('page', params.page.toString());
|
||||
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||
if (params.keyword) qp.append('keyword', params.keyword);
|
||||
if (params.status) qp.append('status', params.status);
|
||||
|
||||
const url = `/api/v1/internal/pre-planting/admin/merges?${qp.toString()}`;
|
||||
this.logger.debug(`[getMerges] 请求: ${url}`);
|
||||
const response = await this.httpClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getMerges] 失败: ${error.message}`);
|
||||
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
try {
|
||||
const url = '/api/v1/internal/pre-planting/admin/stats';
|
||||
this.logger.debug(`[getStats] 请求: ${url}`);
|
||||
const response = await this.httpClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getStats] 失败: ${error.message}`);
|
||||
return {
|
||||
totalOrders: 0,
|
||||
totalPortions: 0,
|
||||
totalAmount: 0,
|
||||
totalMerges: 0,
|
||||
totalTreesMerged: 0,
|
||||
totalUsers: 0,
|
||||
pendingContracts: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,21 +2,28 @@ import {
|
|||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { PrePlantingApplicationService } from '../../application/services/pre-planting-application.service';
|
||||
import { PrismaService } from '../../../infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
@ApiTags('预种计划-内部API')
|
||||
@Controller('internal/pre-planting')
|
||||
export class InternalPrePlantingController {
|
||||
private readonly logger = new Logger(InternalPrePlantingController.name);
|
||||
|
||||
constructor(
|
||||
private readonly prePlantingService: PrePlantingApplicationService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('eligibility/:accountSequence')
|
||||
|
|
@ -28,4 +35,203 @@ export class InternalPrePlantingController {
|
|||
) {
|
||||
return this.prePlantingService.getEligibility(accountSequence);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// [2026-02-27] 新增:管理员查询端点(供 admin-service 内部调用)
|
||||
// ============================================
|
||||
|
||||
@Get('admin/orders')
|
||||
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
async getAdminOrders(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const p = Math.max(1, parseInt(page || '1', 10));
|
||||
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
|
||||
|
||||
const where: any = {};
|
||||
if (status) where.status = status;
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ orderNo: { contains: keyword, mode: 'insensitive' } },
|
||||
{ accountSequence: { contains: keyword, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.prePlantingOrder.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
this.prisma.prePlantingOrder.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
userId: o.userId.toString(),
|
||||
accountSequence: o.accountSequence,
|
||||
portionCount: o.portionCount,
|
||||
pricePerPortion: Number(o.pricePerPortion),
|
||||
totalAmount: Number(o.totalAmount),
|
||||
provinceCode: o.provinceCode,
|
||||
cityCode: o.cityCode,
|
||||
status: o.status,
|
||||
mergedToMergeId: o.mergedToMergeId?.toString() ?? null,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
paidAt: o.paidAt?.toISOString() ?? null,
|
||||
mergedAt: o.mergedAt?.toISOString() ?? null,
|
||||
})),
|
||||
total,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('admin/positions')
|
||||
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
async getAdminPositions(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
const p = Math.max(1, parseInt(page || '1', 10));
|
||||
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
|
||||
|
||||
const where: any = {};
|
||||
if (keyword) {
|
||||
where.accountSequence = { contains: keyword, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.prePlantingPosition.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
this.prisma.prePlantingPosition.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((pos) => ({
|
||||
id: pos.id.toString(),
|
||||
userId: pos.userId.toString(),
|
||||
accountSequence: pos.accountSequence,
|
||||
totalPortions: pos.totalPortions,
|
||||
availablePortions: pos.availablePortions,
|
||||
mergedPortions: pos.mergedPortions,
|
||||
totalTreesMerged: pos.totalTreesMerged,
|
||||
provinceCode: pos.provinceCode ?? null,
|
||||
cityCode: pos.cityCode ?? null,
|
||||
firstPurchaseAt: pos.firstPurchaseAt?.toISOString() ?? null,
|
||||
updatedAt: pos.updatedAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('admin/merges')
|
||||
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiQuery({ name: 'keyword', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
async getAdminMerges(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const p = Math.max(1, parseInt(page || '1', 10));
|
||||
const ps = Math.min(100, Math.max(1, parseInt(pageSize || '20', 10)));
|
||||
|
||||
const where: any = {};
|
||||
if (status) where.contractStatus = status;
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ mergeNo: { contains: keyword, mode: 'insensitive' } },
|
||||
{ accountSequence: { contains: keyword, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.prePlantingMerge.findMany({
|
||||
where,
|
||||
orderBy: { mergedAt: 'desc' },
|
||||
skip: (p - 1) * ps,
|
||||
take: ps,
|
||||
}),
|
||||
this.prisma.prePlantingMerge.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
mergeNo: m.mergeNo,
|
||||
userId: m.userId.toString(),
|
||||
accountSequence: m.accountSequence,
|
||||
sourceOrderNos: m.sourceOrderNos as string[],
|
||||
treeCount: m.treeCount,
|
||||
contractStatus: m.contractStatus,
|
||||
contractSignedAt: m.contractSignedAt?.toISOString() ?? null,
|
||||
miningEnabledAt: m.miningEnabledAt?.toISOString() ?? null,
|
||||
selectedProvince: m.provinceCode ?? null,
|
||||
selectedCity: m.cityCode ?? null,
|
||||
mergedAt: m.mergedAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('admin/stats')
|
||||
@ApiOperation({ summary: '预种统计汇总(管理员视角)' })
|
||||
async getAdminStats() {
|
||||
const [
|
||||
totalOrders,
|
||||
portionsAgg,
|
||||
amountAgg,
|
||||
totalMerges,
|
||||
treesMergedAgg,
|
||||
totalUsers,
|
||||
pendingContracts,
|
||||
] = await Promise.all([
|
||||
this.prisma.prePlantingOrder.count(),
|
||||
this.prisma.prePlantingOrder.aggregate({ _sum: { portionCount: true } }),
|
||||
this.prisma.prePlantingOrder.aggregate({
|
||||
_sum: { totalAmount: true },
|
||||
where: { status: { in: ['PAID', 'MERGED'] } },
|
||||
}),
|
||||
this.prisma.prePlantingMerge.count(),
|
||||
this.prisma.prePlantingMerge.aggregate({ _sum: { treeCount: true } }),
|
||||
this.prisma.prePlantingPosition.count(),
|
||||
this.prisma.prePlantingMerge.count({ where: { contractStatus: 'PENDING' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalOrders,
|
||||
totalPortions: portionsAgg._sum.portionCount ?? 0,
|
||||
totalAmount: Number(amountAgg._sum.totalAmount ?? 0),
|
||||
totalMerges,
|
||||
totalTreesMerged: treesMergedAgg._sum.treeCount ?? 0,
|
||||
totalUsers,
|
||||
pendingContracts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue