webadmin端订单修改
This commit is contained in:
@@ -121,15 +121,36 @@ export type AdminUserRow = {
|
|||||||
orderCount: number;
|
orderCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminOrderItem = {
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
productImage: string;
|
||||||
|
unitPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminOrderRow = {
|
export type AdminOrderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
status: string;
|
status: string;
|
||||||
deliveryType: string;
|
deliveryType: string;
|
||||||
payAmount: number;
|
payAmount: number;
|
||||||
|
productName?: string;
|
||||||
|
productSpec?: string;
|
||||||
|
quantity?: number;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
createdAt: 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 };
|
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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -17,8 +18,14 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||||
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import {
|
||||||
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
ORDER_STATUS_COLORS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
fmtTime,
|
||||||
|
} from '../lib/constants';
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -33,6 +40,19 @@ type ShipDefaults = {
|
|||||||
payMode: string;
|
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 & {
|
type OrderDetail = AdminOrderRow & {
|
||||||
receiverAddress?: string;
|
receiverAddress?: string;
|
||||||
receiverProvince?: string;
|
receiverProvince?: string;
|
||||||
@@ -51,17 +71,58 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
productAmount?: number;
|
productAmount?: number;
|
||||||
freightAmount?: number;
|
freightAmount?: number;
|
||||||
benefitAmount?: number;
|
benefitAmount?: number;
|
||||||
deliveryType?: string;
|
|
||||||
fulfillmentWarehouseId?: string | null;
|
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
payExpireAt?: string | null;
|
payExpireAt?: string | null;
|
||||||
items?: Array<Record<string, unknown>>;
|
productImage?: string;
|
||||||
|
listUnitPrice?: number;
|
||||||
|
items?: AdminOrderItem[];
|
||||||
payment?: Record<string, unknown> | null;
|
payment?: Record<string, unknown> | null;
|
||||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||||
benefitCoupons?: Array<Record<string, unknown>>;
|
benefitCoupons?: Array<Record<string, unknown>>;
|
||||||
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() {
|
export default function OrdersPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
@@ -78,6 +139,11 @@ export default function OrdersPage() {
|
|||||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||||
const [shipping, setShipping] = useState(false);
|
const [shipping, setShipping] = useState(false);
|
||||||
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [shipModalOpen, setShipModalOpen] = useState(false);
|
||||||
|
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
|
||||||
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (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) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
if (values.status) qs.set('status', values.status);
|
if (values.status) qs.set('status', values.status);
|
||||||
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
@@ -105,16 +172,14 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${id}`);
|
|
||||||
setDetail(res);
|
|
||||||
setDrawerOpen(true);
|
|
||||||
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
|
|
||||||
if (defaults) {
|
|
||||||
setShipDefaults(defaults);
|
|
||||||
shipForm.setFieldsValue({
|
shipForm.setFieldsValue({
|
||||||
|
warehouseId: warehouseId || undefined,
|
||||||
provider: defaults.provider,
|
provider: defaults.provider,
|
||||||
weight: defaults.weight,
|
weight: defaults.weight,
|
||||||
payMode: defaults.payMode,
|
payMode: defaults.payMode,
|
||||||
@@ -126,19 +191,63 @@ export default function OrdersPage() {
|
|||||||
fromLat: defaults.fromLat,
|
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<WarehouseOption[]>(`/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<OrderDetail>(`/admin/orders/${id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openShipModal(id: string) {
|
||||||
|
const res = await request<OrderDetail>(`/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<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null));
|
||||||
|
if (defaults) {
|
||||||
|
setShipDefaults(defaults);
|
||||||
|
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() {
|
async function submitShip() {
|
||||||
if (!detail) return;
|
if (!shipTarget) return;
|
||||||
const values = await shipForm.validateFields();
|
const values = await shipForm.validateFields();
|
||||||
setShipping(true);
|
setShipping(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify(values),
|
||||||
});
|
});
|
||||||
message.success('发货成功');
|
message.success('仓配发货成功');
|
||||||
|
setShipTarget(res);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
void load();
|
void load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '发货失败');
|
message.error(e instanceof Error ? e.message : '发货失败');
|
||||||
@@ -148,16 +257,18 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitLogisticsShip() {
|
async function submitLogisticsShip() {
|
||||||
if (!detail) return;
|
if (!shipTarget) return;
|
||||||
const values = await logisticsForm.validateFields();
|
const values = await logisticsForm.validateFields();
|
||||||
setLogisticsShipping(true);
|
setLogisticsShipping(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/logistics-ship`, {
|
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/logistics-ship`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify(values),
|
||||||
});
|
});
|
||||||
message.success('快递单已录入');
|
message.success('快递单已录入');
|
||||||
|
setShipTarget(res);
|
||||||
setDetail(res);
|
setDetail(res);
|
||||||
|
setShipModalOpen(false);
|
||||||
void load();
|
void load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '填单失败');
|
message.error(e instanceof Error ? e.message : '填单失败');
|
||||||
@@ -191,6 +302,25 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
{ 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 (
|
||||||
|
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
||||||
|
{name}{qty}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -227,11 +357,19 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
|
{canShip(row) && (
|
||||||
|
<Button type="link" size="small" onClick={() => void openShipModal(row.id)}>
|
||||||
|
配送
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -255,6 +393,16 @@ export default function OrdersPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="receiverPhone" label="收货手机">
|
<Form.Item name="receiverPhone" label="收货手机">
|
||||||
<Input allowClear />
|
<Input allowClear />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -271,7 +419,7 @@ export default function OrdersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1500 }}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
@@ -295,6 +443,11 @@ export default function OrdersPage() {
|
|||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Space>
|
<Space>
|
||||||
|
{canShip(detail) && (
|
||||||
|
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
|
||||||
|
配送发货
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
<Typography.Text type="secondary">调试改状态</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={detail.status}
|
value={detail.status}
|
||||||
@@ -318,6 +471,13 @@ export default function OrdersPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" title="基本信息">
|
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||||
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开城城市">
|
||||||
|
{detail.city?.name || '—'}
|
||||||
|
{detail.city?.code ? `(${detail.city.code})` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="履约仓">
|
||||||
|
{detail.fulfillmentWarehouse?.name || '未分配'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||||
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||||
@@ -330,6 +490,51 @@ export default function OrdersPage() {
|
|||||||
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>商品</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
dataSource={orderProductRows(detail)}
|
||||||
|
locale={{ emptyText: '无商品信息' }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
render: (name: string, row) => (
|
||||||
|
<Space>
|
||||||
|
{row.productImage ? (
|
||||||
|
<img
|
||||||
|
src={row.productImage}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{name || '—'}
|
||||||
|
{row.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{row.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'unitPrice',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
|
||||||
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
|
||||||
@@ -363,44 +568,87 @@ export default function OrdersPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
{(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
|
|
||||||
<>
|
<>
|
||||||
<Typography.Title level={5}>总部快递填单</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
<Table
|
||||||
适用于跨城订单或同城无仓订单
|
size="small"
|
||||||
</Typography.Paragraph>
|
rowKey="createdAt"
|
||||||
<Form form={logisticsForm} layout="vertical" size="small">
|
pagination={false}
|
||||||
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true }]}>
|
dataSource={detail.statusLogs}
|
||||||
<Input placeholder="如 顺丰速运、京东物流" />
|
columns={[
|
||||||
</Form.Item>
|
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||||
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true }]}>
|
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
||||||
<Input />
|
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||||
</Form.Item>
|
]}
|
||||||
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
/>
|
||||||
<Input placeholder="https://..." />
|
</>
|
||||||
</Form.Item>
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
|
||||||
|
open={shipModalOpen}
|
||||||
|
onCancel={() => setShipModalOpen(false)}
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setShipModalOpen(false)}>取消</Button>
|
||||||
|
{shipMode === 'WAREHOUSE' ? (
|
||||||
|
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
||||||
|
确认仓配发货
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
||||||
提交快递单
|
提交快递单
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</Space>
|
||||||
{detail.fulfillmentWarehouseId && (
|
}
|
||||||
|
>
|
||||||
|
{shipTarget && (
|
||||||
<>
|
<>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
|
||||||
仓配小飞侠重试
|
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
|
||||||
</Typography.Title>
|
<Descriptions.Item label="收货">
|
||||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
{shipTarget.receiverName} {shipTarget.receiverPhone}
|
||||||
仓配订单通常支付后自动推单;失败时可手动重试
|
</Descriptions.Item>
|
||||||
</Typography.Paragraph>
|
<Descriptions.Item label="商品">
|
||||||
<Form form={shipForm} layout="vertical" size="small">
|
{shipTarget.productName || '—'}
|
||||||
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
{shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''}
|
||||||
<Select
|
</Descriptions.Item>
|
||||||
options={[{ value: 'XFX', label: '小飞侠' }]}
|
</Descriptions>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
value={shipMode}
|
||||||
|
onChange={(e) => setShipMode(e.target.value as ShipMode)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'WAREHOUSE', label: '仓库配送(小飞侠)' },
|
||||||
|
{ value: 'EXPRESS', label: '填写快递单号' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{shipMode === 'WAREHOUSE' ? (
|
||||||
|
<Form form={shipForm} layout="vertical" size="small">
|
||||||
|
<Form.Item
|
||||||
|
name="warehouseId"
|
||||||
|
label="履约仓库"
|
||||||
|
rules={[{ required: true, message: '请选择仓库' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={warehouses.length ? '选择仓库' : '该城市暂无仓库'}
|
||||||
|
options={warehouses.map((w) => ({ value: w.id, label: w.name }))}
|
||||||
|
onChange={(id) => void onWarehouseChange(id)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="provider" label="承运商" rules={[{ required: true }]} initialValue="XFX">
|
||||||
|
<Select options={[{ value: 'XFX', label: '小飞侠' }]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
@@ -447,34 +695,26 @@ export default function OrdersPage() {
|
|||||||
),
|
),
|
||||||
}]}
|
}]}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
|
|
||||||
调用小飞侠发货
|
|
||||||
</Button>
|
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
) : (
|
||||||
)}
|
<Form form={logisticsForm} layout="vertical" size="small">
|
||||||
</div>
|
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
|
||||||
)}
|
与仓库端手动发货一致:填写快递公司与运单号即可完成发货
|
||||||
|
</Typography.Paragraph>
|
||||||
{detail.statusLogs && detail.statusLogs.length > 0 && (
|
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true, message: '请填写快递公司' }]}>
|
||||||
<>
|
<Input placeholder="如 顺丰速运、京东物流" />
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
</Form.Item>
|
||||||
<Table
|
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true, message: '请填写运单号' }]}>
|
||||||
size="small"
|
<Input />
|
||||||
rowKey="createdAt"
|
</Form.Item>
|
||||||
pagination={false}
|
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
||||||
dataSource={detail.statusLogs}
|
<Input placeholder="https://..." />
|
||||||
columns={[
|
</Form.Item>
|
||||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
</Form>
|
||||||
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
|
||||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
title={`确认批量删除(${selectedOrders.length} 笔)`}
|
||||||
@@ -501,6 +741,20 @@ export default function OrdersPage() {
|
|||||||
dataSource={selectedOrders}
|
dataSource={selectedOrders}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => row.city?.name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.productName
|
||||||
|
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
|||||||
@@ -178,11 +178,7 @@ export class FulfillmentService {
|
|||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
|
||||||
const isHqQueue =
|
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
|
||||||
order.deliveryType === 'CROSS_CITY' ||
|
|
||||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
|
||||||
|
|
||||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
|
||||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||||
throw new BadRequestException('当前订单状态不可发货');
|
throw new BadRequestException('当前订单状态不可发货');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class AdminOrdersService {
|
|||||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||||
if (query.userId) where.userId = BigInt(query.userId);
|
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.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||||
if (query.createdFrom || query.createdTo) {
|
if (query.createdFrom || query.createdTo) {
|
||||||
where.createdAt = {};
|
where.createdAt = {};
|
||||||
@@ -46,6 +47,8 @@ export class AdminOrdersService {
|
|||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: 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 }),
|
this.prisma.order.count({ where }),
|
||||||
@@ -75,6 +78,18 @@ export class AdminOrdersService {
|
|||||||
city: { select: { id: true, name: true, code: true } },
|
city: { select: { id: true, name: true, code: true } },
|
||||||
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
|
product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
|
||||||
imageResource: { select: { id: true, url: 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('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -101,7 +116,7 @@ export class AdminOrdersService {
|
|||||||
throw new BadRequestException('暂仅支持小飞侠配送');
|
throw new BadRequestException('暂仅支持小飞侠配送');
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await this.prisma.order.findUnique({
|
let order = await this.prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
delivery: true,
|
delivery: true,
|
||||||
@@ -117,7 +132,30 @@ export class AdminOrdersService {
|
|||||||
throw new BadRequestException('该订单已有运单号,请勿重复发货');
|
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;
|
const warehouse = order.fulfillmentWarehouse;
|
||||||
|
if (!warehouse) {
|
||||||
|
throw new BadRequestException('请先选择履约仓库');
|
||||||
|
}
|
||||||
const providerId =
|
const providerId =
|
||||||
order.delivery?.fulfillmentProviderId ??
|
order.delivery?.fulfillmentProviderId ??
|
||||||
warehouse?.fulfillmentProviderId ??
|
warehouse?.fulfillmentProviderId ??
|
||||||
|
|||||||
@@ -711,6 +711,11 @@ export class AdminShipOrderDto {
|
|||||||
@IsIn(['XFX'])
|
@IsIn(['XFX'])
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
||||||
|
/** 可选:指定/改派履约仓后再推仓配 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
warehouseId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cityId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
receiverPhone?: string;
|
receiverPhone?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user