merge(dev_jacy): 仓配可调配履约与三分支推单
This commit is contained in:
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
@@ -74,6 +75,7 @@ export default function App() {
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
|
||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
|
||||
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/fulfillment-providers', label: '仓配管理' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WAREHOUSE_FULFILLMENT_MODE_LABELS,
|
||||
WAREHOUSE_MANAGER_LABELS,
|
||||
WAREHOUSE_STATUS_LABELS,
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
@@ -39,6 +43,13 @@ type Row = {
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
fulfillmentMode?: string;
|
||||
fulfillmentProviderId?: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -48,6 +59,54 @@ type PartnerOption = { id: string; companyName: string };
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
const FULFILLMENT_MODE_OPTIONS = Object.entries(WAREHOUSE_FULFILLMENT_MODE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
function FulfillmentFields({
|
||||
mode,
|
||||
providerOptions,
|
||||
}: {
|
||||
mode: WarehouseFulfillmentMode;
|
||||
providerOptions: FulfillmentProviderDto[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{mode === WarehouseFulfillmentMode.API_AUTO && (
|
||||
<Form.Item name="fulfillmentProviderId" label="仓配承运商" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{mode === WarehouseFulfillmentMode.MANUAL && (
|
||||
<>
|
||||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||||
<Input placeholder="如 顺丰速运" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="manualQueryUrlTemplate"
|
||||
label="物流查询链接模板"
|
||||
extra="可用 {trackingNo} 占位符"
|
||||
>
|
||||
<Input placeholder="https://example.com/track?no={trackingNo}" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Space>
|
||||
<Form.Item name="lng" label="经度">
|
||||
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
|
||||
</Form.Item>
|
||||
<Form.Item name="lat" label="纬度">
|
||||
<InputNumber step={0.001} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CityWarehousesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
@@ -73,6 +132,13 @@ export default function CityWarehousesPage() {
|
||||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||||
WarehouseFulfillmentMode.MANUAL,
|
||||
);
|
||||
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
@@ -88,6 +154,9 @@ export default function CityWarehousesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
|
||||
.then(setProviderOptions)
|
||||
.catch(() => {});
|
||||
}, [loadCities]);
|
||||
|
||||
async function openEdit(row: Row) {
|
||||
@@ -102,7 +171,14 @@ export default function CityWarehousesPage() {
|
||||
managerType: row.managerType,
|
||||
partnerAccountId: row.partnerAccountId,
|
||||
status: row.status,
|
||||
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
|
||||
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
|
||||
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
|
||||
lng: row.lng ?? undefined,
|
||||
lat: row.lat ?? undefined,
|
||||
});
|
||||
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
@@ -149,6 +225,14 @@ export default function CityWarehousesPage() {
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
row.fulfillmentMode === 'API_AUTO'
|
||||
? row.fulfillmentProviderName || 'API'
|
||||
: WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL],
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -341,6 +425,13 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式" initialValue={WarehouseFulfillmentMode.MANUAL}>
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setCreateFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={createFulfillmentMode} providerOptions={providerOptions} />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -399,6 +490,13 @@ export default function CityWarehousesPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentMode" label="履约方式">
|
||||
<Select
|
||||
options={FULFILLMENT_MODE_OPTIONS}
|
||||
onChange={(v) => setEditFulfillmentMode(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FulfillmentFields mode={editFulfillmentMode} providerOptions={providerOptions} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
export default function FulfillmentProvidersPage() {
|
||||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||
setRows(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function openCreate() {
|
||||
setEditRow(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
type: FulfillmentProviderType.API,
|
||||
status: FulfillmentProviderStatus.ACTIVE,
|
||||
capabilitiesJson: JSON.stringify(
|
||||
{ createShipment: true, getTrack: true, callback: true },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FulfillmentProviderDto) {
|
||||
setEditRow(row);
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '',
|
||||
configJson: '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields();
|
||||
if (editRow) {
|
||||
await request(`/admin/fulfillment-providers/${editRow.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
status: v.status,
|
||||
configJson: v.configJson || undefined,
|
||||
capabilitiesJson: v.capabilitiesJson || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await request('/admin/fulfillment-providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setOpen(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v) => (
|
||||
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '能力',
|
||||
render: (_, row) => {
|
||||
const caps = row.capabilities;
|
||||
if (!caps) return '—';
|
||||
return Object.entries(caps)
|
||||
.filter(([, on]) => on)
|
||||
.map(([k]) => k)
|
||||
.join('、') || '—';
|
||||
},
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册第三方履约接口后,仓库设置中方可选择对应承运商
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 小飞侠、京东物流" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="configJson" label="凭证配置 JSON(可选)">
|
||||
<Input.TextArea rows={3} placeholder="API 密钥等,仅存服务端" />
|
||||
</Form.Item>
|
||||
<Form.Item name="capabilitiesJson" label="能力配置 JSON">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,8 @@ type OrderDetail = AdminOrderRow & {
|
||||
productAmount?: number;
|
||||
freightAmount?: number;
|
||||
benefitAmount?: number;
|
||||
deliveryType?: string;
|
||||
fulfillmentWarehouseId?: string | null;
|
||||
paidAt?: string | null;
|
||||
payExpireAt?: string | null;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
@@ -63,6 +65,7 @@ type OrderDetail = AdminOrderRow & {
|
||||
export default function OrdersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -74,6 +77,7 @@ export default function OrdersPage() {
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
|
||||
const [shipping, setShipping] = useState(false);
|
||||
const [logisticsShipping, setLogisticsShipping] = useState(false);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||
@@ -143,6 +147,25 @@ export default function OrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogisticsShip() {
|
||||
if (!detail) return;
|
||||
const values = await logisticsForm.validateFields();
|
||||
setLogisticsShipping(true);
|
||||
try {
|
||||
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/logistics-ship`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('快递单已录入');
|
||||
setDetail(res);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '填单失败');
|
||||
} finally {
|
||||
setLogisticsShipping(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
if (!selectedRowKeys.length) return;
|
||||
setBatchDeleting(true);
|
||||
@@ -324,16 +347,55 @@ export default function OrdersPage() {
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
|
||||
{detail.delivery.logisticsCompany ||
|
||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||
detail.delivery.provider}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
|
||||
{detail.delivery.manualQueryUrl && (
|
||||
<Descriptions.Item label="查询链接">
|
||||
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
打开物流查询
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Title level={5}>发货</Typography.Title>
|
||||
{(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
|
||||
<>
|
||||
<Typography.Title level={5}>总部快递填单</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
适用于跨城订单或同城无仓订单
|
||||
</Typography.Paragraph>
|
||||
<Form form={logisticsForm} layout="vertical" size="small">
|
||||
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 顺丰速运、京东物流" />
|
||||
</Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
|
||||
提交快递单
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{detail.fulfillmentWarehouseId && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
仓配小飞侠重试
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
仓配订单通常支付后自动推单;失败时可手动重试
|
||||
</Typography.Paragraph>
|
||||
<Form form={shipForm} layout="vertical" size="small">
|
||||
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
|
||||
<Select
|
||||
@@ -389,6 +451,8 @@ export default function OrdersPage() {
|
||||
调用小飞侠发货
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||
import { isSubAccount } from './lib/partnerAccess';
|
||||
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -26,7 +26,6 @@ function PrimaryRoutes() {
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
@@ -39,6 +38,7 @@ function PrimaryRoutes() {
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders" element={<OrderListPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
@@ -46,16 +46,30 @@ function PrimaryRoutes() {
|
||||
}
|
||||
|
||||
function SubAccountRoutes() {
|
||||
const { account } = usePartnerSession();
|
||||
const navKind = getPartnerNavKind(account);
|
||||
const isWarehouse = navKind === 'warehouse_staff';
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<SubAccountLayout />}>
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route element={<SubAccountLayout navKind={navKind} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{isWarehouse ? (
|
||||
<Route path="/orders" element={<OrderListPage tabRoot />} />
|
||||
) : (
|
||||
<Route path="/stores" element={<StoreListPage />} />
|
||||
)}
|
||||
<Route path="/me" element={<PartnerMePage />} />
|
||||
</Route>
|
||||
{/* 与主账号一致:录入门店全屏,避免 sticky 底栏被 TabBar 挡住 */}
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
|
||||
{!isWarehouse && (
|
||||
<>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
</>
|
||||
)}
|
||||
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
||||
staffRole: profile.staffRole,
|
||||
permissions: profile.permissions,
|
||||
primaryAccountId: profile.primaryAccountId,
|
||||
primaryPhone: profile.primaryPhone,
|
||||
primaryName: profile.primaryName,
|
||||
hasWechat: profile.hasWechat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import type { PartnerNavKind } from '../lib/partnerAccess';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/stores', end: true, icon: 'store', label: '门店管理' },
|
||||
{ to: '/stores/new', icon: 'add_business', label: '录入门店' },
|
||||
{ to: '/me', icon: 'person', label: '我的' },
|
||||
const STORE_STAFF_TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/stores', icon: 'store', label: '门店管理' },
|
||||
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||
] as const;
|
||||
|
||||
export default function SubAccountLayout() {
|
||||
const WAREHOUSE_STAFF_TABS = [
|
||||
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
|
||||
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
|
||||
{ to: '/me', icon: 'person', label: '个人中心' },
|
||||
] as const;
|
||||
|
||||
type SubAccountLayoutProps = {
|
||||
navKind: PartnerNavKind;
|
||||
};
|
||||
|
||||
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
|
||||
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<nav className="app-tabbar">
|
||||
{TABS.map((tab) => (
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
|
||||
@@ -24,7 +24,7 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
|
||||
export type PartnerSessionProfile = Pick<
|
||||
PartnerMe,
|
||||
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat'
|
||||
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'primaryPhone' | 'primaryName' | 'hasWechat'
|
||||
> & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
@@ -109,6 +109,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
||||
staffRole: me.staffRole ?? undefined,
|
||||
permissions: me.permissions,
|
||||
primaryAccountId: me.primaryAccountId,
|
||||
primaryPhone: me.primaryPhone,
|
||||
primaryName: me.primaryName,
|
||||
hasWechat: me.hasWechat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 合伙人端客服热线(可后续改为环境变量) */
|
||||
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
|
||||
|
||||
export function dialPhone(phone: string) {
|
||||
const normalized = phone.replace(/\s+/g, '');
|
||||
if (!normalized) return false;
|
||||
window.location.href = `tel:${normalized}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function contactSupport() {
|
||||
return dialPhone(PARTNER_SUPPORT_PHONE);
|
||||
}
|
||||
|
||||
export function contactPartnerPhone(phone?: string) {
|
||||
return dialPhone(phone ?? '');
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
|
||||
|
||||
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
|
||||
|
||||
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
||||
return account?.isPrimary !== false;
|
||||
}
|
||||
@@ -17,15 +19,33 @@ export function hasPartnerPermission(
|
||||
return account.permissions?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return isSubAccount(account) ? '/stores/new?step=1' : '/';
|
||||
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
|
||||
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
|
||||
if (!account || isPrimaryAccount(account)) return false;
|
||||
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
|
||||
const hasStore =
|
||||
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
|
||||
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login'];
|
||||
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
|
||||
if (!account || isPrimaryAccount(account)) return 'primary';
|
||||
if (isWarehouseStaff(account)) return 'warehouse_staff';
|
||||
return 'store_staff';
|
||||
}
|
||||
|
||||
export function isSubAccountPath(pathname: string): boolean {
|
||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/login'];
|
||||
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/login'];
|
||||
|
||||
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
|
||||
if (pathname === '/login') return true;
|
||||
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
|
||||
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
|
||||
return prefixes.some(
|
||||
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,147 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { contactSupport } from '../lib/contact';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
export default function CenterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { account, logout } = usePartnerSession();
|
||||
const showPrimaryMenus = isPrimaryAccount(account);
|
||||
const me = account as unknown as Record<string, unknown> | null;
|
||||
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
|
||||
type CenterPageProps = {
|
||||
/** 子账号个人中心复用 */
|
||||
variant?: 'primary' | 'sub';
|
||||
roleLabel?: string;
|
||||
};
|
||||
|
||||
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
|
||||
const { account, logout, refresh } = usePartnerSession();
|
||||
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
|
||||
const [name, setName] = useState(account?.name ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||
}, [isPrimary]);
|
||||
|
||||
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM'));
|
||||
useEffect(() => {
|
||||
setName(account?.name ?? '');
|
||||
}, [account?.name]);
|
||||
|
||||
async function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
toastError('请输入姓名');
|
||||
return;
|
||||
}
|
||||
if (trimmed === account?.name) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await request('PARTNER_H5', '/partner/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: trimmed }),
|
||||
});
|
||||
await refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContactPartner() {
|
||||
if (isPrimary) {
|
||||
if (!contactSupport()) toastError('暂无客服电话');
|
||||
return;
|
||||
}
|
||||
if (!account?.primaryPhone) {
|
||||
toastError('暂无合伙人联系方式');
|
||||
return;
|
||||
}
|
||||
window.location.href = `tel:${account.primaryPhone}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-center-page">
|
||||
<header className="header app-page-header">
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<div className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<div className="partner-profile-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2>
|
||||
<span className="partner-role-badge">城市合伙人</span>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2>
|
||||
<span className="partner-role-badge">{roleLabel || (isPrimary ? '城市合伙人' : '拓店员')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
||||
<span className="body-md">{String(me?.companyName || '郑州')}</span>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
|
||||
<h3 className="headline-md">资产概览</h3>
|
||||
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
明细 <span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link to="/center/bills" className="partner-bills-banner">
|
||||
<div className="partner-bills-banner-left">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">pending_actions</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="body-md" style={{ fontWeight: 500 }}>待确认账单</p>
|
||||
<p className="label-md text-primary">您有 {pendingBills.length || bills.length} 笔账单待确认</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-primary">chevron_right</span>
|
||||
</Link>
|
||||
|
||||
<div className="partner-finance-grid">
|
||||
<div className="partner-finance-card partner-finance-card--hero">
|
||||
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}>账户余额 (元)</p>
|
||||
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
|
||||
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="partner-finance-card">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待结算</p>
|
||||
<p className="headline-md">
|
||||
<span className="text-primary">¥</span>
|
||||
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="partner-finance-card">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>账单笔数</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="headline-md">{bills.length} 笔</span>
|
||||
<span className="material-symbols-outlined text-muted">history</span>
|
||||
</div>
|
||||
<span className="body-md">{account?.companyName || '—'}</span>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>个人信息设置</h3>
|
||||
<div className="partner-menu-card" style={{ padding: '16px' }}>
|
||||
<label className="partner-form-label" htmlFor="center-name">姓名</label>
|
||||
<input
|
||||
id="center-name"
|
||||
className="partner-form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="请输入姓名"
|
||||
maxLength={32}
|
||||
/>
|
||||
<label className="partner-form-label" style={{ marginTop: 16 }}>手机号</label>
|
||||
<input className="partner-form-input" value={account?.phone ?? ''} readOnly disabled />
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>手机号由管理员维护,如需修改请联系总部</p>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
disabled={saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? '保存中…' : '保存资料'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>运营管理</h3>
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/stores" className="partner-menu-item">
|
||||
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>门店管理</span>
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>联系客服</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-menu-item">
|
||||
</button>
|
||||
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>订单中心</span>
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>{isPrimary ? '联系总部' : '联系合伙人'}</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/center/settlement" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
|
||||
</button>
|
||||
{isPrimary && (
|
||||
<Link to="/center/staff" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>子账号管理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>数据周报</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/reshipments" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>补发处理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/leaderboard" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>合伙人贡献榜</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
{showPrimaryMenus && (
|
||||
<Link to="/center/staff" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>子账号管理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<section>
|
||||
<div className="partner-order-summary-header">
|
||||
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
|
||||
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
查看全部 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="partner-order-stats">
|
||||
<div className="partner-order-stat">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--blue">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--green">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>) {
|
||||
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 (
|
||||
<div className="partner-leaderboard-section">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
|
||||
合伙人贡献榜
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{entries.length === 0 && !self ? (
|
||||
<p className="label-md text-muted">暂无排行数据</p>
|
||||
) : (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.accountId}
|
||||
className={`partner-leaderboard-row partner-leaderboard-row--compact${entry.isSelf ? ' partner-leaderboard-row--self' : ''}`}
|
||||
>
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{entry.name}
|
||||
{entry.isSelf ? <span className="partner-leaderboard-self-tag">我的排名</span> : null}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{self && !selfInList ? (
|
||||
<div className="partner-leaderboard-row partner-leaderboard-row--compact partner-leaderboard-row--self">
|
||||
<div className="partner-leaderboard-rank">{self.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{self.name}
|
||||
<span className="partner-leaderboard-self-tag">我的排名</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {self.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{self.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
const [notifOpen, setNotifOpen] = useState(false);
|
||||
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
|
||||
const [leaderboardSelf, setLeaderboardSelf] = useState<PartnerLeaderboardEntry | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '工作台';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Array<Record<string, unknown>>>('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<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders')
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([]));
|
||||
}
|
||||
if (!isWarehouse) {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {});
|
||||
request<Array<Record<string, unknown>>>('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<Record<string, unknown>>('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<Record<string, unknown>>)
|
||||
: [];
|
||||
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 (
|
||||
<div className="page partner-home">
|
||||
<header className="partner-home-header">
|
||||
<h1 className="app-page-title">工作台</h1>
|
||||
<div className="partner-home-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-notif-btn"
|
||||
aria-label="通知"
|
||||
onClick={() => setNotifOpen((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
{hasUnread && <span className="partner-notif-dot" />}
|
||||
</button>
|
||||
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notifOpen && (
|
||||
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h2 className="headline-md">审核通知</h2>
|
||||
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}>关闭</button>
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无审核通知</p>
|
||||
) : (
|
||||
notifications.slice(0, 8).map((n) => (
|
||||
<Link
|
||||
key={String(n.id)}
|
||||
to={`/stores/${n.storeId}`}
|
||||
className="partner-home-store-row"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={() => setNotifOpen(false)}
|
||||
>
|
||||
<div className="partner-home-store-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
|
||||
<p className="label-md text-muted">{String(n.content || '')}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="page partner-home partner-home--flush-top">
|
||||
<main className="partner-home-body">
|
||||
<section className="partner-revenue-card">
|
||||
<p className="partner-revenue-label">
|
||||
实时营业额 (CNY)
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
||||
</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
|
||||
<div className="partner-revenue-grid">
|
||||
<div>
|
||||
<p className="partner-revenue-label">预计利润</p>
|
||||
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="partner-bento">
|
||||
<section className="partner-bento-card">
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">门店总数</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--green" />
|
||||
{isPrimary && (
|
||||
<>
|
||||
<section className="partner-revenue-card">
|
||||
<p className="partner-revenue-label">
|
||||
实时营业额 (CNY)
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
|
||||
</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
|
||||
<div className="partner-revenue-grid">
|
||||
<div>
|
||||
<p className="label-md text-muted">正常运营</p>
|
||||
<p className="headline-md">{activeStores}</p>
|
||||
<p className="partner-revenue-label">预计利润</p>
|
||||
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--red" />
|
||||
<div>
|
||||
<p className="label-md text-muted">异常/闭店</p>
|
||||
<p className="headline-md">{abnormalStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<div className="partner-quick-actions">
|
||||
<Link to="/proxy-order" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">代下单</span>
|
||||
</Link>
|
||||
<Link to="/stores/new" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/reshipments" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
||||
<span className="material-symbols-outlined">assignment_return</span>
|
||||
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
|
||||
</div>
|
||||
<span className="partner-quick-action-label">补发处理</span>
|
||||
</Link>
|
||||
<Link to="/center/settlement" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||
<span className="material-symbols-outlined">account_balance</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">数据周报</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="partner-order-summary-header">
|
||||
<h2 className="headline-md">今日订单量 {orderCount}</h2>
|
||||
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
查看订单详情 <span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="partner-order-stats">
|
||||
<div className="partner-order-stat">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待发货</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--blue">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>配送中</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
|
||||
</div>
|
||||
<div className="partner-order-stat partner-order-stat--green">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>已完成</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-home-store-section">
|
||||
<div className="partner-home-store-header">
|
||||
<h2 className="headline-md">辖区门店</h2>
|
||||
<Link to="/stores" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewStores.length === 0 ? (
|
||||
<div className="partner-home-store-empty">
|
||||
<p className="label-md text-muted">暂无门店</p>
|
||||
<Link to="/stores/new" className="label-md text-primary">录入新店</Link>
|
||||
</div>
|
||||
) : (
|
||||
previewStores.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const status = String(s.status || 'PAUSED').toUpperCase();
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
return (
|
||||
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
||||
<div className="partner-home-store-info">
|
||||
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
||||
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
||||
</section>
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-quick-actions">
|
||||
<Link to="/proxy-order" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
|
||||
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
|
||||
</span>
|
||||
<span className="partner-quick-action-label">代下单</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-expansion-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="partner-leaderboard-section">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
|
||||
合伙人贡献榜
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{leaderboardPreview.length === 0 ? (
|
||||
<p className="label-md text-muted">暂无排行数据</p>
|
||||
) : (
|
||||
leaderboardPreview.map((entry) => (
|
||||
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
<Link to="/stores/new" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/reshipments" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
||||
<span className="material-symbols-outlined">assignment_return</span>
|
||||
{pendingShipBadge > 0 && <span className="partner-quick-badge-count">{pendingShipBadge}</span>}
|
||||
</div>
|
||||
<span className="partner-quick-action-label">补发处理</span>
|
||||
</Link>
|
||||
<Link to="/center/settlement" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--green">
|
||||
<span className="material-symbols-outlined">account_balance</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">数据周报</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWarehouse && (
|
||||
<p className="headline-md" style={{ marginBottom: 8 }}>订单管理</p>
|
||||
)}
|
||||
|
||||
{(isPrimary || isWarehouse) && (
|
||||
<OrderSummarySection
|
||||
title={isWarehouse ? '今日订单' : undefined}
|
||||
todayCount={orderStats.todayCount}
|
||||
pendingShip={orderStats.pendingShip}
|
||||
shipping={orderStats.shipping}
|
||||
completed={orderStats.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isWarehouse && (
|
||||
<>
|
||||
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
|
||||
<div className="partner-bento-header">
|
||||
<h2 className="headline-md">门店总数</h2>
|
||||
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
|
||||
</div>
|
||||
<div className="partner-store-stats">
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--green" />
|
||||
<div>
|
||||
<p className="label-md text-muted">正常运营</p>
|
||||
<p className="headline-md">{activeStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="partner-store-stat">
|
||||
<span className="partner-dot partner-dot--red" />
|
||||
<div>
|
||||
<p className="label-md text-muted">异常/闭店</p>
|
||||
<p className="headline-md">{abnormalStores}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||
</p>
|
||||
</section>
|
||||
<section className="partner-home-store-section">
|
||||
<div className="partner-home-store-header">
|
||||
<h2 className="headline-md">辖区门店</h2>
|
||||
<Link to="/stores" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewStores.length === 0 ? (
|
||||
<div className="partner-home-store-empty">
|
||||
<p className="label-md text-muted">暂无门店</p>
|
||||
<Link to={isPrimary ? '/stores/new' : '/stores/new?step=1'} className="label-md text-primary">录入新店</Link>
|
||||
</div>
|
||||
) : (
|
||||
previewStores.map((s) => {
|
||||
const storeId = String(s.id);
|
||||
const status = String(s.status || 'PAUSED').toUpperCase();
|
||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||
return (
|
||||
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
|
||||
<div className="partner-home-store-info">
|
||||
<p className="headline-md">{String(s.name || '未命名门店')}</p>
|
||||
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
|
||||
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="partner-expansion-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 16 }}>拓店情况</h2>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} self={leaderboardSelf} />
|
||||
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<Record<string, unknown> | 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<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
|
||||
}, [id]);
|
||||
|
||||
async function reload() {
|
||||
if (!id) return;
|
||||
const next = await request<Record<string, unknown>>('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<string, unknown> | undefined;
|
||||
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
|
||||
if (trackingNo) {
|
||||
void navigator.clipboard?.writeText(trackingNo);
|
||||
toastSuccess(`运单号已复制:${trackingNo}`);
|
||||
return;
|
||||
}
|
||||
toastError('暂无配送员联系方式');
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
@@ -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 (
|
||||
<div className="partner-order-detail">
|
||||
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
|
||||
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
|
||||
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}>正品保证</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
|
||||
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
|
||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>佣金明细</p>
|
||||
<div className="partner-info-row">
|
||||
<span className="label-md text-muted">订单佣金</span>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="label-md text-muted">权益核销</span>
|
||||
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>赠送好客权益</p>
|
||||
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="partner-detail-section">
|
||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||
订单信息
|
||||
</h4>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-muted body-md">订单编号</span>
|
||||
<span className="body-md">{String(order.orderNo)}</span>
|
||||
</div>
|
||||
<div className="partner-info-row">
|
||||
<span className="text-muted body-md">订单状态</span>
|
||||
<span className="body-md">{String(order.status)}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-detail-section">
|
||||
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
|
||||
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
|
||||
</section>
|
||||
|
||||
<footer className="partner-order-footer">
|
||||
<div>
|
||||
<span className="label-md text-muted">佣金合计</span>
|
||||
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p>
|
||||
<div className="partner-order-footer-actions">
|
||||
{showShip && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary partner-order-ship-btn--footer"
|
||||
disabled={shipping}
|
||||
onClick={() => void advance('OUT_WAREHOUSE')}
|
||||
>
|
||||
{shipping ? '发货中…' : '确认发货'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
|
||||
onClick={handleContactCourier}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||
联系配送员
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
|
||||
联系配送员
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<details className="partner-dev-tools">
|
||||
<summary>Dev: Mock 推进配送</summary>
|
||||
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
|
||||
<button key={s} type="button" onClick={() => advance(s)}>{s}</button>
|
||||
<button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -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<Record<string, unknown>> }>({ list: [] });
|
||||
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
const [shippingId, setShippingId] = useState<string | null>(null);
|
||||
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
|
||||
const [shipModalId, setShipModalId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = '订单管理';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/orders').then(setData);
|
||||
request<{ list: Array<Record<string, unknown>> }>('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<Record<string, unknown>> }>('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 (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="订单中心" onBack={() => navigate('/')} />
|
||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
|
||||
<div className="partner-segment">
|
||||
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}>订单列表</button>
|
||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||
{!tabRoot && (
|
||||
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}>权益记录</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card">
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||
</div>
|
||||
<div className="partner-order-product">
|
||||
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="material-symbols-outlined text-muted">liquor</span>
|
||||
<div key={orderId} className="partner-order-card-wrap">
|
||||
<Link to={`/orders/${orderId}`} className="partner-order-card">
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
|
||||
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||
<span className="partner-benefit-tag">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
|
||||
¥{Math.round(payAmount * 0.2)}权益
|
||||
</span>
|
||||
<div className="partner-order-product">
|
||||
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span className="material-symbols-outlined text-muted">liquor</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
|
||||
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-order-address">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||
</div>
|
||||
<div className="partner-order-commission">
|
||||
<div>
|
||||
<p className="label-md text-muted">下单佣金</p>
|
||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
|
||||
<div className="partner-order-address">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">核销佣金</p>
|
||||
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">获赠权益</p>
|
||||
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-order-ship-btn"
|
||||
disabled={shippingId === orderId}
|
||||
onClick={(e) => void handleShip(orderId, e)}
|
||||
>
|
||||
填写运单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'coupons' && (
|
||||
{tab === 'coupons' && !tabRoot && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">累计已发放权益金额</p>
|
||||
@@ -134,6 +177,50 @@ export default function OrderListPage() {
|
||||
<p className="text-muted body-md text-center">权益记录 preV1 占位</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shipModalId && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md">填写运单</h3>
|
||||
<label className="partner-ship-field">
|
||||
<span>快递公司</span>
|
||||
<input
|
||||
value={shipForm.logisticsCompany}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
|
||||
placeholder="如 顺丰速运"
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>运单号</span>
|
||||
<input
|
||||
value={shipForm.trackingNo}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-ship-field">
|
||||
<span>查询链接(可选)</span>
|
||||
<input
|
||||
value={shipForm.manualQueryUrl}
|
||||
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={shippingId === shipModalId}
|
||||
onClick={() => void submitManualShip()}
|
||||
>
|
||||
{shippingId === shipModalId ? '提交中…' : '确认发货'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="page partner-center-page partner-me-page">
|
||||
<header className="header app-page-header">
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<section className="partner-profile-card">
|
||||
<div className="partner-profile-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '合伙人'}</h2>
|
||||
<span className="partner-role-badge">{roleLabel}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
|
||||
<span className="body-md">{account?.companyName || '—'}</span>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>账号资料</h3>
|
||||
<div className="partner-menu-card" style={{ padding: '16px' }}>
|
||||
<label className="partner-form-label" htmlFor="me-name">姓名</label>
|
||||
<input
|
||||
id="me-name"
|
||||
className="partner-form-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="请输入姓名"
|
||||
maxLength={32}
|
||||
/>
|
||||
|
||||
<label className="partner-form-label" style={{ marginTop: 16 }}>手机号</label>
|
||||
<input
|
||||
className="partner-form-input"
|
||||
value={account?.phone ?? ''}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
||||
手机号由管理员维护,如需修改请联系总部
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
disabled={saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? '保存中…' : '保存资料'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-menu-section">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>快捷入口</h3>
|
||||
<div className="partner-menu-card">
|
||||
<Link to="/stores" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>门店管理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/stores/new?step=1" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">add_business</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>录入新门店</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" className="partner-logout-btn" onClick={logout}>
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="page partner-store-page">
|
||||
<header className="header app-page-header">
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
</header>
|
||||
|
||||
<div className="partner-page-title-block">
|
||||
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
|
||||
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
|
||||
</div>
|
||||
|
||||
<div className="page partner-store-page partner-home--flush-top">
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,15 @@ type OrderItem = {
|
||||
|
||||
type OrderDelivery = {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
manualQueryUrl?: string;
|
||||
providerOrderNo?: string;
|
||||
};
|
||||
|
||||
type TrackNode = {
|
||||
trackInfo?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type OrderPayment = {
|
||||
@@ -101,8 +110,10 @@ function progressActiveIndex(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryProviderLabel(provider?: string) {
|
||||
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
||||
function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
|
||||
if (provider === 'LOGISTICS') return '快递配送';
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -124,13 +135,27 @@ export default function OrderDetailPage() {
|
||||
const [copyHint, setCopyHint] = useState('');
|
||||
const [shareToast, setShareToast] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT';
|
||||
|
||||
async function loadTrack() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
|
||||
setTrackNodes(data.nodes ?? []);
|
||||
} catch {
|
||||
setTrackNodes([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrder() {
|
||||
if (!id) return;
|
||||
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
|
||||
setOrder(data);
|
||||
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
|
||||
void loadTrack();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -353,11 +378,49 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
<div className="order-detail-kv">
|
||||
<span>配送方式</span>
|
||||
<span>{deliveryProviderLabel(order.delivery?.provider)}</span>
|
||||
<span>
|
||||
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
|
||||
</span>
|
||||
</div>
|
||||
{order.delivery?.trackingNo && (
|
||||
<div className="order-detail-kv">
|
||||
<span>运单号</span>
|
||||
<span>{order.delivery.trackingNo}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.delivery?.manualQueryUrl && (
|
||||
<div className="order-detail-kv">
|
||||
<span>物流查询</span>
|
||||
<a
|
||||
href={order.delivery.manualQueryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="order-detail-link"
|
||||
>
|
||||
查看物流
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{trackNodes.length > 0 && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
<span className="material-symbols-outlined">timeline</span>
|
||||
物流动态
|
||||
</h3>
|
||||
<div className="order-detail-kv-list order-detail-kv-list--bordered">
|
||||
{trackNodes.map((node, index) => (
|
||||
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
|
||||
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
|
||||
<span>{node.trackInfo || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isReship && (
|
||||
<section className="order-detail-card">
|
||||
<h3 className="order-detail-card-title">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
import type { WarehouseFulfillmentMode, WarehouseManagerType, WarehouseStatus } from './enums';
|
||||
|
||||
export interface CityWarehouseDto {
|
||||
id: string;
|
||||
@@ -11,6 +11,14 @@ export interface CityWarehouseDto {
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: WarehouseStatus;
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId: string | null;
|
||||
fulfillmentProviderName?: string | null;
|
||||
fulfillmentProviderCode?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -23,6 +31,12 @@ export interface CreateCityWarehouseInput {
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: string;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string;
|
||||
manualCarrierLabel?: string;
|
||||
manualQueryUrlTemplate?: string;
|
||||
lng?: number;
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCityWarehouseInput {
|
||||
@@ -33,4 +47,10 @@ export interface UpdateCityWarehouseInput {
|
||||
managerType?: WarehouseManagerType;
|
||||
partnerAccountId?: string | null;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
@@ -168,6 +168,36 @@ export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
|
||||
[WarehouseStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export enum FulfillmentProviderType {
|
||||
API = 'API',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export enum FulfillmentProviderStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
}
|
||||
|
||||
export enum WarehouseFulfillmentMode {
|
||||
API_AUTO = 'API_AUTO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export const FULFILLMENT_PROVIDER_TYPE_LABELS: Record<FulfillmentProviderType, string> = {
|
||||
[FulfillmentProviderType.API]: 'API 对接',
|
||||
[FulfillmentProviderType.MANUAL]: '自管',
|
||||
};
|
||||
|
||||
export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record<FulfillmentProviderStatus, string> = {
|
||||
[FulfillmentProviderStatus.ACTIVE]: '启用',
|
||||
[FulfillmentProviderStatus.DISABLED]: '停用',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_FULFILLMENT_MODE_LABELS: Record<WarehouseFulfillmentMode, string> = {
|
||||
[WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单',
|
||||
[WarehouseFulfillmentMode.MANUAL]: '自管手工填单',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums';
|
||||
import type { WarehouseFulfillmentMode } from './enums';
|
||||
|
||||
export interface FulfillmentProviderDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status: FulfillmentProviderStatus;
|
||||
capabilities?: {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
} | null;
|
||||
hasConfig: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateFulfillmentProviderInput {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFulfillmentProviderInput {
|
||||
name?: string;
|
||||
type?: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export interface ManualShipOrderInput {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseFulfillmentConfig {
|
||||
fulfillmentMode: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: string | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
}
|
||||
@@ -18,5 +18,6 @@ export * from './partner';
|
||||
export * from './shop';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './legal';
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface PartnerMe {
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
/** 子账号联系主账号用 */
|
||||
primaryPhone?: string;
|
||||
primaryName?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerMeRequest {
|
||||
|
||||
@@ -13,6 +13,7 @@ async function main() {
|
||||
await prisma.storeAccount.deleteMany();
|
||||
await prisma.store.deleteMany();
|
||||
await prisma.partnerBill.deleteMany();
|
||||
await prisma.fulfillmentProvider.deleteMany();
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
const result = await prisma.commonCity.deleteMany();
|
||||
|
||||
@@ -141,6 +141,21 @@ enum WarehouseStatus {
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum FulfillmentProviderType {
|
||||
API
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum FulfillmentProviderStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum WarehouseFulfillmentMode {
|
||||
API_AUTO
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum PartnerStaffRole {
|
||||
PARTNER
|
||||
INTERNAL
|
||||
@@ -496,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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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<boolean> {
|
||||
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<PartnerPermissionKey[]>(
|
||||
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('当前子账号无此操作权限');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import {
|
||||
Prisma,
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from './partner-city.service';
|
||||
@@ -13,9 +18,27 @@ export type CreateCityWarehouseInput = {
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: bigint;
|
||||
status?: WarehouseStatus;
|
||||
fulfillmentMode?: WarehouseFulfillmentMode;
|
||||
fulfillmentProviderId?: bigint;
|
||||
manualCarrierLabel?: string;
|
||||
manualQueryUrlTemplate?: string;
|
||||
lng?: number;
|
||||
lat?: number;
|
||||
};
|
||||
|
||||
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
|
||||
export type UpdateCityWarehouseInput = Partial<
|
||||
Omit<
|
||||
CreateCityWarehouseInput,
|
||||
'partnerAccountId' | 'fulfillmentProviderId' | 'manualCarrierLabel' | 'manualQueryUrlTemplate' | 'lng' | 'lat'
|
||||
>
|
||||
> & {
|
||||
partnerAccountId?: bigint | null;
|
||||
fulfillmentProviderId?: bigint | null;
|
||||
manualCarrierLabel?: string | null;
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
lng?: number | null;
|
||||
lat?: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CityWarehouseService {
|
||||
@@ -29,6 +52,7 @@ export class CityWarehouseService {
|
||||
where: { cityId },
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -51,6 +75,7 @@ export class CityWarehouseService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -73,6 +98,7 @@ export class CityWarehouseService {
|
||||
async create(cityId: bigint, input: CreateCityWarehouseInput) {
|
||||
await this.assertCityExists(cityId);
|
||||
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
|
||||
await this.validateFulfillment(input.fulfillmentMode, input.fulfillmentProviderId);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.create({
|
||||
data: {
|
||||
@@ -84,9 +110,17 @@ export class CityWarehouseService {
|
||||
managerType: input.managerType,
|
||||
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
fulfillmentMode: input.fulfillmentMode ?? 'MANUAL',
|
||||
fulfillmentProviderId:
|
||||
input.fulfillmentMode === 'API_AUTO' ? input.fulfillmentProviderId : null,
|
||||
manualCarrierLabel: input.manualCarrierLabel?.trim() || null,
|
||||
manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null,
|
||||
lng: input.lng != null ? input.lng : null,
|
||||
lat: input.lat != null ? input.lat : null,
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
@@ -102,8 +136,16 @@ export class CityWarehouseService {
|
||||
managerType === 'PARTNER'
|
||||
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
|
||||
: null;
|
||||
const fulfillmentMode = input.fulfillmentMode ?? current.fulfillmentMode;
|
||||
const fulfillmentProviderId =
|
||||
fulfillmentMode === 'API_AUTO'
|
||||
? input.fulfillmentProviderId !== undefined
|
||||
? input.fulfillmentProviderId
|
||||
: current.fulfillmentProviderId
|
||||
: null;
|
||||
|
||||
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
|
||||
await this.validateFulfillment(fulfillmentMode, fulfillmentProviderId ?? undefined);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.update({
|
||||
where: { id },
|
||||
@@ -117,9 +159,22 @@ export class CityWarehouseService {
|
||||
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
|
||||
: {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.fulfillmentMode !== undefined ? { fulfillmentMode } : {}),
|
||||
...(input.fulfillmentMode !== undefined || input.fulfillmentProviderId !== undefined
|
||||
? { fulfillmentProviderId }
|
||||
: {}),
|
||||
...(input.manualCarrierLabel !== undefined
|
||||
? { manualCarrierLabel: input.manualCarrierLabel?.trim() || null }
|
||||
: {}),
|
||||
...(input.manualQueryUrlTemplate !== undefined
|
||||
? { manualQueryUrlTemplate: input.manualQueryUrlTemplate?.trim() || null }
|
||||
: {}),
|
||||
...(input.lng !== undefined ? { lng: input.lng } : {}),
|
||||
...(input.lat !== undefined ? { lat: input.lat } : {}),
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
fulfillmentProvider: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
@@ -170,6 +225,19 @@ export class CityWarehouseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateFulfillment(
|
||||
mode?: WarehouseFulfillmentMode,
|
||||
providerId?: bigint,
|
||||
) {
|
||||
if (mode === 'API_AUTO') {
|
||||
if (!providerId) throw new BadRequestException('API 自动推单须选择仓配承运商');
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
throw new BadRequestException('所选仓配承运商不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCityExists(cityId: bigint) {
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
|
||||
if (!city) throw new NotFoundException('开城城市不存在');
|
||||
@@ -185,9 +253,16 @@ export class CityWarehouseService {
|
||||
managerType: string;
|
||||
partnerAccountId: bigint | null;
|
||||
status: string;
|
||||
fulfillmentMode: string;
|
||||
fulfillmentProviderId: bigint | null;
|
||||
manualCarrierLabel: string | null;
|
||||
manualQueryUrlTemplate: string | null;
|
||||
lng: Prisma.Decimal | null;
|
||||
lat: Prisma.Decimal | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
partnerAccount?: { id: bigint; companyName: string | null } | null;
|
||||
fulfillmentProvider?: { id: bigint; code: string; name: string } | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
@@ -200,6 +275,14 @@ export class CityWarehouseService {
|
||||
partnerAccountId: row.partnerAccountId?.toString() ?? null,
|
||||
partnerCompanyName: row.partnerAccount?.companyName ?? null,
|
||||
status: row.status,
|
||||
fulfillmentMode: row.fulfillmentMode,
|
||||
fulfillmentProviderId: row.fulfillmentProviderId?.toString() ?? null,
|
||||
fulfillmentProviderName: row.fulfillmentProvider?.name ?? null,
|
||||
fulfillmentProviderCode: row.fulfillmentProvider?.code ?? null,
|
||||
manualCarrierLabel: row.manualCarrierLabel,
|
||||
manualQueryUrlTemplate: row.manualQueryUrlTemplate,
|
||||
lng: row.lng != null ? Number(row.lng) : null,
|
||||
lat: row.lat != null ? Number(row.lat) : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -101,9 +101,38 @@ export class PartnerCityService {
|
||||
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
|
||||
const warehouseIds = await this.resolveManagedWarehouseIds(primary.id);
|
||||
if (warehouseIds.length > 0) {
|
||||
return { fulfillmentWarehouseId: { in: warehouseIds } };
|
||||
}
|
||||
return { cityId: primary.cityId };
|
||||
}
|
||||
|
||||
/** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */
|
||||
async resolveManagedWarehouseIds(partnerAccountId: bigint): Promise<bigint[]> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
const ids = new Set<bigint>();
|
||||
|
||||
if (primary.managedWarehouseId) {
|
||||
ids.add(primary.managedWarehouseId);
|
||||
}
|
||||
|
||||
const managed = await this.prisma.cityWarehouse.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ partnerAccountId: primary.id },
|
||||
...(primary.managedWarehouseId ? [{ id: primary.managedWarehouseId }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
for (const row of managed) ids.add(row.id);
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
export type CreateFulfillmentProviderInput = {
|
||||
code: string;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
};
|
||||
|
||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||
|
||||
type Capabilities = {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentProviderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActiveApiProviders() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
where: { status: 'ACTIVE', type: 'API' },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async getById(id: bigint) {
|
||||
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(input: CreateFulfillmentProviderInput) {
|
||||
const code = input.code.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9_]+$/.test(code)) {
|
||||
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
|
||||
}
|
||||
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
|
||||
if (existing) throw new BadRequestException('承运商编码已存在');
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
configJson: input.configJson?.trim() || null,
|
||||
capabilitiesJson: input.capabilitiesJson?.trim() || null,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
||||
await this.getById(id);
|
||||
const row = await this.prisma.fulfillmentProvider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.type !== undefined ? { type: input.type } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}),
|
||||
...(input.capabilitiesJson !== undefined
|
||||
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
parseCapabilities(raw: string | null): Capabilities | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Capabilities;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
configJson: string | null;
|
||||
capabilitiesJson: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
||||
hasConfig: Boolean(row.configJson),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
import { FulfillmentService } from './fulfillment.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
||||
providers: [FulfillmentProviderService, FulfillmentService],
|
||||
exports: [FulfillmentProviderService, FulfillmentService],
|
||||
})
|
||||
export class FulfillmentModule {}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
|
||||
export type ManualShipInput = {
|
||||
logisticsCompany: string;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
export type HqLogisticsShipInput = ManualShipInput;
|
||||
|
||||
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentService {
|
||||
private readonly logger = new Logger(FulfillmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly courier: CourierService,
|
||||
@Inject(forwardRef(() => TradeService))
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
|
||||
async dispatchAfterPay(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order || order.payStatus !== 'PAID') return;
|
||||
|
||||
if (order.deliveryType === 'CROSS_CITY') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
|
||||
if (!warehouse) {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { fulfillmentWarehouseId: warehouse.id },
|
||||
});
|
||||
|
||||
if (warehouse.fulfillmentMode === 'MANUAL') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
||||
where: { id: warehouse.fulfillmentProviderId },
|
||||
});
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
await this.dispatchApiAuto(order, warehouse, provider);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!XFX_CODES.has(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = this.getShipDefaults();
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat;
|
||||
|
||||
try {
|
||||
const result = await this.courier.createShipment({
|
||||
outNumber: order.orderNo,
|
||||
from: {
|
||||
name: warehouse.contactName,
|
||||
mobile: warehouse.contactPhone,
|
||||
address: warehouse.address,
|
||||
addressDetail: warehouse.name,
|
||||
coordinate: { lng: fromLng, lat: fromLat },
|
||||
},
|
||||
to: {
|
||||
name: order.receiverName,
|
||||
mobile: order.receiverPhone,
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
weight: defaults.weight,
|
||||
payMode: defaults.payMode,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider: 'XFX' as const,
|
||||
fulfillmentProviderId: provider.id,
|
||||
trackingNo: result.trackingNumber,
|
||||
providerOrderNo: String(result.providerShipmentId),
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
await tx.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
externalNo: result.trackingNumber,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.logDispatchFailure(order, provider, message);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
}
|
||||
}
|
||||
|
||||
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在或无权操作');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
|
||||
const queryUrl =
|
||||
input.manualQueryUrl?.trim() ||
|
||||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: queryUrl,
|
||||
operator: 'WAREHOUSE_MANUAL',
|
||||
});
|
||||
}
|
||||
|
||||
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
const isHqQueue =
|
||||
order.deliveryType === 'CROSS_CITY' ||
|
||||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
|
||||
|
||||
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: input.manualQueryUrl?.trim(),
|
||||
operator: 'HQ_LOGISTICS',
|
||||
provider: 'LOGISTICS',
|
||||
});
|
||||
}
|
||||
|
||||
async getOrderTrack(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order?.delivery) {
|
||||
return { nodes: [], manualQueryUrl: null };
|
||||
}
|
||||
|
||||
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||
try {
|
||||
const nodes = await this.courier.getTrack({
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
} catch {
|
||||
// fall through to manual fields
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [],
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyManualShip(
|
||||
order: Order & { delivery: { trackingNo: string | null } | null },
|
||||
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
|
||||
) {
|
||||
const now = new Date();
|
||||
const provider = input.provider ?? 'MANUAL';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider,
|
||||
logisticsCompany: input.logisticsCompany,
|
||||
trackingNo: input.trackingNo,
|
||||
manualQueryUrl: input.manualQueryUrl || null,
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { delivery: true, fulfillmentWarehouse: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWarehouseForLocalOrder(cityId: bigint) {
|
||||
return this.prisma.cityWarehouse.findFirst({
|
||||
where: { cityId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDeliveryRecord(
|
||||
orderId: bigint,
|
||||
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
|
||||
fulfillmentProviderId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (existing) return;
|
||||
await this.prisma.orderDelivery.create({
|
||||
data: {
|
||||
orderId,
|
||||
provider,
|
||||
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'FAILED',
|
||||
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
||||
if (!warehouseId) return undefined;
|
||||
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
||||
const tpl = wh?.manualQueryUrlTemplate;
|
||||
if (!tpl) return undefined;
|
||||
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
|
||||
}
|
||||
|
||||
private getShipDefaults() {
|
||||
return {
|
||||
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
|
||||
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,22 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
|
||||
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
|
||||
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
|
||||
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
|
||||
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import type {
|
||||
WarehouseFulfillmentMode,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
function mapWarehouseFulfillment(dto: CreateCityWarehouseDto | UpdateCityWarehouseDto) {
|
||||
return {
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId: dto.fulfillmentProviderId ? BigInt(dto.fulfillmentProviderId) : undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel ?? undefined,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate ?? undefined,
|
||||
lng: dto.lng ?? undefined,
|
||||
lat: dto.lat ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('admin/cities/:cityId/warehouses')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -33,6 +48,7 @@ export class AdminCityWarehousesController {
|
||||
managerType: dto.managerType as WarehouseManagerType,
|
||||
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
...mapWarehouseFulfillment(dto),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,11 +79,22 @@ export class AdminCityWarehouseMutationsController {
|
||||
managerType: dto.managerType as WarehouseManagerType | undefined,
|
||||
partnerAccountId:
|
||||
dto.partnerAccountId === null
|
||||
? undefined
|
||||
? null
|
||||
: dto.partnerAccountId
|
||||
? BigInt(dto.partnerAccountId)
|
||||
: undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
fulfillmentMode: dto.fulfillmentMode as WarehouseFulfillmentMode | undefined,
|
||||
fulfillmentProviderId:
|
||||
dto.fulfillmentProviderId === null
|
||||
? null
|
||||
: dto.fulfillmentProviderId
|
||||
? BigInt(dto.fulfillmentProviderId)
|
||||
: undefined,
|
||||
manualCarrierLabel: dto.manualCarrierLabel,
|
||||
manualQueryUrlTemplate: dto.manualQueryUrlTemplate,
|
||||
lng: dto.lng,
|
||||
lat: dto.lat,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import {
|
||||
CreateFulfillmentProviderDto,
|
||||
UpdateFulfillmentProviderDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
|
||||
@Controller('admin/fulfillment-providers')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminFulfillmentProvidersController {
|
||||
constructor(private readonly service: FulfillmentProviderService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.listAll();
|
||||
}
|
||||
|
||||
@Get('active-api')
|
||||
listActiveApi() {
|
||||
return this.service.listActiveApiProviders();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateFulfillmentProviderDto) {
|
||||
return this.service.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'FULFILLMENT_PROVIDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateFulfillmentProviderDto) {
|
||||
return this.service.update(BigInt(id), {
|
||||
name: dto.name,
|
||||
type: dto.type as FulfillmentProviderType | undefined,
|
||||
status: dto.status as FulfillmentProviderStatus | undefined,
|
||||
configJson: dto.configJson,
|
||||
capabilitiesJson: dto.capabilitiesJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@@ -51,6 +51,17 @@ export class AdminOrdersController {
|
||||
return this.ordersService.shipOrder(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Post(':id/logistics-ship')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_SHIP,
|
||||
refType: 'ORDER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
shipLogistics(@Param('id') id: string, @Body() dto: HqLogisticsShipDto) {
|
||||
return this.ordersService.shipLogistics(BigInt(id), dto);
|
||||
}
|
||||
|
||||
/** preV1 调试:直接改订单状态,不走业务校验 */
|
||||
@Put(':id/status')
|
||||
@HqOperation({
|
||||
|
||||
@@ -8,8 +8,9 @@ import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-comp
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminShipOrderDto } from './dto/admin-mutate.dto';
|
||||
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
@@ -17,6 +18,7 @@ export class AdminOrdersService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly xiaofeixiaService: AdminXiaofeixiaService,
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -183,6 +185,12 @@ export class AdminOrdersService {
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
/** 总部传统快递填单(同城无仓 / 跨城) */
|
||||
async shipLogistics(id: bigint, dto: HqLogisticsShipDto) {
|
||||
await this.fulfillmentService.shipHqLogistics(id, dto);
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async batchDeleteOrders(ids: bigint[]) {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (!uniqueIds.length) {
|
||||
|
||||
@@ -474,6 +474,30 @@ export class CreateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualCarrierLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lng?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lat?: number;
|
||||
}
|
||||
|
||||
export class UpdateCityWarehouseDto {
|
||||
@@ -505,8 +529,100 @@ export class UpdateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API_AUTO', 'MANUAL'])
|
||||
fulfillmentMode?: 'API_AUTO' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
fulfillmentProviderId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualCarrierLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
manualQueryUrlTemplate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lng?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsNumber()
|
||||
lat?: number | null;
|
||||
}
|
||||
|
||||
export class CreateFulfillmentProviderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class UpdateFulfillmentProviderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['API', 'MANUAL'])
|
||||
type?: 'API' | 'MANUAL';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
configJson?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
capabilitiesJson?: string;
|
||||
}
|
||||
|
||||
export class ManualShipOrderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
logisticsCompany: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
trackingNo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manualQueryUrl?: string;
|
||||
}
|
||||
|
||||
export class HqLogisticsShipDto extends ManualShipOrderDto {}
|
||||
|
||||
export class CreateStoreMediaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
@@ -54,9 +55,10 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
import { AdminDeployController } from './admin-deploy.controller';
|
||||
import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -89,6 +91,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
AdminFulfillmentProvidersController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { PromoModule } from '../promo/promo.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import {
|
||||
TradeController,
|
||||
PartnerOrderController,
|
||||
@@ -24,6 +25,7 @@ import { TradeService } from './trade.service';
|
||||
CityScopeModule,
|
||||
PromoModule,
|
||||
forwardRef(() => BenefitModule),
|
||||
forwardRef(() => FulfillmentModule),
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import type { FreightPayType } from '@prisma/client';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { buildOrderClientLocationSnapshot } from '../../common/geo/client-locati
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@@ -45,6 +47,8 @@ export class TradeService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly promoCodeService: PromoCodeService,
|
||||
@Inject(forwardRef(() => FulfillmentService))
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
@@ -268,8 +272,7 @@ export class TradeService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
|
||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
@@ -281,6 +284,15 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
private async afterOrderPaid(orderId: bigint) {
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (refreshed?.status === 'PENDING_SHIP') {
|
||||
await this.deliveryProvider.scheduleAutoAdvance(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 微信支付回调:幂等更新订单为已支付并发券 */
|
||||
async handlePaySuccess(params: {
|
||||
orderNo: string;
|
||||
@@ -361,8 +373,7 @@ export class TradeService {
|
||||
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
|
||||
if (refreshed?.payStatus === 'PAID') {
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
await this.afterOrderPaid(order.id);
|
||||
this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
|
||||
eventName: 'pay_success',
|
||||
refType: 'ORDER',
|
||||
@@ -401,6 +412,7 @@ export class TradeService {
|
||||
benefitCoupon: true,
|
||||
imageResource: true,
|
||||
product: true,
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -411,6 +423,15 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async getOrderTrack(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -492,7 +513,7 @@ export class TradeService {
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } } },
|
||||
include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
@@ -507,7 +528,7 @@ export class TradeService {
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
include: { delivery: true, user: true, imageResource: true },
|
||||
include: { delivery: true, user: true, imageResource: true, fulfillmentWarehouse: { select: { id: true, name: true } } },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
const statusLogs = await this.prisma.commonEvent.findMany({
|
||||
@@ -517,6 +538,37 @@ export class TradeService {
|
||||
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
|
||||
}
|
||||
|
||||
async partnerManualShip(
|
||||
partnerAccountId: bigint,
|
||||
orderId: bigint,
|
||||
input: { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string },
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const warehouseIds = await this.partnerCityService.resolveManagedWarehouseIds(primary.id);
|
||||
if (!warehouseIds.length) throw new BadRequestException('当前账号未绑定仓库');
|
||||
|
||||
await this.fulfillmentService.shipManualByWarehouse(orderId, warehouseIds, input);
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_order_ship',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
extraJson: { mode: 'manual' },
|
||||
});
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
async getPartnerOrderTrack(partnerAccountId: bigint, orderId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.fulfillmentService.getOrderTrack(orderId);
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
|
||||
+8
-4
@@ -130,8 +130,8 @@
|
||||
└─ 30 分钟未付取消
|
||||
```
|
||||
|
||||
- **同城**:推小飞侠(已取货拍照→已发出→已送达拍照);送达未确认 24h 自动完成
|
||||
- **跨城**:推总部物流(到付);订单佣金归总部
|
||||
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
|
||||
- **跨城**:总部传统快递到付填单;订单佣金归总部
|
||||
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
|
||||
|
||||
### 3.3 佣金与结算
|
||||
@@ -207,10 +207,14 @@
|
||||
| 破损退货 | 同意/驳回 | 负责仓取回→退款 |
|
||||
| 退货退款 | 同意/驳回 | 通知归属合伙人+负责仓取回→退款 |
|
||||
|
||||
### 3.7 城市多仓(Wave 3)
|
||||
### 3.7 城市多仓与仓配(Wave 3)
|
||||
|
||||
- 一城多仓;每仓最多关联 1 名管仓合伙人
|
||||
- 佣金与仓无关;仓用于工单协同
|
||||
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择
|
||||
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
|
||||
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单
|
||||
- 同城无仓 / 跨城:总部传统快递填单
|
||||
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
|
||||
- 未关联合伙人的仓 → 总部直派
|
||||
|
||||
### 3.8 弱网核销兜底(Wave 3 · OPT-006)
|
||||
|
||||
Reference in New Issue
Block a user