Upgrade scenic picker with search, location sorting, permission apply/status, and blocking home dialog; add cooperation order module and order source picker improvements with unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
517 lines
18 KiB
Swift
517 lines
18 KiB
Swift
//
|
||
// OrderListViewModel.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
struct MultiVerifyParseData: Equatable {
|
||
let orderNumber: String
|
||
let userId: Int
|
||
let time: Int64
|
||
let checkSum: String
|
||
}
|
||
|
||
struct OrderSourcePerson: Equatable {
|
||
let key: String
|
||
let name: String
|
||
let phone: String
|
||
}
|
||
|
||
struct OrderSourceLead: Equatable {
|
||
let key: String
|
||
let userPhone: String
|
||
let remark: String
|
||
let images: [String]
|
||
}
|
||
|
||
enum OrderSourceMapping {
|
||
|
||
/// 将 API 带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceLead()`。
|
||
static func lead(from entity: ReferralOrderEntity) -> OrderSourceLead {
|
||
let orderNo = entity.displayReferralOrderNo
|
||
let remark: String
|
||
if !entity.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
remark = entity.remark
|
||
} else if !entity.statusLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
remark = entity.statusLabel
|
||
} else {
|
||
remark = "带客单 \(orderNo)"
|
||
}
|
||
return OrderSourceLead(
|
||
key: orderNo,
|
||
userPhone: entity.userMobile,
|
||
remark: remark,
|
||
images: entity.images
|
||
)
|
||
}
|
||
|
||
/// 将已绑定带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceSelection()`。
|
||
static func selection(from referral: BoundReferralOrderEntity) -> OrderSourceSelection? {
|
||
let referralNo = referral.orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !referralNo.isEmpty else { return nil }
|
||
|
||
let personName = referral.displaySalerName.isEmpty ? "—" : referral.displaySalerName
|
||
let person = OrderSourcePerson(
|
||
key: referral.saleUserId > 0 ? String(referral.saleUserId) : "",
|
||
name: personName,
|
||
phone: referral.salerPhone
|
||
)
|
||
let remark: String
|
||
if !referral.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
remark = referral.remark
|
||
} else if !referral.displayText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
remark = referral.displayText
|
||
} else {
|
||
remark = "带客单号 \(referralNo)"
|
||
}
|
||
let lead = OrderSourceLead(
|
||
key: referralNo,
|
||
userPhone: referral.userMobile,
|
||
remark: remark,
|
||
images: []
|
||
)
|
||
return OrderSourceSelection(person: person, lead: lead)
|
||
}
|
||
}
|
||
|
||
struct OrderSourceSelection: Equatable {
|
||
let person: OrderSourcePerson
|
||
let lead: OrderSourceLead
|
||
}
|
||
|
||
/// 摄影师/景区管理员订单列表 ViewModel,对齐 Android `OrderListViewModel`。
|
||
final class OrderListViewModel {
|
||
|
||
private(set) var orderList: [OrderEntity] = []
|
||
private(set) var searchText = ""
|
||
private(set) var filterLabel = "全部"
|
||
private(set) var tabIndex = 0
|
||
private(set) var rTabIndex = 0
|
||
private(set) var startTime: Date?
|
||
private(set) var endTime: Date?
|
||
private(set) var isRefreshing = false
|
||
private(set) var canLoadMore = false
|
||
private(set) var isLoadingMore = false
|
||
|
||
private(set) var multiVerifyPendingData: MultiVerifyParseData?
|
||
private(set) var multiVerifyScenicSpotList: [MultiTravelScenicSpotEntity] = []
|
||
private(set) var selectedMultiVerifySpot: MultiTravelScenicSpotEntity?
|
||
private(set) var signInResponse: OrderSignInResponse?
|
||
private(set) var writeOffResponse: WriteOffOrderResponse?
|
||
|
||
private(set) var orderSourcePeople: [OrderSourcePerson] = []
|
||
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||
private(set) var loadingOrderSourceLeadsFor: String?
|
||
private(set) var isLoadingOrderSourcePeople = false
|
||
|
||
var onStateChange: (() -> Void)?
|
||
var onShowMessage: ((String) -> Void)?
|
||
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||
|
||
private var currentPage = 1
|
||
private var totalCount = 0
|
||
|
||
private let apiDateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar(identifier: .gregorian)
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter
|
||
}()
|
||
|
||
var isScenicAdminReadOnly: Bool {
|
||
AppStore.shared.currentAppRole == .scenicAdmin
|
||
}
|
||
|
||
var listAPIPath: String {
|
||
isScenicAdminReadOnly
|
||
? "/api/app/scenic-admin/order/list"
|
||
: "/api/yf-handset-app/photog/order/listv2"
|
||
}
|
||
|
||
func refreshOrderList(api: OrderAPI) async {
|
||
currentPage = 1
|
||
isRefreshing = true
|
||
isLoadingMore = false
|
||
orderList = []
|
||
notifyStateChange()
|
||
await loadOrderList(api: api, append: false)
|
||
}
|
||
|
||
func loadMore(api: OrderAPI) async {
|
||
guard !isLoadingMore, !isRefreshing else { return }
|
||
guard orderList.count < totalCount else { return }
|
||
isLoadingMore = true
|
||
currentPage += 1
|
||
notifyStateChange()
|
||
await loadOrderList(api: api, append: true)
|
||
}
|
||
|
||
func shouldLoadMore(at index: Int) -> Bool {
|
||
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
|
||
return index >= max(0, orderList.count - 3)
|
||
}
|
||
|
||
func onSearchTextChange(_ text: String) {
|
||
searchText = text
|
||
notifyStateChange()
|
||
}
|
||
|
||
func onFilterChange(orderStatus: Int, isRefined: Int) {
|
||
tabIndex = orderStatus
|
||
rTabIndex = isRefined
|
||
filterLabel = PhotographerOrderFilter.from(tabIndex: orderStatus, rTabIndex: isRefined).label
|
||
notifyStateChange()
|
||
}
|
||
|
||
func onDateRangeSelected(start: Date?, end: Date?) {
|
||
startTime = start
|
||
endTime = end
|
||
notifyStateChange()
|
||
}
|
||
|
||
func cancelOrder(orderNumber: String, api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
do {
|
||
try await api.cancelOrder(CancelRequest(orderNumber: orderNumber))
|
||
onShowMessage?("取消成功")
|
||
await refreshOrderList(api: api)
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func refundOrder(_ request: OrderRefundRequest, api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
do {
|
||
try await api.refundOrder(request)
|
||
onShowMessage?("退款成功")
|
||
await refreshOrderList(api: api)
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int, api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
do {
|
||
try await api.giftRetouch(SendGiftRequest(orderNumber: orderNumber, photoNum: photoNum, videoNum: videoNum))
|
||
onShowMessage?("赠送成功")
|
||
await refreshOrderList(api: api)
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func updateRefined(orderNumber: String, isRefined: Int, api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
do {
|
||
try await api.updateRefined(orderNumber: orderNumber, isRefined: isRefined)
|
||
await refreshOrderList(api: api)
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func processScanResult(_ scanResult: String, api: OrderAPI) async {
|
||
if scanResult.isEmpty {
|
||
onShowMessage?("请扫描正确的二维码")
|
||
return
|
||
}
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
|
||
if let parsed = QRCodeScanResult.parse(scanResult) {
|
||
switch parsed.method {
|
||
case "multi_verify":
|
||
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
|
||
onShowMessage?("请扫描正确的多点位核销码")
|
||
return
|
||
}
|
||
multiVerifyPendingData = MultiVerifyParseData(
|
||
orderNumber: parsed.orderNumber,
|
||
userId: parsed.userId,
|
||
time: parsed.timeLong,
|
||
checkSum: parsed.checkSum
|
||
)
|
||
notifyStateChange()
|
||
return
|
||
case SaleUserQrParser.methodSaleUserBind:
|
||
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
|
||
onNavigateBindAcquirer?(saleUserId)
|
||
} else {
|
||
onShowMessage?("请扫描正确的获客员二维码")
|
||
}
|
||
return
|
||
case "order_verify":
|
||
await performOrderVerify(jsonBody: parsed.rawJSON, api: api)
|
||
return
|
||
case "check_in", "":
|
||
await performCheckIn(scanResult: scanResult, api: api)
|
||
return
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
await performCheckIn(scanResult: scanResult, api: api)
|
||
}
|
||
|
||
func fetchScenicSpotsAndShowSheet(lat: Double, lng: Double, api: OrderAPI) async {
|
||
guard let data = multiVerifyPendingData else { return }
|
||
do {
|
||
let response = try await api.multiTravelScenicSpotList(
|
||
orderNumber: data.orderNumber,
|
||
lat: lat,
|
||
lng: lng
|
||
)
|
||
multiVerifyScenicSpotList = response.entityList
|
||
selectedMultiVerifySpot = multiVerifyScenicSpotList.first
|
||
notifyStateChange()
|
||
if multiVerifyScenicSpotList.isEmpty {
|
||
onShowMessage?("暂无可核销的打卡点")
|
||
clearMultiVerifyPending()
|
||
}
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
clearMultiVerifyPending()
|
||
}
|
||
}
|
||
|
||
func onMultiVerifySpotSelected(_ spot: MultiTravelScenicSpotEntity) {
|
||
selectedMultiVerifySpot = spot
|
||
notifyStateChange()
|
||
}
|
||
|
||
func onMultiVerifyConfirm(api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
guard let data = multiVerifyPendingData, let spot = selectedMultiVerifySpot else {
|
||
onShowMessage?("请选择一个打卡点")
|
||
return
|
||
}
|
||
do {
|
||
let response = try await api.multiTravelOrderVerify(
|
||
MultiTravelOrderVerifyRequest(
|
||
orderNumber: data.orderNumber,
|
||
userId: data.userId,
|
||
time: data.time,
|
||
checkSum: data.checkSum,
|
||
scenicSpotId: spot.scenicSpotId
|
||
)
|
||
)
|
||
signInResponse = response
|
||
clearMultiVerifySheet()
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
clearMultiVerifyPending()
|
||
}
|
||
}
|
||
|
||
func clearMultiVerifySheet() {
|
||
multiVerifyScenicSpotList = []
|
||
selectedMultiVerifySpot = nil
|
||
multiVerifyPendingData = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
func clearMultiVerifyPending() {
|
||
multiVerifyPendingData = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
func clearSignInResponse() {
|
||
signInResponse = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
func clearWriteOffResponse() {
|
||
writeOffResponse = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
func shouldShowOrderSource(for order: OrderEntity) -> Bool {
|
||
guard !isScenicAdminReadOnly else { return false }
|
||
return order.orderType == OrderTypeCode.photographerFollow
|
||
|| order.orderType == OrderTypeCode.photographerFollowOfflineScan
|
||
}
|
||
|
||
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||
orderSourceSelection(orderNumber: order.orderNumber, referralOrder: order.referralOrder)
|
||
}
|
||
|
||
/// 按订单号读取当前绑定状态,供订单来源 Sheet 使用。
|
||
func orderSourceSelection(orderNumber: String, referralOrder: BoundReferralOrderEntity? = nil) -> OrderSourceSelection? {
|
||
if let selection = orderSourceSelections[orderNumber] { return selection }
|
||
if let referral = referralOrder ?? orderList.first(where: { $0.orderNumber == orderNumber })?.referralOrder {
|
||
return OrderSourceMapping.selection(from: referral)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
||
orderSourceLeads = [:]
|
||
guard !isLoadingOrderSourcePeople else { return }
|
||
isLoadingOrderSourcePeople = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLoadingOrderSourcePeople = false
|
||
notifyStateChange()
|
||
}
|
||
let storeId = AppStore.shared.currentStoreId
|
||
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||
do {
|
||
let response = try await api.cooperativeSalers(storeId: storeIdParam)
|
||
orderSourcePeople = response.list.map {
|
||
OrderSourcePerson(key: String($0.saleUserId), name: $0.displayName, phone: $0.displayPhone)
|
||
}
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
|
||
guard loadingOrderSourceLeadsFor == nil else { return }
|
||
loadingOrderSourceLeadsFor = person.key
|
||
notifyStateChange()
|
||
defer {
|
||
loadingOrderSourceLeadsFor = nil
|
||
notifyStateChange()
|
||
}
|
||
do {
|
||
let response = try await api.referralOrders(saleUserId: person.key)
|
||
orderSourceLeads[person.key] = response.list.map(OrderSourceMapping.lead(from:))
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func bindOrderSource(orderNumber: String, selection: OrderSourceSelection, api: OrderAPI) async {
|
||
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||
do {
|
||
try await api.bindReferralOrder(orderNumber: orderNumber, referralOrderNo: selection.lead.key)
|
||
orderSourceSelections[orderNumber] = selection
|
||
onShowMessage?("绑定成功")
|
||
await refreshOrderList(api: api)
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func loadOrderList(api: OrderAPI, append: Bool) async {
|
||
let scenicId = AppStore.shared.currentScenicId
|
||
guard scenicId > 0 else {
|
||
finishLoading()
|
||
onShowMessage?("当前景区信息缺失")
|
||
return
|
||
}
|
||
|
||
let start = startTime.map { apiDateFormatter.string(from: $0) }
|
||
let end = endTime.map { apiDateFormatter.string(from: $0) }
|
||
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
do {
|
||
let response: OrderListResponse<OrderEntity>
|
||
if isScenicAdminReadOnly {
|
||
response = try await api.scenicAdminOrderList(
|
||
scenicId: scenicId,
|
||
page: currentPage,
|
||
orderStatus: tabIndex,
|
||
startTime: start,
|
||
endTime: end,
|
||
userPhone: phone.isEmpty ? nil : phone,
|
||
isRefined: rTabIndex
|
||
)
|
||
} else {
|
||
response = try await api.photographerOrderList(
|
||
scenicId: scenicId,
|
||
page: currentPage,
|
||
orderStatus: tabIndex,
|
||
startTime: start,
|
||
endTime: end,
|
||
userPhone: phone.isEmpty ? nil : phone,
|
||
isRefined: rTabIndex
|
||
)
|
||
}
|
||
totalCount = response.total
|
||
if append {
|
||
orderList.append(contentsOf: response.list)
|
||
} else {
|
||
orderList = response.list
|
||
}
|
||
syncOrderSourceSelections()
|
||
canLoadMore = orderList.count < totalCount
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
finishLoading()
|
||
}
|
||
|
||
private func performCheckIn(scanResult: String, api: OrderAPI) async {
|
||
guard let jsonBody = appendScenicId(to: scanResult) else {
|
||
onShowMessage?("请扫描正确的二维码")
|
||
return
|
||
}
|
||
do {
|
||
signInResponse = try await api.orderCheckIn(jsonBody: jsonBody)
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func performOrderVerify(jsonBody: String, api: OrderAPI) async {
|
||
do {
|
||
writeOffResponse = try await api.orderVerify(jsonBody: jsonBody)
|
||
notifyStateChange()
|
||
} catch {
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func appendScenicId(to scanResult: String) -> String? {
|
||
guard let data = scanResult.data(using: .utf8),
|
||
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||
return nil
|
||
}
|
||
json["scenic_id"] = String(AppStore.shared.currentScenicId)
|
||
guard let updated = try? JSONSerialization.data(withJSONObject: json),
|
||
let string = String(data: updated, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
|
||
private func syncOrderSourceSelections() {
|
||
var updated = orderSourceSelections
|
||
for order in orderList {
|
||
if let selection = orderSourceSelection(for: order) {
|
||
updated[order.orderNumber] = selection
|
||
}
|
||
}
|
||
orderSourceSelections = updated
|
||
}
|
||
|
||
@discardableResult
|
||
private func abortIfScenicAdminReadOnlyMutation() -> Bool {
|
||
guard isScenicAdminReadOnly else { return false }
|
||
onShowMessage?("景区管理员仅可查看")
|
||
return true
|
||
}
|
||
|
||
private func finishLoading() {
|
||
isRefreshing = false
|
||
isLoadingMore = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|