feat(store): 门店订单页新增发货按钮及选择发货人功能

- 物流页发货信息依赖 shopOrderDelivery,之前门店确认完成不创建发货单导致显示为空
- 在门店订单页确认收款后增加发货按钮,允许选择门店店员作为发货人并创建发货单
- 新增接口 saveShopOrderDelivery 调用 POST /shop/shop-order-delivery 创建发货单
- 补全 shopStoreUser 模型字段,支持店员姓名、电话、角色等信息
- 订单列表支持发货操作按钮显示及样式,增加对应操作逻辑及UI弹窗选择发货人员
- 发货后订单状态置为已发货(deliveryStatus=20),同步更新发货时间和物流页显示
- 用户可选择门店全部店员(含经理)作为发货人员,确认完成按钮可与发货按钮共存,行为保持兼容
- 完成相关接口调用错误提示与加载状态处理,确保用户体验
- taro 构建通过,无类型错误
This commit is contained in:
2026-07-17 03:49:03 +08:00
parent fff2ceb40d
commit 7071ec408e
4 changed files with 202 additions and 4 deletions

View File

@@ -74,5 +74,23 @@
- **字段确认**:用户选择「送达时间」用 `order.deliveryTime`,且**保留**现有「发货时间」行 → `deliveryTime` 在两行各显示一次(用户明知并接受)
- **改动**第408-413行 `{order.expirationTime && ...过期时间}` 改为 `{order.deliveryTime && ...送达时间}`,数据用 `formatTime(order.deliveryTime)`
## 门店订单页「发货」按钮 + 选发货人员写 shopOrderDelivery解决物流页发货信息为空
- **根因**:物流页 `pages/order/logistics.tsx` 的「发货信息」卡片依赖 `shopOrderDelivery` 发货单;门店「确认完成」只改订单状态、不建发货单 → `delivery` 为 null → 卡片不渲染(空白)
- **方案**:在「确认收款」之后加「发货」按钮,选门店店员作为发货人,写入 `shopOrderDelivery`,从源头让物流页有数据
- **后端可行性(已确认)**`POST /shop/shop-order-delivery`(save) 已存在,实体字段全可空最少传 orderId`listShopStoreUser({storeId})` 已存在;请求层自动注入 `TenantId` 请求头(无需手动传租户);`ShopOrderDelivery` 实体有 orderId/deliveryMethod/sendName/sendPhone/sendAddress
- **前端改动**
- 新增 `src/api/shop/shopOrderDelivery/index.ts``saveShopOrderDelivery(data)` 封装 `POST /shop/shop-order-delivery`
- 补全 `src/api/shop/shopStoreUser/model``ShopStoreUser` 类型缺的 `name`/`phone`/`roleType`/`storeName`/`image`(前端模型此前不完整,后端实体有)
- `src/pages/store/orders/index.tsx`
- `OpType` 增加 `'ship'``OP_LABEL`/`OP_DESC` 补 ship
- `getOrderActions`:已付款 且 `deliveryStatus<20`(或未发货)且 `orderStatus!==1` 时显示「发货」(紫色 `bg-purple-500`)
- 新增状态 `showShipModal`/`clerkList`/`selectedClerkId`/`loadingClerks`/`shipping`
- 新增 `openShipModal(order)`(拉 `listShopStoreUser({storeId: order.storeId})`)与 `handleConfirmShip()`(先 `saveShopOrderDelivery({orderId, deliveryMethod:20, sendName:clerk.name, sendPhone:clerk.phone, sendAddress:order.storeName})`,再 `updateShopOrder({orderId, deliveryStatus:20, deliveryTime})`
- 渲染「选择发货人员」底部弹层(店员列表可点选,确认发货按钮置灰直到选中)
- **设计决策(用户确认)**:① 发货即把订单置「已发货」(deliveryStatus=20);② 发货人可选门店全部店员(经理+店员)
- **物流页**:无需改动,`shopOrderDelivery` 存在即自动渲染发货人
- **验证**tsc 仅剩预存 `@tarojs/taro` 类型缺失环境报错,本文件无新错误
- ⚠️ 行为说明:已付款未发货时「发货」与「确认完成」两个按钮同时显示;店员可跳过发货直接确认完成(该订单仍无发货单,物流页空白,与旧行为一致);如需强制「先发货才能确认完成」,可隐藏未发货订单的「确认完成」按钮(一行判断改动),待用户决定
### 验证
- `npx taro build --type weapp` 构建成功

View File

@@ -0,0 +1,18 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api'
import type { ShopOrderDelivery } from '@/api/shop/shopOrder/model'
/**
* 创建发货单(门店发货:记录发货人员)
* 后端 POST /shop/shop-order-deliverysave
*/
export async function saveShopOrderDelivery(data: ShopOrderDelivery) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-order-delivery',
data
)
if (res.code === 0) {
return res.message
}
return Promise.reject(new Error(res.message))
}

View File

@@ -10,6 +10,16 @@ export interface ShopStoreUser {
storeId?: number;
// 用户ID
userId?: number;
// 姓名
name?: string;
// 手机号
phone?: string;
// 头像
image?: string;
// 角色类型: 1-门店经理, 2-店员
roleType?: number;
// 门店名称(关联字段)
storeName?: string;
// 备注
comments?: string;
// 排序号

View File

@@ -3,10 +3,12 @@ import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components
import Taro, {useDidShow} from '@tarojs/taro'
import {pageShopOrder, updateShopOrder, confirmOfflinePayment} from '@/api/shop/shopOrder'
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
import {saveShopOrderDelivery} from '@/api/shop/shopOrderDelivery'
import {TenantId} from '@/config/app'
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
import { getCompressedImageUrl } from '@/utils/image'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { getMyClerk, listShopStoreUser } from '@/api/shop/shopStoreUser'
import type {ShopStoreUser} from '@/api/shop/shopStoreUser/model'
import { ensurePrivacyAuthorized } from '@/api/system/file'
definePageConfig({
@@ -26,18 +28,20 @@ const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[]
]
// ─── 操作类型 ─────────────────────────────────────────────────────
type OpType = 'confirmPay' | 'complete' | 'editPrice'
type OpType = 'confirmPay' | 'complete' | 'editPrice' | 'ship'
const OP_LABEL: Record<OpType, string> = {
confirmPay: '收款',
complete: '已完成',
editPrice: '金额',
ship: '发货',
}
const OP_DESC: Record<OpType, string> = {
confirmPay: '确认已收到线下付款,订单将进入待发货状态',
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
editPrice: '修改订单实付金额',
ship: '选择发货人员并生成发货单,订单将进入已发货状态',
}
// ─── 图片上传 ─────────────────────────────────────────────────────
@@ -135,6 +139,10 @@ function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
if (order.payStatus) {
actions.push({label: '确认完成', type: 'complete'})
}
// 发货确认收款后、且尚未发货deliveryStatus<20时显示
if (order.payStatus && (order.deliveryStatus == null || order.deliveryStatus < 20) && order.orderStatus !== 1) {
actions.push({label: '发货', type: 'ship'})
}
return actions
}
@@ -161,6 +169,13 @@ export default function StoreOrdersPage() {
const [editPayPrice, setEditPayPrice] = useState('')
const [editReason, setEditReason] = useState('')
// 发货弹窗状态
const [showShipModal, setShowShipModal] = useState(false)
const [clerkList, setClerkList] = useState<ShopStoreUser[]>([])
const [selectedClerkId, setSelectedClerkId] = useState<number | null>(null)
const [loadingClerks, setLoadingClerks] = useState(false)
const [shipping, setShipping] = useState(false)
// 搜索searchInput 受控输入框searchKeyword 为已提交的搜索词
const [searchInput, setSearchInput] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
@@ -439,6 +454,58 @@ export default function StoreOrdersPage() {
}
}
/** 打开发货弹窗:拉取门店店员列表供选择发货人员 */
const openShipModal = async (order: ShopOrder) => {
setCurrentOrder(order)
setSelectedClerkId(null)
setClerkList([])
setShowShipModal(true)
setLoadingClerks(true)
try {
const res = await listShopStoreUser({storeId: order.storeId})
setClerkList(res || [])
} catch (e) {
Taro.showToast({title: '加载店员失败', icon: 'none'})
} finally {
setLoadingClerks(false)
}
}
/** 确认发货:创建发货单并记录发货人员,订单置为已发货 */
const handleConfirmShip = async () => {
if (!currentOrder) return
const clerk = clerkList.find(c => c.id === selectedClerkId)
if (!clerk) {
Taro.showToast({title: '请选择发货人员', icon: 'none'})
return
}
setShipping(true)
try {
// 1) 创建发货单,记录发货人信息
await saveShopOrderDelivery({
orderId: currentOrder.orderId,
deliveryMethod: 20, // 20=无需物流(门店自送/自提,无快递单号)
sendName: clerk.name,
sendPhone: clerk.phone,
// 发货地址取门店名称(店员实体无地址字段)
sendAddress: currentOrder.storeName || '',
})
// 2) 订单置为已发货deliveryStatus=20物流页与列表状态同步更新
await updateShopOrder({
orderId: currentOrder.orderId,
deliveryStatus: 20,
deliveryTime: formatDateTime(new Date()),
})
Taro.showToast({title: '发货成功', icon: 'success'})
setShowShipModal(false)
loadOrders(activeTab, 1) // 刷新列表(发货按钮随之隐藏)
} catch (e: any) {
Taro.showToast({title: e.message || '发货失败', icon: 'none'})
} finally {
setShipping(false)
}
}
/** 关闭订单 */
const handleCloseOrder = (order: ShopOrder) => {
Taro.showModal({
@@ -600,12 +667,18 @@ export default function StoreOrdersPage() {
? 'bg-blue-500'
: act.type === 'editPrice'
? 'bg-orange-500'
: act.type === 'ship'
? 'bg-purple-500'
: 'border border-blue-500'
}`}
onClick={() => act.type === 'editPrice' ? openEditPriceModal(order) : openModal(order, act.type)}
onClick={() => act.type === 'editPrice'
? openEditPriceModal(order)
: act.type === 'ship'
? openShipModal(order)
: openModal(order, act.type)}
>
<Text className={`text-sm ${
act.type === 'complete' || act.type === 'editPrice' || act.type === 'confirmPay' ? 'text-white' : 'text-blue-500'
act.type === 'complete' || act.type === 'editPrice' || act.type === 'confirmPay' || act.type === 'ship' ? 'text-white' : 'text-blue-500'
}`}>{act.label}</Text>
</View>
))}
@@ -867,6 +940,85 @@ export default function StoreOrdersPage() {
</View>
</View>
)}
{/* 发货弹窗:选择发货人员 */}
{showShipModal && currentOrder && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
<View className='absolute inset-0 bg-black/50' onClick={() => setShowShipModal(false)}/>
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
</Text>
{/* 订单信息 */}
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
<Text className='text-sm text-gray-700 block'>{currentOrder.orderNo}</Text>
<Text className='text-sm text-gray-700 mt-1 block'>
<Text
className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
</Text>
</View>
{/* 店员列表 */}
<View className='max-h-80 overflow-y-auto mb-5'>
{loadingClerks ? (
<View className='py-8 flex justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
) : clerkList.length === 0 ? (
<View className='py-8 flex justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
clerkList.map(clerk => {
const selected = clerk.id === selectedClerkId
return (
<View
key={clerk.id}
className={`flex items-center justify-between px-4 py-3 rounded-xl mb-2 border ${
selected ? 'border-purple-500 bg-purple-50' : 'border-gray-200'
}`}
onClick={() => setSelectedClerkId(clerk.id ?? null)}
>
<View className='flex-1'>
<Text className='text-sm text-gray-800'>
{clerk.name}
<Text className='text-xs text-gray-400 ml-2'>
{clerk.roleType === 1 ? '经理' : '店员'}
</Text>
</Text>
<Text className='text-xs text-gray-500 mt-0.5 block'>{clerk.phone}</Text>
</View>
{selected && (
<Text className='text-purple-500 text-sm'></Text>
)}
</View>
)
})
)}
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={() => setShowShipModal(false)}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
selectedClerkId != null ? 'bg-purple-500' : 'bg-gray-300'
}`}
onClick={selectedClerkId != null && !shipping ? handleConfirmShip : undefined}
>
{shipping ? (
<Text className='text-sm text-white'>...</Text>
) : (
<Text className='text-sm text-white'></Text>
)}
</View>
</View>
</View>
</View>
)}
</View>
)
}