From c643b0b9791840835c0b8f5e262d5518cae20f7b Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 08:45:33 +0800 Subject: [PATCH] =?UTF-8?q?webadmin=E7=AB=AF=E8=AE=A2=E5=8D=95=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/api.ts | 23 +- apps/admin-web/src/pages/OrdersPage.tsx | 500 +++++++++++++----- .../fulfillment/fulfillment.service.ts | 6 +- .../src/modules/ops/admin-orders.service.ts | 40 +- .../src/modules/ops/dto/admin-mutate.dto.ts | 5 + .../src/modules/ops/dto/admin-query.dto.ts | 4 + 6 files changed, 448 insertions(+), 130 deletions(-) diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 04fa6fa..7950ddb 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -121,15 +121,36 @@ export type AdminUserRow = { orderCount: number; }; +export type AdminOrderItem = { + productName: string; + productSpec: string; + productImage: string; + unitPrice: number; + quantity: number; +}; + export type AdminOrderRow = { id: string; orderNo: string; status: string; deliveryType: string; payAmount: number; + productName?: string; + productSpec?: string; + quantity?: number; receiverName: string; receiverPhone: string; createdAt: string; + cityId?: string; + city?: { id: string; name: string; code: string }; + fulfillmentWarehouseId?: string | null; + fulfillmentWarehouse?: { id: string; name: string } | null; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; - delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null }; + delivery?: { + provider: string; + trackingNo: string | null; + providerOrderNo: string | null; + logisticsCompany?: string | null; + manualQueryUrl?: string | null; + }; }; diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 405b0c6..505c314 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -9,6 +9,7 @@ import { Input, InputNumber, Modal, + Radio, Select, Space, Table, @@ -17,8 +18,14 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { request, type AdminOrderRow, type Paginated } from '../lib/api'; -import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; +import { + ADMIN_OPTIONS_PAGE_SIZE, + DELIVERY_PROVIDER_LABELS, + ORDER_STATUS_COLORS, + ORDER_STATUS_LABELS, + fmtTime, +} from '../lib/constants'; type ShipDefaults = { provider: string; @@ -33,6 +40,19 @@ type ShipDefaults = { payMode: string; }; +type CityOption = { id: string; name: string; code: string }; + +type WarehouseOption = { + id: string; + name: string; + status?: string; + contactName: string; + contactPhone: string; + address: string; + lng?: number | null; + lat?: number | null; +}; + type OrderDetail = AdminOrderRow & { receiverAddress?: string; receiverProvince?: string; @@ -51,17 +71,58 @@ type OrderDetail = AdminOrderRow & { productAmount?: number; freightAmount?: number; benefitAmount?: number; - deliveryType?: string; - fulfillmentWarehouseId?: string | null; paidAt?: string | null; payExpireAt?: string | null; - items?: Array>; + productImage?: string; + listUnitPrice?: number; + items?: AdminOrderItem[]; payment?: Record | null; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; benefitCoupons?: Array>; - city?: { name: string; code: string }; + fulfillmentWarehouse?: { + id: string; + name: string; + contactName?: string; + contactPhone?: string; + address?: string; + lng?: number | null; + lat?: number | null; + } | null; }; +type ShipMode = 'WAREHOUSE' | 'EXPRESS'; + +function orderProductRows(detail: OrderDetail): AdminOrderItem[] { + if (detail.items?.length) return detail.items; + if (!detail.productName) return []; + return [{ + productName: detail.productName, + productSpec: detail.productSpec ?? '', + productImage: detail.productImage ?? '', + unitPrice: Number(detail.listUnitPrice ?? 0), + quantity: detail.quantity ?? 1, + }]; +} + +function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) { + return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo; +} + +function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults { + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: wh.contactName || base?.fromName || '杜康仓库', + fromMobile: wh.contactPhone || base?.fromMobile || '13800000000', + fromAddress: wh.address || base?.fromAddress || '', + fromAddressDetail: wh.name || base?.fromAddressDetail || '', + fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665), + fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757), + weight: base?.weight ?? 2, + payMode: base?.payMode ?? '1', + }; +} + export default function OrdersPage() { const [form] = Form.useForm(); const [shipForm] = Form.useForm(); @@ -78,6 +139,11 @@ export default function OrdersPage() { const [shipDefaults, setShipDefaults] = useState(null); const [shipping, setShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false); + const [cities, setCities] = useState([]); + const [shipModalOpen, setShipModalOpen] = useState(false); + const [shipTarget, setShipTarget] = useState(null); + const [shipMode, setShipMode] = useState('EXPRESS'); + const [warehouses, setWarehouses] = useState([]); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -91,6 +157,7 @@ export default function OrdersPage() { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.status) qs.set('status', values.status); + if (values.cityId) qs.set('cityId', values.cityId); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); const res = await request>(`/admin/orders?${qs}`); setData(res); @@ -105,40 +172,82 @@ export default function OrdersPage() { useEffect(() => { void request('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {}); + void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items ?? [])) + .catch(() => {}); }, []); + function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) { + shipForm.setFieldsValue({ + warehouseId: warehouseId || undefined, + 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 loadWarehousesForOrder(order: OrderDetail) { + const cityId = order.city?.id || order.cityId; + if (!cityId) { + setWarehouses([]); + return [] as WarehouseOption[]; + } + const rows = await request(`/admin/cities/${cityId}/warehouses`).catch(() => []); + const active = rows.filter((w) => !w.status || w.status === 'ACTIVE'); + setWarehouses(active); + return active; + } + 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); + } + + async function openShipModal(id: string) { + const res = await request(`/admin/orders/${id}`); + setShipTarget(res); + setDetail(res); + logisticsForm.resetFields(); + const rows = await loadWarehousesForOrder(res); + const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id || rows[0]?.id; + const preferred = rows.find((w) => w.id === preferredWarehouseId) || rows[0]; + const defaults = preferred + ? warehouseToDefaults(preferred, shipDefaults) + : (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, - }); + applyShipDefaults(defaults, preferred?.id); } + setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS'); + setShipModalOpen(true); + } + + async function onWarehouseChange(warehouseId: string) { + const wh = warehouses.find((w) => w.id === warehouseId); + if (!wh) return; + applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId); } async function submitShip() { - if (!detail) return; + if (!shipTarget) return; const values = await shipForm.validateFields(); setShipping(true); try { - const res = await request(`/admin/orders/${detail.id}/ship`, { + const res = await request(`/admin/orders/${shipTarget.id}/ship`, { method: 'POST', body: JSON.stringify(values), }); - message.success('发货成功'); + message.success('仓配发货成功'); + setShipTarget(res); setDetail(res); + setShipModalOpen(false); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '发货失败'); @@ -148,16 +257,18 @@ export default function OrdersPage() { } async function submitLogisticsShip() { - if (!detail) return; + if (!shipTarget) return; const values = await logisticsForm.validateFields(); setLogisticsShipping(true); try { - const res = await request(`/admin/orders/${detail.id}/logistics-ship`, { + const res = await request(`/admin/orders/${shipTarget.id}/logistics-ship`, { method: 'POST', body: JSON.stringify(values), }); message.success('快递单已录入'); + setShipTarget(res); setDetail(res); + setShipModalOpen(false); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '填单失败'); @@ -191,6 +302,25 @@ export default function OrdersPage() { const columns: ColumnsType = [ { title: '订单号', dataIndex: 'orderNo', width: 180 }, + { + title: '城市', + width: 90, + render: (_, row) => row.city?.name || '—', + }, + { + title: '商品', + width: 180, + ellipsis: true, + render: (_, row) => { + const name = row.productName || '—'; + const qty = row.quantity != null ? ` ×${row.quantity}` : ''; + return ( + + {name}{qty} + + ); + }, + }, { title: '状态', dataIndex: 'status', @@ -227,11 +357,19 @@ export default function OrdersPage() { }, { title: '操作', - width: 80, + width: 140, + fixed: 'right', render: (_, row) => ( - + + + {canShip(row) && ( + + )} + ), }, ]; @@ -255,6 +393,16 @@ export default function OrdersPage() { ({ value: c.id, label: c.name }))} + /> + @@ -271,7 +419,7 @@ export default function OrdersPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 1500 }} rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as string[]), @@ -295,6 +443,11 @@ export default function OrdersPage() { onClose={() => setDrawerOpen(false)} extra={detail && ( + {canShip(detail) && ( + + )} 调试改状态 - - - - - - - - - - - )} - - {detail.fulfillmentWarehouseId && ( - <> - - 仓配小飞侠重试 - - - 仓配订单通常支付后自动推单;失败时可手动重试 - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - }]} - /> - - - - )} - - )} - {detail.statusLogs && detail.statusLogs.length > 0 && ( <> 状态流转 @@ -476,6 +588,134 @@ export default function OrdersPage() { )} + setShipModalOpen(false)} + width={560} + destroyOnClose + footer={ + + + {shipMode === 'WAREHOUSE' ? ( + + ) : ( + + )} + + } + > + {shipTarget && ( + <> + + {shipTarget.city?.name || '—'} + + {shipTarget.receiverName} {shipTarget.receiverPhone} + + + {shipTarget.productName || '—'} + {shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''} + + + + setShipMode(e.target.value as ShipMode)} + optionType="button" + buttonStyle="solid" + style={{ marginBottom: 16 }} + options={[ + { value: 'WAREHOUSE', label: '仓库配送(小飞侠)' }, + { value: 'EXPRESS', label: '填写快递单号' }, + ]} + /> + + {shipMode === 'WAREHOUSE' ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }]} + /> +
+ ) : ( +
+ + 与仓库端手动发货一致:填写快递公司与运单号即可完成发货 + + + + + + + + + + +
+ )} + + )} +
+ row.city?.name || '—', + }, + { + title: '商品', + width: 160, + ellipsis: true, + render: (_, row) => + row.productName + ? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}` + : '—', + }, { title: '状态', dataIndex: 'status', width: 100, render: (s) => ( - {ORDER_STATUS_LABELS[s] || s} - ), + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index df0a52c..6915a83 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -178,11 +178,7 @@ export class FulfillmentService { }); if (!order) throw new NotFoundException('订单不存在'); - const isHqQueue = - order.deliveryType === 'CROSS_CITY' || - (order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId); - - if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货'); + // HQ 可对任意待发货单填快递单号(含仓配单手动填单) if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { throw new BadRequestException('当前订单状态不可发货'); } 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 6fa57d0..724b34e 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -30,6 +30,7 @@ export class AdminOrdersService { if (query.orderNo) where.orderNo = { contains: query.orderNo }; if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['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.createdFrom || query.createdTo) { where.createdAt = {}; @@ -46,6 +47,8 @@ export class AdminOrdersService { include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } }, + city: { select: { id: true, name: true, code: true } }, + fulfillmentWarehouse: { select: { id: true, name: true } }, }, }), this.prisma.order.count({ where }), @@ -75,6 +78,18 @@ export class AdminOrdersService { city: { select: { id: true, name: true, code: true } }, product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, imageResource: { select: { id: true, url: true } }, + fulfillmentWarehouse: { + select: { + id: true, + name: true, + contactName: true, + contactPhone: true, + address: true, + lng: true, + lat: true, + fulfillmentMode: true, + }, + }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -101,7 +116,7 @@ export class AdminOrdersService { throw new BadRequestException('暂仅支持小飞侠配送'); } - const order = await this.prisma.order.findUnique({ + let order = await this.prisma.order.findUnique({ where: { id }, include: { delivery: true, @@ -117,7 +132,30 @@ export class AdminOrdersService { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } + if (dto.warehouseId) { + const warehouseId = BigInt(dto.warehouseId); + const warehouseRow = await this.prisma.cityWarehouse.findFirst({ + where: { id: warehouseId, cityId: order.cityId, status: 'ACTIVE' }, + }); + if (!warehouseRow) { + throw new BadRequestException('仓库不存在或不属于该订单开城城市'); + } + if (order.fulfillmentWarehouseId !== warehouseId) { + await this.prisma.order.update({ + where: { id }, + data: { fulfillmentWarehouseId: warehouseId }, + }); + order = await this.prisma.order.findUniqueOrThrow({ + where: { id }, + include: { delivery: true, fulfillmentWarehouse: true }, + }); + } + } + const warehouse = order.fulfillmentWarehouse; + if (!warehouse) { + throw new BadRequestException('请先选择履约仓库'); + } const providerId = order.delivery?.fulfillmentProviderId ?? warehouse?.fulfillmentProviderId ?? 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 a1ede94..e8fe9c5 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 @@ -711,6 +711,11 @@ export class AdminShipOrderDto { @IsIn(['XFX']) provider: string; + /** 可选:指定/改派履约仓后再推仓配 */ + @IsOptional() + @IsString() + warehouseId?: string; + @IsOptional() @IsString() fromName?: string; diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index b9e9021..d1a0199 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto { @IsString() userId?: string; + @IsOptional() + @IsString() + cityId?: string; + @IsOptional() @IsString() receiverPhone?: string;