@@ -24,6 +24,7 @@ export const HqOperationAction = {
|
||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||
ORDER_DELETE: 'ORDER_DELETE',
|
||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||
ORDER_EXPORT: 'ORDER_EXPORT',
|
||||
STORE_CREATE: 'STORE_CREATE',
|
||||
STORE_UPDATE: 'STORE_UPDATE',
|
||||
STORE_STATUS: 'STORE_STATUS',
|
||||
@@ -147,6 +148,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||
[HqOperationAction.ORDER_DELETE]: '删除订单',
|
||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
|
||||
import ExcelJS from 'exceljs';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
const EXPORT_HEADERS = [
|
||||
'订单号',
|
||||
'下单时间',
|
||||
'状态',
|
||||
'商品',
|
||||
'规格',
|
||||
'数量',
|
||||
'实付',
|
||||
'好客权益',
|
||||
'收货人',
|
||||
'手机',
|
||||
'收货地址',
|
||||
] as const;
|
||||
|
||||
export type OrderExportRow = {
|
||||
orderNo: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
quantity: number;
|
||||
payAmount: number;
|
||||
benefitBrief: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
function formatBenefitBrief(order: {
|
||||
benefitAmount?: { toString(): string } | number | null;
|
||||
benefitCoupon?: {
|
||||
totalAmount?: { toString(): string } | number;
|
||||
usedAmount?: { toString(): string } | number;
|
||||
balance?: { toString(): string } | number;
|
||||
} | null;
|
||||
}): string {
|
||||
const coupon = order.benefitCoupon;
|
||||
if (coupon) {
|
||||
return `总额¥${Number(coupon.totalAmount).toFixed(2)} / 已用¥${Number(coupon.usedAmount).toFixed(2)} / 余¥${Number(coupon.balance).toFixed(2)}`;
|
||||
}
|
||||
if (order.benefitAmount != null) return `¥${Number(order.benefitAmount).toFixed(2)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function formatExportDateTime(value?: Date | null): string {
|
||||
if (!value) return '';
|
||||
return value.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
export function mapOrderToExportRow(order: {
|
||||
orderNo: string;
|
||||
createdAt: Date;
|
||||
status: string;
|
||||
productName: string;
|
||||
productSpec: string;
|
||||
quantity: number;
|
||||
payAmount: { toString(): string } | number;
|
||||
benefitAmount?: { toString(): string } | number | null;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverProvince: string;
|
||||
receiverCity: string;
|
||||
receiverDistrict: string;
|
||||
receiverAddress: string;
|
||||
benefitCoupon?: {
|
||||
totalAmount?: { toString(): string } | number;
|
||||
usedAmount?: { toString(): string } | number;
|
||||
balance?: { toString(): string } | number;
|
||||
} | null;
|
||||
deliveryType?: string;
|
||||
}): OrderExportRow {
|
||||
const isOnSite =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ||
|
||||
order.receiverAddress === '现场取货' ||
|
||||
order.receiverAddress === '现场提货' ||
|
||||
(order.receiverProvince === '现场' && order.receiverCity === '现场');
|
||||
const address = isOnSite
|
||||
? '现场取货'
|
||||
: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}${order.receiverAddress}`;
|
||||
return {
|
||||
orderNo: order.orderNo,
|
||||
createdAt: formatExportDateTime(order.createdAt),
|
||||
status: ORDER_STATUS_LABELS[order.status] || order.status,
|
||||
productName: order.productName,
|
||||
productSpec: order.productSpec,
|
||||
quantity: order.quantity,
|
||||
payAmount: Number(order.payAmount),
|
||||
benefitBrief: formatBenefitBrief(order),
|
||||
receiverName: order.receiverName,
|
||||
receiverPhone: order.receiverPhone,
|
||||
address,
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToCells(row: OrderExportRow): string[] {
|
||||
return [
|
||||
row.orderNo,
|
||||
row.createdAt,
|
||||
row.status,
|
||||
row.productName,
|
||||
row.productSpec,
|
||||
String(row.quantity),
|
||||
row.payAmount.toFixed(2),
|
||||
row.benefitBrief,
|
||||
row.receiverName,
|
||||
row.receiverPhone,
|
||||
row.address,
|
||||
];
|
||||
}
|
||||
|
||||
function resolvePdfFontPath(): string {
|
||||
const candidates = [
|
||||
process.env.EXPORT_PDF_FONT_PATH,
|
||||
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
|
||||
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
|
||||
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
|
||||
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
|
||||
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||
'C:\\Windows\\Fonts\\msyh.ttc',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
|
||||
}
|
||||
|
||||
export async function buildOrdersXlsx(rows: OrderExportRow[]): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('订单');
|
||||
sheet.addRow([...EXPORT_HEADERS]);
|
||||
for (const row of rows) {
|
||||
sheet.addRow(rowToCells(row));
|
||||
}
|
||||
sheet.columns.forEach((col) => {
|
||||
col.width = 16;
|
||||
});
|
||||
sheet.getColumn(4).width = 22;
|
||||
sheet.getColumn(8).width = 36;
|
||||
sheet.getColumn(11).width = 36;
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function buildOrdersPdf(rows: OrderExportRow[]): Promise<Buffer> {
|
||||
const fontPath = resolvePdfFontPath();
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: 24,
|
||||
bufferPages: true,
|
||||
});
|
||||
doc.on('data', (chunk) => chunks.push(chunk as Buffer));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
doc.registerFont('zh', fontPath);
|
||||
doc.font('zh');
|
||||
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const colWidths = [72, 78, 48, 88, 64, 32, 48, 110, 48, 72, 120];
|
||||
const scale = pageWidth / colWidths.reduce((sum, w) => sum + w, 0);
|
||||
const widths = colWidths.map((w) => w * scale);
|
||||
const rowHeight = 28;
|
||||
const fontSize = 7;
|
||||
let y = doc.page.margins.top;
|
||||
|
||||
const drawRow = (cells: string[], isHeader = false) => {
|
||||
let x = doc.page.margins.left;
|
||||
const height = isHeader ? 24 : rowHeight;
|
||||
if (y + height > doc.page.height - doc.page.margins.bottom) {
|
||||
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
|
||||
y = doc.page.margins.top;
|
||||
}
|
||||
doc.fontSize(isHeader ? 8 : fontSize);
|
||||
cells.forEach((cell, index) => {
|
||||
doc.rect(x, y, widths[index], height).stroke('#dddddd');
|
||||
doc.text(cell || '', x + 2, y + 4, {
|
||||
width: widths[index] - 4,
|
||||
height: height - 6,
|
||||
lineBreak: true,
|
||||
});
|
||||
x += widths[index];
|
||||
});
|
||||
y += height;
|
||||
};
|
||||
|
||||
drawRow([...EXPORT_HEADERS], true);
|
||||
for (const row of rows) {
|
||||
drawRow(rowToCells(row));
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return `订单导出_${stamp}_${count}条.${format}`;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -26,6 +26,25 @@ export class AdminOrdersController {
|
||||
return this.ordersService.listBigScreen(limit);
|
||||
}
|
||||
|
||||
@Post('export/preview')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
previewExport(@Body() dto: AdminOrdersExportDto) {
|
||||
return this.ordersService.previewExport(dto);
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_EXPORT,
|
||||
refType: 'ORDER',
|
||||
includeBody: true,
|
||||
})
|
||||
exportOrders(@Body() dto: AdminOrdersExportDto) {
|
||||
return this.ordersService.exportOrders(dto);
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders_delete')
|
||||
|
||||
@@ -7,12 +7,35 @@ import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import { AdminRedeemService } from './admin-redeem.service';
|
||||
import {
|
||||
buildExportFilename,
|
||||
buildOrdersPdf,
|
||||
buildOrdersXlsx,
|
||||
mapOrderToExportRow,
|
||||
} from './admin-order-export.util';
|
||||
|
||||
const ORDER_EXPORT_MAX = 5000;
|
||||
|
||||
type OrderFilterInput = Pick<
|
||||
AdminOrdersQueryDto,
|
||||
| 'orderNo'
|
||||
| 'status'
|
||||
| 'orderType'
|
||||
| 'userId'
|
||||
| 'cityId'
|
||||
| 'receiverPhone'
|
||||
| 'fulfillmentHold'
|
||||
| 'createdFrom'
|
||||
| 'createdTo'
|
||||
| 'excludeTest'
|
||||
| 'deliveryType'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
@@ -60,23 +83,7 @@ export class AdminOrdersService {
|
||||
async list(query: AdminOrdersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
||||
where.fulfillmentHold = true;
|
||||
}
|
||||
if (query.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
const where = this.buildOrderWhere(query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
@@ -89,6 +96,9 @@ export class AdminOrdersService {
|
||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
benefitCoupon: {
|
||||
select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
@@ -97,6 +107,114 @@ export class AdminOrdersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async previewExport(dto: AdminOrdersExportDto) {
|
||||
const count = await this.countExportOrders(dto);
|
||||
return { count, max: ORDER_EXPORT_MAX, exceeds: count > ORDER_EXPORT_MAX };
|
||||
}
|
||||
|
||||
async exportOrders(dto: AdminOrdersExportDto) {
|
||||
const count = await this.countExportOrders(dto);
|
||||
if (count > ORDER_EXPORT_MAX) {
|
||||
throw new BadRequestException(`超过 ${ORDER_EXPORT_MAX} 条,请缩小日期或筛选条件`);
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new BadRequestException('没有可导出的订单');
|
||||
}
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: this.buildExportWhere(dto),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ORDER_EXPORT_MAX,
|
||||
include: {
|
||||
city: { select: { name: true } },
|
||||
delivery: { select: { trackingNo: true } },
|
||||
benefitCoupon: {
|
||||
select: { totalAmount: true, usedAmount: true, balance: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = orders.map((order) => mapOrderToExportRow(order));
|
||||
const buffer =
|
||||
dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows);
|
||||
const filename = buildExportFilename(dto.format, rows.length);
|
||||
const mimeType =
|
||||
dto.format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
return {
|
||||
filename,
|
||||
mimeType,
|
||||
contentBase64: buffer.toString('base64'),
|
||||
count: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
private buildOrderWhere(query: OrderFilterInput): Prisma.OrderWhereInput {
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||
if (query.deliveryType) {
|
||||
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
|
||||
}
|
||||
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
||||
where.fulfillmentHold = true;
|
||||
}
|
||||
if (query.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
private buildExportWhere(dto: AdminOrdersExportDto): Prisma.OrderWhereInput {
|
||||
if (dto.scope === 'selected') {
|
||||
const ids = (dto.ids ?? []).map((id) => BigInt(id));
|
||||
if (!ids.length) {
|
||||
throw new BadRequestException('请先勾选要导出的订单');
|
||||
}
|
||||
return { id: { in: ids } };
|
||||
}
|
||||
|
||||
const normalized = this.normalizeExportFilter(dto);
|
||||
return this.buildOrderWhere(normalized);
|
||||
}
|
||||
|
||||
private async countExportOrders(dto: AdminOrdersExportDto): Promise<number> {
|
||||
return this.prisma.order.count({ where: this.buildExportWhere(dto) });
|
||||
}
|
||||
|
||||
private normalizeExportFilter(dto: AdminOrdersExportDto): OrderFilterInput {
|
||||
const createdFrom = dto.createdFrom;
|
||||
const createdTo = dto.createdTo;
|
||||
if (!createdFrom && !createdTo) {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 30);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
...dto,
|
||||
createdFrom: from.toISOString().slice(0, 10),
|
||||
createdTo: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
return { ...dto, createdFrom, createdTo };
|
||||
}
|
||||
|
||||
private endOfDay(dateStr: string): Date {
|
||||
const end = new Date(dateStr);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return end;
|
||||
}
|
||||
|
||||
async getOrderTrack(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
@@ -93,6 +93,66 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
/** HQ 订单导出(筛选 + 勾选范围) */
|
||||
export class AdminOrdersExportDto {
|
||||
@IsIn(['filter', 'selected'])
|
||||
scope: 'filter' | 'selected';
|
||||
|
||||
@IsIn(['xlsx', 'pdf'])
|
||||
format: 'xlsx' | 'pdf';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ids?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
|
||||
orderType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
fulfillmentHold?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
|
||||
Reference in New Issue
Block a user