diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 86d1122..7b80897 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index fc5b93b..ea65028 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
+ { key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx
index c063654..095f2a7 100644
--- a/apps/admin-web/src/pages/CityWarehousesPage.tsx
+++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx
@@ -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 && (
+
+
+ )}
+ {mode === WarehouseFulfillmentMode.MANUAL && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
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.HQ);
const [editManagerType, setEditManagerType] = useState(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState();
+ const [createFulfillmentMode, setCreateFulfillmentMode] = useState(
+ WarehouseFulfillmentMode.MANUAL,
+ );
+ const [editFulfillmentMode, setEditFulfillmentMode] = useState(
+ WarehouseFulfillmentMode.MANUAL,
+ );
+ const [providerOptions, setProviderOptions] = useState([]);
const loadCities = useCallback(async () => {
const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +154,9 @@ export default function CityWarehousesPage() {
useEffect(() => {
void loadCities();
+ void request('/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() {
+
+
+
@@ -399,6 +490,13 @@ export default function CityWarehousesPage() {
+
+
+
diff --git a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx
new file mode 100644
index 0000000..6533f96
--- /dev/null
+++ b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [editRow, setEditRow] = useState(null);
+ const [form] = Form.useForm();
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await request('/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 = [
+ { 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) => (
+
+ {FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
+
+ ),
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx
index b518c7f..405b0c6 100644
--- a/apps/admin-web/src/pages/OrdersPage.tsx
+++ b/apps/admin-web/src/pages/OrdersPage.tsx
@@ -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>;
@@ -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 | 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(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(`/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 && (
- {DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
+ {detail.delivery.logisticsCompany ||
+ DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
+ detail.delivery.provider}
{detail.delivery.trackingNo || '—'}
{detail.delivery.providerOrderNo || '—'}
+ {detail.delivery.manualQueryUrl && (
+
+
+ 打开物流查询
+
+
+ )}
)}
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
- 发货
+ {(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
+ <>
+ 总部快递填单
+
+ 适用于跨城订单或同城无仓订单
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {detail.fulfillmentWarehouseId && (
+ <>
+
+ 仓配小飞侠重试
+
+
+ 仓配订单通常支付后自动推单;失败时可手动重试
+
)}
diff --git a/apps/h5-partner/src/pages/OrderListPage.tsx b/apps/h5-partner/src/pages/OrderListPage.tsx
index e5b3ac9..c9e683a 100644
--- a/apps/h5-partner/src/pages/OrderListPage.tsx
+++ b/apps/h5-partner/src/pages/OrderListPage.tsx
@@ -41,6 +41,8 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
const [dateFilter, setDateFilter] = useState('today');
const [statusFilter, setStatusFilter] = useState('ALL');
const [shippingId, setShippingId] = useState(null);
+ const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
+ const [shipModalId, setShipModalId] = useState(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> }>('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) {
{String(o.receiverAddress || '收货地址')}
- {canShip(String(o.status)) && (
+ {canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
)}
@@ -162,6 +177,50 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
权益记录 preV1 占位
)}
+
+ {shipModalId && (
+ setShipModalId(null)}>
+
+
+ )}
);
}
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css
index 09ad919..be60113 100644
--- a/apps/h5-partner/src/styles.css
+++ b/apps/h5-partner/src/styles.css
@@ -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;
diff --git a/apps/h5-user/src/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx
index daa0676..53304d4 100644
--- a/apps/h5-user/src/pages/OrderDetailPage.tsx
+++ b/apps/h5-user/src/pages/OrderDetailPage.tsx
@@ -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([]);
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('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() {
配送方式
- {deliveryProviderLabel(order.delivery?.provider)}
+
+ {deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
+
+ {order.delivery?.trackingNo && (
+
+ 运单号
+ {order.delivery.trackingNo}
+
+ )}
+ {order.delivery?.manualQueryUrl && (
+
+ )}
+ {trackNodes.length > 0 && (
+
+
+ timeline
+ 物流动态
+
+
+ {trackNodes.map((node, index) => (
+
+ {node.createdAt ? formatDateTime(node.createdAt) : '—'}
+ {node.trackInfo || '—'}
+
+ ))}
+
+
+ )}
+
{!isReship && (
diff --git a/packages/shared-types/src/city-warehouse.ts b/packages/shared-types/src/city-warehouse.ts
index 72d2b0b..a36978b 100644
--- a/packages/shared-types/src/city-warehouse.ts
+++ b/packages/shared-types/src/city-warehouse.ts
@@ -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;
}
diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts
index 7a3f121..35f1f81 100644
--- a/packages/shared-types/src/enums.ts
+++ b/packages/shared-types/src/enums.ts
@@ -168,6 +168,36 @@ export const WAREHOUSE_STATUS_LABELS: Record = {
[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.API]: 'API 对接',
+ [FulfillmentProviderType.MANUAL]: '自管',
+};
+
+export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record = {
+ [FulfillmentProviderStatus.ACTIVE]: '启用',
+ [FulfillmentProviderStatus.DISABLED]: '停用',
+};
+
+export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record = {
+ [WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单',
+ [WarehouseFulfillmentMode.MANUAL]: '自管手工填单',
+};
+
export enum AccountStatus {
ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED',
diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts
new file mode 100644
index 0000000..2883eab
--- /dev/null
+++ b/packages/shared-types/src/fulfillment-provider.ts
@@ -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;
+}
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index ae2578f..8ffd900 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -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';
diff --git a/server/dukang-api/prisma/clear-cities.ts b/server/dukang-api/prisma/clear-cities.ts
index e0c528e..c76b043 100644
--- a/server/dukang-api/prisma/clear-cities.ts
+++ b/server/dukang-api/prisma/clear-cities.ts
@@ -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();
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 78912e8..856da00 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -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,25 +511,51 @@ model CommonCity {
@@map("common_city")
}
-model CityWarehouse {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- cityId BigInt @map("city_id") @db.UnsignedBigInt
- name String @db.VarChar(128)
- address String @db.VarChar(256)
- contactName String @map("contact_name") @db.VarChar(64)
- contactPhone String @map("contact_phone") @db.VarChar(20)
- managerType WarehouseManagerType @map("manager_type")
- partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
- status WarehouseStatus @default(ACTIVE)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+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)
- city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
- partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
- managedBy PartnerAccount? @relation("ManagedWarehouse")
+ warehouses CityWarehouse[]
+ deliveries OrderDelivery[]
+
+ @@map("common_fulfillment_provider")
+}
+
+model CityWarehouse {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ name String @db.VarChar(128)
+ address String @db.VarChar(256)
+ contactName String @map("contact_name") @db.VarChar(64)
+ contactPhone String @map("contact_phone") @db.VarChar(20)
+ 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)
+ 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,24 +923,30 @@ model Order {
@@index([payExternalNo])
@@index([ipCity])
@@index([gpsCity])
+ @@index([fulfillmentWarehouseId])
@@map("user_order")
}
model OrderDelivery {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
- provider DeliveryProvider
- providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
- trackingNo String? @map("tracking_no") @db.VarChar(64)
- 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)
- signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ 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)
+ signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
- signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
+ 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")
}
diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts
index 57c698c..f798a86 100644
--- a/server/dukang-api/prisma/seed-v31.ts
+++ b/server/dukang-api/prisma/seed-v31.ts
@@ -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,
+
},
});
diff --git a/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts
index 11160aa..faf8128 100644
--- a/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts
+++ b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts
@@ -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;
+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(),
});
diff --git a/server/dukang-api/src/modules/city-scope/partner-city.service.ts b/server/dukang-api/src/modules/city-scope/partner-city.service.ts
index 5095396..461b6ca 100644
--- a/server/dukang-api/src/modules/city-scope/partner-city.service.ts
+++ b/server/dukang-api/src/modules/city-scope/partner-city.service.ts
@@ -101,9 +101,38 @@ export class PartnerCityService {
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise {
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 {
+ const primary = await this.resolvePrimaryAccount(partnerAccountId);
+ const ids = new Set();
+
+ 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 {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts
new file mode 100644
index 0000000..a4b4042
--- /dev/null
+++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts
@@ -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;
+
+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(),
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts
new file mode 100644
index 0000000..a86a87e
--- /dev/null
+++ b/server/dukang-api/src/modules/fulfillment/fulfillment.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
new file mode 100644
index 0000000..90ce54f
--- /dev/null
+++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
@@ -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('SHIP_FROM_LNG') || 113.665),
+ fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757),
+ weight: 2,
+ payMode: CourierPayMode.SENDER,
+ };
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts
index 8df8da0..c600b86 100644
--- a/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts
@@ -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,
});
}
diff --git a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts
new file mode 100644
index 0000000..98fd5d2
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts
@@ -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,
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
index 036007c..eb9031b 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
@@ -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({
diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts
index 8a1f0b9..f36e2ab 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts
@@ -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) {
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
index 1064ab8..c9d3e30 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -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()
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 5561259..4c823a7 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts
index 2713874..9028752 100644
--- a/server/dukang-api/src/modules/trade/trade.controller.ts
+++ b/server/dukang-api/src/modules/trade/trade.controller.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts
index 921d140..c848b92 100644
--- a/server/dukang-api/src/modules/trade/trade.module.ts
+++ b/server/dukang-api/src/modules/trade/trade.module.ts
@@ -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: [
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index d68707e..31be90d 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -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) {
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);
diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md
index 36fa540..e2a75b8 100644
--- a/杜康好客-v3-PRD.md
+++ b/杜康好客-v3-PRD.md
@@ -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)