同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
@@ -74,6 +75,7 @@ export default function App() {
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
|
||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
|
||||
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WAREHOUSE_FULFILLMENT_MODE_LABELS,
|
||||
WAREHOUSE_MANAGER_LABELS,
|
||||
WAREHOUSE_STATUS_LABELS,
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
@@ -39,6 +43,13 @@ type Row = {
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
fulfillmentMode?: string;
|
||||
fulfillmentProviderId?: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -48,6 +59,54 @@ type PartnerOption = { id: string; companyName: string };
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
const FULFILLMENT_MODE_OPTIONS = Object.entries(WAREHOUSE_FULFILLMENT_MODE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
function FulfillmentFields({
|
||||
mode,
|
||||
providerOptions,
|
||||
}: {
|
||||
mode: WarehouseFulfillmentMode;
|
||||
providerOptions: FulfillmentProviderDto[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{mode === WarehouseFulfillmentMode.API_AUTO && (
|
||||
<Form.Item name="fulfillmentProviderId" label="仓配承运商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{mode === WarehouseFulfillmentMode.MANUAL && (
|
||||
<>
|
||||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||
<Input placeholder="如 顺丰速运" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="manualQueryUrlTemplate"
|
||||
label="物流查询链接模板"
|
||||
extra="可用 {trackingNo} 占位符"
|
||||
>
|
||||
<Input placeholder="https://example.com/track?no={trackingNo}" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Space>
|
||||
<Form.Item name="lng" label="经度">
|
||||
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
|
||||
</Form.Item>
|
||||
<Form.Item name="lat" label="纬度">
|
||||
<InputNumber step={0.001} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CityWarehousesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
@@ -73,6 +132,13 @@ export default function CityWarehousesPage() {
|
||||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
@@ -88,6 +154,9 @@ export default function CityWarehousesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
|
||||
.then(setProviderOptions)
|
||||
.catch(() => {});
|
||||
}, [loadCities]);
|
||||
|
||||
async function openEdit(row: Row) {
|
||||
@@ -102,7 +171,14 @@ export default function CityWarehousesPage() {
|
||||
managerType: row.managerType,
|
||||
partnerAccountId: row.partnerAccountId,
|
||||
status: row.status,
|
||||
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
|
||||
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
|
||||
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
|
||||
lng: row.lng ?? undefined,
|
||||
lat: row.lat ?? undefined,
|
||||
});
|
||||
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
@@ -149,6 +225,14 @@ export default function CityWarehousesPage() {
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
row.fulfillmentMode === 'API_AUTO'
|
||||
? row.fulfillmentProviderName || 'API'
|
||||
: WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL],
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -341,6 +425,13 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式" initialValue={WarehouseFulfillmentMode.MANUAL}>
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setCreateFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={createFulfillmentMode} providerOptions={providerOptions} />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -399,6 +490,13 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式">
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setEditFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={editFulfillmentMode} providerOptions={providerOptions} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
export default function FulfillmentProvidersPage() {
|
||||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||
setRows(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function openCreate() {
|
||||
setEditRow(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
type: FulfillmentProviderType.API,
|
||||
status: FulfillmentProviderStatus.ACTIVE,
|
||||
capabilitiesJson: JSON.stringify(
|
||||
{ createShipment: true, getTrack: true, callback: true },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FulfillmentProviderDto) {
|
||||
setEditRow(row);
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '',
|
||||
configJson: '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields();
|
||||
if (editRow) {
|
||||
await request(`/admin/fulfillment-providers/${editRow.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
status: v.status,
|
||||
configJson: v.configJson || undefined,
|
||||
capabilitiesJson: v.capabilitiesJson || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/fulfillment-providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setOpen(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v) => (
|
||||
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '能力',
|
||||
render: (_, row) => {
|
||||
const caps = row.capabilities;
|
||||
if (!caps) return '—';
|
||||
return Object.entries(caps)
|
||||
.filter(([, on]) => on)
|
||||
.map(([k]) => k)
|
||||
.join('、') || '—';
|
||||
},
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册第三方履约接口后,仓库设置中方可选择对应承运商
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 小飞侠、京东物流" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="configJson" label="凭证配置 JSON(可选)">
|
||||
<Input.TextArea rows={3} placeholder="API 密钥等,仅存服务端" />
|
||||
</Form.Item>
|
||||
<Form.Item name="capabilitiesJson" label="能力配置 JSON">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,8 @@ type OrderDetail = AdminOrderRow & {
|
||||
productAmount?: number;
|
||||
freightAmount?: number;
|
||||
benefitAmount?: number;
|
||||
deliveryType?: string;
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
paidAt?: string | null;
|
||||
payExpireAt?: string | null;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
@@ -63,6 +65,7 @@ type OrderDetail = AdminOrderRow & {
|
||||
export default function OrdersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -74,6 +77,7 @@ export default function OrdersPage() {
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||
const [shipping, setShipping] = useState(false);
|
||||
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
@@ -143,6 +147,25 @@ export default function OrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogisticsShip() {
|
||||
if (!detail) return;
|
||||
const values = await logisticsForm.validateFields();
|
||||
setLogisticsShipping(true);
|
||||
try {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/logistics-ship`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('快递单已录入');
|
||||
setDetail(res);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '填单失败');
|
||||
} finally {
|
||||
setLogisticsShipping(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
if (!selectedRowKeys.length) return;
|
||||
setBatchDeleting(true);
|
||||
@@ -324,16 +347,55 @@ export default function OrdersPage() {
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
|
||||
{detail.delivery.logisticsCompany ||
|
||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||
detail.delivery.provider}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||
{detail.delivery.manualQueryUrl && (
|
||||
<Descriptions.Item label="查询链接">
|
||||
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
打开物流查询
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Title level={5}>发货</Typography.Title>
|
||||
{(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
|
||||
<>
|
||||
<Typography.Title level={5}>总部快递填单</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
适用于跨城订单或同城无仓订单
|
||||
</Typography.Paragraph>
|
||||
<Form form={logisticsForm} layout="vertical" size="small">
|
||||
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 顺丰速运、京东物流" />
|
||||
</Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
||||
提交快递单
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{detail.fulfillmentWarehouseId && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
仓配小飞侠重试
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
仓配订单通常支付后自动推单;失败时可手动重试
|
||||
</Typography.Paragraph>
|
||||
<Form form={shipForm} layout="vertical" size="small">
|
||||
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
||||
<Select
|
||||
@@ -389,6 +451,8 @@ export default function OrdersPage() {
|
||||
调用小飞侠发货
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [shippingId, setShippingId] = useState<string | null>(null);
|
||||
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '订单管理';
|
||||
@@ -64,14 +66,27 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
async function handleShip(orderId: string, e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShippingId(orderId);
|
||||
setShipModalId(orderId);
|
||||
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||
}
|
||||
|
||||
async function submitManualShip() {
|
||||
if (!shipModalId) return;
|
||||
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
|
||||
window.alert('请填写快递公司和运单号');
|
||||
return;
|
||||
}
|
||||
setShippingId(shipModalId);
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/orders/${orderId}/mock-advance-delivery`, {
|
||||
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetStatus: 'OUT_WAREHOUSE' }),
|
||||
body: JSON.stringify(shipForm),
|
||||
});
|
||||
setShipModalId(null);
|
||||
const next = await request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders');
|
||||
setData(next);
|
||||
} catch (err) {
|
||||
window.alert(err instanceof Error ? err.message : '发货失败');
|
||||
} finally {
|
||||
setShippingId(null);
|
||||
}
|
||||
@@ -137,14 +152,14 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{canShip(String(o.status)) && (
|
||||
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-order-ship-btn"
|
||||
disabled={shippingId === orderId}
|
||||
onClick={(e) => void handleShip(orderId, e)}
|
||||
>
|
||||
{shippingId === orderId ? '发货中…' : '确认发货'}
|
||||
填写运单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,6 +177,50 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shipModalId && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md">填写运单</h3>
|
||||
<label className="partner-ship-field">
|
||||
<span>快递公司</span>
|
||||
<input
|
||||
value={shipForm.logisticsCompany}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
|
||||
placeholder="如 顺丰速运"
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>运单号</span>
|
||||
<input
|
||||
value={shipForm.trackingNo}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>查询链接(可选)</span>
|
||||
<input
|
||||
value={shipForm.manualQueryUrl}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={shippingId === shipModalId}
|
||||
onClick={() => void submitManualShip()}
|
||||
>
|
||||
{shippingId === shipModalId ? '提交中…' : '确认发货'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3129,6 +3129,50 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.partner-ship-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.partner-ship-modal {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.partner-ship-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.partner-ship-field input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.partner-ship-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.partner-ship-actions .partner-btn-secondary,
|
||||
.partner-ship-actions .partner-btn-primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
|
||||
.app-page-title {
|
||||
display: none !important;
|
||||
|
||||
@@ -18,6 +18,15 @@ type OrderItem = {
|
||||
|
||||
type OrderDelivery = {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
manualQueryUrl?: string;
|
||||
providerOrderNo?: string;
|
||||
};
|
||||
|
||||
type TrackNode = {
|
||||
trackInfo?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type OrderPayment = {
|
||||
@@ -101,8 +110,10 @@ function progressActiveIndex(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryProviderLabel(provider?: string) {
|
||||
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
||||
function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
||||
if (provider === 'LOGISTICS') return '快递配送';
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -124,13 +135,27 @@ export default function OrderDetailPage() {
|
||||
const [copyHint, setCopyHint] = useState('');
|
||||
const [shareToast, setShareToast] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT';
|
||||
|
||||
async function loadTrack() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
|
||||
setTrackNodes(data.nodes ?? []);
|
||||
} catch {
|
||||
setTrackNodes([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrder() {
|
||||
if (!id) return;
|
||||
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
|
||||
setOrder(data);
|
||||
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
|
||||
void loadTrack();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -353,11 +378,49 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>配送方式</span>
|
||||
<span>{deliveryProviderLabel(order.delivery?.provider)}</span>
|
||||
<span>
|
||||
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
|
||||
</span>
|
||||
</div>
|
||||
{order.delivery?.trackingNo && (
|
||||
<div className="order-detail-kv">
|
||||
<span>运单号</span>
|
||||
<span>{order.delivery.trackingNo}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.delivery?.manualQueryUrl && (
|
||||
<div className="order-detail-kv">
|
||||
<span>物流查询</span>
|
||||
<a
|
||||
href={order.delivery.manualQueryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="order-detail-link"
|
||||
>
|
||||
查看物流
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{trackNodes.length > 0 && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
<span className="material-symbols-outlined">timeline</span>
|
||||
物流动态
|
||||
</h3>
|
||||
<div className="order-detail-kv-list order-detail-kv-list--bordered">
|
||||
{trackNodes.map((node, index) => (
|
||||
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
|
||||
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
|
||||
<span>{node.trackInfo || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isReship && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
import type { WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
|
||||
export interface CityWarehouseDto {
|
||||
id: string;
|
||||
@@ -11,6 +11,14 @@ export interface CityWarehouseDto {
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: WarehouseStatus;
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
fulfillmentProviderCode?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -23,6 +31,12 @@ export interface CreateCityWarehouseInput {
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: string;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string;
|
||||
manualCarrierLabel?: string;
|
||||
manualQueryUrlTemplate?: string;
|
||||
lng?: number;
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCityWarehouseInput {
|
||||
@@ -33,4 +47,10 @@ export interface UpdateCityWarehouseInput {
|
||||
managerType?: WarehouseManagerType;
|
||||
partnerAccountId?: string | null;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
@@ -168,6 +168,36 @@ export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
|
||||
[WarehouseStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export enum FulfillmentProviderType {
|
||||
API = 'API',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export enum FulfillmentProviderStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
}
|
||||
|
||||
export enum WarehouseFulfillmentMode {
|
||||
API_AUTO = 'API_AUTO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export const FULFILLMENT_PROVIDER_TYPE_LABELS: Record<FulfillmentProviderType, string> = {
|
||||
[FulfillmentProviderType.API]: 'API 对接',
|
||||
[FulfillmentProviderType.MANUAL]: '自管',
|
||||
};
|
||||
|
||||
export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record<FulfillmentProviderStatus, string> = {
|
||||
[FulfillmentProviderStatus.ACTIVE]: '启用',
|
||||
[FulfillmentProviderStatus.DISABLED]: '停用',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
|
||||
[WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单',
|
||||
[WarehouseFulfillmentMode.MANUAL]: '自管手工填单',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums';
|
||||
import type { WarehouseFulfillmentMode } from './enums';
|
||||
|
||||
export interface FulfillmentProviderDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status: FulfillmentProviderStatus;
|
||||
capabilities?: {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
} | null;
|
||||
hasConfig: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateFulfillmentProviderInput {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFulfillmentProviderInput {
|
||||
name?: string;
|
||||
type?: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export interface ManualShipOrderInput {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseFulfillmentConfig {
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
@@ -18,5 +18,6 @@ export * from './partner';
|
||||
export * from './shop';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './legal';
|
||||
|
||||
@@ -13,6 +13,7 @@ async function main() {
|
||||
await prisma.storeAccount.deleteMany();
|
||||
await prisma.store.deleteMany();
|
||||
await prisma.partnerBill.deleteMany();
|
||||
await prisma.fulfillmentProvider.deleteMany();
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
const result = await prisma.commonCity.deleteMany();
|
||||
|
||||
@@ -141,6 +141,21 @@ enum WarehouseStatus {
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum FulfillmentProviderType {
|
||||
API
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum FulfillmentProviderStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum WarehouseFulfillmentMode {
|
||||
API_AUTO
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum PartnerStaffRole {
|
||||
PARTNER
|
||||
INTERNAL
|
||||
@@ -496,6 +511,23 @@ model CommonCity {
|
||||
@@map("common_city")
|
||||
}
|
||||
|
||||
model FulfillmentProvider {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
type FulfillmentProviderType
|
||||
status FulfillmentProviderStatus @default(ACTIVE)
|
||||
configJson String? @map("config_json") @db.Text
|
||||
capabilitiesJson String? @map("capabilities_json") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
warehouses CityWarehouse[]
|
||||
deliveries OrderDelivery[]
|
||||
|
||||
@@map("common_fulfillment_provider")
|
||||
}
|
||||
|
||||
model CityWarehouse {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
@@ -506,15 +538,24 @@ model CityWarehouse {
|
||||
managerType WarehouseManagerType @map("manager_type")
|
||||
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
|
||||
status WarehouseStatus @default(ACTIVE)
|
||||
fulfillmentMode WarehouseFulfillmentMode @default(MANUAL) @map("fulfillment_mode")
|
||||
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
|
||||
manualCarrierLabel String? @map("manual_carrier_label") @db.VarChar(64)
|
||||
manualQueryUrlTemplate String? @map("manual_query_url_template") @db.VarChar(512)
|
||||
lng Decimal? @db.Decimal(10, 7)
|
||||
lat Decimal? @db.Decimal(10, 7)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
|
||||
managedBy PartnerAccount? @relation("ManagedWarehouse")
|
||||
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
|
||||
fulfilledOrders Order[] @relation("OrderFulfillmentWarehouse")
|
||||
|
||||
@@index([cityId])
|
||||
@@index([partnerAccountId])
|
||||
@@index([fulfillmentProviderId])
|
||||
@@map("common_city_warehouse")
|
||||
}
|
||||
|
||||
@@ -859,12 +900,14 @@ model Order {
|
||||
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
|
||||
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
|
||||
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
|
||||
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
fulfillmentWarehouse CityWarehouse? @relation("OrderFulfillmentWarehouse", fields: [fulfillmentWarehouseId], references: [id], onDelete: SetNull)
|
||||
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
|
||||
reshipments Order[] @relation("OrderReshipment")
|
||||
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
||||
@@ -880,6 +923,7 @@ model Order {
|
||||
@@index([payExternalNo])
|
||||
@@index([ipCity])
|
||||
@@index([gpsCity])
|
||||
@@index([fulfillmentWarehouseId])
|
||||
@@map("user_order")
|
||||
}
|
||||
|
||||
@@ -887,8 +931,11 @@ model OrderDelivery {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||
provider DeliveryProvider
|
||||
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
|
||||
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
|
||||
trackingNo String? @map("tracking_no") @db.VarChar(64)
|
||||
logisticsCompany String? @map("logistics_company") @db.VarChar(64)
|
||||
manualQueryUrl String? @map("manual_query_url") @db.VarChar(512)
|
||||
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
|
||||
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
|
||||
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
|
||||
@@ -896,8 +943,10 @@ model OrderDelivery {
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
|
||||
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([fulfillmentProviderId])
|
||||
@@map("user_order_delivery")
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ async function main() {
|
||||
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
|
||||
await prisma.fulfillmentProvider.deleteMany();
|
||||
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
|
||||
await prisma.commonCity.deleteMany();
|
||||
@@ -175,6 +177,21 @@ async function main() {
|
||||
|
||||
|
||||
|
||||
const xfxProvider = await prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code: 'XFX',
|
||||
name: '小飞侠',
|
||||
type: 'API',
|
||||
status: 'ACTIVE',
|
||||
capabilitiesJson: JSON.stringify({
|
||||
createShipment: true,
|
||||
getTrack: true,
|
||||
callback: true,
|
||||
cancel: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const warehouse = await prisma.cityWarehouse.create({
|
||||
|
||||
data: {
|
||||
@@ -195,6 +212,14 @@ async function main() {
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
fulfillmentMode: 'API_AUTO',
|
||||
|
||||
fulfillmentProviderId: xfxProvider.id,
|
||||
|
||||
lng: 113.665,
|
||||
|
||||
lat: 34.757,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import {
|
||||
Prisma,
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from './partner-city.service';
|
||||
@@ -13,9 +18,27 @@ export type CreateCityWarehouseInput = {
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: bigint;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: bigint;
|
||||
manualCarrierLabel?: string;
|
||||
manualQueryUrlTemplate?: string;
|
||||
lng?: number;
|
||||
lat?: number;
|
||||
};
|
||||
|
||||
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
|
||||
export type UpdateCityWarehouseInput = Partial<
|
||||
Omit<
|
||||
CreateCityWarehouseInput,
|
||||
'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat'
|
||||
>
|
||||
> & {
|
||||
partnerAccountId?: bigint | null;
|
||||
fulfillmentProviderId?: bigint | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CityWarehouseService {
|
||||
@@ -29,6 +52,7 @@ export class CityWarehouseService {
|
||||
where: { cityId },
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -51,6 +75,7 @@ export class CityWarehouseService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -73,6 +98,7 @@ export class CityWarehouseService {
|
||||
async create(cityId: bigint, input: CreateCityWarehouseInput) {
|
||||
await this.assertCityExists(cityId);
|
||||
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
|
||||
await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.create({
|
||||
data: {
|
||||
@@ -84,9 +110,17 @@ export class CityWarehouseService {
|
||||
managerType: input.managerType,
|
||||
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
fulfillmentMode: input.fulfillmentMode ?? 'MANUAL',
|
||||
fulfillmentProviderId:
|
||||
input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null,
|
||||
manualCarrierLabel: input.manualCarrierLabel?.trim() || null,
|
||||
manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null,
|
||||
lng: input.lng != null ? input.lng : null,
|
||||
lat: input.lat != null ? input.lat : null,
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
@@ -102,8 +136,16 @@ export class CityWarehouseService {
|
||||
managerType === 'PARTNER'
|
||||
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
|
||||
: null;
|
||||
const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode;
|
||||
const fulfillmentProviderId =
|
||||
fulfillmentMode === 'API_AUTO'
|
||||
? input.fulfillmentProviderId !== undefined
|
||||
? input.fulfillmentProviderId
|
||||
: current.fulfillmentProviderId
|
||||
: null;
|
||||
|
||||
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
|
||||
await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.update({
|
||||
where: { id },
|
||||
@@ -117,9 +159,22 @@ export class CityWarehouseService {
|
||||
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
|
||||
: {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}),
|
||||
...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined
|
||||
? { fulfillmentProviderId }
|
||||
: {}),
|
||||
...(input.manualCarrierLabel !== undefined
|
||||
? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null }
|
||||
: {}),
|
||||
...(input.manualQueryUrlTemplate !== undefined
|
||||
? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null }
|
||||
: {}),
|
||||
...(input.lng !== undefined ? { lng: input.lng } : {}),
|
||||
...(input.lat !== undefined ? { lat: input.lat } : {}),
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
@@ -170,6 +225,19 @@ export class CityWarehouseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateFulfillment(
|
||||
mode?: WarehouseFulfillmentMode,
|
||||
providerId?: bigint,
|
||||
) {
|
||||
if (mode === 'API_AUTO') {
|
||||
if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商');
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
throw new BadRequestException('所选仓配承运商不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCityExists(cityId: bigint) {
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
|
||||
if (!city) throw new NotFoundException('开城城市不存在');
|
||||
@@ -185,9 +253,16 @@ export class CityWarehouseService {
|
||||
managerType: string;
|
||||
partnerAccountId: bigint | null;
|
||||
status: string;
|
||||
fulfillmentMode: string;
|
||||
fulfillmentProviderId: bigint | null;
|
||||
manualCarrierLabel: string | null;
|
||||
manualQueryUrlTemplate: string | null;
|
||||
lng: Prisma.Decimal | null;
|
||||
lat: Prisma.Decimal | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
partnerAccount?: { id: bigint; companyName: string | null } | null;
|
||||
fulfillmentProvider?: { id: bigint; code: string; name: string } | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
@@ -200,6 +275,14 @@ export class CityWarehouseService {
|
||||
partnerAccountId: row.partnerAccountId?.toString() ?? null,
|
||||
partnerCompanyName: row.partnerAccount?.companyName ?? null,
|
||||
status: row.status,
|
||||
fulfillmentMode: row.fulfillmentMode,
|
||||
fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null,
|
||||
fulfillmentProviderName: row.fulfillmentProvider?.name ?? null,
|
||||
fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null,
|
||||
manualCarrierLabel: row.manualCarrierLabel,
|
||||
manualQueryUrlTemplate: row.manualQueryUrlTemplate,
|
||||
lng: row.lng != null ? Number(row.lng) : null,
|
||||
lat: row.lat != null ? Number(row.lat) : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -101,9 +101,38 @@ export class PartnerCityService {
|
||||
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
|
||||
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
|
||||
if (warehouseIds.length > 0) {
|
||||
return { fulfillmentWarehouseId: { in: warehouseIds } };
|
||||
}
|
||||
return { cityId: primary.cityId };
|
||||
}
|
||||
|
||||
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
|
||||
async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise<bigint[]> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
const ids = new Set<bigint>();
|
||||
|
||||
if (primary.managedWarehouseId) {
|
||||
ids.add(primary.managedWarehouseId);
|
||||
}
|
||||
|
||||
const managed = await this.prisma.cityWarehouse.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ partnerAccountId: primary.id },
|
||||
...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
for (const row of managed) ids.add(row.id);
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
export type CreateFulfillmentProviderInput = {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
};
|
||||
|
||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||
|
||||
type Capabilities = {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentProviderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActiveApiProviders() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
where: { status: 'ACTIVE', type: 'API' },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async getById(id: bigint) {
|
||||
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(input: CreateFulfillmentProviderInput) {
|
||||
const code = input.code.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9_]+$/.test(code)) {
|
||||
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
|
||||
}
|
||||
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
|
||||
if (existing) throw new BadRequestException('承运商编码已存在');
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
configJson: input.configJson?.trim() || null,
|
||||
capabilitiesJson: input.capabilitiesJson?.trim() || null,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
||||
await this.getById(id);
|
||||
const row = await this.prisma.fulfillmentProvider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.type !== undefined ? { type: input.type } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}),
|
||||
...(input.capabilitiesJson !== undefined
|
||||
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
parseCapabilities(raw: string | null): Capabilities | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Capabilities;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
configJson: string | null;
|
||||
capabilitiesJson: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
||||
hasConfig: Boolean(row.configJson),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
import { FulfillmentService } from './fulfillment.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
||||
providers: [FulfillmentProviderService, FulfillmentService],
|
||||
exports: [FulfillmentProviderService, FulfillmentService],
|
||||
})
|
||||
export class FulfillmentModule {}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
|
||||
export type ManualShipInput = {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
export type HqLogisticsShipInput = ManualShipInput;
|
||||
|
||||
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentService {
|
||||
private readonly logger = new Logger(FulfillmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly courier: CourierService,
|
||||
@Inject(forwardRef(() => TradeService))
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
|
||||
async dispatchAfterPay(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order || order.payStatus !== 'PAID') return;
|
||||
|
||||
if (order.deliveryType === 'CROSS_CITY') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
|
||||
if (!warehouse) {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { fulfillmentWarehouseId: warehouse.id },
|
||||
});
|
||||
|
||||
if (warehouse.fulfillmentMode === 'MANUAL') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
||||
where: { id: warehouse.fulfillmentProviderId },
|
||||
});
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
await this.dispatchApiAuto(order, warehouse, provider);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!XFX_CODES.has(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = this.getShipDefaults();
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat;
|
||||
|
||||
try {
|
||||
const result = await this.courier.createShipment({
|
||||
outNumber: order.orderNo,
|
||||
from: {
|
||||
name: warehouse.contactName,
|
||||
mobile: warehouse.contactPhone,
|
||||
address: warehouse.address,
|
||||
addressDetail: warehouse.name,
|
||||
coordinate: { lng: fromLng, lat: fromLat },
|
||||
},
|
||||
to: {
|
||||
name: order.receiverName,
|
||||
mobile: order.receiverPhone,
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
weight: defaults.weight,
|
||||
payMode: defaults.payMode,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider: 'XFX' as const,
|
||||
fulfillmentProviderId: provider.id,
|
||||
trackingNo: result.trackingNumber,
|
||||
providerOrderNo: String(result.providerShipmentId),
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
await tx.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
externalNo: result.trackingNumber,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.logDispatchFailure(order, provider, message);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
}
|
||||
}
|
||||
|
||||
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在或无权操作');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
|
||||
const queryUrl =
|
||||
input.manualQueryUrl?.trim() ||
|
||||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: queryUrl,
|
||||
operator: 'WAREHOUSE_MANUAL',
|
||||
});
|
||||
}
|
||||
|
||||
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
const isHqQueue =
|
||||
order.deliveryType === 'CROSS_CITY' ||
|
||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
||||
|
||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: input.manualQueryUrl?.trim(),
|
||||
operator: 'HQ_LOGISTICS',
|
||||
provider: 'LOGISTICS',
|
||||
});
|
||||
}
|
||||
|
||||
async getOrderTrack(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order?.delivery) {
|
||||
return { nodes: [], manualQueryUrl: null };
|
||||
}
|
||||
|
||||
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||
try {
|
||||
const nodes = await this.courier.getTrack({
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
} catch {
|
||||
// fall through to manual fields
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [],
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyManualShip(
|
||||
order: Order & { delivery: { trackingNo: string | null } | null },
|
||||
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
|
||||
) {
|
||||
const now = new Date();
|
||||
const provider = input.provider ?? 'MANUAL';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider,
|
||||
logisticsCompany: input.logisticsCompany,
|
||||
trackingNo: input.trackingNo,
|
||||
manualQueryUrl: input.manualQueryUrl || null,
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { delivery: true, fulfillmentWarehouse: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWarehouseForLocalOrder(cityId: bigint) {
|
||||
return this.prisma.cityWarehouse.findFirst({
|
||||
where: { cityId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDeliveryRecord(
|
||||
orderId: bigint,
|
||||
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
|
||||
fulfillmentProviderId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (existing) return;
|
||||
await this.prisma.orderDelivery.create({
|
||||
data: {
|
||||
orderId,
|
||||
provider,
|
||||
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'FAILED',
|
||||
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
||||
if (!warehouseId) return undefined;
|
||||
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
||||
const tpl = wh?.manualQueryUrlTemplate;
|
||||
if (!tpl) return undefined;
|
||||
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
|
||||
}
|
||||
|
||||
private getShipDefaults() {
|
||||
return {
|
||||
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
|
||||
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,22 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
|
||||
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
|
||||
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
|
||||
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
|
||||
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import type {
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) {
|
||||
return {
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined,
|
||||
lng: dto.lng ?? undefined,
|
||||
lat: dto.lat ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('admin/cities/:cityId/warehouses')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -33,6 +48,7 @@ export class AdminCityWarehousesController {
|
||||
managerType: dto.managerType as WarehouseManagerType,
|
||||
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
...mapWarehouseFulfillment(dto),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,11 +79,22 @@ export class AdminCityWarehouseMutationsController {
|
||||
managerType: dto.managerType as WarehouseManagerType | undefined,
|
||||
partnerAccountId:
|
||||
dto.partnerAccountId === null
|
||||
? undefined
|
||||
? null
|
||||
: dto.partnerAccountId
|
||||
? BigInt(dto.partnerAccountId)
|
||||
: undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId:
|
||||
dto.fulfillmentProviderId === null
|
||||
? null
|
||||
: dto.fulfillmentProviderId
|
||||
? BigInt(dto.fulfillmentProviderId)
|
||||
: undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate,
|
||||
lng: dto.lng,
|
||||
lat: dto.lat,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import {
|
||||
CreateFulfillmentProviderDto,
|
||||
UpdateFulfillmentProviderDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
|
||||
@Controller('admin/fulfillment-providers')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminFulfillmentProvidersController {
|
||||
constructor(private readonly service: FulfillmentProviderService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.listAll();
|
||||
}
|
||||
|
||||
@Get('active-api')
|
||||
listActiveApi() {
|
||||
return this.service.listActiveApiProviders();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateFulfillmentProviderDto) {
|
||||
return this.service.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) {
|
||||
return this.service.update(BigInt(id), {
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType | undefined,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
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, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@@ -51,6 +51,17 @@ export class AdminOrdersController {
|
||||
return this.ordersService.shipOrder(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Post(':id/logistics-ship')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_SHIP,
|
||||
refType: 'ORDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) {
|
||||
return this.ordersService.shipLogistics(BigInt(id), dto);
|
||||
}
|
||||
|
||||
/** preV1 调试:直接改订单状态,不走业务校验 */
|
||||
@Put(':id/status')
|
||||
@HqOperation({
|
||||
|
||||
@@ -8,8 +8,9 @@ import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-comp
|
||||
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 { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
@@ -17,6 +18,7 @@ export class AdminOrdersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -183,6 +185,12 @@ export class AdminOrdersService {
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
/** 总部传统快递填单(同城无仓 / 跨城) */
|
||||
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
|
||||
await this.fulfillmentService.shipHqLogistics(id, dto);
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async batchDeleteOrders(ids: bigint[]) {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (!uniqueIds.length) {
|
||||
|
||||
@@ -474,6 +474,30 @@ export class CreateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualCarrierLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lng?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export class UpdateCityWarehouseDto {
|
||||
@@ -505,8 +529,100 @@ export class UpdateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualCarrierLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lng?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
export class CreateFulfillmentProviderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class UpdateFulfillmentProviderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type?: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class ManualShipOrderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
logisticsCompany: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
trackingNo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export class HqLogisticsShipDto extends ManualShipOrderDto {}
|
||||
|
||||
export class CreateStoreMediaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
@@ -54,9 +55,10 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
import { AdminDeployController } from './admin-deploy.controller';
|
||||
import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -89,6 +91,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PartnerProxyOrderPreviewDto,
|
||||
PartnerProxyOrderSendSmsDto,
|
||||
} from './dto/partner-proxy-order.dto';
|
||||
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
||||
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -42,6 +43,11 @@ export class TradeController {
|
||||
return this.tradeService.getOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/track')
|
||||
track(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getOrderTrack(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/pay')
|
||||
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.payOrder(user.actorId, BigInt(id), user.clientApp);
|
||||
@@ -91,6 +97,21 @@ export class PartnerOrderController {
|
||||
return this.tradeService.getPartnerOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/track')
|
||||
track(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getPartnerOrderTrack(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/manual-ship')
|
||||
@RequirePartnerPermissions('warehouse:manage')
|
||||
manualShip(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ManualShipOrderDto,
|
||||
) {
|
||||
return this.tradeService.partnerManualShip(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post(':id/mock-advance-delivery')
|
||||
mockAdvance(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { PromoModule } from '../promo/promo.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import {
|
||||
TradeController,
|
||||
PartnerOrderController,
|
||||
@@ -24,6 +25,7 @@ import { TradeService } from './trade.service';
|
||||
CityScopeModule,
|
||||
PromoModule,
|
||||
forwardRef(() => BenefitModule),
|
||||
forwardRef(() => FulfillmentModule),
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import type { FreightPayType } from '@prisma/client';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { buildOrderClientLocationSnapshot } from '../../common/geo/client-locati
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@@ -45,6 +47,8 @@ export class TradeService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly promoCodeService: PromoCodeService,
|
||||
@Inject(forwardRef(() => FulfillmentService))
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
@@ -268,8 +272,7 @@ export class TradeService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
|
||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
@@ -281,6 +284,15 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
private async afterOrderPaid(orderId: bigint) {
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (refreshed?.status === 'PENDING_SHIP') {
|
||||
await this.deliveryProvider.scheduleAutoAdvance(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信支付回调:幂等更新订单为已支付并发券 */
|
||||
async handlePaySuccess(params: {
|
||||
orderNo: string;
|
||||
@@ -361,8 +373,7 @@ export class TradeService {
|
||||
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||
if (refreshed?.payStatus === 'PAID') {
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
refType: 'ORDER',
|
||||
@@ -401,6 +412,7 @@ export class TradeService {
|
||||
benefitCoupon: true,
|
||||
imageResource: true,
|
||||
product: true,
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -411,6 +423,15 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async getOrderTrack(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -492,7 +513,7 @@ export class TradeService {
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } } },
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
@@ -507,7 +528,7 @@ export class TradeService {
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
include: { delivery: true, user: true, imageResource: true },
|
||||
include: { delivery: true, user: true, imageResource: true, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
const statusLogs = await this.prisma.commonEvent.findMany({
|
||||
@@ -517,6 +538,37 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async partnerManualShip(
|
||||
partnerAccountId: bigint,
|
||||
orderId: bigint,
|
||||
input: { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string },
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const warehouseIds = await this.partnerCityService.resolveManagedWarehouseIds(primary.id);
|
||||
if (!warehouseIds.length) throw new BadRequestException('当前账号未绑定仓库');
|
||||
|
||||
await this.fulfillmentService.shipManualByWarehouse(orderId, warehouseIds, input);
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_order_ship',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
extraJson: { mode: 'manual' },
|
||||
});
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
async getPartnerOrderTrack(partnerAccountId: bigint, orderId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
|
||||
+8
-4
@@ -130,8 +130,8 @@
|
||||
└─ 30 分钟未付取消
|
||||
```
|
||||
|
||||
- **同城**:推小飞侠(已取货拍照→已发出→已送达拍照);送达未确认 24h 自动完成
|
||||
- **跨城**:推总部物流(到付);订单佣金归总部
|
||||
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
|
||||
- **跨城**:总部传统快递到付填单;订单佣金归总部
|
||||
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
|
||||
|
||||
### 3.3 佣金与结算
|
||||
@@ -207,10 +207,14 @@
|
||||
| 破损退货 | 同意/驳回 | 负责仓取回→退款 |
|
||||
| 退货退款 | 同意/驳回 | 通知归属合伙人+负责仓取回→退款 |
|
||||
|
||||
### 3.7 城市多仓(Wave 3)
|
||||
### 3.7 城市多仓与仓配(Wave 3)
|
||||
|
||||
- 一城多仓;每仓最多关联 1 名管仓合伙人
|
||||
- 佣金与仓无关;仓用于工单协同
|
||||
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择
|
||||
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
|
||||
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单
|
||||
- 同城无仓 / 跨城:总部传统快递填单
|
||||
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
|
||||
- 未关联合伙人的仓 → 总部直派
|
||||
|
||||
### 3.8 弱网核销兜底(Wave 3 · OPT-006)
|
||||
|
||||
Reference in New Issue
Block a user