|
|
|
|
@@ -27,9 +27,11 @@ import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/s
|
|
|
|
|
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
|
|
|
|
|
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
|
|
|
|
|
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
|
|
|
|
|
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
|
|
|
|
|
import { listShopStoreRider } from '@/api/shop/shopStoreRider'
|
|
|
|
|
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
|
|
|
|
|
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
|
|
|
|
|
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon } from '@/utils/geofence'
|
|
|
|
|
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
|
|
|
|
|
|
|
|
|
|
const MIN_START_QTY = 10
|
|
|
|
|
|
|
|
|
|
@@ -62,6 +64,8 @@ const OrderConfirm = () => {
|
|
|
|
|
const [stores, setStores] = useState<ShopStore[]>([])
|
|
|
|
|
const [storeLoading, setStoreLoading] = useState(false)
|
|
|
|
|
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
|
|
|
|
|
const storeAutoPickingRef = useRef(false)
|
|
|
|
|
const storeManualPickedRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
// 水票:用于“立即送水”下单(用水票抵扣,无需支付)
|
|
|
|
|
const [tickets, setTickets] = useState<GltUserTicket[]>([])
|
|
|
|
|
@@ -135,6 +139,23 @@ const OrderConfirm = () => {
|
|
|
|
|
return dayjs(sendTime).format('YYYY-MM-DD')
|
|
|
|
|
}, [sendTime])
|
|
|
|
|
|
|
|
|
|
const distanceMeters = (a: { lng: number; lat: number }, b: { lng: number; lat: number }) => {
|
|
|
|
|
const toRad = (x: number) => (x * Math.PI) / 180
|
|
|
|
|
const R = 6371000 // meters
|
|
|
|
|
const dLat = toRad(b.lat - a.lat)
|
|
|
|
|
const dLng = toRad(b.lng - a.lng)
|
|
|
|
|
const lat1 = toRad(a.lat)
|
|
|
|
|
const lat2 = toRad(b.lat)
|
|
|
|
|
const sin1 = Math.sin(dLat / 2)
|
|
|
|
|
const sin2 = Math.sin(dLng / 2)
|
|
|
|
|
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
|
|
|
|
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parseStoreCoords = (s: ShopStore) => {
|
|
|
|
|
return parseLngLatFromText((s.lngAndLat || s.location || '').trim())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadFences = async (): Promise<ShopStoreFence[]> => {
|
|
|
|
|
if (fencesLoadedRef.current) return fences
|
|
|
|
|
if (fencesPromiseRef.current) return fencesPromiseRef.current
|
|
|
|
|
@@ -243,21 +264,133 @@ const OrderConfirm = () => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadStores = async () => {
|
|
|
|
|
if (storeLoading) return
|
|
|
|
|
const loadStores = async (): Promise<ShopStore[]> => {
|
|
|
|
|
if (storeLoading) return stores
|
|
|
|
|
try {
|
|
|
|
|
setStoreLoading(true)
|
|
|
|
|
const list = await listShopStore()
|
|
|
|
|
setStores((list || []).filter(s => s?.isDelete !== 1))
|
|
|
|
|
const usable = (list || []).filter(s => s?.isDelete !== 1)
|
|
|
|
|
setStores(usable)
|
|
|
|
|
return usable
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('获取门店列表失败:', e)
|
|
|
|
|
setStores([])
|
|
|
|
|
Taro.showToast({title: '获取门店列表失败', icon: 'none'})
|
|
|
|
|
return []
|
|
|
|
|
} finally {
|
|
|
|
|
setStoreLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ensureStoreDetail = async (s: ShopStore): Promise<ShopStore> => {
|
|
|
|
|
if (!s?.id) return s
|
|
|
|
|
// If backend already returned "delivery area polygon"/warehouse info, skip extra request.
|
|
|
|
|
if (s.points || s.warehouseId) return s
|
|
|
|
|
try {
|
|
|
|
|
const full = await getShopStore(s.id)
|
|
|
|
|
return full || s
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pickNearestStore = async (p: { lng: number; lat: number }, list: ShopStore[]) => {
|
|
|
|
|
const usable = (list || []).filter(s => s?.isDelete !== 1)
|
|
|
|
|
if (!usable.length) return null
|
|
|
|
|
|
|
|
|
|
// 1) If a store has a polygon (points) and the delivery point is inside, prefer those stores.
|
|
|
|
|
const inside: { s: ShopStore; d?: number }[] = []
|
|
|
|
|
const outside: { s: ShopStore; d?: number }[] = []
|
|
|
|
|
for (const s of usable) {
|
|
|
|
|
const poly = parseFencePoints(s?.points)
|
|
|
|
|
const isInside = poly.length >= 3 ? pointInPolygon(p, poly) : false
|
|
|
|
|
const coords = parseStoreCoords(s)
|
|
|
|
|
const d = coords ? distanceMeters(p, coords) : undefined
|
|
|
|
|
;(isInside ? inside : outside).push({ s, d })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortByDistanceThenSortNo = (a: { s: ShopStore; d?: number }, b: { s: ShopStore; d?: number }) => {
|
|
|
|
|
const da = a.d ?? Number.POSITIVE_INFINITY
|
|
|
|
|
const db = b.d ?? Number.POSITIVE_INFINITY
|
|
|
|
|
if (da !== db) return da - db
|
|
|
|
|
const sa = a.s.sortNumber ?? Number.POSITIVE_INFINITY
|
|
|
|
|
const sb = b.s.sortNumber ?? Number.POSITIVE_INFINITY
|
|
|
|
|
if (sa !== sb) return sa - sb
|
|
|
|
|
return (a.s.id || 0) - (b.s.id || 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const best = (inside.length ? inside.sort(sortByDistanceThenSortNo) : outside.sort(sortByDistanceThenSortNo))[0]?.s
|
|
|
|
|
if (!best) return null
|
|
|
|
|
return ensureStoreDetail(best)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolveStoreForOrder = async (opts?: { silent?: boolean }): Promise<ShopStore | null> => {
|
|
|
|
|
// If user already picked a store on this page, don't auto override.
|
|
|
|
|
if (storeManualPickedRef.current && selectedStore?.id) return selectedStore
|
|
|
|
|
|
|
|
|
|
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
|
|
|
|
|
if (!p) {
|
|
|
|
|
// If we already have a selected store, allow ordering to continue (can't auto-pick though).
|
|
|
|
|
if (!opts?.silent && !selectedStore?.id) {
|
|
|
|
|
Taro.showToast({ title: '该地址缺少经纬度,无法自动分配门店,请在地址里选择地图定位后重试', icon: 'none' })
|
|
|
|
|
}
|
|
|
|
|
return selectedStore?.id ? selectedStore : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (storeAutoPickingRef.current) return selectedStore?.id ? selectedStore : null
|
|
|
|
|
storeAutoPickingRef.current = true
|
|
|
|
|
try {
|
|
|
|
|
const list = stores.length ? stores : await loadStores()
|
|
|
|
|
|
|
|
|
|
// If a store is already selected and its polygon contains the point, keep it.
|
|
|
|
|
if (selectedStore?.id) {
|
|
|
|
|
const full = await ensureStoreDetail(selectedStore)
|
|
|
|
|
const poly = parseFencePoints(full?.points)
|
|
|
|
|
if (poly.length >= 3 && pointInPolygon(p, poly)) {
|
|
|
|
|
if (full !== selectedStore) {
|
|
|
|
|
setSelectedStore(full)
|
|
|
|
|
saveSelectedStoreToStorage(full)
|
|
|
|
|
}
|
|
|
|
|
return full
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const best = await pickNearestStore(p, list)
|
|
|
|
|
if (best?.id) {
|
|
|
|
|
setSelectedStore(best)
|
|
|
|
|
saveSelectedStoreToStorage(best)
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
return selectedStore?.id ? selectedStore : null
|
|
|
|
|
} finally {
|
|
|
|
|
storeAutoPickingRef.current = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolveAutoRiderForStore = async (storeId: number): Promise<ShopStoreRider | null> => {
|
|
|
|
|
try {
|
|
|
|
|
const list = await listShopStoreRider({ storeId, status: 1 })
|
|
|
|
|
const usable = (list || [])
|
|
|
|
|
.filter(r => r?.isDelete !== 1)
|
|
|
|
|
// Prefer enabled/online/auto-dispatch riders; fallback to any usable rider.
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
const score = (r: ShopStoreRider) => {
|
|
|
|
|
const enabled = r.status === 1 ? 1000 : 0
|
|
|
|
|
const online = r.workStatus === 1 ? 100 : (r.workStatus === 2 ? 50 : 0)
|
|
|
|
|
const auto = r.autoDispatchEnabled === 1 ? 10 : 0
|
|
|
|
|
const prio = Number(r.dispatchPriority || 0)
|
|
|
|
|
return enabled + online + auto + prio
|
|
|
|
|
}
|
|
|
|
|
return score(b) - score(a)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return usable[0] || null
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('自动获取配送员失败,将由后台/门店人工派单:', e)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
const openStorePopup = async () => {
|
|
|
|
|
setStorePopupVisible(true)
|
|
|
|
|
@@ -307,14 +440,16 @@ const OrderConfirm = () => {
|
|
|
|
|
Taro.showToast({ title: '请先登录', icon: 'none' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!selectedStore?.id) {
|
|
|
|
|
Taro.showToast({ title: '请选择门店', icon: 'none' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!address?.id) {
|
|
|
|
|
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storeForOrder = await resolveStoreForOrder()
|
|
|
|
|
if (!storeForOrder?.id) {
|
|
|
|
|
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!selectedTicket?.id) {
|
|
|
|
|
Taro.showToast({ title: '请选择水票', icon: 'none' })
|
|
|
|
|
return
|
|
|
|
|
@@ -360,15 +495,21 @@ const OrderConfirm = () => {
|
|
|
|
|
setSubmitLoading(true)
|
|
|
|
|
Taro.showLoading({ title: '提交中...' })
|
|
|
|
|
|
|
|
|
|
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
|
|
|
|
|
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
|
|
|
|
|
|
|
|
|
|
await addGltTicketOrder({
|
|
|
|
|
userTicketId: selectedTicket.id,
|
|
|
|
|
storeId: selectedStore.id,
|
|
|
|
|
storeId: storeForOrder.id,
|
|
|
|
|
addressId: address.id,
|
|
|
|
|
totalNum: finalQty,
|
|
|
|
|
buyerRemarks: orderRemark,
|
|
|
|
|
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
|
|
|
|
// Backend may take userId from token; pass-through is harmless if backend ignores it.
|
|
|
|
|
userId,
|
|
|
|
|
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
|
|
|
|
|
riderName: autoRider?.realName,
|
|
|
|
|
riderPhone: autoRider?.mobile,
|
|
|
|
|
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@@ -432,6 +573,14 @@ const OrderConfirm = () => {
|
|
|
|
|
loadAllData({ silent: hasInitialLoadedRef.current })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Auto-pick nearest store by delivery address (best-effort, won't override manual selection).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!address?.id) return
|
|
|
|
|
if (storeManualPickedRef.current) return
|
|
|
|
|
resolveStoreForOrder({ silent: true })
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [address?.id, address?.lng, address?.lat])
|
|
|
|
|
|
|
|
|
|
// Background pre-check when we already have an address coordinate (no permission prompt).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false
|
|
|
|
|
@@ -716,6 +865,7 @@ const OrderConfirm = () => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setSelectedStore(storeToSave)
|
|
|
|
|
storeManualPickedRef.current = true
|
|
|
|
|
saveSelectedStoreToStorage(storeToSave)
|
|
|
|
|
setStorePopupVisible(false)
|
|
|
|
|
Taro.showToast({title: '门店已切换', icon: 'success'})
|
|
|
|
|
|