diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index f5f82ef..f098bcf 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -12,6 +12,7 @@ import PartnerAccountsPage from './pages/PartnerAccountsPage'; import BenefitCouponsPage from './pages/BenefitCouponsPage'; import BenefitLedgersPage from './pages/BenefitLedgersPage'; import RedeemRecordsPage from './pages/RedeemRecordsPage'; +import RedeemDebugPage from './pages/RedeemDebugPage'; import DeliveriesPage from './pages/DeliveriesPage'; import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage'; import HqAccountsPage from './pages/HqAccountsPage'; @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index b8e0a03..8ea19ca 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -61,6 +61,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/benefit/coupons', label: '权益券' }, { key: '/benefit/ledgers', label: '流水' }, { key: '/redeem-records', label: '核销记录' }, + { key: '/redeem/debug', label: '核销调试' }, { key: '/store-payouts', label: '门店打款' }, ], }, diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index cbcbd90..88d6ac5 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -1,6 +1,12 @@ /** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */ export const ADMIN_OPTIONS_PAGE_SIZE = 100; +export const DELIVERY_PROVIDER_LABELS: Record = { + XFX: '小飞侠', + LOGISTICS: '物流快递', + MANUAL: '手动', +}; + export const ORDER_STATUS_LABELS: Record = { PENDING_PAY: '待付款', PENDING_SHIP: '待发货', diff --git a/apps/admin-web/src/lib/product-detail-templates.ts b/apps/admin-web/src/lib/product-detail-templates.ts index 95e49f2..8115596 100644 --- a/apps/admin-web/src/lib/product-detail-templates.ts +++ b/apps/admin-web/src/lib/product-detail-templates.ts @@ -1,5 +1,5 @@ /** 商品详情页文案模板(Admin 一键套用) */ -export const TEMPLATE_MAX_DETAIL_IMAGES = 20; +export const TEMPLATE_MAX_DETAIL_IMAGES = 30; export type ProductDetailTemplateContent = { storyTitle?: string; diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 4a5ed7b..ebc0c2f 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -1,10 +1,14 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, Button, + Collapse, Descriptions, Drawer, Form, Input, + InputNumber, + Modal, Select, Space, Table, @@ -14,7 +18,20 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderRow, type Paginated } from '../lib/api'; -import { ORDER_STATUS_LABELS } from '../lib/constants'; +import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; + +type ShipDefaults = { + provider: string; + providerLabel: string; + fromName: string; + fromMobile: string; + fromAddress: string; + fromAddressDetail: string; + fromLng: number; + fromLat: number; + weight: number; + payMode: string; +}; type OrderDetail = AdminOrderRow & { receiverAddress?: string; @@ -45,12 +62,23 @@ type OrderDetail = AdminOrderRow & { export default function OrdersPage() { const [form] = Form.useForm(); + const [shipForm] = Form.useForm(); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); + const [batchDeleting, setBatchDeleting] = useState(false); + const [shipDefaults, setShipDefaults] = useState(null); + const [shipping, setShipping] = useState(false); + + const selectedOrders = useMemo( + () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), + [data?.items, selectedRowKeys], + ); const load = useCallback(async () => { setLoading(true); @@ -71,10 +99,71 @@ export default function OrdersPage() { void load(); }, [load]); + useEffect(() => { + void request('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {}); + }, []); + async function openDetail(id: string) { const res = await request(`/admin/orders/${id}`); setDetail(res); setDrawerOpen(true); + const defaults = shipDefaults ?? await request('/admin/orders/ship-defaults').catch(() => null); + if (defaults) { + setShipDefaults(defaults); + shipForm.setFieldsValue({ + provider: defaults.provider, + weight: defaults.weight, + payMode: defaults.payMode, + fromName: defaults.fromName, + fromMobile: defaults.fromMobile, + fromAddress: defaults.fromAddress, + fromAddressDetail: defaults.fromAddressDetail, + fromLng: defaults.fromLng, + fromLat: defaults.fromLat, + }); + } + } + + async function submitShip() { + if (!detail) return; + const values = await shipForm.validateFields(); + setShipping(true); + try { + const res = await request(`/admin/orders/${detail.id}/ship`, { + method: 'POST', + body: JSON.stringify(values), + }); + message.success('发货成功'); + setDetail(res); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '发货失败'); + } finally { + setShipping(false); + } + } + + async function confirmBatchDelete() { + if (!selectedRowKeys.length) return; + setBatchDeleting(true); + try { + const res = await request<{ deleted: number; message: string }>('/admin/orders/batch-delete', { + method: 'POST', + body: JSON.stringify({ ids: selectedRowKeys }), + }); + message.success(res.message || `已删除 ${res.deleted} 笔订单`); + setBatchDeleteOpen(false); + setSelectedRowKeys([]); + if (detail && selectedRowKeys.includes(detail.id)) { + setDrawerOpen(false); + setDetail(null); + } + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '批量删除失败'); + } finally { + setBatchDeleting(false); + } } const columns: ColumnsType = [ @@ -124,7 +213,16 @@ export default function OrdersPage() { return (
- 订单监控 + + 订单监控 + +
{ setPage(1); void load(); }}> @@ -149,6 +247,10 @@ export default function OrdersPage() { columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} + rowSelection={{ + selectedRowKeys, + onChange: (keys) => setSelectedRowKeys(keys as string[]), + }} pagination={{ current: page, pageSize, @@ -215,11 +317,75 @@ export default function OrdersPage() { {detail.delivery && ( - {detail.delivery.provider} + + {DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider} + {detail.delivery.trackingNo || '—'} + {detail.delivery.providerOrderNo || '—'} )} + {['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && ( +
+ 发货 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }]} + /> + + +
+ )} + {detail.statusLogs && detail.statusLogs.length > 0 && ( <> 状态流转 @@ -239,6 +405,44 @@ export default function OrdersPage() { )} + + void confirmBatchDelete()} + onCancel={() => setBatchDeleteOpen(false)} + width={720} + destroyOnClose + > + + {ORDER_STATUS_LABELS[s] || s}, + }, + { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, + { title: '收货人', dataIndex: 'receiverName', width: 90 }, + { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + ]} + /> + ); } diff --git a/apps/admin-web/src/pages/RedeemDebugPage.tsx b/apps/admin-web/src/pages/RedeemDebugPage.tsx new file mode 100644 index 0000000..5ca1924 --- /dev/null +++ b/apps/admin-web/src/pages/RedeemDebugPage.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { + Alert, Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message, +} from 'antd'; +import { request } from '../lib/api'; + +type ApiResult = Record; + +function ResultBox({ data }: { data: ApiResult | null }) { + if (!data) return 调用后在此显示结果; + return ( +
+      {JSON.stringify(data, null, 2)}
+    
+ ); +} + +export default function RedeemDebugPage() { + const [createForm] = Form.useForm(); + const [previewForm] = Form.useForm(); + const [confirmForm] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [createResult, setCreateResult] = useState(null); + const [previewResult, setPreviewResult] = useState(null); + const [confirmResult, setConfirmResult] = useState(null); + + async function invoke( + path: string, + body: unknown, + setResult: (v: ApiResult | null) => void, + successMsg: string, + ) { + setLoading(true); + setResult(null); + try { + const res = await request(path, { + method: 'POST', + body: JSON.stringify(body), + }); + setResult(res); + message.success(successMsg); + if (path.includes('create-token') && res.token) { + previewForm.setFieldsValue({ token: res.token }); + confirmForm.setFieldsValue({ token: res.token }); + } + } catch (e) { + message.error(e instanceof Error ? e.message : '调用失败'); + } finally { + setLoading(false); + } + } + + return ( +
+ 核销调试 + + + +
+ +
+ + + + + + + + + + + + + + +
+ + + + + + + + ), + }, + { + key: 'preview', + label: '2. 预览核销', + children: ( + + + +
+ + + + + + + + +
+ + + + + + + + ), + }, + { + key: 'confirm', + label: '3. 确认核销', + children: ( + + + +
+ + + + + + + + + + +
+ + + + + + + + ), + }, + ]} + /> + + ); +} diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index e087af5..694a078 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Alert, Button, + Checkbox, Descriptions, Drawer, Form, @@ -37,6 +38,28 @@ type UserDetail = AdminUserRow & { addressCount?: number; }; +type BatchDeletePreviewItem = { + id: string; + userNo: string; + nickname: string | null; + phone: string | null; + hasRisk: boolean; + unfinishedOrders: UserOrderRow[]; + redeemRecords: Array<{ + id: string; + redeemNo: string; + amount: number; + createdAt: string; + payoutStatus: string | null; + }>; +}; + +type BatchDeletePreview = { + items: BatchDeletePreviewItem[]; + hasRisk: boolean; + total: number; +}; + export default function UsersPage() { const navigate = useNavigate(); const [form] = Form.useForm(); @@ -49,6 +72,13 @@ export default function UsersPage() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(''); const [deleting, setDeleting] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); + const [batchDeleteStep, setBatchDeleteStep] = useState<1 | 2>(1); + const [batchPreview, setBatchPreview] = useState(null); + const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); + const [batchDeleting, setBatchDeleting] = useState(false); + const [batchRiskAck, setBatchRiskAck] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -99,6 +129,7 @@ export default function UsersPage() { setDeleteOpen(false); setDrawerOpen(false); setDetail(null); + setSelectedRowKeys((keys) => keys.filter((k) => k !== detail.id)); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '删除失败'); @@ -107,6 +138,60 @@ export default function UsersPage() { } } + async function openBatchDeleteModal() { + if (!selectedRowKeys.length) return; + setBatchDeleteStep(1); + setBatchRiskAck(false); + setBatchPreview(null); + setBatchDeleteOpen(true); + setBatchPreviewLoading(true); + try { + const res = await request('/admin/users/batch-delete/preview', { + method: 'POST', + body: JSON.stringify({ ids: selectedRowKeys }), + }); + setBatchPreview(res); + } catch (e) { + message.error(e instanceof Error ? e.message : '预检失败'); + setBatchDeleteOpen(false); + } finally { + setBatchPreviewLoading(false); + } + } + + async function confirmBatchDelete(confirmRisk: boolean) { + if (!selectedRowKeys.length) return; + setBatchDeleting(true); + try { + const res = await request<{ deleted: number; message: string }>('/admin/users/batch-delete', { + method: 'POST', + body: JSON.stringify({ ids: selectedRowKeys, confirmRisk }), + }); + message.success(res.message || `已删除 ${res.deleted} 名用户`); + setBatchDeleteOpen(false); + setBatchPreview(null); + if (detail && selectedRowKeys.includes(detail.id)) { + setDrawerOpen(false); + setDetail(null); + } + setSelectedRowKeys([]); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '批量删除失败'); + } finally { + setBatchDeleting(false); + } + } + + function handleBatchDeleteOk() { + if (!batchPreview) return; + if (batchPreview.hasRisk && batchDeleteStep === 1) { + setBatchDeleteStep(2); + return; + } + void confirmBatchDelete(batchPreview.hasRisk); + } + const orderColumns: ColumnsType = [ { title: '订单号', dataIndex: 'orderNo', width: 170 }, { @@ -177,7 +262,16 @@ export default function UsersPage() { return (
- 用户监控 + + 用户监控 + +
{ setPage(1); void load(); }}> @@ -214,6 +308,11 @@ export default function UsersPage() { columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} + rowSelection={{ + selectedRowKeys, + preserveSelectedRowKeys: true, + onChange: (keys) => setSelectedRowKeys(keys as string[]), + }} pagination={{ current: page, pageSize, @@ -338,6 +437,150 @@ export default function UsersPage() { )} + + handleBatchDeleteOk()} + onCancel={() => { + if (batchDeleteStep === 2) { + setBatchDeleteStep(1); + setBatchRiskAck(false); + return; + } + setBatchDeleteOpen(false); + }} + width={800} + destroyOnClose + > + {batchPreviewLoading && ( + 正在检查关联订单与核销记录… + )} + {!batchPreviewLoading && batchPreview && batchDeleteStep === 1 && ( + <> + +
row.hasRisk, + expandedRowRender: (row) => ( +
+ {row.unfinishedOrders.length > 0 && ( + <> + 未完成订单({row.unfinishedOrders.length}) +
+ + )} + {row.redeemRecords.length > 0 && ( + <> + 核销记录({row.redeemRecords.length}) +
`¥${v}` }, + { + title: '打款状态', + dataIndex: 'payoutStatus', + width: 100, + render: (v) => (v === 'PENDING' ? 待打款 : v === 'PAID' ? 已打款 : '—'), + }, + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + ]} + /> + + )} + + ), + }} + columns={[ + { title: '用户编号', dataIndex: 'userNo', width: 120 }, + { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, + { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, + { + title: '风险', + width: 200, + render: (_, row) => (row.hasRisk ? ( + + 需关注 + {row.unfinishedOrders.length > 0 && ( + 未完成订单 {row.unfinishedOrders.length} + )} + {row.redeemRecords.length > 0 && ( + 核销 {row.redeemRecords.length} + )} + + ) : ( + 无关联风险 + )), + }, + ]} + /> + + )} + {!batchPreviewLoading && batchPreview && batchDeleteStep === 2 && ( + <> + + 以下 {batchPreview.items.filter((i) => i.hasRisk).length} 名用户存在未完成订单或核销记录。 + 确认后将永久删除这些订单、核销记录、权益券及门店打款关联数据,且不可恢复。 + + )} + /> +
i.hasRisk)} + columns={[ + { title: '用户编号', dataIndex: 'userNo', width: 120 }, + { title: '未完成订单', width: 110, render: (_, row) => row.unfinishedOrders.length }, + { title: '核销记录', width: 90, render: (_, row) => row.redeemRecords.length }, + ]} + /> + setBatchRiskAck(e.target.checked)}> + 我确认删除上述用户的未完成订单、核销记录及相关业务数据 + + + )} + ); } diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 86061b8..98dfcc9 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -67,3 +67,10 @@ XIAOFEIXIA_MCH_ID= XIAOFEIXIA_API_KEY= XIAOFEIXIA_SIGN_TYPE=MD5 # XIAOFEIXIA_APP_ID= +# HQ 订单发货默认寄件信息 +SHIP_FROM_NAME=杜康仓库 +SHIP_FROM_MOBILE=13800000000 +SHIP_FROM_ADDRESS=河南省郑州市金水区 +SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓 +SHIP_FROM_LNG=113.665 +SHIP_FROM_LAT=34.757 diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts index 7bab823..f6f74bc 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { AdminOrdersService } from './admin-orders.service'; -import { UpdateOrderStatusDto } from './dto/admin-mutate.dto'; +import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto'; import { AdminOrdersQueryDto } from './dto/admin-query.dto'; @Controller('admin/orders') @@ -14,11 +15,28 @@ export class AdminOrdersController { return this.ordersService.list(query); } + @Post('batch-delete') + @UseGuards(SuperAdminGuard) + batchDelete(@Body() dto: BatchDeleteOrdersDto) { + return this.ordersService.batchDeleteOrders(dto.ids.map((id) => BigInt(id))); + } + + @Get('ship-defaults') + shipDefaults() { + return this.ordersService.getShipDefaults(); + } + @Get(':id') detail(@Param('id') id: string) { return this.ordersService.detail(BigInt(id)); } + /** HQ 发货:调用小飞侠创建运单并更新配送信息 */ + @Post(':id/ship') + ship(@Param('id') id: string, @Body() dto: AdminShipOrderDto) { + return this.ordersService.shipOrder(BigInt(id), dto); + } + /** preV1 调试:直接改订单状态,不走业务校验 */ @Put(':id/status') updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) { diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 7b974b8..8a1f0b9 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -1,17 +1,23 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; 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 { AdminShipOrderDto } from './dto/admin-mutate.dto'; +import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; @Injectable() export class AdminOrdersService { constructor( private readonly prisma: PrismaService, private readonly tradeService: TradeService, + private readonly xiaofeixiaService: AdminXiaofeixiaService, + private readonly config: ConfigService, ) {} async list(query: AdminOrdersQueryDto) { @@ -87,4 +93,156 @@ export class AdminOrdersService { await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG'); return this.detail(id); } + + getShipDefaults() { + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: this.config.get('SHIP_FROM_NAME') || '杜康仓库', + fromMobile: this.config.get('SHIP_FROM_MOBILE') || '13800000000', + fromAddress: this.config.get('SHIP_FROM_ADDRESS') || '河南省郑州市金水区', + fromAddressDetail: this.config.get('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓', + fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), + fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), + weight: 2, + payMode: '1', + }; + } + + async shipOrder(id: bigint, dto: AdminShipOrderDto) { + if (dto.provider !== 'XFX') { + throw new BadRequestException('暂仅支持小飞侠配送'); + } + + const order = await this.prisma.order.findUnique({ + where: { id }, + include: { delivery: true }, + }); + if (!order) throw new NotFoundException('订单不存在'); + + if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { + throw new BadRequestException('当前订单状态不可发货'); + } + if (order.delivery?.trackingNo) { + throw new BadRequestException('该订单已有运单号,请勿重复发货'); + } + + const defaults = this.getShipDefaults(); + const shipmentDto: XiaofeixiaCreateShipmentDto = { + outNumber: order.orderNo, + fromName: dto.fromName || defaults.fromName, + fromMobile: dto.fromMobile || defaults.fromMobile, + fromAddress: dto.fromAddress || defaults.fromAddress, + fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail, + fromLng: dto.fromLng ?? defaults.fromLng, + fromLat: dto.fromLat ?? defaults.fromLat, + toName: order.receiverName, + toMobile: order.receiverPhone, + toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, + toAddressDetail: order.receiverAddress, + goodsName: order.productName, + goodsNum: order.quantity, + weight: dto.weight ?? defaults.weight, + payMode: dto.payMode || defaults.payMode, + remark: dto.remark || `HQ发货 ${order.orderNo}`, + }; + + const result = await this.xiaofeixiaService.createShipment(shipmentDto); + if (!result.ok || !result.data) { + throw new BadRequestException(result.error || '小飞侠创建运单失败'); + } + + const { providerShipmentId, trackingNumber } = result.data; + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + if (order.delivery) { + await tx.orderDelivery.update({ + where: { orderId: id }, + data: { + provider: 'XFX', + trackingNo: trackingNumber, + providerOrderNo: String(providerShipmentId), + shippingAt: now, + }, + }); + } else { + await tx.orderDelivery.create({ + data: { + orderId: id, + provider: 'XFX', + trackingNo: trackingNumber, + providerOrderNo: String(providerShipmentId), + shippingAt: now, + }, + }); + } + }); + + await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP'); + return this.detail(id); + } + + async batchDeleteOrders(ids: bigint[]) { + const uniqueIds = [...new Set(ids)]; + if (!uniqueIds.length) { + return { ok: true, deleted: 0, message: '未选择订单' }; + } + + const orders = await this.prisma.order.findMany({ + where: { id: { in: uniqueIds } }, + select: { id: true, orderNo: true }, + }); + if (!orders.length) throw new NotFoundException('订单不存在'); + + const orderIds = orders.map((o) => o.id); + + await this.prisma.$transaction(async (tx) => { + await this.deleteOrdersInTx(tx, orderIds); + }); + + return { + ok: true, + deleted: orderIds.length, + orderNos: orders.map((o) => o.orderNo), + message: '订单及关联业务数据已删除,状态流转等业务日志已保留', + }; + } + + private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) { + if (!orderIds.length) return; + + const couponIds = ( + await tx.benefitCoupon.findMany({ + where: { orderId: { in: orderIds } }, + select: { id: true }, + }) + ).map((c) => c.id); + + if (couponIds.length) { + const redeemIds = ( + await tx.redeemRecord.findMany({ + where: { couponId: { in: couponIds } }, + select: { id: true }, + }) + ).map((r) => r.id); + + if (redeemIds.length) { + await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); + await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); + await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } }); + } + + await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } }); + } + + await tx.order.updateMany({ + where: { originOrderId: { in: orderIds } }, + data: { originOrderId: null }, + }); + await tx.commonTicket.deleteMany({ + where: { refType: 'ORDER', refId: { in: orderIds } }, + }); + await tx.order.deleteMany({ where: { id: { in: orderIds } } }); + } } diff --git a/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts index d648fa6..7005c33 100644 --- a/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts +++ b/server/dukang-api/src/modules/ops/admin-product-detail-templates.service.ts @@ -25,7 +25,7 @@ type TemplateRow = { updatedAt: Date; }; -const MAX_TEMPLATE_DETAIL_IMAGES = 20; +const MAX_TEMPLATE_DETAIL_IMAGES = 30; @Injectable() export class AdminProductDetailTemplatesService { diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts new file mode 100644 index 0000000..b560da5 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { AdminRedeemDebugService } from './admin-redeem-debug.service'; +import type { + AdminRedeemDebugCreateTokenDto, + AdminRedeemDebugStoreTokenDto, +} from './dto/admin-mutate.dto'; + +@Controller('admin/redeem/debug') +@UseGuards(HqAuthGuard) +export class AdminRedeemDebugController { + constructor(private readonly service: AdminRedeemDebugService) {} + + /** preV1 调试:为用户生成核销码 */ + @Post('create-token') + createToken(@Body() dto: AdminRedeemDebugCreateTokenDto) { + return this.service.createToken(dto); + } + + /** preV1 调试:门店侧预览核销 */ + @Post('preview') + preview(@Body() dto: AdminRedeemDebugStoreTokenDto) { + return this.service.preview(dto); + } + + /** preV1 调试:门店侧确认核销 */ + @Post('confirm') + confirm(@Body() dto: AdminRedeemDebugStoreTokenDto) { + return this.service.confirm(dto); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts new file mode 100644 index 0000000..a99478c --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { RedeemService } from '../redeem/redeem.service'; +import type { + AdminRedeemDebugCreateTokenDto, + AdminRedeemDebugStoreTokenDto, +} from './dto/admin-mutate.dto'; + +@Injectable() +export class AdminRedeemDebugService { + constructor( + private readonly prisma: PrismaService, + private readonly redeemService: RedeemService, + ) {} + + private async resolveStoreAccountId(storeId: string): Promise { + const account = await this.prisma.storeAccount.findFirst({ + where: { storeId: BigInt(storeId), status: 'ACTIVE' }, + orderBy: { id: 'asc' }, + select: { id: true, store: { select: { name: true } } }, + }); + if (!account) { + throw new NotFoundException('该门店无可用账户,请先创建门店账户'); + } + return account.id; + } + + async createToken(dto: AdminRedeemDebugCreateTokenDto) { + return this.redeemService.createToken(BigInt(dto.userId), { + amount: dto.amount, + couponId: dto.couponId, + storeId: dto.storeId, + }); + } + + async preview(dto: AdminRedeemDebugStoreTokenDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.previewRedeem(storeAccountId, dto.token); + } + + async confirm(dto: AdminRedeemDebugStoreTokenDto) { + const storeAccountId = await this.resolveStoreAccountId(dto.storeId); + return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token }); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts index 0acc807..54848ea 100644 --- a/server/dukang-api/src/modules/ops/admin-users.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Delete, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; import { AdminUsersService } from './admin-users.service'; import { AdminUsersQueryDto } from './dto/admin-query.dto'; +import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto'; @Controller('admin/users') @UseGuards(HqAuthGuard) @@ -14,6 +15,21 @@ export class AdminUsersController { return this.usersService.list(query); } + @Post('batch-delete/preview') + @UseGuards(SuperAdminGuard) + previewBatchDelete(@Body() dto: BatchDeleteUsersDto) { + return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id))); + } + + @Post('batch-delete') + @UseGuards(SuperAdminGuard) + batchDelete(@Body() dto: BatchDeleteUsersConfirmDto) { + return this.usersService.batchDeleteUsers( + dto.ids.map((id) => BigInt(id)), + dto.confirmRisk, + ); + } + @Get(':id') detail(@Param('id') id: string) { return this.usersService.detail(BigInt(id)); diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 18d3cff..9637995 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -1,9 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminUsersQueryDto } from './dto/admin-query.dto'; +const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const; + function mapAdminUserRow(u: { id: bigint; userNo: string; @@ -115,46 +117,108 @@ export class AdminUsersService { }); } + async previewBatchDelete(ids: bigint[]) { + const uniqueIds = [...new Set(ids)]; + if (!uniqueIds.length) { + return { items: [], hasRisk: false, total: 0 }; + } + + const users = await this.prisma.user.findMany({ + where: { id: { in: uniqueIds } }, + select: { + id: true, + userNo: true, + nickname: true, + phone: true, + orders: { + where: { status: { notIn: [...FINISHED_ORDER_STATUSES] } }, + select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }, + redeemRecords: { + select: { + id: true, + redeemNo: true, + amount: true, + createdAt: true, + payout: { select: { status: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const foundIds = new Set(users.map((u) => u.id.toString())); + const missing = uniqueIds.filter((id) => !foundIds.has(id.toString())); + if (missing.length) { + throw new NotFoundException('部分用户不存在'); + } + + const items = users.map((u) => { + const unfinishedOrders = u.orders; + const redeemRecords = u.redeemRecords.map((r) => ({ + id: r.id, + redeemNo: r.redeemNo, + amount: r.amount, + createdAt: r.createdAt, + payoutStatus: r.payout?.status ?? null, + })); + const hasRisk = unfinishedOrders.length > 0 || redeemRecords.length > 0; + return { + id: u.id, + userNo: u.userNo, + nickname: u.nickname, + phone: u.phone, + unfinishedOrders, + redeemRecords, + hasRisk, + }; + }); + + return serializeBigInt({ + items, + hasRisk: items.some((i) => i.hasRisk), + total: items.length, + }); + } + + async batchDeleteUsers(ids: bigint[], confirmRisk: boolean) { + const preview = await this.previewBatchDelete(ids); + if (preview.hasRisk && !confirmRisk) { + const riskyUsers = preview.items.filter((i) => i.hasRisk); + throw new BadRequestException({ + message: '所选用户存在未完成订单或核销记录,需二次确认后删除', + code: 'USER_DELETE_RISK', + riskyUsers: riskyUsers.map((u) => ({ + id: u.id, + userNo: u.userNo, + unfinishedOrderCount: u.unfinishedOrders.length, + redeemRecordCount: u.redeemRecords.length, + })), + }); + } + + const userIds = preview.items.map((i) => BigInt(String(i.id))); + await this.prisma.$transaction(async (tx) => { + for (const id of userIds) { + await this.deleteUserInTx(tx, id); + } + }); + + return { + ok: true, + deleted: userIds.length, + message: '用户及关联业务数据已删除,行为日志已保留', + }; + } + async deleteUser(id: bigint) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('用户不存在'); await this.prisma.$transaction(async (tx) => { - const orderIds = ( - await tx.order.findMany({ where: { userId: id }, select: { id: true } }) - ).map((o) => o.id); - - const redeemIds = ( - await tx.redeemRecord.findMany({ where: { userId: id }, select: { id: true } }) - ).map((r) => r.id); - - if (redeemIds.length) { - await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); - await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); - await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } }); - } - - await tx.benefitCoupon.deleteMany({ where: { userId: id } }); - - if (orderIds.length) { - await tx.order.updateMany({ - where: { originOrderId: { in: orderIds } }, - data: { originOrderId: null }, - }); - await tx.commonTicket.deleteMany({ - where: { refType: 'ORDER', refId: { in: orderIds } }, - }); - await tx.order.deleteMany({ where: { id: { in: orderIds } } }); - } - - if (user.avatarResourceId) { - await tx.commonResource.updateMany({ - where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id }, - data: { status: 'DELETED' }, - }); - } - - await tx.user.delete({ where: { id } }); + await this.deleteUserInTx(tx, id); }); return { @@ -162,4 +226,50 @@ export class AdminUsersService { message: '用户及关联业务数据已删除,行为日志已保留', }; } + + private async deleteUserInTx(tx: Prisma.TransactionClient, id: bigint) { + const user = await tx.user.findUnique({ where: { id } }); + if (!user) return; + + await tx.user.updateMany({ + where: { mergedIntoUserId: id }, + data: { mergedIntoUserId: null }, + }); + + const orderIds = ( + await tx.order.findMany({ where: { userId: id }, select: { id: true } }) + ).map((o) => o.id); + + const redeemIds = ( + await tx.redeemRecord.findMany({ where: { userId: id }, select: { id: true } }) + ).map((r) => r.id); + + if (redeemIds.length) { + await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); + await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); + await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } }); + } + + await tx.benefitCoupon.deleteMany({ where: { userId: id } }); + + if (orderIds.length) { + await tx.order.updateMany({ + where: { originOrderId: { in: orderIds } }, + data: { originOrderId: null }, + }); + await tx.commonTicket.deleteMany({ + where: { refType: 'ORDER', refId: { in: orderIds } }, + }); + await tx.order.deleteMany({ where: { id: { in: orderIds } } }); + } + + if (user.avatarResourceId) { + await tx.commonResource.updateMany({ + where: { id: user.avatarResourceId, ownerType: 'USER', ownerId: id }, + data: { status: 'DELETED' }, + }); + } + + await tx.user.delete({ where: { id } }); + } } diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index b7d58fd..d2dc4e2 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,4 +1,5 @@ -import { IsArray, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -307,6 +308,96 @@ export class UpdateOrderStatusDto { status: string; } +export class BatchDeleteOrdersDto { + @IsArray() + @IsString({ each: true }) + ids: string[]; +} + +export class BatchDeleteUsersDto { + @IsArray() + @IsString({ each: true }) + ids: string[]; +} + +export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto { + @IsBoolean() + confirmRisk: boolean; +} + +/** HQ 订单发货(目前仅小飞侠 XFX) */ +export class AdminShipOrderDto { + @IsIn(['XFX']) + provider: string; + + @IsOptional() + @IsString() + fromName?: string; + + @IsOptional() + @IsString() + fromMobile?: string; + + @IsOptional() + @IsString() + fromAddress?: string; + + @IsOptional() + @IsString() + fromAddressDetail?: string; + + @IsOptional() + @IsNumber() + fromLng?: number; + + @IsOptional() + @IsNumber() + fromLat?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.01) + weight?: number; + + @IsOptional() + @IsIn(['1', '2']) + payMode?: string; + + @IsOptional() + @IsString() + remark?: string; +} + +export class AdminRedeemDebugCreateTokenDto { + @IsString() + @IsNotEmpty() + userId: string; + + @Type(() => Number) + @IsNumber() + @Min(0.01) + amount: number; + + @IsOptional() + @IsString() + couponId?: string; + + @IsOptional() + @IsString() + storeId?: string; +} + +export class AdminRedeemDebugStoreTokenDto { + @IsString() + @IsNotEmpty() + storeId: string; + + @IsString() + @IsNotEmpty() + token: string; +} + export class UpdateDeliveryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index e89392b..c32711f 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -33,9 +33,12 @@ import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller'; import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service'; +import { RedeemModule } from '../redeem/redeem.module'; +import { AdminRedeemDebugController } from './admin-redeem-debug.controller'; +import { AdminRedeemDebugService } from './admin-redeem-debug.service'; @Module({ - imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule], + imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], controllers: [ AdminDashboardController, AdminUsersController, @@ -56,6 +59,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ AdminTicketsController, AdminXiaofeixiaController, AdminProductDetailTemplatesController, + AdminRedeemDebugController, ], providers: [ AdminDashboardService, @@ -73,6 +77,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ AdminTicketsService, AdminXiaofeixiaService, AdminProductDetailTemplatesService, + AdminRedeemDebugService, SuperAdminGuard, ], })