merge(dev_jacy): 仓配可调配履约与三分支推单

This commit is contained in:
2026-07-16 17:51:32 +08:00
48 changed files with 2427 additions and 666 deletions
+2
View File
@@ -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>
);
}
+66 -2
View File
@@ -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>
)}
+20 -6
View File
@@ -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 element={<SubAccountLayout navKind={navKind} />}>
<Route path="/" element={<HomePage />} />
{isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
)}
<Route path="/me" element={<PartnerMePage />} />
</Route>
{/* 与主账号一致:录入门店全屏,避免 sticky 底栏被 TabBar 挡住 */}
{!isWarehouse && (
<>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
<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}
+3 -1
View File
@@ -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,
};
}
+17
View File
@@ -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 ?? '');
}
+25 -5
View File
@@ -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}/`)),
);
}
+92 -107
View File
@@ -1,140 +1,125 @@
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>}
<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 && (
</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>
+245 -159
View File
@@ -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);
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) => setLeaderboardPreview(data.list.slice(0, 2)))
.catch(() => setLeaderboardPreview([]));
}, [navigate]);
.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,55 +209,16 @@ 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">
{isPrimary && (
<>
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
@@ -110,9 +232,61 @@ export default function HomePage() {
</div>
</div>
</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-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>
{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>
</>
)}
<div className="partner-bento">
<section className="partner-bento-card">
{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>
@@ -135,66 +309,6 @@ export default function HomePage() {
</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>
@@ -203,7 +317,7 @@ export default function HomePage() {
{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>
<Link to={isPrimary ? '/stores/new' : '/stores/new?step=1'} className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
@@ -230,50 +344,22 @@ export default function HomePage() {
<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>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</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>
);
+57 -44
View File
@@ -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) {
if (!id) return;
setShipping(true);
try {
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);
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>
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
<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>
</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>
)}
+111 -24
View File
@@ -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>
{!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div>
{tab === 'orders' && (
@@ -82,8 +130,10 @@ 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 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>
@@ -95,37 +145,30 @@ export default function OrderListPage() {
<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>
</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>
<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>
{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>
);
}
+7 -130
View File
@@ -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 ? '仓库管理员' : '拓店员'} />;
}
+5 -10
View File
@@ -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">
+106
View File
@@ -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;
+66 -3
View File
@@ -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">
+21 -1
View File
@@ -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;
}
+30
View File
@@ -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;
}
+1
View File
@@ -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';
+3
View File
@@ -10,6 +10,9 @@ export interface PartnerMe {
staffRole?: PartnerStaffRole;
permissions?: string[];
primaryAccountId?: string;
/** 子账号联系主账号用 */
primaryPhone?: string;
primaryName?: string;
}
export interface UpdatePartnerMeRequest {
+1
View File
@@ -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();
+49
View File
@@ -141,6 +141,21 @@ enum WarehouseStatus {
PAUSED
}
enum FulfillmentProviderType {
API
MANUAL
}
enum FulfillmentProviderStatus {
ACTIVE
DISABLED
}
enum WarehouseFulfillmentMode {
API_AUTO
MANUAL
}
enum PartnerStaffRole {
PARTNER
INTERNAL
@@ -496,6 +511,23 @@ model CommonCity {
@@map("common_city")
}
model FulfillmentProvider {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
type FulfillmentProviderType
status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
warehouses CityWarehouse[]
deliveries OrderDelivery[]
@@map("common_fulfillment_provider")
}
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
@@ -506,15 +538,24 @@ model CityWarehouse {
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
fulfillmentMode WarehouseFulfillmentMode @default(MANUAL) @map("fulfillment_mode")
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
manualCarrierLabel String? @map("manual_carrier_label") @db.VarChar(64)
manualQueryUrlTemplate String? @map("manual_query_url_template") @db.VarChar(512)
lng Decimal? @db.Decimal(10, 7)
lat Decimal? @db.Decimal(10, 7)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
fulfilledOrders Order[] @relation("OrderFulfillmentWarehouse")
@@index([cityId])
@@index([partnerAccountId])
@@index([fulfillmentProviderId])
@@map("common_city_warehouse")
}
@@ -859,12 +900,14 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
fulfillmentWarehouse CityWarehouse? @relation("OrderFulfillmentWarehouse", fields: [fulfillmentWarehouseId], references: [id], onDelete: SetNull)
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
@@ -880,6 +923,7 @@ model Order {
@@index([payExternalNo])
@@index([ipCity])
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@map("user_order")
}
@@ -887,8 +931,11 @@ model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider
fulfillmentProviderId BigInt? @map("fulfillment_provider_id") @db.UnsignedBigInt
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
trackingNo String? @map("tracking_no") @db.VarChar(64)
logisticsCompany String? @map("logistics_company") @db.VarChar(64)
manualQueryUrl String? @map("manual_query_url") @db.VarChar(512)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
@@ -896,8 +943,10 @@ model OrderDelivery {
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fulfillmentProvider FulfillmentProvider? @relation(fields: [fulfillmentProviderId], references: [id], onDelete: SetNull)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId])
@@map("user_order_delivery")
}
+25
View File
@@ -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({
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
View File
@@ -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