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 && ( + + + + + + + + )} + + + + + + + + + + ); +} + 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() { setCreateFulfillmentMode(v)} + /> + + @@ -399,6 +490,13 @@ export default function CityWarehousesPage() { setEditFulfillmentMode(v)} + /> + + 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 ( +
+ +
+ + 仓配管理 + + + 注册第三方履约接口后,仓库设置中方可选择对应承运商 + +
+ +
+ + + + setOpen(false)} + onOk={() => void submit()} + width={560} + > +
+ + + + + + + + + + + + + + + + +
+ + ); +} 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 && ( + <> + + 仓配小飞侠重试 + + + 仓配订单通常支付后自动推单;失败时可手动重试 +
setName(e.target.value)} + placeholder="请输入姓名" + maxLength={32} + /> + + +

手机号由管理员维护,如需修改请联系总部

+
-

运营管理

- + + + {isPrimary && ( + +
+
group
+ 子账号管理 +
chevron_right -
- - -
-
monitoring
- 数据周报 -
- chevron_right - - -
-
assignment_return
- 补发处理 -
- chevron_right - - -
-
military_tech
- 合伙人贡献榜 -
- chevron_right - - {showPrimaryMenus && ( - -
-
group
- 子账号管理 -
- chevron_right - + )}
diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index ab9325b..a84ae54 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -1,46 +1,207 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; import { fetchPartnerLeaderboard } from '../lib/leaderboard'; +import { getPartnerNavKind } from '../lib/partnerAccess'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } - const HOME_STORE_PREVIEW_LIMIT = 6; +const LEADERBOARD_PREVIEW_LIMIT = 3; + +function OrderSummarySection({ + title, + todayCount, + pendingShip, + shipping, + completed, + ordersLink = '/orders', +}: { + title?: string; + todayCount: number; + pendingShip: number; + shipping: number; + completed: number; + ordersLink?: string; +}) { + return ( +
+
+

{title || `今日订单 ${todayCount}`}

+ + 查看全部 chevron_right + +
+
+
+

待发货

+

{pendingShip}

+
+
+

配送中

+

{shipping}

+
+
+

已完成

+

{completed}

+
+
+
+ ); +} + +function isPendingShip(status: string) { + const s = status.toUpperCase(); + return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP'; +} + +function isShipping(status: string) { + const s = status.toUpperCase(); + return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE'; +} + +function isCompleted(status: string) { + return status.toUpperCase() === 'COMPLETED'; +} + +function isToday(dateStr: string) { + const d = new Date(dateStr); + const now = new Date(); + return d.getFullYear() === now.getFullYear() + && d.getMonth() === now.getMonth() + && d.getDate() === now.getDate(); +} + +function summarizeOrders(orders: Array>) { + let pendingShip = 0; + let shipping = 0; + let completed = 0; + let todayCount = 0; + for (const o of orders) { + const status = String(o.status || ''); + if (isToday(String(o.createdAt || ''))) todayCount += 1; + if (isPendingShip(status)) pendingShip += 1; + else if (isShipping(status)) shipping += 1; + else if (isCompleted(status)) completed += 1; + } + return { pendingShip, shipping, completed, todayCount }; +} + +function LeaderboardPreview({ + entries, + self, +}: { + entries: PartnerLeaderboardEntry[]; + self?: PartnerLeaderboardEntry; +}) { + const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false; + + return ( +
+
+

+ military_tech + 合伙人贡献榜 +

+ 查看全部 +
+ {entries.length === 0 && !self ? ( +

暂无排行数据

+ ) : ( + <> + {entries.map((entry) => ( +
+
{entry.rank}
+
+

+ {entry.name} + {entry.isSelf ? 我的排名 : null} + ({entry.roleLabel}) +

+

累计拓店 {entry.totalStores} 间

+
+
+

{entry.periodStores} 间

+

本月新增

+
+
+ ))} + {self && !selfInList ? ( +
+
{self.rank}
+
+

+ {self.name} + 我的排名 +

+

累计拓店 {self.totalStores} 间

+
+
+

{self.periodStores} 间

+

本月新增

+
+
+ ) : null} + + )} +
+ ); +} + export default function HomePage() { const navigate = useNavigate(); + const { account } = usePartnerSession(); + const navKind = getPartnerNavKind(account); + const isPrimary = navKind === 'primary'; + const isWarehouse = navKind === 'warehouse_staff'; + const [dash, setDash] = useState | null>(null); const [stores, setStores] = useState>>([]); - const [leaderboardPreview, setLeaderboardPreview] = useState([]); - const [notifOpen, setNotifOpen] = useState(false); + const [orders, setOrders] = useState>>([]); + const [leaderboardEntries, setLeaderboardEntries] = useState([]); + const [leaderboardSelf, setLeaderboardSelf] = useState(); + + useEffect(() => { + document.title = '工作台'; + }, []); useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - request>('PARTNER_H5', '/partner/dashboard').then(setDash); - request>>('PARTNER_H5', '/partner/stores').then(setStores); - fetchPartnerLeaderboard('month') - .then((data) => setLeaderboardPreview(data.list.slice(0, 2))) - .catch(() => setLeaderboardPreview([])); - }, [navigate]); + if (isWarehouse || isPrimary) { + request<{ list: Array> }>('PARTNER_H5', '/partner/orders') + .then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) + .catch(() => setOrders([])); + } + if (!isWarehouse) { + request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); + request>>('PARTNER_H5', '/partner/stores').then(setStores).catch(() => []); + fetchPartnerLeaderboard('month') + .then((data) => { + setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); + setLeaderboardSelf(data.self); + }) + .catch(() => { + setLeaderboardEntries([]); + setLeaderboardSelf(undefined); + }); + } else { + request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); + } + }, [navigate, isWarehouse, isPrimary]); const storeCount = Number(dash?.storeCount || stores.length || 0); - const orderCount = Number(dash?.orderCount || 0); + const orderStats = useMemo(() => summarizeOrders(orders), [orders]); const pendingAuditCount = Number(dash?.pendingAuditCount || 0); - const notifications = Array.isArray(dash?.notifications) - ? (dash.notifications as Array>) - : []; - const hasUnread = notifications.length > 0; const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const abnormalStores = Math.max(0, storeCount - activeStores); - const revenue = orderCount * 128.45; - const profit = revenue * 0.25; - const pendingShip = Math.ceil(orderCount * 0.04); - const shipping = Math.ceil(orderCount * 0.12); - const completed = Math.max(0, orderCount - pendingShip - shipping); const monthNew = stores.filter((s) => { const created = new Date(String(s.createdAt || '')); const now = new Date(); @@ -48,232 +209,157 @@ export default function HomePage() { }).length; const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); + const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0); + const revenue = orderCount * 128.45; + const profit = revenue * 0.25; + const pendingShipBadge = orderStats.pendingShip; return ( -
-
-

工作台

-
- -
- person -
-
-
- - {notifOpen && ( -
-
-

审核通知

- -
- {notifications.length === 0 ? ( -

暂无审核通知

- ) : ( - notifications.slice(0, 8).map((n) => ( - setNotifOpen(false)} - > -
-

{String(n.title || '门店审核')}

-

{String(n.content || '')}

-
- - )) - )} -
- )} - +
-
-

- 实时营业额 (CNY) - info -

-
{fmtMoney(revenue)}
-
-
-

预计利润

-

¥ {fmtMoney(profit)}

-
-
-
- -
-
-
-

门店总数

- {storeCount} -
-
-
- + {isPrimary && ( + <> +
+

+ 实时营业额 (CNY) + info +

+
{fmtMoney(revenue)}
+
-

正常运营

-

{activeStores}

+

预计利润

+

¥ {fmtMoney(profit)}

-
- -
-

异常/闭店

-

{abnormalStores}

-
-
-
-
- -
-
- -
- shopping_cart_checkout -
- 代下单 - - -
- add_business -
- 录入新店 - - -
- assignment_return - {pendingShip > 0 && {pendingShip}} -
- 补发处理 - - -
- account_balance -
- 财务对账 - - -
- monitoring -
- 数据周报 - -
-
-
- -
-
-

今日订单量 {orderCount}

- - 查看订单详情 chevron_right - -
-
-
-

待发货

-

{pendingShip}

-
-
-

配送中

-

{shipping}

-
-
-

已完成

-

{completed}

-
-
-
- -
-
-

辖区门店

- 查看全部 -
- {previewStores.length === 0 ? ( -
-

暂无门店

- 录入新店 -
- ) : ( - previewStores.map((s) => { - const storeId = String(s.id); - const status = String(s.status || 'PAUSED').toUpperCase(); - const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); - return ( - -
-

{String(s.name || '未命名门店')}

-

{String(s.address || s.district || '')}

+
+
+
+ +
+ shopping_cart_checkout
- - {audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)} - + 代下单 - ); - }) - )} -
- -
-

拓店情况

-
-
-

本月新增签约

-
- {monthNew} -
-
-
-

待审核门店

- {pendingAuditCount} -
-
- -
-
-

- military_tech - 合伙人贡献榜 -

- 查看全部 -
- {leaderboardPreview.length === 0 ? ( -

暂无排行数据

- ) : ( - leaderboardPreview.map((entry) => ( -
-
{entry.rank}
-
-

- {entry.name} - ({entry.roleLabel}) -

-

累计拓店 {entry.totalStores} 间

+ +
+ add_business
-
-

{entry.periodStores} 间

-

本月新增

+ 录入新店 + + +
+ assignment_return + {pendingShipBadge > 0 && {pendingShipBadge}} +
+ 补发处理 + + +
+ account_balance +
+ 财务对账 + + +
+ monitoring +
+ 数据周报 + +
+
+ + )} + + {isWarehouse && ( +

订单管理

+ )} + + {(isPrimary || isWarehouse) && ( + + )} + + {!isWarehouse && ( + <> +
+
+

门店总数

+ {storeCount} +
+
+
+ +
+

正常运营

+

{activeStores}

- )) - )} -
+
+ +
+

异常/闭店

+

{abnormalStores}

+
+
+
+ -

- {String(dash?.companyName || '郑州合伙人')} · 辖区管理 -

- +
+
+

辖区门店

+ 查看全部 +
+ {previewStores.length === 0 ? ( +
+

暂无门店

+ 录入新店 +
+ ) : ( + previewStores.map((s) => { + const storeId = String(s.id); + const status = String(s.status || 'PAUSED').toUpperCase(); + const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); + return ( + +
+

{String(s.name || '未命名门店')}

+

{String(s.address || s.district || '')}

+
+ + {audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)} + + + ); + }) + )} +
+ +
+

拓店情况

+
+
+

本月新增签约

+ {monthNew} +
+
+

待审核门店

+ {pendingAuditCount} +
+
+ + + +

+ {String(dash?.companyName || '郑州合伙人')} · 辖区管理 +

+
+ + )}
); diff --git a/apps/h5-partner/src/pages/OrderDetailPage.tsx b/apps/h5-partner/src/pages/OrderDetailPage.tsx index e53b3ef..ce594b9 100644 --- a/apps/h5-partner/src/pages/OrderDetailPage.tsx +++ b/apps/h5-partner/src/pages/OrderDetailPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { request } from '../lib/api'; +import { toastError, toastSuccess } from '../lib/toast'; const TIMELINE = [ { key: 'confirm', label: '待确认' }, @@ -28,21 +29,55 @@ function statusBanner(status: string) { return { title: status, desc: '', icon: 'receipt_long' }; } +function canShip(status: string) { + const s = status.toUpperCase(); + return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP'; +} + export default function OrderDetailPage() { const { id } = useParams(); const navigate = useNavigate(); const [order, setOrder] = useState | null>(null); + const [shipping, setShipping] = useState(false); useEffect(() => { - if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); + document.title = '订单详情'; + }, []); + + useEffect(() => { + if (id) void request>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); }, [id]); + async function reload() { + if (!id) return; + const next = await request>('PARTNER_H5', `/partner/orders/${id}`); + setOrder(next); + } + async function advance(status: string) { - await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, { - method: 'POST', - body: JSON.stringify({ targetStatus: status }), - }); - if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); + if (!id) return; + setShipping(true); + try { + await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, { + method: 'POST', + body: JSON.stringify({ targetStatus: status }), + }); + await reload(); + toastSuccess('已更新配送状态'); + } finally { + setShipping(false); + } + } + + function handleContactCourier() { + const delivery = order?.delivery as Record | undefined; + const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim(); + if (trackingNo) { + void navigator.clipboard?.writeText(trackingNo); + toastSuccess(`运单号已复制:${trackingNo}`); + return; + } + toastError('暂无配送员联系方式'); } if (!order) return
加载中...
; @@ -50,6 +85,7 @@ export default function OrderDetailPage() { const banner = statusBanner(String(order.status)); const currentIdx = statusIndex(String(order.status)); const payAmount = Number(order.payAmount || 0); + const showShip = canShip(String(order.status)); return (
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
liquor
-
- 正品保证 -

{String(order.productName || '杜康好酒')}

@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
-
-
-

佣金明细

-
- 订单佣金 - ¥{(payAmount * 0.04).toFixed(2)} -
-
- 权益核销 - ¥{(payAmount * 0.04).toFixed(2)} -
-
-
-

赠送好客权益

- ¥{Math.round(payAmount * 0.2)} -
-
- -
-

- - 订单信息 -

-
- 订单编号 - {String(order.orderNo)} -
-
- 订单状态 - {String(order.status)} -
-
-

@@ -156,21 +156,34 @@ export default function OrderDetailPage() {

-
- 佣金合计 -

¥{(payAmount * 0.08).toFixed(2)}

+
+ {showShip && ( + + )} +
-
{import.meta.env.DEV && (
Dev: Mock 推进配送 {['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => ( - + ))}
)} diff --git a/apps/h5-partner/src/pages/OrderListPage.tsx b/apps/h5-partner/src/pages/OrderListPage.tsx index 286d748..c9e683a 100644 --- a/apps/h5-partner/src/pages/OrderListPage.tsx +++ b/apps/h5-partner/src/pages/OrderListPage.tsx @@ -29,16 +29,28 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [ { key: 'ABNORMAL', label: '异常' }, ]; -export default function OrderListPage() { +type OrderListPageProps = { + /** Tab 根页:无返回顶栏 */ + tabRoot?: boolean; +}; + +export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { const navigate = useNavigate(); const [data, setData] = useState<{ list: Array> }>({ list: [] }); const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); 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 = '订单管理'; + }, []); useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - request('PARTNER_H5', '/partner/orders').then(setData); + request<{ list: Array> }>('PARTNER_H5', '/partner/orders').then(setData); }, [navigate]); const filtered = useMemo(() => data.list.filter((o) => { @@ -51,13 +63,49 @@ export default function OrderListPage() { return true; }), [data.list, statusFilter]); + async function handleShip(orderId: string, e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + 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/${shipModalId}/manual-ship`, { + method: 'POST', + 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); + } + } + + function canShip(status: string) { + const s = status.toUpperCase(); + return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP'; + } + return ( -
- navigate('/')} /> +
+ {!tabRoot && navigate('/')} />}
- + {!tabRoot && ( + + )}
{tab === 'orders' && ( @@ -82,50 +130,45 @@ export default function OrderListPage() { {filtered.map((o) => { const st = orderStatusLabel(String(o.status)); const payAmount = Number(o.payAmount || 0); + const orderId = String(o.id); return ( - -
- NO. {String(o.orderNo)} - {st.label} -
-
-
- liquor +
+ +
+ NO. {String(o.orderNo)} + {st.label}
-
-

{String(o.productName || '杜康好酒')}

-

¥{payAmount.toFixed(2)}

- - confirmation_number - ¥{Math.round(payAmount * 0.2)}权益 - +
+
+ liquor +
+
+

{String(o.productName || '杜康好酒')}

+

¥{payAmount.toFixed(2)}

+
-
-
- location_on -

{String(o.receiverAddress || '收货地址')}

-
-
-
-

下单佣金

-

¥{(payAmount * 0.04).toFixed(2)}

+
+ location_on +

{String(o.receiverAddress || '收货地址')}

-
-

核销佣金

-

¥0.00

-
-
-

获赠权益

-

¥{Math.round(payAmount * 0.2)}

-
-
- + + {canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && ( + + )} +
); })} )} - {tab === 'coupons' && ( + {tab === 'coupons' && !tabRoot && (

累计已发放权益金额

@@ -134,6 +177,50 @@ export default function OrderListPage() {

权益记录 preV1 占位

)} + + {shipModalId && ( +
setShipModalId(null)}> +
e.stopPropagation()}> +

填写运单

+ + + +
+ + +
+
+
+ )}
); } diff --git a/apps/h5-partner/src/pages/PartnerMePage.tsx b/apps/h5-partner/src/pages/PartnerMePage.tsx index b93cb88..19db2ea 100644 --- a/apps/h5-partner/src/pages/PartnerMePage.tsx +++ b/apps/h5-partner/src/pages/PartnerMePage.tsx @@ -1,135 +1,12 @@ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types'; -import { request } from '../lib/api'; +import { getPartnerNavKind } from '../lib/partnerAccess'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; -import { toastError, toastSuccess } from '../lib/toast'; +import CenterPage from './CenterPage'; +/** 子账号个人中心:复用精简版 Center 布局 */ export default function PartnerMePage() { - const { account, refresh, logout } = usePartnerSession(); - const [name, setName] = useState(account?.name ?? ''); - const [saving, setSaving] = useState(false); + const { account } = usePartnerSession(); + const navKind = getPartnerNavKind(account); + const isWarehouse = navKind === 'warehouse_staff'; - useEffect(() => { - setName(account?.name ?? ''); - }, [account?.name]); - - const roleLabel = account?.staffRole - ? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole - : '拓店账号'; - - async function handleSave() { - const trimmed = name.trim(); - if (!trimmed) { - toastError('请输入姓名'); - return; - } - if (trimmed === account?.name) { - toastSuccess('已保存'); - return; - } - setSaving(true); - try { - await request('PARTNER_H5', '/partner/me', { - method: 'PUT', - body: JSON.stringify({ name: trimmed }), - }); - await refresh(); - toastSuccess('已保存'); - } catch (e) { - toastError(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); - } - } - - return ( -
-
-

杜康好客

-
- -
-
- person -
-
-
-

{account?.name || '合伙人'}

- {roleLabel} -
-
- location_on - {account?.companyName || '—'} -
-

{account?.phone || ''}

-
-
- -
-

账号资料

-
- - setName(e.target.value)} - placeholder="请输入姓名" - maxLength={32} - /> - - - -

- 手机号由管理员维护,如需修改请联系总部 -

- - -
-
- -
-

快捷入口

-
- -
-
store
- 门店管理 -
- chevron_right - - -
-
add_business
- 录入新门店 -
- chevron_right - -
-
- - - -
-

传承千年 · 杜康好客

-
-
- ); + return ; } diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx index 5fe95e3..de8ff34 100644 --- a/apps/h5-partner/src/pages/StoreListPage.tsx +++ b/apps/h5-partner/src/pages/StoreListPage.tsx @@ -42,6 +42,10 @@ export default function StoreListPage() { void loadStores(); }, [navigate, loadStores]); + useEffect(() => { + document.title = readonly ? '我的门店' : '门店管理'; + }, [readonly]); + const filtered = useMemo(() => stores.filter((s) => { const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); @@ -78,16 +82,7 @@ export default function StoreListPage() { } return ( -
-
-

杜康好客

-
- -
-

{readonly ? '我的门店' : '门店管理'}

-

{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}

-
- +
{error &&

{error}

}
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index c24aff5..be60113 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -3067,6 +3067,112 @@ body { border-top: 1px dashed rgba(166, 29, 36, 0.15); } +.partner-home--flush-top .partner-home-body, +.partner-home--flush-top.partner-store-page, +.partner-home--flush-top.partner-center-page { + padding-top: 0; +} + +.partner-home--flush-top .partner-sticky-filter { + padding-top: 12px; +} + +.partner-leaderboard-row--self { + background: rgba(166, 29, 36, 0.08); + border: 1px solid rgba(166, 29, 36, 0.18); + border-radius: 10px; +} + +.partner-leaderboard-self-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + color: var(--color-heritage-red); + background: rgba(166, 29, 36, 0.12); + border-radius: 4px; + vertical-align: middle; +} + +.partner-order-card-wrap { + margin: 0 var(--space-page) 12px; +} + +.partner-order-ship-btn { + display: block; + width: 100%; + margin-top: -4px; + padding: 10px 16px; + border: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); + background: var(--color-heritage-red); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.partner-order-ship-btn:disabled { + opacity: 0.6; +} + +.partner-order-footer-actions { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.partner-order-ship-btn--footer { + width: 100%; + 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/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index 847e132..28f339d 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -10,6 +10,9 @@ export interface PartnerMe { staffRole?: PartnerStaffRole; permissions?: string[]; primaryAccountId?: string; + /** 子账号联系主账号用 */ + primaryPhone?: string; + primaryName?: string; } export interface UpdatePartnerMeRequest { 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/common/decorators/partner-permission.decorator.ts b/server/dukang-api/src/common/decorators/partner-permission.decorator.ts new file mode 100644 index 0000000..98c1dd0 --- /dev/null +++ b/server/dukang-api/src/common/decorators/partner-permission.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; +import type { PartnerPermissionKey } from '@dukang/shared-types'; + +export const PARTNER_PERMISSIONS_KEY = 'partner_permissions'; + +export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) => + SetMetadata(PARTNER_PERMISSIONS_KEY, permissions); diff --git a/server/dukang-api/src/common/guards/partner-permission.guard.ts b/server/dukang-api/src/common/guards/partner-permission.guard.ts new file mode 100644 index 0000000..12ac3c9 --- /dev/null +++ b/server/dukang-api/src/common/guards/partner-permission.guard.ts @@ -0,0 +1,45 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { PartnerPermissionKey } from '@dukang/shared-types'; +import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator'; +import { PrismaService } from '../prisma/prisma.module'; +import { AuthUser, JwtAuthGuard } from './jwt-auth.guard'; + +@Injectable() +export class PartnerPermissionGuard implements CanActivate { + constructor( + private readonly jwtAuthGuard: JwtAuthGuard, + private readonly prisma: PrismaService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + await this.jwtAuthGuard.canActivate(context); + const req = context.switchToHttp().getRequest(); + const user = req.user as AuthUser; + if (user.actorType !== 'PARTNER') { + throw new ForbiddenException('仅合伙人可操作'); + } + const account = await this.prisma.partnerAccount.findUniqueOrThrow({ + where: { id: user.actorId }, + }); + if (account.isPrimary === 1) return true; + + const required = this.reflector.getAllAndOverride( + PARTNER_PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + if (!required?.length) return true; + + const perms = Array.isArray(account.permissions) + ? (account.permissions as string[]) + : []; + if (required.some((p) => perms.includes(p))) return true; + throw new ForbiddenException('当前子账号无此操作权限'); + } +} 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/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index 8702871..45ecd70 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -21,6 +21,7 @@ import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { StoreMembershipService } from '../../common/guards/store-membership.service'; @@ -54,6 +55,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard, + PartnerPermissionGuard, ShopStoreGuard, ], exports: [ @@ -68,6 +70,7 @@ import { StoreMembershipService } from '../../common/guards/store-membership.ser OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard, + PartnerPermissionGuard, ShopStoreGuard, ], }) 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/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index f346dcb..1cdf07c 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -263,6 +263,8 @@ export class PartnerMeController { staffRole: account.staffRole ?? undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined, primaryAccountId: primary.id.toString(), + primaryPhone: primary.phone, + primaryName: primary.name, companyName: primary.companyName ?? undefined, hasWechat: !!account.wxOpenId, }; diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index 5e14a48..d9d44db 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -3,6 +3,8 @@ import { StoreService } from './store.service'; import { RedeemService } from '../redeem/redeem.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator'; +import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -76,7 +78,8 @@ export class PartnerStoreController { } @Controller('partner/dashboard') -@UseGuards(JwtAuthGuard, PartnerPrimaryGuard) +@UseGuards(JwtAuthGuard, PartnerPermissionGuard) +@RequirePartnerPermissions('store:create', 'store:manage', 'order:view', 'warehouse:manage') export class PartnerDashboardController { constructor(private readonly storeService: StoreService) {} diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index a50a30f..36a2c13 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -468,33 +468,56 @@ export class StoreService { async partnerDashboard(partnerAccountId: bigint) { const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId); - this.assertPrimaryAccount(account); - const partnerStoreIds = await this.prisma.store.findMany({ - where: { partnerAccountId: primaryId }, - select: { id: true }, + const primaryAccount = await this.prisma.partnerAccount.findUniqueOrThrow({ + where: { id: primaryId }, }); - const storeIds = partnerStoreIds.map((s) => s.id); - const [storeCount, orderCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([ - this.prisma.store.count({ where: { partnerAccountId: primaryId } }), - this.prisma.order.count({ - where: await this.partnerCityService.buildPartnerOrderWhere(primaryId), - }), + const orderCount = await this.prisma.order.count({ + where: await this.partnerCityService.buildPartnerOrderWhere(primaryId), + }); + + let storeWhere: { partnerAccountId: bigint; id?: { in: bigint[] } } = { + partnerAccountId: primaryId, + }; + if (this.isSubAccount(account)) { + const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId); + if (storeIds.length === 0) { + return { + storeCount: 0, + orderCount, + companyName: primaryAccount.companyName ?? '', + recentStores: [], + pendingAuditCount: 0, + notifications: [], + }; + } + storeWhere = { partnerAccountId: primaryId, id: { in: storeIds } }; + } + + const storeIdsForNotices = ( + await this.prisma.store.findMany({ + where: storeWhere, + select: { id: true }, + }) + ).map((s) => s.id); + + const [storeCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([ + this.prisma.store.count({ where: storeWhere }), this.prisma.store.findMany({ - where: { partnerAccountId: primaryId }, + where: storeWhere, select: { id: true, name: true, status: true, auditStatus: true, createdAt: true }, orderBy: { createdAt: 'desc' }, take: 10, }), this.prisma.store.count({ - where: { partnerAccountId: primaryId, auditStatus: 'PENDING' }, + where: { ...storeWhere, auditStatus: 'PENDING' }, }), - storeIds.length === 0 + storeIdsForNotices.length === 0 ? Promise.resolve([]) : this.prisma.commonEvent.findMany({ where: { eventType: 'STORE_AUDIT', refType: 'STORE', - refId: { in: storeIds }, + refId: { in: storeIdsForNotices }, status: { in: ['APPROVED', 'REJECTED'] }, actorType: 'HQ', }, @@ -515,7 +538,7 @@ export class StoreService { return { storeCount, orderCount, - companyName: account.companyName ?? '', + companyName: primaryAccount.companyName ?? '', recentStores: serializeBigInt(recentStores), pendingAuditCount, notifications: serializeBigInt( diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 4dcc1f0..9028752 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -3,12 +3,15 @@ import type { Request } from 'express'; import { TradeService } from './trade.service'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator'; +import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PartnerProxyOrderCreateDto, PartnerProxyOrderPreviewDto, PartnerProxyOrderSendSmsDto, } from './dto/partner-proxy-order.dto'; +import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto'; @Controller('trade/orders') @UseGuards(JwtAuthGuard) @@ -40,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); @@ -70,7 +78,8 @@ export class TradeController { } @Controller('partner/orders') -@UseGuards(JwtAuthGuard, PartnerPrimaryGuard) +@UseGuards(JwtAuthGuard, PartnerPermissionGuard) +@RequirePartnerPermissions('order:view', 'warehouse:manage') export class PartnerOrderController { constructor(private readonly tradeService: TradeService) {} @@ -88,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)