diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 909870f..7950ddb 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -79,6 +79,11 @@ export type DashboardStats = { partnersTotal: number; redeemToday: number; deliveriesTotal: number; + pendingPayouts?: number; + /** 合伙人已确认、待总部审核打款 */ + pendingBills?: number; + pendingPartnerDraftBills?: number; + openTickets?: number; ordersByStatus: Array<{ status: string; count: number }>; }; @@ -116,15 +121,36 @@ export type AdminUserRow = { orderCount: number; }; +export type AdminOrderItem = { + productName: string; + productSpec: string; + productImage: string; + unitPrice: number; + quantity: number; +}; + export type AdminOrderRow = { id: string; orderNo: string; status: string; deliveryType: string; payAmount: number; + productName?: string; + productSpec?: string; + quantity?: number; receiverName: string; receiverPhone: string; createdAt: string; + cityId?: string; + city?: { id: string; name: string; code: string }; + fulfillmentWarehouseId?: string | null; + fulfillmentWarehouse?: { id: string; name: string } | null; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; - delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null }; + delivery?: { + provider: string; + trackingNo: string | null; + providerOrderNo: string | null; + logisticsCompany?: string | null; + manualQueryUrl?: string | null; + }; }; diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx index 095f2a7..874b0d8 100644 --- a/apps/admin-web/src/pages/CityWarehousesPage.tsx +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -11,6 +11,7 @@ import { Popconfirm, Select, Space, + Switch, Table, Tag, Typography, @@ -59,29 +60,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, + onModeChange, }: { mode: WarehouseFulfillmentMode; providerOptions: FulfillmentProviderDto[]; + onModeChange: (mode: WarehouseFulfillmentMode) => void; }) { + const autoShip = mode === WarehouseFulfillmentMode.API_AUTO; return ( <> - {mode === WarehouseFulfillmentMode.API_AUTO && ( - + ({ checked: v === WarehouseFulfillmentMode.API_AUTO })} + getValueFromEvent={(checked: boolean) => + checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL + } + > + { + onModeChange( + checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL, + ); + }} + /> + + {autoShip && ( + @@ -183,18 +209,17 @@ export default function CityWarehousesPage() { } function onManagerTypeChange( - type: WarehouseManagerType, - form: typeof createForm | typeof editForm, - setType: (v: WarehouseManagerType) => void, + v: WarehouseManagerType, + form: typeof createForm, + setType: (t: WarehouseManagerType) => void, ) { - setType(type); - if (type !== WarehouseManagerType.PARTNER) { + setType(v); + if (v !== WarehouseManagerType.PARTNER) { form.setFieldValue('partnerAccountId', undefined); } } const columns: ColumnsType = [ - { title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 }, { title: '城市', width: 100, @@ -204,17 +229,17 @@ export default function CityWarehousesPage() { ), }, - { title: '地址', dataIndex: 'address', ellipsis: true }, + { title: '仓库', dataIndex: 'name', width: 140, ellipsis: true }, + { title: '地址', dataIndex: 'address', width: 180, ellipsis: true }, { title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '电话', dataIndex: 'contactPhone', width: 120 }, { - title: '管仓类型', - dataIndex: 'managerType', - width: 100, - render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v, + title: '管仓', + width: 90, + render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType, }, { - title: '管仓合伙人', + title: '合伙人', dataIndex: 'partnerCompanyName', width: 120, ellipsis: true, @@ -226,12 +251,14 @@ export default function CityWarehousesPage() { ), }, { - title: '履约', - width: 120, + title: '自动发货', + width: 130, render: (_, row) => - row.fulfillmentMode === 'API_AUTO' - ? row.fulfillmentProviderName || 'API' - : WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL], + row.fulfillmentMode === 'API_AUTO' ? ( + {row.fulfillmentProviderName || '自动'} + ) : ( + {WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]} + ), }, { title: '状态', @@ -274,21 +301,23 @@ export default function CityWarehousesPage() { return (
- - 仓库管理 - + 仓库 @@ -296,7 +325,7 @@ export default function CityWarehousesPage() { type="info" showIcon style={{ marginBottom: 16 }} - message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。" + message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。" />
- + + + + - - - - - - - - - - + + {showXfxFields && ( + <> + + 小飞侠接口参数 + + + + + + + + + + + + + + + )}
diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 405b0c6..e4958e3 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -9,6 +9,7 @@ import { Input, InputNumber, Modal, + Radio, Select, Space, Table, @@ -17,8 +18,14 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { request, type AdminOrderRow, type Paginated } from '../lib/api'; -import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; +import { + ADMIN_OPTIONS_PAGE_SIZE, + DELIVERY_PROVIDER_LABELS, + ORDER_STATUS_COLORS, + ORDER_STATUS_LABELS, + fmtTime, +} from '../lib/constants'; type ShipDefaults = { provider: string; @@ -33,6 +40,21 @@ type ShipDefaults = { payMode: string; }; +type CityOption = { id: string; name: string; code: string }; + +type WarehouseOption = { + id: string; + name: string; + cityId?: string; + cityName?: string; + status?: string; + contactName: string; + contactPhone: string; + address: string; + lng?: number | null; + lat?: number | null; +}; + type OrderDetail = AdminOrderRow & { receiverAddress?: string; receiverProvince?: string; @@ -51,17 +73,58 @@ type OrderDetail = AdminOrderRow & { productAmount?: number; freightAmount?: number; benefitAmount?: number; - deliveryType?: string; - fulfillmentWarehouseId?: string | null; paidAt?: string | null; payExpireAt?: string | null; - items?: Array>; + productImage?: string; + listUnitPrice?: number; + items?: AdminOrderItem[]; payment?: Record | null; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; benefitCoupons?: Array>; - city?: { name: string; code: string }; + fulfillmentWarehouse?: { + id: string; + name: string; + contactName?: string; + contactPhone?: string; + address?: string; + lng?: number | null; + lat?: number | null; + } | null; }; +type ShipMode = 'WAREHOUSE' | 'EXPRESS'; + +function orderProductRows(detail: OrderDetail): AdminOrderItem[] { + if (detail.items?.length) return detail.items; + if (!detail.productName) return []; + return [{ + productName: detail.productName, + productSpec: detail.productSpec ?? '', + productImage: detail.productImage ?? '', + unitPrice: Number(detail.listUnitPrice ?? 0), + quantity: detail.quantity ?? 1, + }]; +} + +function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) { + return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo; +} + +function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults { + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: wh.contactName || base?.fromName || '杜康仓库', + fromMobile: wh.contactPhone || base?.fromMobile || '13800000000', + fromAddress: wh.address || base?.fromAddress || '', + fromAddressDetail: wh.name || base?.fromAddressDetail || '', + fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665), + fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757), + weight: base?.weight ?? 2, + payMode: base?.payMode ?? '1', + }; +} + export default function OrdersPage() { const [form] = Form.useForm(); const [shipForm] = Form.useForm(); @@ -78,6 +141,11 @@ export default function OrdersPage() { const [shipDefaults, setShipDefaults] = useState(null); const [shipping, setShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false); + const [cities, setCities] = useState([]); + const [shipModalOpen, setShipModalOpen] = useState(false); + const [shipTarget, setShipTarget] = useState(null); + const [shipMode, setShipMode] = useState('EXPRESS'); + const [warehouses, setWarehouses] = useState([]); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -91,6 +159,7 @@ export default function OrdersPage() { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.status) qs.set('status', values.status); + if (values.cityId) qs.set('cityId', values.cityId); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); const res = await request>(`/admin/orders?${qs}`); setData(res); @@ -105,40 +174,100 @@ export default function OrdersPage() { useEffect(() => { void request('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {}); + void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items ?? [])) + .catch(() => {}); }, []); + function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) { + shipForm.setFieldsValue({ + warehouseId: warehouseId || undefined, + provider: defaults.provider, + weight: defaults.weight, + payMode: defaults.payMode, + fromName: defaults.fromName, + fromMobile: defaults.fromMobile, + fromAddress: defaults.fromAddress, + fromAddressDetail: defaults.fromAddressDetail, + fromLng: defaults.fromLng, + fromLat: defaults.fromLat, + }); + } + + async function loadWarehouses() { + const res = await request>( + `/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`, + ).catch(() => null); + const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE'); + setWarehouses(rows); + return rows; + } + async function openDetail(id: string) { const res = await request(`/admin/orders/${id}`); setDetail(res); setDrawerOpen(true); - const defaults = shipDefaults ?? await request('/admin/orders/ship-defaults').catch(() => null); + } + + async function openShipModal(id: string) { + const res = await request(`/admin/orders/${id}`); + setShipTarget(res); + setDetail(res); + logisticsForm.setFieldsValue({ + logisticsCompany: '', + trackingNo: '', + manualQueryUrl: '', + }); + const rows = await loadWarehouses(); + const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id; + const preferred = + rows.find((w) => w.id === preferredWarehouseId) || + rows.find((w) => w.cityId === (res.city?.id || res.cityId)) || + rows[0]; + const defaults = preferred + ? warehouseToDefaults(preferred, shipDefaults) + : (shipDefaults ?? await request('/admin/orders/ship-defaults').catch(() => null)); if (defaults) { setShipDefaults(defaults); - shipForm.setFieldsValue({ - provider: defaults.provider, - weight: defaults.weight, - payMode: defaults.payMode, - fromName: defaults.fromName, - fromMobile: defaults.fromMobile, - fromAddress: defaults.fromAddress, - fromAddressDetail: defaults.fromAddressDetail, - fromLng: defaults.fromLng, - fromLat: defaults.fromLat, - }); + applyShipDefaults(defaults, preferred?.id); + } else { + shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' }); } + setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS'); + setShipModalOpen(true); + } + + async function onWarehouseChange(warehouseId: string) { + const wh = warehouses.find((w) => w.id === warehouseId); + if (!wh) return; + applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId); } async function submitShip() { - if (!detail) return; + if (!shipTarget) return; const values = await shipForm.validateFields(); setShipping(true); try { - const res = await request(`/admin/orders/${detail.id}/ship`, { + const res = await request(`/admin/orders/${shipTarget.id}/ship`, { method: 'POST', - body: JSON.stringify(values), + body: JSON.stringify({ + provider: values.provider || 'XFX', + warehouseId: values.warehouseId, + weight: values.weight, + payMode: values.payMode, + remark: values.remark, + fromName: values.fromName, + fromMobile: values.fromMobile, + fromAddress: values.fromAddress, + fromAddressDetail: values.fromAddressDetail, + fromLng: values.fromLng, + fromLat: values.fromLat, + }), }); - message.success('发货成功'); + message.success('选仓发货成功'); + setShipTarget(res); setDetail(res); + setShipModalOpen(false); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '发货失败'); @@ -148,16 +277,28 @@ export default function OrdersPage() { } async function submitLogisticsShip() { - if (!detail) return; - const values = await logisticsForm.validateFields(); + if (!shipTarget) return; + const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']); + const logisticsCompany = String(values.logisticsCompany ?? '').trim(); + const trackingNo = String(values.trackingNo ?? '').trim(); + if (!logisticsCompany || !trackingNo) { + message.error('请填写快递公司与运单号'); + return; + } setLogisticsShipping(true); try { - const res = await request(`/admin/orders/${detail.id}/logistics-ship`, { + const res = await request(`/admin/orders/${shipTarget.id}/logistics-ship`, { method: 'POST', - body: JSON.stringify(values), + body: JSON.stringify({ + logisticsCompany, + trackingNo, + manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined, + }), }); message.success('快递单已录入'); + setShipTarget(res); setDetail(res); + setShipModalOpen(false); void load(); } catch (e) { message.error(e instanceof Error ? e.message : '填单失败'); @@ -191,6 +332,25 @@ export default function OrdersPage() { const columns: ColumnsType = [ { title: '订单号', dataIndex: 'orderNo', width: 180 }, + { + title: '城市', + width: 90, + render: (_, row) => row.city?.name || '—', + }, + { + title: '商品', + width: 180, + ellipsis: true, + render: (_, row) => { + const name = row.productName || '—'; + const qty = row.quantity != null ? ` ×${row.quantity}` : ''; + return ( + + {name}{qty} + + ); + }, + }, { title: '状态', dataIndex: 'status', @@ -227,11 +387,19 @@ export default function OrdersPage() { }, { title: '操作', - width: 80, + width: 140, + fixed: 'right', render: (_, row) => ( - + + + {canShip(row) && ( + + )} + ), }, ]; @@ -255,6 +423,16 @@ export default function OrdersPage() { ({ value: c.id, label: c.name }))} + /> + @@ -271,7 +449,7 @@ export default function OrdersPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 1500 }} rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as string[]), @@ -295,6 +473,11 @@ export default function OrdersPage() { onClose={() => setDrawerOpen(false)} extra={detail && ( + {canShip(detail) && ( + + )} 调试改状态 -
- - - - - - - - - - )} - - {detail.fulfillmentWarehouseId && ( - <> - - 仓配小飞侠重试 - - - 仓配订单通常支付后自动推单;失败时可手动重试 - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - }]} - /> - - - - )} - - )} - {detail.statusLogs && detail.statusLogs.length > 0 && ( <> 状态流转 @@ -476,6 +618,150 @@ export default function OrdersPage() { )} + setShipModalOpen(false)} + width={560} + destroyOnClose={false} + forceRender + footer={ + + + {shipMode === 'WAREHOUSE' ? ( + + ) : ( + + )} + + } + > + {shipTarget && ( + <> + + {shipTarget.city?.name || '—'} + + {shipTarget.receiverName} {shipTarget.receiverPhone} + + + {shipTarget.productName || '—'} + {shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''} + + + + setShipMode(e.target.value as ShipMode)} + optionType="button" + buttonStyle="solid" + style={{ marginBottom: 16 }} + options={[ + { value: 'WAREHOUSE', label: '选仓配送' }, + { value: 'EXPRESS', label: '填写快递单号' }, + ]} + /> + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }]} + /> +
+
+ +
+
+ + 填写快递公司与运单号即可完成发货 + + + + + + + + + + +
+
+ + )} +
+ row.city?.name || '—', + }, + { + title: '商品', + width: 160, + ellipsis: true, + render: (_, row) => + row.productName + ? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}` + : '—', + }, { title: '状态', dataIndex: 'status', width: 100, render: (s) => ( - {ORDER_STATUS_LABELS[s] || s} - ), + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index d9cd155..8680957 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -4,6 +4,7 @@ import { Card, DatePicker, Form, + Input, Modal, Select, Space, @@ -29,6 +30,7 @@ type Row = { status: string; periodStart: string; periodEnd: string; + rejectReason?: string | null; partner?: { companyName?: string; phone?: string }; partnerAccount?: { companyName?: string; phone?: string }; }; @@ -36,15 +38,17 @@ type Row = { type PartnerOption = { id: string; companyName: string; phone?: string }; const STATUS_LABELS: Record = { - DRAFT: '草稿', - CONFIRMED: '已确认', + DRAFT: '待合伙人确认', + CONFIRMED: '待打款审核', PAID: '已打款', + REJECTED: '已驳回', }; const STATUS_COLORS: Record = { DRAFT: 'default', - CONFIRMED: 'blue', + CONFIRMED: 'orange', PAID: 'green', + REJECTED: 'red', }; export default function PartnerBillsPage() { @@ -66,6 +70,10 @@ export default function PartnerBillsPage() { const [genOpen, setGenOpen] = useState(false); const [genForm] = Form.useForm(); const [exporting, setExporting] = useState(false); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectForm] = Form.useForm(); + const [rejecting, setRejecting] = useState(false); useEffect(() => { void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -95,7 +103,7 @@ export default function PartnerBillsPage() { async function confirmBill(id: string) { await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); - message.success('已确认'); + message.success('已代确认,进入待打款审核'); reload(); } @@ -104,10 +112,33 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), }); - message.success('已标记打款'); + message.success('已通过并标记打款'); reload(); } + function openReject(row: Row) { + setRejectTarget(row); + rejectForm.resetFields(); + setRejectOpen(true); + } + + async function submitReject(values: { reason: string }) { + if (!rejectTarget) return; + setRejecting(true); + try { + await request(`/admin/partner-bills/${rejectTarget.id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: values.reason.trim() }), + }); + message.success('已驳回打款申请'); + setRejectOpen(false); + setRejectTarget(null); + reload(); + } finally { + setRejecting(false); + } + } + async function exportExcel() { setExporting(true); try { @@ -163,24 +194,38 @@ export default function PartnerBillsPage() { { title: '状态', dataIndex: 'status', - width: 90, - render: (s) => {STATUS_LABELS[s] || s}, + width: 120, + render: (s, row) => ( + + {STATUS_LABELS[s] || s} + {s === 'REJECTED' && row.rejectReason ? ( + + {row.rejectReason} + + ) : null} + + ), }, { title: '操作', - width: 160, + width: 220, fixed: 'right', render: (_, row) => ( - + {row.status === 'DRAFT' && ( )} {row.status === 'CONFIRMED' && ( - + <> + + + )} ), @@ -194,7 +239,7 @@ export default function PartnerBillsPage() { 合伙人账单 - T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例) + T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由) @@ -242,7 +287,7 @@ export default function PartnerBillsPage() { setConfirmed(e.target.checked)} /> - 我已核对数据无误,同意根据此账单进行结算 - - - +
+ + +
+ )} ); diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index fbb34f2..ca63791 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -1,10 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import type { PartnerBillDto } from '@dukang/shared-types'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; 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'; +import { listPartnerStaff } from '../lib/staff'; +import { toastError, toastSuccess } from '../lib/toast'; +import { + authorizePartnerWechat, + fetchClientConfig, + handlePartnerWechatLoginResult, +} from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; type CenterPageProps = { /** 子账号个人中心复用 */ @@ -12,38 +21,47 @@ type CenterPageProps = { roleLabel?: string; }; +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) { - const { account, logout, refresh } = usePartnerSession(); + const { account, logout, refresh, applySession } = usePartnerSession(); const isPrimary = variant === 'primary' && isPrimaryAccount(account); - const [name, setName] = useState(account?.name ?? ''); - const [saving, setSaving] = useState(false); + const [authorizing, setAuthorizing] = useState(false); + const [bills, setBills] = useState([]); + const [staffCount, setStaffCount] = useState(0); useEffect(() => { document.title = isPrimary ? '合伙人中心' : '个人中心'; }, [isPrimary]); useEffect(() => { - setName(account?.name ?? ''); - }, [account?.name]); + if (!isPrimary) return; + void request('PARTNER_H5', '/partner/settlement/bills') + .then(setBills) + .catch(() => setBills([])); + void listPartnerStaff() + .then((list) => setStaffCount(list.length)) + .catch(() => setStaffCount(0)); + }, [isPrimary]); - 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); - } - } + const finance = useMemo(() => { + const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED'); + const settled = bills.filter((b) => b.status === 'PAID'); + const paidCount = settled.length; + const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length; + return { + pendingBillCount: pending.length, + rejectedCount, + pendingTotal: bills + .filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED') + .reduce((s, b) => s + Number(b.totalAmount || 0), 0), + settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + paidCount, + }; + }, [bills]); function handleContactPartner() { if (isPrimary) { @@ -57,86 +75,206 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag window.location.href = `tel:${account.primaryPhone}`; } + async function handleAvatarAuth() { + if (authorizing) return; + if (!isWechatEnv()) { + toastError('请在微信内打开以授权头像昵称'); + return; + } + setAuthorizing(true); + try { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) { + toastError('微信授权暂未开启'); + return; + } + const result = await authorizePartnerWechat(); + if (result) { + const session = handlePartnerWechatLoginResult(result); + if (session) applySession(session); + await refresh(); + toastSuccess('微信授权成功'); + } + // result 为空表示已跳转 OAuth,无需额外处理 + } catch (e) { + toastError(e instanceof Error ? e.message : '微信授权失败'); + } finally { + setAuthorizing(false); + } + } + + const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—'; + const displayName = account?.wxNickname?.trim() || account?.name || '合伙人'; + const avatarUrl = account?.wxAvatarUrl?.trim() || ''; + const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); + return (
-
- person -
-
-
-

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

- {roleLabel || (isPrimary ? '城市合伙人' : '拓店员')} + +
+
+

{displayName}

+ {badgeLabel}
-
+
location_on - {account?.companyName || '—'} + {locationLabel}
-

{account?.phone || ''}

-
-
- -
-

个人信息设置

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

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

- -
-
- -
-
- - - {isPrimary && ( - -
-
group
- 子账号管理 -
- chevron_right - + {!isPrimary && ( +

{account?.phone || ''}

)}
+ {isPrimary && ( + <> +
+
+

资产概览

+ + 明细 chevron_right + +
+ + {finance.pendingBillCount > 0 && ( + +
+
+ receipt_long +
+
+

+ {finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'} +

+

+ {finance.rejectedCount > 0 + ? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)` + : `您有 ${finance.pendingBillCount} 笔账单待确认`} +

+
+
+ chevron_right + + )} + +
+
+

账户余额 (元)

+

{fmtMoney(finance.balance)}

+
+ +

待结算

+

¥ {fmtMoney(finance.pendingTotal)}

+ + +

+ 提现记录 + history +

+

{finance.paidCount} 笔

+ +
+
+ +
+
+

运营管理

+
+
+ +
+
+ badge +
+ 我的员工 +
+
+ {staffCount > 0 && {staffCount}人} + chevron_right +
+ + +
+
+ storefront +
+ 门店审核记录 +
+ chevron_right + + +
+
+ description +
+ 合同管理 +
+
+ 待续约 + chevron_right +
+ + +
+
+ + )} + + {!isPrimary && ( +
+
+ + +
+
+ )} + -
+

传承千年 · 杜康好客

diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index a84ae54..6e07efc 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -3,14 +3,12 @@ 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 { getPartnerNavKind, hasWarehouseAccess } 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; @@ -92,67 +90,42 @@ function summarizeOrders(orders: Array>) { return { pendingShip, shipping, completed, todayCount }; } -function LeaderboardPreview({ - entries, - self, -}: { - entries: PartnerLeaderboardEntry[]; - self?: PartnerLeaderboardEntry; -}) { - const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false; - +function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) { return ( -
+

- military_tech + emoji_events 合伙人贡献榜

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

暂无排行数据

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

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

-

累计拓店 {entry.totalStores} 间

-
-
-

{entry.periodStores} 间

-

本月新增

-
+ entries.map((entry) => ( +
+
+ person + {entry.rank}
- ))} - {self && !selfInList ? ( -
-
{self.rank}
-
-

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

-

累计拓店 {self.totalStores} 间

-
-
-

{self.periodStores} 间

-

本月新增

-
+
+

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

+

累计拓店: {entry.totalStores} 间

- ) : null} - +
+

{entry.periodStores} 间

+

本月新增

+
+
+ )) )} -
+
); } @@ -162,12 +135,12 @@ export default function HomePage() { const navKind = getPartnerNavKind(account); const isPrimary = navKind === 'primary'; const isWarehouse = navKind === 'warehouse_staff'; + const warehouseOk = hasWarehouseAccess(account); const [dash, setDash] = useState | null>(null); const [stores, setStores] = useState>>([]); const [orders, setOrders] = useState>>([]); const [leaderboardEntries, setLeaderboardEntries] = useState([]); - const [leaderboardSelf, setLeaderboardSelf] = useState(); useEffect(() => { document.title = '工作台'; @@ -175,10 +148,12 @@ export default function HomePage() { useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - if (isWarehouse || isPrimary) { + if ((isWarehouse || isPrimary) && warehouseOk) { request<{ list: Array> }>('PARTNER_H5', '/partner/orders') .then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) .catch(() => setOrders([])); + } else { + setOrders([]); } if (!isWarehouse) { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); @@ -186,16 +161,14 @@ export default function HomePage() { fetchPartnerLeaderboard('month') .then((data) => { setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); - setLeaderboardSelf(data.self); }) .catch(() => { setLeaderboardEntries([]); - setLeaderboardSelf(undefined); }); } else { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); } - }, [navigate, isWarehouse, isPrimary]); + }, [navigate, isWarehouse, isPrimary, warehouseOk]); const storeCount = Number(dash?.storeCount || stores.length || 0); const orderStats = useMemo(() => summarizeOrders(orders), [orders]); @@ -207,12 +180,19 @@ export default function HomePage() { const now = new Date(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); }).length; + const lastMonthNew = stores.filter((s) => { + const created = new Date(String(s.createdAt || '')); + const now = new Date(); + const last = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear(); + }).length; + const monthGrowthPct = lastMonthNew > 0 + ? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100) + : (monthNew > 0 ? 100 : 0); - 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 (
@@ -246,13 +226,6 @@ export default function HomePage() {
录入新店 - -
- assignment_return - {pendingShipBadge > 0 && {pendingShipBadge}} -
- 补发处理 -
account_balance @@ -274,7 +247,7 @@ export default function HomePage() {

订单管理

)} - {(isPrimary || isWarehouse) && ( + {(isPrimary || isWarehouse) && warehouseOk && ( )} + {(isPrimary || isWarehouse) && !warehouseOk && ( +
+
+ warehouse +

未配置仓库管理权限

+

+ 购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。 +

+
+
+ )} + {!isWarehouse && ( <>
@@ -309,55 +294,31 @@ export default function HomePage() {
-
-
-

辖区门店

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

暂无门店

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

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

-

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

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

拓店情况

本月新增签约

- {monthNew} +
+ {monthNew} + {monthGrowthPct !== 0 && ( + 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}> + + {monthGrowthPct > 0 ? 'trending_up' : 'trending_down'} + + {Math.abs(monthGrowthPct)}% + + )} +

待审核门店

{pendingAuditCount}
- - - -

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

+ + )} diff --git a/apps/h5-partner/src/pages/OrderListPage.tsx b/apps/h5-partner/src/pages/OrderListPage.tsx index c9e683a..bd7ce97 100644 --- a/apps/h5-partner/src/pages/OrderListPage.tsx +++ b/apps/h5-partner/src/pages/OrderListPage.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { isLoggedIn, request } from '../lib/api'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; +import { hasWarehouseAccess } from '../lib/partnerAccess'; type DateFilter = 'today' | '7d' | '30d'; type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL'; @@ -34,15 +36,24 @@ type OrderListPageProps = { tabRoot?: boolean; }; +type OrdersResponse = { + list: Array>; + hasWarehouseAccess?: boolean; + message?: string; +}; + export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { const navigate = useNavigate(); - const [data, setData] = useState<{ list: Array> }>({ list: [] }); + const { account } = usePartnerSession(); + const warehouseOk = hasWarehouseAccess(account); + const [data, setData] = useState({ list: [] }); const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); const [dateFilter, setDateFilter] = useState('today'); const [statusFilter, setStatusFilter] = useState('ALL'); const [shippingId, setShippingId] = useState(null); const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' }); const [shipModalId, setShipModalId] = useState(null); + const [loaded, setLoaded] = useState(false); useEffect(() => { document.title = '订单管理'; @@ -50,8 +61,22 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - request<{ list: Array> }>('PARTNER_H5', '/partner/orders').then(setData); - }, [navigate]); + if (!warehouseOk) { + setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' }); + setLoaded(true); + return; + } + request('PARTNER_H5', '/partner/orders') + .then((res) => { + setData({ + list: Array.isArray(res.list) ? res.list : [], + hasWarehouseAccess: res.hasWarehouseAccess, + message: res.message, + }); + }) + .catch(() => setData({ list: [] })) + .finally(() => setLoaded(true)); + }, [navigate, warehouseOk]); const filtered = useMemo(() => data.list.filter((o) => { if (statusFilter === 'ALL') return true; @@ -83,8 +108,12 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { body: JSON.stringify(shipForm), }); setShipModalId(null); - const next = await request<{ list: Array> }>('PARTNER_H5', '/partner/orders'); - setData(next); + const next = await request('PARTNER_H5', '/partner/orders'); + setData({ + list: Array.isArray(next.list) ? next.list : [], + hasWarehouseAccess: next.hasWarehouseAccess, + message: next.message, + }); } catch (err) { window.alert(err instanceof Error ? err.message : '发货失败'); } finally { @@ -97,6 +126,21 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP'; } + if (loaded && !warehouseOk) { + return ( +
+ {!tabRoot && navigate('/')} />} +
+ warehouse +

未配置仓库管理权限

+

+ 购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。 +

+
+
+ ); + } + return (
{!tabRoot && navigate('/')} />} diff --git a/apps/h5-partner/src/pages/SettlementPage.tsx b/apps/h5-partner/src/pages/SettlementPage.tsx index 71b645c..f843c66 100644 --- a/apps/h5-partner/src/pages/SettlementPage.tsx +++ b/apps/h5-partner/src/pages/SettlementPage.tsx @@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader'; import type { PartnerBillDto } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; -type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; +type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -15,6 +15,7 @@ function billStatusLabel(status: string) { case 'DRAFT': return '待结算'; case 'CONFIRMED': return '审核中'; case 'PAID': return '已结算'; + case 'REJECTED': return '已驳回'; default: return status; } } @@ -24,6 +25,7 @@ function billStatusClass(status: string) { case 'DRAFT': return 'partner-settlement-status--pending'; case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'PAID': return 'partner-settlement-status--settled'; + case 'REJECTED': return 'partner-settlement-status--rejected'; default: return ''; } } @@ -70,9 +72,10 @@ export default function SettlementPage() { const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; if (!matchMonth) return false; if (statusFilter === 'all') return true; - if (statusFilter === 'pending') return b.status === 'DRAFT'; + if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED'; if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; + if (statusFilter === 'rejected') return b.status === 'REJECTED'; return true; }); }, [bills, month, statusFilter]); @@ -93,8 +96,9 @@ export default function SettlementPage() { const statusTabs: Array<{ key: StatusFilter; label: string }> = [ { key: 'all', label: '全部' }, { key: 'pending', label: '待结算' }, - { key: 'settled', label: '已结算' }, { key: 'reviewing', label: '审核中' }, + { key: 'rejected', label: '已驳回' }, + { key: 'settled', label: '已结算' }, ]; return ( @@ -188,7 +192,12 @@ export default function SettlementPage() { 订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}

-

+ {bill.status === 'REJECTED' && bill.rejectReason ? ( +

+ 驳回:{bill.rejectReason} +

+ ) : null} +

¥{fmtMoney(Number(bill.totalAmount || 0))}

diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx index de8ff34..006b07b 100644 --- a/apps/h5-partner/src/pages/StoreListPage.tsx +++ b/apps/h5-partner/src/pages/StoreListPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { isSubAccount } from '../lib/partnerAccess'; @@ -25,11 +25,13 @@ const FILTERS: { key: StatusFilter; label: string }[] = [ export default function StoreListPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { account } = usePartnerSession(); const readonly = isSubAccount(account); const [stores, setStores] = useState>>([]); const [q, setQ] = useState(''); - const [filter, setFilter] = useState('ALL'); + const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter; + const [filter, setFilter] = useState(initialFilter); const [updatingId, setUpdatingId] = useState(null); const [error, setError] = useState(''); diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index be60113..c58d87e 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -621,9 +621,6 @@ html { .partner-expansion-split { display: flex; - border-bottom: 1px solid rgba(226, 190, 188, 0.2); - padding-bottom: var(--space-md); - margin-bottom: var(--space-md); } .partner-expansion-split > div { @@ -635,6 +632,28 @@ html { border-left: 1px solid rgba(226, 190, 188, 0.2); } +.partner-expansion-value-row { + display: flex; + align-items: center; + gap: 8px; +} + +.partner-expansion-trend { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 600; +} + +.partner-expansion-trend--up { + color: #16a34a; +} + +.partner-expansion-trend--down { + color: var(--color-heritage-red); +} + .partner-form-error { margin: 0 20px 12px; padding: 10px 12px; @@ -845,6 +864,57 @@ html { color: #fff; } +.partner-leaderboard-preview .partner-leaderboard-header { + margin-bottom: 12px; +} + +.partner-leaderboard-avatar { + position: relative; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface-container-low); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.partner-leaderboard-avatar .material-symbols-outlined { + font-size: 22px; + color: var(--color-subtle-gray); + font-variation-settings: 'FILL' 1; +} + +.partner-leaderboard-avatar-badge { + position: absolute; + left: -2px; + top: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #9ca3af; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; + box-shadow: 0 0 0 2px #fff; +} + +.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge { + background: #d4a017; +} + +.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge { + background: #9ca3af; +} + +.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge { + background: #cd7f32; +} + .partner-leaderboard-info { flex: 1; min-width: 0; @@ -2402,6 +2472,14 @@ nav.app-tabbar .app-tabbar-label { justify-content: center; position: relative; flex-shrink: 0; + padding: 0; + cursor: pointer; + overflow: hidden; +} + +.partner-profile-avatar:disabled { + opacity: 0.7; + cursor: wait; } .partner-profile-avatar .material-symbols-outlined { @@ -2410,22 +2488,81 @@ nav.app-tabbar .app-tabbar-label { font-variation-settings: 'FILL' 1; } +.partner-profile-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.partner-profile-avatar-hint { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 2px 0; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + text-align: center; + color: #fff; + background: rgba(166, 29, 36, 0.82); +} + +.partner-profile-main { + flex: 1; + min-width: 0; +} + +.partner-profile-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.partner-profile-name { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1.3; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.partner-profile-location { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + color: var(--color-subtle-gray); +} + .partner-role-badge { + flex-shrink: 0; background: var(--color-secondary-container); color: var(--color-on-secondary-container); padding: 4px 8px; border-radius: 8px; font-family: var(--font-label); font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + line-height: 1.2; +} + +.partner-assets-section { + margin-top: var(--space-lg); } .partner-bills-banner { margin: 0 var(--space-page) 12px; padding: 16px; - background: rgba(166, 29, 36, 0.05); - border: 1px solid rgba(166, 29, 36, 0.1); + background: rgba(166, 29, 36, 0.06); + border: 1px solid rgba(166, 29, 36, 0.12); border-radius: var(--radius-md); display: flex; align-items: center; @@ -2464,13 +2601,100 @@ nav.app-tabbar .app-tabbar-label { border-radius: var(--radius-md); box-shadow: var(--shadow-card); border: 1px solid rgba(166, 29, 36, 0.05); + text-decoration: none; + color: inherit; + display: block; } .partner-finance-card--hero { - grid-column: span 2; - background: var(--color-heritage-red); + grid-column: 1 / -1; + background: linear-gradient(135deg, #a61d24 0%, #820012 100%); color: #fff; box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); + border: none; + padding: 20px 24px; +} + +.partner-finance-hero-label { + font-size: 13px; + opacity: 0.88; + margin: 0 0 8px; +} + +.partner-finance-hero-amount { + font-size: 32px; + font-weight: 700; + letter-spacing: 0.02em; + margin: 0; + line-height: 1.2; +} + +.partner-finance-value { + margin-top: 8px; + font-size: 18px; + font-weight: 700; + color: var(--color-on-surface); +} + +.partner-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 var(--space-page) 8px; +} + +.partner-menu-section .partner-section-head { + margin: 0 0 8px; +} + +.partner-menu-item-right { + display: flex; + align-items: center; + gap: 6px; +} + +.partner-menu-badge { + font-size: 12px; + font-weight: 600; + color: var(--color-heritage-red); + background: rgba(166, 29, 36, 0.08); + padding: 2px 8px; + border-radius: 999px; +} + +.partner-menu-status { + font-size: 12px; + color: #2563eb; + font-weight: 500; +} + +.partner-logout-btn { + width: calc(100% - 2 * var(--space-page)); + margin: var(--space-lg) var(--space-page) 0; + padding: 16px; + border: 1px solid rgba(166, 29, 36, 0.08); + border-radius: var(--radius-md); + background: var(--color-card); + box-shadow: var(--shadow-card); + color: var(--color-heritage-red); + font-family: var(--font-headline); + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.partner-logout-btn .material-symbols-outlined { + color: var(--color-heritage-red); +} + +.partner-center-footer { + text-align: center; + opacity: 0.35; + padding: 24px 0 8px; } .partner-menu-section { @@ -2489,14 +2713,28 @@ nav.app-tabbar .app-tabbar-label { display: flex; align-items: center; justify-content: space-between; + width: 100%; padding: 16px; + border: none; border-bottom: 1px solid var(--color-surface-container); + border-radius: 0; + background: transparent; color: inherit; + font: inherit; + text-align: left; text-decoration: none; + cursor: pointer; + box-sizing: border-box; + -webkit-appearance: none; + appearance: none; } .partner-menu-item:last-child { border-bottom: none; } +.partner-menu-item:active { + background: rgba(166, 29, 36, 0.04); +} + .partner-menu-item-left { display: flex; align-items: center; @@ -2514,24 +2752,6 @@ nav.app-tabbar .app-tabbar-label { color: var(--color-heritage-red); } -.partner-logout-btn { - width: calc(100% - 40px); - margin: var(--space-lg) var(--space-page); - padding: 16px; - border: none; - border-radius: var(--radius-md); - background: var(--color-card); - box-shadow: var(--shadow-card); - color: var(--color-error); - font-size: 16px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - /* ── Settlement page ── */ .partner-settlement-page { min-height: 100vh; @@ -2779,6 +2999,11 @@ nav.app-tabbar .app-tabbar-label { color: var(--color-success-green); } +.partner-settlement-status--rejected { + background: rgba(166, 29, 36, 0.12); + color: var(--color-heritage-red); +} + .partner-settlement-item-meta { display: flex; align-items: center; @@ -3067,22 +3292,28 @@ body { border-top: 1px dashed rgba(166, 29, 36, 0.15); } -.partner-home--flush-top .partner-home-body, +.partner-warehouse-denied { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 20px 12px; +} + +.partner-warehouse-denied .material-symbols-outlined { + font-size: 36px; + font-variation-settings: 'FILL' 0; +} + .partner-home--flush-top.partner-store-page, .partner-home--flush-top.partner-center-page { - padding-top: 0; + padding-top: 12px; } .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; diff --git a/apps/mini-user/README.md b/apps/mini-user/README.md index 1e86778..d38dac8 100644 --- a/apps/mini-user/README.md +++ b/apps/mini-user/README.md @@ -18,7 +18,7 @@ pnpm --filter @dukang/mini-user dev:weapp | 环节 | 文件 | 说明 | |------|------|------| -| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 | +| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `http://localhost:3000`;连远程设 `VITE_API_TARGET=https://dkapi.runxian.top` | | **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` | ### 微信登录 `invalid code` @@ -30,17 +30,25 @@ pnpm --filter @dukang/mini-user dev:weapp | 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` | | 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) | -**本地联调(不接真实微信)**:API 指到本机且开启 Mock: +**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock: ```bash # 终端 1 -pnpm dev:api # server/.env 保持 MOCK_WECHAT=true +pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true # 终端 2 -$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp +pnpm dev:mini-user # H5 预览 :5177 +# 或 +pnpm --filter @dukang/mini-user dev:weapp ``` -**连远程 API**:在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。 +**连远程 API**: + +```bash +$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev +``` + +并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。 ## 页面结构(18 页) diff --git a/apps/mini-user/config/index.ts b/apps/mini-user/config/index.ts index 561bb71..6d0556b 100644 --- a/apps/mini-user/config/index.ts +++ b/apps/mini-user/config/index.ts @@ -2,8 +2,8 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { defineConfig } from '@tarojs/cli'; -/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */ -const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top'; +/** 小程序/H5 请求的后端 origin(不含 /api);发版/连远程请设 VITE_API_TARGET */ +const API_ORIGIN = process.env.VITE_API_TARGET ?? 'http://localhost:3000'; const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 5213ddb..eb63ffc 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -42,6 +42,9 @@ type OrderPreview = { payAmount: number; benefitAmount: number; city?: { localMinQty: number; crossMinQty: number }; + quantityOk?: boolean; + quantityMessage?: string | null; + minQty?: number; }; function formatAddress(a: Address) { @@ -53,7 +56,7 @@ export default function OrderConfirmPage() { const checkoutCtx = readCheckoutContext(router.params); const productId = checkoutCtx.productId ?? ''; const forceCross = checkoutCtx.cross === true; - const [quantity, setQuantity] = useState(Math.max(2, Number(checkoutCtx.qty || 2))); + const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2))); const [addresses, setAddresses] = useState([]); const [addressId, setAddressId] = useState(checkoutCtx.addressId || ''); const [preview, setPreview] = useState(null); @@ -91,7 +94,7 @@ export default function OrderConfirmPage() { .then((data) => { if (!cancelled) { setPreview(data); - setMsg(''); + setMsg(data.quantityOk === false ? (data.quantityMessage || '') : ''); } }) .catch((e) => { @@ -115,18 +118,14 @@ export default function OrderConfirmPage() { ); const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY'; - const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2); + const minQty = + preview?.minQty ?? + (isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2)); + const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false; + const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading; function updateQuantity(next: number) { - if (next < minQty) { - setMsg( - !isCross - ? `同城配送至少购买 ${minQty} 瓶` - : `跨城配送至少购买 ${minQty} 瓶(1箱)`, - ); - return; - } - setMsg(''); + if (next < 1) return; setQuantity(next); } @@ -158,8 +157,18 @@ export default function OrderConfirmPage() { } async function submit() { - if (!addressId) { - setMsg('请选择收货地址'); + if (!canSubmit) { + if (!addressId) { + setMsg('请选择收货地址'); + return; + } + if (!quantityOk) { + setMsg( + isCross + ? `跨城配送至少购买 ${minQty} 瓶(1箱)` + : `同城配送至少购买 ${minQty} 瓶`, + ); + } return; } @@ -206,6 +215,13 @@ export default function OrderConfirmPage() { } const productImage = preview?.product ? getProductMainImage(preview.product) : ''; + const submitLabel = loading + ? '提交中…' + : !addressId + ? '请选择地址' + : !quantityOk + ? `至少购买 ${minQty} 瓶` + : '提交订单'; return ( @@ -241,7 +257,8 @@ export default function OrderConfirmPage() { {isCross ? ( - 该地址超出同城配送范围,将由总部物流发货,运费到付。 + 该地址超出同城配送范围,将由总部物流发货,运费到付 + {quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。 ) : null} @@ -269,7 +286,7 @@ export default function OrderConfirmPage() { 购买数量 updateQuantity(quantity - 1)} > @@ -283,6 +300,13 @@ export default function OrderConfirmPage() { + {!quantityOk ? ( + + {isCross + ? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量` + : `同城配送至少购买 ${minQty} 瓶,请调整数量`} + + ) : null} @@ -320,10 +344,13 @@ export default function OrderConfirmPage() { !loading && void submit()} + className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`} + onClick={() => { + if (!canSubmit) return; + void submit(); + }} > - {loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'} + {submitLabel} diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 5e0eed8..4f64095 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -93,6 +93,11 @@ color: var(--color-on-surface); } +.order-qty-btn--disabled { + opacity: 0.4; + pointer-events: none; +} + .order-qty-value { margin: 0 14px; font-size: 16px; @@ -101,6 +106,14 @@ text-align: center; } +.order-qty-hint { + display: block; + margin-top: 10px; + font-size: 13px; + color: var(--color-heritage-red); + line-height: 1.4; +} + .order-row { display: flex; justify-content: space-between; @@ -180,6 +193,13 @@ justify-content: center; } +.order-confirm-submit--disabled { + background: #c9c5c0; + color: #fff; + opacity: 0.85; + pointer-events: none; +} + .order-tabs { display: flex; padding: 0 var(--space-page); diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 35f1f81..96d0b61 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -194,8 +194,8 @@ export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record = { - [WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单', - [WarehouseFulfillmentMode.MANUAL]: '自管手工填单', + [WarehouseFulfillmentMode.API_AUTO]: '自动发货', + [WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)', }; export enum AccountStatus { diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts index 2883eab..8976be1 100644 --- a/packages/shared-types/src/fulfillment-provider.ts +++ b/packages/shared-types/src/fulfillment-provider.ts @@ -1,6 +1,24 @@ import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type { WarehouseFulfillmentMode } from './enums'; +/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson) */ +export interface XiaofeixiaProviderConfig { + apiUrl: string; + mchId: string; + apiKey: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; +} + +/** 回显用(不含明文 apiKey) */ +export interface XiaofeixiaProviderConfigPublic { + apiUrl: string; + mchId: string; + signType: 'MD5' | 'HMAC-SHA256'; + appId?: string; + hasApiKey: boolean; +} + export interface FulfillmentProviderDto { id: string; code: string; @@ -14,6 +32,8 @@ export interface FulfillmentProviderDto { cancel?: boolean; } | null; hasConfig: boolean; + /** 小飞侠等承运商结构化配置(脱敏) */ + xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null; createdAt: string; updatedAt: string; } @@ -25,6 +45,8 @@ export interface CreateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + /** 结构化小飞侠配置;有则覆盖写入 configJson */ + xiaofeixiaConfig?: Partial; } export interface UpdateFulfillmentProviderInput { @@ -33,6 +55,7 @@ export interface UpdateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; } export interface ManualShipOrderInput { @@ -49,3 +72,9 @@ export interface WarehouseFulfillmentConfig { lng?: number | null; lat?: number | null; } + +export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const; + +export function isXfxProviderCode(code: string): boolean { + return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase()); +} diff --git a/packages/shared-types/src/partner-log.ts b/packages/shared-types/src/partner-log.ts index 900d660..b17fee7 100644 --- a/packages/shared-types/src/partner-log.ts +++ b/packages/shared-types/src/partner-log.ts @@ -25,7 +25,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record = { [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', [HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单', [HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算', + [HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index b78d739..701fa2f 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -6,7 +6,6 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ { key: 'sms', label: '短信' }, { key: 'wechat', label: '微信' }, { key: 'oss', label: '对象存储 OSS' }, - { key: 'courier', label: '同城配送' }, { key: 'app', label: '应用链接' }, { key: 'deploy', label: '发布部署' }, ]; @@ -16,7 +15,6 @@ const G = { sms: 'sms', wechat: 'wechat', oss: 'oss', - courier: 'courier', app: 'app', deploy: 'deploy', } as const; @@ -79,19 +77,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, - { key: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' }, - { key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false }, - { key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' }, - { key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts index e01acb3..d0cc76d 100644 --- a/server/dukang-api/src/integrations/courier/courier.service.ts +++ b/server/dukang-api/src/integrations/courier/courier.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { COURIER_PROVIDER } from './courier.constants'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -24,32 +25,32 @@ export class CourierService { return this.provider.code; } - createShipment(input: CreateShipmentInput): Promise { - return this.provider.createShipment(input); + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise { + return this.provider.createShipment(input, options); } - cancelShipment(query: ShipmentQuery): Promise { - return this.provider.cancelShipment(query); + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.cancelShipment(query, options); } - getShipment(query: ShipmentQuery): Promise { - return this.provider.getShipment(query); + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getShipment(query, options); } - batchGetShipments(query: BatchShipmentQuery): Promise { - return this.provider.batchGetShipments(query); + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.batchGetShipments(query, options); } - getTrack(query: ShipmentQuery): Promise { - return this.provider.getTrack(query); + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getTrack(query, options); } - checkDeliveryCoverage(toAddress: string): Promise { - return this.provider.checkDeliveryCoverage(toAddress); + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { + return this.provider.checkDeliveryCoverage(toAddress, options); } - estimateFreight(weight: number): Promise { - return this.provider.estimateFreight(weight); + estimateFreight(weight: number, options?: CourierCallOptions): Promise { + return this.provider.estimateFreight(weight, options); } buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts index 4d52d15..58cfdda 100644 --- a/server/dukang-api/src/integrations/courier/courier.types.ts +++ b/server/dukang-api/src/integrations/courier/courier.types.ts @@ -1,4 +1,10 @@ import { CourierProviderCode } from './courier.constants'; +import type { XiaofeixiaConfig } from './courier.config'; + +/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */ +export interface CourierCallOptions { + xiaofeixia?: XiaofeixiaConfig; +} export interface CourierCoordinate { lng: number; @@ -106,12 +112,12 @@ export interface TrackCallbackResponse { export interface ICourierProvider { readonly code: CourierProviderCode; - createShipment(input: CreateShipmentInput): Promise; - cancelShipment(query: ShipmentQuery): Promise; - getShipment(query: ShipmentQuery): Promise; - batchGetShipments(query: BatchShipmentQuery): Promise; - getTrack(query: ShipmentQuery): Promise; - checkDeliveryCoverage(toAddress: string): Promise; - estimateFreight(weight: number): Promise; + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise; + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise; + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise; + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise; + estimateFreight(weight: number, options?: CourierCallOptions): Promise; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; } diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index 659f3bc..7dd7e35 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CourierApiError } from '../courier.error'; -import { CourierConfigService } from '../courier.config'; +import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config'; import { PrismaService } from '../../../common/prisma/prisma.module'; import { logCourierCall, @@ -21,12 +21,12 @@ export class XiaofeixiaClient { private readonly prisma: PrismaService, ) {} - async request(cmd: string, bizParams: RequestParams): Promise { - const cfg = this.courierConfig.load().xiaofeixia; + async request(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise { + const cfg = override ?? this.courierConfig.load().xiaofeixia; const scene = sceneForXfxCmd(cmd); const externalNo = this.pickExternalNo(bizParams); - if (!cfg.mchId || !cfg.apiKey) { + if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) { await logCourierCall(this.prisma, { scene, requestUrl: cfg.apiUrl || '(未配置)', @@ -36,7 +36,7 @@ export class XiaofeixiaClient { externalNo, }); throw new CourierApiError( - '小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', + '小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key', 'CONFIG_ERROR', 'XIAOFEIXIA', ); diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts index ea67094..4de6b96 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -3,6 +3,7 @@ import { CourierProviderCode } from '../courier.constants'; import { CourierApiError } from '../courier.error'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -29,28 +30,32 @@ export class XiaofeixiaProvider implements ICourierProvider { constructor(private readonly client: XiaofeixiaClient) {} - async createShipment(input: CreateShipmentInput): Promise { - const data = await this.client.request(XIAOFEIXIA_CMD.CREATE_ORDER, { - customerId: input.customerId, - outNumber: input.outNumber, - fromAddress: input.from.address, - fromAddressDetail: input.from.addressDetail, - fromCoordinate: this.formatCoordinate(input.from.coordinate), - fromMobile: input.from.mobile, - fromName: input.from.name, - toAddress: input.to.address, - toAddressDetail: input.to.addressDetail, - toCoordinate: this.formatCoordinate(input.to.coordinate), - toMobile: input.to.mobile, - toName: input.to.name, - goodsName: input.goodsName, - goodsNum: input.goodsNum, - weight: input.weight, - insuredSumPrice: input.insuredSumPrice, - collectionPrice: input.collectionPrice, - payMode: input.payMode, - remark: input.remark, - }); + async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise { + const data = await this.client.request( + XIAOFEIXIA_CMD.CREATE_ORDER, + { + customerId: input.customerId, + outNumber: input.outNumber, + fromAddress: input.from.address, + fromAddressDetail: input.from.addressDetail, + fromCoordinate: this.formatCoordinate(input.from.coordinate), + fromMobile: input.from.mobile, + fromName: input.from.name, + toAddress: input.to.address, + toAddressDetail: input.to.addressDetail, + toCoordinate: this.formatCoordinate(input.to.coordinate), + toMobile: input.to.mobile, + toName: input.to.name, + goodsName: input.goodsName, + goodsNum: input.goodsNum, + weight: input.weight, + insuredSumPrice: input.insuredSumPrice, + collectionPrice: input.collectionPrice, + payMode: input.payMode, + remark: input.remark, + }, + options?.xiaofeixia, + ); return { providerShipmentId: data.id, @@ -58,24 +63,32 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async cancelShipment(query: ShipmentQuery): Promise { + async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + await this.client.request( + XIAOFEIXIA_CMD.CANCEL_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); } - async getShipment(query: ShipmentQuery): Promise { + async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.GET_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.GET_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return this.mapOrderDetail(data); } - async batchGetShipments(query: BatchShipmentQuery): Promise { + async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { const number = query.trackingNumbers?.join(','); const outNumber = query.outNumbers?.join(','); @@ -83,27 +96,36 @@ export class XiaofeixiaProvider implements ICourierProvider { throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code); } - const data = await this.client.request(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { - number, - outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.BATCH_GET_ORDER, + { + number, + outNumber, + }, + options?.xiaofeixia, + ); return (data ?? []).map((item) => this.mapOrderDetail(item)); } - async getTrack(query: ShipmentQuery): Promise { + async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.TRACK_ROUTE, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.TRACK_ROUTE, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return data ?? []; } - async checkDeliveryCoverage(toAddress: string): Promise { + async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.DELIVERY_COVERAGE, { toAddress }, + options?.xiaofeixia, ); return { @@ -113,10 +135,11 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async estimateFreight(weight: number): Promise { + async estimateFreight(weight: number, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.ESTIMATE_FREIGHT, { weight }, + options?.xiaofeixia, ); return { freightPrice: data.freightPrice }; diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index ba44808..01e2e0d 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -48,7 +48,7 @@ export class WechatMockProvider implements IWechatProvider { return { openId, nickname: 'Mock微信用户', - headImgUrl: undefined, + headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`, }; } diff --git a/server/dukang-api/src/modules/city-scope/partner-city.service.ts b/server/dukang-api/src/modules/city-scope/partner-city.service.ts index 461b6ca..69e94e4 100644 --- a/server/dukang-api/src/modules/city-scope/partner-city.service.ts +++ b/server/dukang-api/src/modules/city-scope/partner-city.service.ts @@ -103,10 +103,16 @@ export class PartnerCityService { if (!primary.cityId) return { id: -1n }; const warehouseIds = await this.resolveManagedWarehouseIds(primary.id); - if (warehouseIds.length > 0) { - return { fulfillmentWarehouseId: { in: warehouseIds } }; + // 未配置管仓:不推送/不展示酒订单(同城无仓由总部履约) + if (warehouseIds.length === 0) { + return { id: -1n }; } - return { cityId: primary.cityId }; + return { fulfillmentWarehouseId: { in: warehouseIds } }; + } + + async hasManagedWarehouse(partnerAccountId: bigint): Promise { + const ids = await this.resolveManagedWarehouseIds(partnerAccountId); + return ids.length > 0; } /** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */ diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts index a4b4042..cb82e16 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts @@ -1,7 +1,13 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; +import { + isXfxProviderCode, + type XiaofeixiaProviderConfig, + type XiaofeixiaProviderConfigPublic, +} from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config'; export type CreateFulfillmentProviderInput = { code: string; @@ -10,6 +16,7 @@ export type CreateFulfillmentProviderInput = { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; }; export type UpdateFulfillmentProviderInput = Partial; @@ -21,6 +28,8 @@ type Capabilities = { cancel?: boolean; }; +const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do'; + @Injectable() export class FulfillmentProviderService { constructor(private readonly prisma: PrismaService) {} @@ -46,6 +55,44 @@ export class FulfillmentProviderService { return this.toDto(row); } + /** 供推单使用:解析完整小飞侠凭证(含 apiKey) */ + async resolveXiaofeixiaConfig(providerId: bigint): Promise { + const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } }); + if (!row) throw new NotFoundException('仓配承运商不存在'); + if (!isXfxProviderCode(row.code)) { + throw new BadRequestException('该承运商不是小飞侠'); + } + const cfg = this.parseXiaofeixiaConfig(row.configJson); + if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) { + throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key'); + } + return { + apiUrl: cfg.apiUrl, + mchId: cfg.mchId, + apiKey: cfg.apiKey, + signType: this.resolveSignType(cfg.signType), + appId: cfg.appId || undefined, + }; + } + + /** 取第一个启用的小飞侠承运商配置(联调/兼容) */ + async resolveDefaultXiaofeixiaConfig(): Promise { + const row = await this.prisma.fulfillmentProvider.findFirst({ + where: { + status: 'ACTIVE', + type: 'API', + code: { in: ['XFX', 'XIAOFEIXIA'] }, + }, + orderBy: { updatedAt: 'desc' }, + }); + if (!row?.configJson) return null; + try { + return await this.resolveXiaofeixiaConfig(row.id); + } catch { + return null; + } + } + async create(input: CreateFulfillmentProviderInput) { const code = input.code.trim().toUpperCase(); if (!/^[A-Z0-9_]+$/.test(code)) { @@ -54,28 +101,54 @@ export class FulfillmentProviderService { const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } }); if (existing) throw new BadRequestException('承运商编码已存在'); + const configJson = this.resolveConfigJsonForWrite(code, null, input); + if (isXfxProviderCode(code) && input.type === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, true); + } + 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, + configJson, + capabilitiesJson: + input.capabilitiesJson?.trim() || + (isXfxProviderCode(code) + ? JSON.stringify({ + createShipment: true, + getTrack: true, + callback: true, + cancel: true, + }) + : null), }, }); return this.toDto(row); } async update(id: bigint, input: UpdateFulfillmentProviderInput) { - await this.getById(id); + const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('仓配承运商不存在'); + + const code = current.code; + const configJson = + input.xiaofeixiaConfig !== undefined || input.configJson !== undefined + ? this.resolveConfigJsonForWrite(code, current.configJson, input) + : undefined; + + if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, false); + } + 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 } : {}), + ...(configJson !== undefined ? { configJson } : {}), ...(input.capabilitiesJson !== undefined ? { capabilitiesJson: input.capabilitiesJson?.trim() || null } : {}), @@ -93,6 +166,74 @@ export class FulfillmentProviderService { } } + parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== 'object') return null; + return { + apiUrl: String(parsed.apiUrl ?? '').trim(), + mchId: String(parsed.mchId ?? '').trim(), + apiKey: String(parsed.apiKey ?? '').trim(), + signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: parsed.appId ? String(parsed.appId).trim() : undefined, + }; + } catch { + return null; + } + } + + private resolveConfigJsonForWrite( + code: string, + existingRaw: string | null, + input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput, + ): string | null { + if (isXfxProviderCode(code) && input.xiaofeixiaConfig) { + const existing = this.parseXiaofeixiaConfig(existingRaw); + const next: XiaofeixiaProviderConfig = { + apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(), + mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(), + apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(), + signType: + input.xiaofeixiaConfig.signType === 'HMAC-SHA256' + ? 'HMAC-SHA256' + : input.xiaofeixiaConfig.signType === 'MD5' + ? 'MD5' + : existing?.signType ?? 'MD5', + appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined, + }; + return JSON.stringify(next); + } + if (input.configJson !== undefined) { + return input.configJson?.trim() || null; + } + return existingRaw; + } + + private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) { + const cfg = this.parseXiaofeixiaConfig(configJson); + if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址'); + if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号'); + if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key'); + if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写'); + } + + private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null { + const cfg = this.parseXiaofeixiaConfig(raw); + if (!cfg) return null; + return { + apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL, + mchId: cfg.mchId, + signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: cfg.appId, + hasApiKey: Boolean(cfg.apiKey), + }; + } + + private resolveSignType(raw?: string): XiaofeixiaSignType { + return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5'; + } + private toDto(row: { id: bigint; code: string; @@ -112,6 +253,9 @@ export class FulfillmentProviderService { status: row.status, capabilities: this.parseCapabilities(row.capabilitiesJson), hasConfig: Boolean(row.configJson), + xiaofeixiaConfig: isXfxProviderCode(row.code) + ? this.toPublicXiaofeixiaConfig(row.configJson) + : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index 90ce54f..6915a83 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; +import { isXfxProviderCode } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { TradeService } from '../trade/trade.service'; +import { FulfillmentProviderService } from './fulfillment-provider.service'; export type ManualShipInput = { logisticsCompany: string; @@ -14,16 +16,14 @@ export type ManualShipInput = { 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, + private readonly fulfillmentProviderService: FulfillmentProviderService, @Inject(forwardRef(() => TradeService)) private readonly tradeService: TradeService, ) {} @@ -69,38 +69,50 @@ export class FulfillmentService { } async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { - if (!XFX_CODES.has(provider.code)) { + if (!isXfxProviderCode(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; + let xfxConfig: XiaofeixiaConfig; + try { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id); + } 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); + return; + } + + const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; + const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; 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 }, + 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: 2, + payMode: CourierPayMode.SENDER, + remark: `仓配自动发货 ${order.orderNo}`, }, - 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}`, - }); + { xiaofeixia: xfxConfig }, + ); const now = new Date(); await this.prisma.$transaction(async (tx) => { @@ -166,11 +178,7 @@ export class FulfillmentService { }); if (!order) throw new NotFoundException('订单不存在'); - const isHqQueue = - order.deliveryType === 'CROSS_CITY' || - (order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId); - - if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货'); + // HQ 可对任意待发货单填快递单号(含仓配单手动填单) if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { throw new BadRequestException('当前订单状态不可发货'); } @@ -196,10 +204,20 @@ export class FulfillmentService { 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, - }); + const options = order.delivery.fulfillmentProviderId + ? { + xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig( + order.delivery.fulfillmentProviderId, + ), + } + : undefined; + const nodes = await this.courier.getTrack( + { + trackingNumber: order.delivery.trackingNo ?? undefined, + outNumber: order.orderNo, + }, + options, + ); return { nodes, manualQueryUrl: order.delivery.manualQueryUrl, @@ -208,7 +226,7 @@ export class FulfillmentService { logisticsCompany: order.delivery.logisticsCompany, }; } catch { - // fall through to manual fields + // fall through } } @@ -294,13 +312,4 @@ export class FulfillmentService { if (!tpl) return undefined; return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo)); } - - private getShipDefaults() { - return { - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: CourierPayMode.SENDER, - }; - } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index fd67017..645bd8b 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -231,6 +231,9 @@ export class AuthService { isPrimary: number; staffRole: string | null; permissions?: unknown; + wxOpenId?: string | null; + wxNickname?: string | null; + wxAvatarUrl?: string | null; }, primary: { id: bigint; companyName: string | null }, ) { @@ -243,9 +246,37 @@ export class AuthService { staffRole: account.staffRole ?? undefined, companyName: primary.companyName ?? undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined, + hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } + /** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */ + private async syncPartnerWechatProfile( + partnerAccountId: bigint, + accessToken: string | undefined, + openId: string, + ) { + if (!accessToken) return null; + try { + const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, { + refType: 'PARTNER', + refId: partnerAccountId, + }); + const data: { wxNickname?: string; wxAvatarUrl?: string } = {}; + if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64); + if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512); + if (!data.wxNickname && !data.wxAvatarUrl) return null; + return this.prisma.partnerAccount.update({ + where: { id: partnerAccountId }, + data, + }); + } catch { + return null; + } + } + private async assertPartnerAccountByPhone(phone: string) { const account = await this.prisma.partnerAccount.findUnique({ where: { phone }, @@ -1547,7 +1578,7 @@ export class AuthService { throw new BadRequestException('该微信已绑定其他合伙人账号'); } - const updated = await this.prisma.partnerAccount.update({ + let updated = await this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data: { wxOpenId: session.openId, @@ -1555,6 +1586,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + updated.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) updated = synced; const primary = await this.resolvePrimaryAccount(updated.id); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); @@ -1593,6 +1630,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + account.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) account = synced; const primary = await this.resolvePrimaryAccount(account.id); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 95babe4..a1eb27a 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -22,6 +22,7 @@ export class AdminDashboardService { deliveriesTotal, pendingPayouts, pendingBills, + pendingPartnerDraftBills, openTickets, ] = await Promise.all([ this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), @@ -42,7 +43,8 @@ export class AdminDashboardService { this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), this.prisma.orderDelivery.count(), this.prisma.storePayout.count({ where: { status: 'PENDING' } }), - this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }), + this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }), + this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), ]); @@ -57,7 +59,9 @@ export class AdminDashboardService { redeemToday, deliveriesTotal, pendingPayouts, + /** 合伙人已确认、待总部审核打款 */ pendingBills, + pendingPartnerDraftBills, openTickets, ordersByStatus: ordersByStatus.map((row) => ({ status: row.status, diff --git a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts index 98fd5d2..c79005b 100644 --- a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts @@ -39,9 +39,15 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } + @Get(':id') + detail(@Param('id') id: string) { + return this.service.getById(BigInt(id)); + } + @Put(':id') @HqOperation({ action: HqOperationAction.WAREHOUSE_UPDATE, @@ -56,6 +62,7 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } } diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index f36e2ab..d6e04cb 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -11,6 +10,7 @@ import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; @Injectable() export class AdminOrdersService { @@ -19,7 +19,7 @@ export class AdminOrdersService { private readonly tradeService: TradeService, private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly fulfillmentService: FulfillmentService, - private readonly config: ConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} async list(query: AdminOrdersQueryDto) { @@ -30,6 +30,7 @@ export class AdminOrdersService { if (query.orderNo) where.orderNo = { contains: query.orderNo }; if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals']; if (query.userId) where.userId = BigInt(query.userId); + if (query.cityId) where.cityId = BigInt(query.cityId); if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.createdFrom || query.createdTo) { where.createdAt = {}; @@ -46,6 +47,8 @@ export class AdminOrdersService { include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } }, + city: { select: { id: true, name: true, code: true } }, + fulfillmentWarehouse: { select: { id: true, name: true } }, }, }), this.prisma.order.count({ where }), @@ -75,6 +78,18 @@ export class AdminOrdersService { city: { select: { id: true, name: true, code: true } }, product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, imageResource: { select: { id: true, url: true } }, + fulfillmentWarehouse: { + select: { + id: true, + name: true, + contactName: true, + contactPhone: true, + address: true, + lng: true, + lat: true, + fulfillmentMode: true, + }, + }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -96,29 +111,17 @@ export class AdminOrdersService { return this.detail(id); } - getShipDefaults() { - return { - provider: 'XFX', - providerLabel: '小飞侠', - fromName: this.config.get('SHIP_FROM_NAME') || '杜康仓库', - fromMobile: this.config.get('SHIP_FROM_MOBILE') || '13800000000', - fromAddress: this.config.get('SHIP_FROM_ADDRESS') || '河南省郑州市金水区', - fromAddressDetail: this.config.get('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓', - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: '1', - }; - } - async shipOrder(id: bigint, dto: AdminShipOrderDto) { if (dto.provider !== 'XFX') { throw new BadRequestException('暂仅支持小飞侠配送'); } - const order = await this.prisma.order.findUnique({ + let order = await this.prisma.order.findUnique({ where: { id }, - include: { delivery: true }, + include: { + delivery: true, + fulfillmentWarehouse: true, + }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -129,7 +132,46 @@ export class AdminOrdersService { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } - const defaults = this.getShipDefaults(); + if (dto.warehouseId) { + const warehouseId = BigInt(dto.warehouseId); + const warehouseRow = await this.prisma.cityWarehouse.findFirst({ + where: { id: warehouseId, status: 'ACTIVE' }, + }); + if (!warehouseRow) { + throw new BadRequestException('仓库不存在或已停用'); + } + if (order.fulfillmentWarehouseId !== warehouseId) { + await this.prisma.order.update({ + where: { id }, + data: { fulfillmentWarehouseId: warehouseId }, + }); + order = await this.prisma.order.findUniqueOrThrow({ + where: { id }, + include: { delivery: true, fulfillmentWarehouse: true }, + }); + } + } + + const warehouse = order.fulfillmentWarehouse; + if (!warehouse) { + throw new BadRequestException('请先选择履约仓库'); + } + const providerId = + order.delivery?.fulfillmentProviderId ?? + warehouse?.fulfillmentProviderId ?? + null; + + let xfxConfig; + if (providerId) { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId); + } else { + xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + if (!xfxConfig) { + throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商'); + } + } + + const defaults = this.getShipDefaults(warehouse); const shipmentDto: XiaofeixiaCreateShipmentDto = { outNumber: order.orderNo, fromName: dto.fromName || defaults.fromName, @@ -149,13 +191,14 @@ export class AdminOrdersService { remark: dto.remark || `HQ发货 ${order.orderNo}`, }; - const result = await this.xiaofeixiaService.createShipment(shipmentDto); + const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig); if (!result.ok || !result.data) { throw new BadRequestException(result.error || '小飞侠创建运单失败'); } const { providerShipmentId, trackingNumber } = result.data; const now = new Date(); + const resolvedProviderId = providerId; await this.prisma.$transaction(async (tx) => { if (order.delivery) { @@ -163,6 +206,7 @@ export class AdminOrdersService { where: { orderId: id }, data: { provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -173,6 +217,7 @@ export class AdminOrdersService { data: { orderId: id, provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -185,6 +230,40 @@ export class AdminOrdersService { return this.detail(id); } + getShipDefaults(warehouse?: { + contactName: string; + contactPhone: string; + address: string; + name: string; + lng: { toNumber?: () => number } | number | null; + lat: { toNumber?: () => number } | number | null; + } | null) { + const lng = + warehouse?.lng != null + ? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng + ? Number(warehouse.lng) + : Number(warehouse.lng) + : 113.665; + const lat = + warehouse?.lat != null + ? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat + ? Number(warehouse.lat) + : Number(warehouse.lat) + : 34.757; + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: warehouse?.contactName || '杜康仓库', + fromMobile: warehouse?.contactPhone || '13800000000', + fromAddress: warehouse?.address || '河南省郑州市金水区', + fromAddressDetail: warehouse?.name || '杜康酒业仓', + fromLng: lng, + fromLat: lat, + weight: 2, + payMode: '1', + }; + } + /** 总部传统快递填单(同城无仓 / 跨城) */ async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { await this.fulfillmentService.shipHqLogistics(id, dto); diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts index 4f9bef2..4c2bc46 100644 --- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts +++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { CourierConfigService } from '../../integrations/courier/courier.config'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { CourierApiError } from '../../integrations/courier/courier.error'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; @@ -8,6 +9,7 @@ import type { CreateShipmentInput, ShipmentQuery, } from '../../integrations/courier/courier.types'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import type { XiaofeixiaBatchShipmentQueryDto, XiaofeixiaCheckCoverageDto, @@ -27,48 +29,61 @@ export class AdminXiaofeixiaService { constructor( private readonly courier: CourierService, private readonly courierConfig: CourierConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} - getConfig() { - const cfg = this.courierConfig.load(); - const xfx = cfg.xiaofeixia; + async getConfig() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + const envCfg = this.courierConfig.load().xiaofeixia; + const xfx = fromDb ?? envCfg; return { - provider: cfg.provider, + provider: this.courierConfig.load().provider, activeProvider: this.courier.activeProvider, + source: fromDb ? 'fulfillment_provider' : 'env', apiUrl: xfx.apiUrl, appId: xfx.appId ?? null, mchId: xfx.mchId || null, mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null, hasApiKey: Boolean(xfx.apiKey), signType: xfx.signType, - ready: Boolean(xfx.mchId && xfx.apiKey), + ready: Boolean(xfx.mchId && xfx.apiKey && xfx.apiUrl), + hint: fromDb + ? '凭证来自仓配管理中启用的小飞侠承运商' + : '未在仓配管理配置,回退到环境变量(请迁移至仓配管理)', }; } async estimateFreight(dto: XiaofeixiaEstimateFreightDto) { - return this.wrap(() => this.courier.estimateFreight(dto.weight)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.estimateFreight(dto.weight, options)); } async checkCoverage(dto: XiaofeixiaCheckCoverageDto) { - return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress, options)); } - async createShipment(dto: XiaofeixiaCreateShipmentDto) { + async createShipment(dto: XiaofeixiaCreateShipmentDto, xfxOverride?: XiaofeixiaConfig) { const input = this.mapCreateInput(dto); - return this.wrap(() => this.courier.createShipment(input)); + const options = xfxOverride + ? { xiaofeixia: xfxOverride } + : await this.callOptions(); + return this.wrap(() => this.courier.createShipment(input, options)); } async cancelShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); + const options = await this.callOptions(); return this.wrap(async () => { - await this.courier.cancelShipment(query); + await this.courier.cancelShipment(query, options); return { cancelled: true }; }); } async getShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getShipment(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getShipment(query, options)); } async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) { @@ -76,12 +91,19 @@ export class AdminXiaofeixiaService { trackingNumbers: dto.trackingNumbers?.filter(Boolean), outNumbers: dto.outNumbers?.filter(Boolean), }; - return this.wrap(() => this.courier.batchGetShipments(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.batchGetShipments(query, options)); } async getTrack(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getTrack(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getTrack(query, options)); + } + + private async callOptions() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + return fromDb ? { xiaofeixia: fromDb } : undefined; } private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery { diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index c9d3e30..e8fe9c5 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -583,6 +583,16 @@ export class CreateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class UpdateFulfillmentProviderDto { @@ -605,6 +615,16 @@ export class UpdateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class ManualShipOrderDto { @@ -691,6 +711,11 @@ export class AdminShipOrderDto { @IsIn(['XFX']) provider: string; + /** 可选:指定/改派履约仓后再推仓配 */ + @IsOptional() + @IsString() + warehouseId?: string; + @IsOptional() @IsString() fromName?: string; diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index b9e9021..d1a0199 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto { @IsString() userId?: string; + @IsOptional() + @IsString() + cityId?: string; + @IsOptional() @IsString() receiverPhone?: string; diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 1cdf07c..011d715 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -9,6 +9,7 @@ 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 { PrismaService } from '../../common/prisma/prisma.module'; +import { PartnerCityService } from '../city-scope/partner-city.service'; class UpdatePartnerMeDto { @IsOptional() @@ -25,6 +26,11 @@ export class SettlementController { bills(@CurrentUser() user: AuthUser) { return this.settlementService.listPartnerBills(user.actorId); } + + @Post('bills/:id/confirm') + confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id)); + } } @Controller('shop/payouts') @@ -193,6 +199,20 @@ export class AdminPartnerBillController { markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) { return this.settlementService.markPartnerBillPaid(BigInt(id), body); } + + @Post(':id/reject') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_REJECT, + refType: 'PARTNER_BILL', + refIdParam: 'id', + includeBody: true, + }) + reject(@Param('id') id: string, @Body() body: { reason?: string }) { + if (!body?.reason?.trim()) { + throw new BadRequestException('请填写驳回理由'); + } + return this.settlementService.rejectPartnerBill(BigInt(id), body.reason); + } } @Controller('admin/winery-bills') @@ -224,7 +244,10 @@ export class AdminWineryBillController { @Controller('partner/me') @UseGuards(JwtAuthGuard) export class PartnerMeController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly partnerCityService: PartnerCityService, + ) {} @Get() async me(@CurrentUser() user: AuthUser) { @@ -255,6 +278,7 @@ export class PartnerMeController { where: { id: account.parentAccountId }, }); } + const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); return { id: account.id.toString(), name: account.name, @@ -267,6 +291,10 @@ export class PartnerMeController { primaryName: primary.name, companyName: primary.companyName ?? undefined, hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, + managedWarehouseId: primary.managedWarehouseId?.toString() ?? null, + hasWarehouseAccess, }; } } diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index ea8dae4..b32e329 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -529,11 +529,70 @@ export class SettlementService { async confirmPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认'); + if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { + throw new BadRequestException('仅待确认或已驳回账单可确认'); + } const updated = await this.prisma.partnerBill.update({ where: { id }, - data: { status: 'CONFIRMED', confirmedAt: new Date() }, + data: { + status: 'CONFIRMED', + confirmedAt: new Date(), + rejectReason: null, + }, + }); + + return serializeBigInt(updated); + } + + /** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED) */ + async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } }); + if (!bill || bill.partnerAccountId !== primary.id) { + throw new NotFoundException('账单不存在'); + } + if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { + throw new BadRequestException('当前账单不可再次申请打款'); + } + + const updated = await this.prisma.partnerBill.update({ + where: { id: billId }, + data: { + status: 'CONFIRMED', + confirmedAt: new Date(), + rejectReason: null, + }, + }); + + this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { + partnerAccountId: primary.id, + eventName: 'partner_bill_confirm', + refType: 'PARTNER_BILL', + refId: billId, + extraJson: { billNo: bill.billNo }, + }); + + return serializeBigInt(updated); + } + + async rejectPartnerBill(id: bigint, reason: string) { + const rejectReason = reason.trim(); + if (!rejectReason) throw new BadRequestException('请填写驳回理由'); + if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字'); + + const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status !== 'CONFIRMED') { + throw new BadRequestException('仅待打款审核的账单可驳回'); + } + + const updated = await this.prisma.partnerBill.update({ + where: { id }, + data: { + status: 'REJECTED', + rejectReason, + }, }); return serializeBigInt(updated); @@ -542,11 +601,11 @@ export class SettlementService { async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款'); + if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款'); const updated = await this.prisma.partnerBill.update({ where: { id }, - data: { status: 'PAID', paidAt: new Date() }, + data: { status: 'PAID', paidAt: new Date(), rejectReason: null }, }); return serializeBigInt(updated); @@ -577,6 +636,7 @@ export class SettlementService { '状态', '确认时间', '打款时间', + '驳回理由', ].join(','); const rows = bills.map((b) => [ @@ -591,6 +651,7 @@ export class SettlementService { b.status, b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', + csvEscape(b.rejectReason ?? ''), ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 31be90d..59dbb1d 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -75,7 +75,6 @@ export class TradeService { city.localMinQty, city.crossMinQty, ); - if (!check.ok) throw new BadRequestException(check.message); const unitPrice = Number(product.price); const productAmount = unitPrice * body.quantity; @@ -96,6 +95,10 @@ export class TradeService { payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, city: serializeBigInt(city), + /** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */ + quantityOk: check.ok, + quantityMessage: check.ok ? null : (check.message ?? null), + minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty, }; } @@ -110,6 +113,9 @@ export class TradeService { req: Request, ) { const preview = await this.preview(userId, body); + if (preview.quantityOk === false) { + throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求'); + } const address = await this.prisma.userAddress.findFirst({ where: { id: BigInt(body.addressId), userId }, }); @@ -509,6 +515,7 @@ export class TradeService { async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const [list, total] = await Promise.all([ this.prisma.order.findMany({ @@ -520,7 +527,14 @@ export class TradeService { }), this.prisma.order.count({ where }), ]); - return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize }; + return { + list: serializeBigInt(list.map(mapOrderCompat)), + total, + page, + pageSize, + hasWarehouseAccess, + message: hasWarehouseAccess ? undefined : '未配置仓库管理权限,购酒订单由总部履约', + }; } async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {