Implement orders tab aligned with Android, including scan verify flows.
Add role-based order/deposit lists, AVFoundation QR scanning, verify dialogs, and ViewModel tests so photographers, scenic admins, and store admins match Android behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
243
suixinkan/Features/Orders/API/OrderAPI.swift
Normal file
243
suixinkan/Features/Orders/API/OrderAPI.swift
Normal file
@ -0,0 +1,243 @@
|
||||
//
|
||||
// OrderAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单模块 API,对齐 Android `NetworkApi` 订单区域。
|
||||
final class OrderAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
func photographerOrderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int = OrderListQueryBuilder.photographerPageSize,
|
||||
orderStatus: Int = 0,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
userPhone: String? = nil,
|
||||
isRefined: Int = 0
|
||||
) async throws -> OrderListResponse<OrderEntity> {
|
||||
var items: [URLQueryItem] = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||
]
|
||||
if let orderStatus = OrderListQueryBuilder.orderStatusQuery(tabIndex: orderStatus) {
|
||||
items.append(URLQueryItem(name: "order_status", value: orderStatus))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||
if let isRefined = OrderListQueryBuilder.isRefinedQuery(rTabIndex: isRefined) {
|
||||
items.append(URLQueryItem(name: "is_refined", value: String(isRefined)))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/order/listv2", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
func scenicAdminOrderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int = OrderListQueryBuilder.photographerPageSize,
|
||||
orderStatus: Int = 0,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
userPhone: String? = nil,
|
||||
isRefined: Int = 0
|
||||
) async throws -> OrderListResponse<OrderEntity> {
|
||||
var items: [URLQueryItem] = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||
]
|
||||
if let orderStatus = OrderListQueryBuilder.orderStatusQuery(tabIndex: orderStatus) {
|
||||
items.append(URLQueryItem(name: "order_status", value: orderStatus))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||
if let isRefined = OrderListQueryBuilder.isRefinedQuery(rTabIndex: isRefined) {
|
||||
items.append(URLQueryItem(name: "is_refined", value: String(isRefined)))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/scenic-admin/order/list", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int = OrderListQueryBuilder.storePageSize,
|
||||
userPhone: String? = nil,
|
||||
orderStatus: Int? = nil,
|
||||
orderType: Int? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil
|
||||
) async throws -> StoreOrderListData {
|
||||
var items: [URLQueryItem] = [
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||
]
|
||||
if let storeId { items.append(URLQueryItem(name: "store_id", value: String(storeId))) }
|
||||
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||
if let orderStatus { items.append(URLQueryItem(name: "order_status", value: String(orderStatus))) }
|
||||
if let orderType { items.append(URLQueryItem(name: "order_type", value: String(orderType))) }
|
||||
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/store/order/list", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
func orderCheckIn(jsonBody: String) async throws -> OrderSignInResponse {
|
||||
try await client.sendRawJSON(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/check-in",
|
||||
jsonBody: jsonBody
|
||||
)
|
||||
}
|
||||
|
||||
func orderVerify(jsonBody: String) async throws -> WriteOffOrderResponse {
|
||||
try await client.sendRawJSON(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/order-verify",
|
||||
jsonBody: jsonBody
|
||||
)
|
||||
}
|
||||
|
||||
func multiTravelScenicSpotList(orderNumber: String, lat: Double, lng: Double) async throws -> MultiTravelScenicSpotListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/scenic-spot-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "lat", value: String(lat)),
|
||||
URLQueryItem(name: "lng", value: String(lng)),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func multiTravelOrderVerify(_ request: MultiTravelOrderVerifyRequest) async throws -> OrderSignInResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/order-verify",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func cancelOrder(_ request: CancelRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/cancel", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
func refundOrder(_ request: OrderRefundRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/refund", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
func giftRetouch(_ request: SendGiftRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/gift-retouch", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
func updateRefined(orderNumber: String, isRefined: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/refined",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "is_refined", value: String(isRefined)),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func cooperativeSalers(storeId: String?, page: String = "1", pageSize: String = "50") async throws -> CooperativeSalerListResponse {
|
||||
var items = [
|
||||
URLQueryItem(name: "page", value: page),
|
||||
URLQueryItem(name: "page_size", value: pageSize),
|
||||
]
|
||||
if let storeId { items.append(URLQueryItem(name: "store_id", value: storeId)) }
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cooperative-salers", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
func referralOrders(saleUserId: String, page: String = "1", pageSize: String = "50") async throws -> ReferralOrderListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/referral-orders",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "sale_user_id", value: saleUserId),
|
||||
URLQueryItem(name: "page", value: page),
|
||||
URLQueryItem(name: "page_size", value: pageSize),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func bindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/sale-user/bind-referral-order",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "referral_order_no", value: referralOrderNo),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func storeOrderVerify(_ request: StoreOrderVerifyRequest) async throws -> WriteOffOrderResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/store/order/order-verify", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
func storeOrderRefund(_ request: StoreOrderRefundRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/store/order/refund", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailData {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: String(storeId)),
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
876
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
876
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
@ -0,0 +1,876 @@
|
||||
//
|
||||
// OrderModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - List Response
|
||||
|
||||
/// 订单列表分页响应,字段为 `list`(非 `data`)。
|
||||
struct OrderListResponse<T: Decodable>: Decodable {
|
||||
let total: Int
|
||||
let list: [T]
|
||||
|
||||
init(total: Int = 0, list: [T] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photographer Order
|
||||
|
||||
struct OrderEntity: Decodable, Equatable {
|
||||
let photogUid: Int
|
||||
let orderNumber: String
|
||||
let remark: String?
|
||||
let scenicAreaId: Int
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let userId: Int
|
||||
let projectId: Int
|
||||
let phone: String
|
||||
let orderAmount: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let payTypeName: String
|
||||
let payType: Int
|
||||
let projectName: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let depositPayTime: String
|
||||
let createdAt: String
|
||||
let photoTravel: PhotoTravelEntity?
|
||||
let refundAmount: String
|
||||
let multiTravel: MultiTravelEntity?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
let canBindReferral: Int
|
||||
let showOrderSource: Int
|
||||
let referralOrderNo: String
|
||||
let referralSalerName: String
|
||||
let referralSalerPhotogRemarkName: String
|
||||
let referralOrder: BoundReferralOrderEntity?
|
||||
|
||||
var displayReferralSalerName: String {
|
||||
OrderDisplayName.saleUser(remark: referralSalerPhotogRemarkName, primary: referralSalerName)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
case remark
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case userId = "user_id"
|
||||
case projectId = "project_id"
|
||||
case phone
|
||||
case orderAmount = "order_amount"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payType = "pay_type"
|
||||
case projectName = "project_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case createdAt = "created_at"
|
||||
case photoTravel = "photo_travel"
|
||||
case refundAmount = "refund_amount"
|
||||
case multiTravel = "multi_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
case canBindReferral = "can_bind_referral"
|
||||
case showOrderSource = "show_order_source"
|
||||
case referralOrderNo = "referral_order_no"
|
||||
case referralSalerName = "referral_saler_name"
|
||||
case referralSalerPhotogRemarkName = "photog_remark_name"
|
||||
case referralOrder = "referral_order"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
photogUid = try container.decodeIfPresent(Int.self, forKey: .photogUid) ?? 0
|
||||
orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) ?? ""
|
||||
remark = try container.decodeIfPresent(String.self, forKey: .remark)
|
||||
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId) ?? 0
|
||||
payTime = try container.decodeIfPresent(String.self, forKey: .payTime) ?? ""
|
||||
completeTime = try container.decodeIfPresent(String.self, forKey: .completeTime) ?? ""
|
||||
orderStatus = try container.decodeIfPresent(Int.self, forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeIfPresent(String.self, forKey: .orderStatusName) ?? ""
|
||||
userId = try container.decodeIfPresent(Int.self, forKey: .userId) ?? 0
|
||||
projectId = try container.decodeIfPresent(Int.self, forKey: .projectId) ?? 0
|
||||
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
|
||||
orderAmount = try Self.decodeFlexibleString(container: container, key: .orderAmount)
|
||||
actualPayAmount = try Self.decodeFlexibleString(container: container, key: .actualPayAmount)
|
||||
actualRefundAmount = try Self.decodeFlexibleString(container: container, key: .actualRefundAmount, defaultValue: "0")
|
||||
payTypeName = try container.decodeIfPresent(String.self, forKey: .payTypeName) ?? ""
|
||||
payType = try container.decodeIfPresent(Int.self, forKey: .payType) ?? 0
|
||||
projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? ""
|
||||
orderType = try container.decodeIfPresent(Int.self, forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeIfPresent(String.self, forKey: .orderTypeLabel) ?? ""
|
||||
depositPayTime = try container.decodeIfPresent(String.self, forKey: .depositPayTime) ?? ""
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||
photoTravel = try container.decodeIfPresent(PhotoTravelEntity.self, forKey: .photoTravel)
|
||||
refundAmount = try Self.decodeFlexibleString(container: container, key: .refundAmount, defaultValue: "0")
|
||||
multiTravel = try container.decodeIfPresent(MultiTravelEntity.self, forKey: .multiTravel)
|
||||
isNeedEdit = try container.decodeIfPresent(Bool.self, forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeIfPresent(Int.self, forKey: .isRefined) ?? 0
|
||||
canBindReferral = try container.decodeIfPresent(Int.self, forKey: .canBindReferral) ?? 0
|
||||
showOrderSource = try container.decodeIfPresent(Int.self, forKey: .showOrderSource) ?? 0
|
||||
referralOrderNo = try container.decodeIfPresent(String.self, forKey: .referralOrderNo) ?? ""
|
||||
referralSalerName = try container.decodeIfPresent(String.self, forKey: .referralSalerName) ?? ""
|
||||
referralSalerPhotogRemarkName = try container.decodeIfPresent(String.self, forKey: .referralSalerPhotogRemarkName) ?? ""
|
||||
referralOrder = try container.decodeIfPresent(BoundReferralOrderEntity.self, forKey: .referralOrder)
|
||||
}
|
||||
|
||||
private static func decodeFlexibleString(
|
||||
container: KeyedDecodingContainer<CodingKeys>,
|
||||
key: CodingKeys,
|
||||
defaultValue: String = ""
|
||||
) throws -> String {
|
||||
if let string = try? container.decode(String.self, forKey: key) {
|
||||
return string
|
||||
}
|
||||
if let intValue = try? container.decode(Int.self, forKey: key) {
|
||||
return String(intValue)
|
||||
}
|
||||
if let doubleValue = try? container.decode(Double.self, forKey: key) {
|
||||
return String(doubleValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundReferralOrderEntity: Decodable, Equatable {
|
||||
let id: Int
|
||||
let orderNo: String
|
||||
let saleUserId: Int
|
||||
let salerName: String
|
||||
let photogRemarkName: String
|
||||
let salerPhone: String
|
||||
let displayText: String
|
||||
let userMobile: String
|
||||
let remark: String
|
||||
let bindOrderNumber: String
|
||||
let sourceType: Int
|
||||
let sourceTypeLabel: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let createdAt: String
|
||||
|
||||
var displaySalerName: String {
|
||||
OrderDisplayName.saleUser(remark: photogRemarkName, primary: salerName)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNo = "order_no"
|
||||
case saleUserId = "sale_user_id"
|
||||
case salerName = "saler_name"
|
||||
case photogRemarkName = "photog_remark_name"
|
||||
case salerPhone = "saler_phone"
|
||||
case displayText = "display_text"
|
||||
case userMobile = "user_mobile"
|
||||
case remark
|
||||
case bindOrderNumber = "bind_order_number"
|
||||
case sourceType = "source_type"
|
||||
case sourceTypeLabel = "source_type_label"
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoTravelEntity: Decodable, Equatable {
|
||||
let orderPhotoNum: Int
|
||||
let orderVideoNum: Int
|
||||
let canCancelOrder: Bool
|
||||
let needCheckIn: Bool
|
||||
let checkInTime: String
|
||||
let canGiftRetouch: Bool
|
||||
let retouchGiftTime: String
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
case canCancelOrder = "can_cancel_order"
|
||||
case needCheckIn = "need_check_in"
|
||||
case checkInTime = "check_in_time"
|
||||
case canGiftRetouch = "can_gift_retouch"
|
||||
case retouchGiftTime = "retouch_gift_time"
|
||||
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
||||
case retouchGiftVideoNum = "retouch_gift_video_num"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderPhotoNum = try container.decodeIfPresent(Int.self, forKey: .orderPhotoNum) ?? 0
|
||||
orderVideoNum = try container.decodeIfPresent(Int.self, forKey: .orderVideoNum) ?? 0
|
||||
canCancelOrder = try container.decodeIfPresent(Bool.self, forKey: .canCancelOrder) ?? false
|
||||
needCheckIn = try container.decodeIfPresent(Bool.self, forKey: .needCheckIn) ?? false
|
||||
checkInTime = try container.decodeIfPresent(String.self, forKey: .checkInTime) ?? ""
|
||||
canGiftRetouch = try container.decodeIfPresent(Bool.self, forKey: .canGiftRetouch) ?? false
|
||||
retouchGiftTime = try container.decodeIfPresent(String.self, forKey: .retouchGiftTime) ?? ""
|
||||
retouchGiftPhotoNum = try container.decodeIfPresent(Int.self, forKey: .retouchGiftPhotoNum) ?? 0
|
||||
retouchGiftVideoNum = try container.decodeIfPresent(Int.self, forKey: .retouchGiftVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelEntity: Decodable, Equatable {
|
||||
let materialList: [MultiTravelMaterialEntity]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case materialList = "material_list"
|
||||
}
|
||||
|
||||
init(materialList: [MultiTravelMaterialEntity] = []) {
|
||||
self.materialList = materialList
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
materialList = try container.decodeIfPresent([MultiTravelMaterialEntity].self, forKey: .materialList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelMaterialEntity: Decodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
case fileType = "file_type"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Store Order
|
||||
|
||||
struct StoreOrderListData: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [StoreOrderItem]
|
||||
|
||||
init(total: Int = 0, list: [StoreOrderItem] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreOrderItem: Decodable, Equatable {
|
||||
let orderNumber: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let createdAt: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let payTypeName: String
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let userId: Int
|
||||
let phone: String
|
||||
let projectId: Int
|
||||
let projectName: String
|
||||
let remark: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case createdAt = "created_at"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case userId = "user_id"
|
||||
case phone
|
||||
case projectId = "project_id"
|
||||
case projectName = "project_name"
|
||||
case remark
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) ?? ""
|
||||
orderType = try container.decodeIfPresent(Int.self, forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeIfPresent(String.self, forKey: .orderTypeLabel) ?? ""
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||
orderStatus = try container.decodeIfPresent(Int.self, forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeIfPresent(String.self, forKey: .orderStatusName) ?? ""
|
||||
actualPayAmount = try container.decodeIfPresent(String.self, forKey: .actualPayAmount) ?? ""
|
||||
actualRefundAmount = try container.decodeIfPresent(String.self, forKey: .actualRefundAmount) ?? ""
|
||||
payTypeName = try container.decodeIfPresent(String.self, forKey: .payTypeName) ?? ""
|
||||
payTime = try container.decodeIfPresent(String.self, forKey: .payTime) ?? ""
|
||||
completeTime = try container.decodeIfPresent(String.self, forKey: .completeTime) ?? ""
|
||||
userId = try container.decodeIfPresent(Int.self, forKey: .userId) ?? 0
|
||||
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
|
||||
projectId = try container.decodeIfPresent(Int.self, forKey: .projectId) ?? 0
|
||||
projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? ""
|
||||
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreOrderType {
|
||||
static let offlineProduct = 4
|
||||
static let multiTravelShoot = 19
|
||||
}
|
||||
|
||||
// MARK: - Request Bodies
|
||||
|
||||
struct CancelRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderRefundRequest: Encodable {
|
||||
var orderNumber: String
|
||||
var refundType: Int
|
||||
var refundAmount: String
|
||||
var refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
case refundAmount = "refund_amount"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct SendGiftRequest: Encodable {
|
||||
let orderNumber: String
|
||||
let photoNum: Int
|
||||
let videoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case photoNum = "photo_num"
|
||||
case videoNum = "video_num "
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreOrderRefundRequest: Encodable {
|
||||
let storeId: Int
|
||||
let orderNumber: String
|
||||
let refundType: Int
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case storeId = "store_id"
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
case refundAmount = "refund_amount"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreOrderVerifyRequest: Encodable {
|
||||
let method: String
|
||||
let userId: Int
|
||||
let orderNumber: String
|
||||
let time: String
|
||||
let storeId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case method
|
||||
case userId = "user_id"
|
||||
case orderNumber = "order_number"
|
||||
case time
|
||||
case storeId = "store_id"
|
||||
}
|
||||
|
||||
init(userId: Int, orderNumber: String, time: String, storeId: Int) {
|
||||
method = "order_verify"
|
||||
self.userId = userId
|
||||
self.orderNumber = orderNumber
|
||||
self.time = time
|
||||
self.storeId = storeId
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelOrderVerifyRequest: Encodable {
|
||||
let method: String
|
||||
let orderNumber: String
|
||||
let userId: Int
|
||||
let time: Int64
|
||||
let checkSum: String
|
||||
let scenicSpotId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case method
|
||||
case orderNumber = "order_number"
|
||||
case userId = "user_id"
|
||||
case time
|
||||
case checkSum = "check_sum"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
}
|
||||
|
||||
init(orderNumber: String, userId: Int, time: Int64, checkSum: String, scenicSpotId: Int) {
|
||||
method = "multi_verify"
|
||||
self.orderNumber = orderNumber
|
||||
self.userId = userId
|
||||
self.time = time
|
||||
self.checkSum = checkSum
|
||||
self.scenicSpotId = scenicSpotId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Responses
|
||||
|
||||
struct OrderSignInResponse: Decodable, Equatable {
|
||||
let checkIn: Bool
|
||||
let orderNumber: String
|
||||
let projectName: String
|
||||
let photogName: String
|
||||
let scenicName: String
|
||||
let orderAmount: String
|
||||
let depositAmount: String
|
||||
let depositPayTime: String
|
||||
let userPhone: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case checkIn = "check_in"
|
||||
case orderNumber = "order_number"
|
||||
case projectName = "project_name"
|
||||
case photogName = "photog_name"
|
||||
case scenicName = "scenic_name"
|
||||
case orderAmount = "order_amount"
|
||||
case depositAmount = "deposit_amount"
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case userPhone = "user_phone"
|
||||
}
|
||||
}
|
||||
|
||||
struct WriteOffOrderResponse: Decodable, Equatable {
|
||||
let orderVerify: Bool
|
||||
let orderNumber: String
|
||||
let projectName: String
|
||||
let scenicName: String
|
||||
let orderAmount: String
|
||||
let payTime: String
|
||||
let userPhone: String
|
||||
let verifyTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderVerify = "order_verify"
|
||||
case orderNumber = "order_number"
|
||||
case projectName = "project_name"
|
||||
case scenicName = "scenic_name"
|
||||
case orderAmount = "order_amount"
|
||||
case payTime = "pay_time"
|
||||
case userPhone = "user_phone"
|
||||
case verifyTime = "time"
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelScenicSpotListResponse: Decodable {
|
||||
let scenicSpot: [MultiTravelScenicSpotItem]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpot = "scenic_spot"
|
||||
}
|
||||
|
||||
var entityList: [MultiTravelScenicSpotEntity] {
|
||||
scenicSpot?.map { $0.toEntity() } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelScenicSpotItem: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let cover: String
|
||||
let scenicName: String
|
||||
let scenicId: Int
|
||||
let distance: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, cover
|
||||
case scenicName = "scenic_name"
|
||||
case scenicId = "scenic_id"
|
||||
case distance
|
||||
}
|
||||
|
||||
func toEntity() -> MultiTravelScenicSpotEntity {
|
||||
MultiTravelScenicSpotEntity(
|
||||
scenicSpotId: id,
|
||||
name: name,
|
||||
coverUrl: cover,
|
||||
scenicName: scenicName,
|
||||
distanceText: Self.formatDistance(distance),
|
||||
address: scenicName
|
||||
)
|
||||
}
|
||||
|
||||
private static func formatDistance(_ distance: Double) -> String {
|
||||
if distance <= 0 { return "当前点位" }
|
||||
if distance < 1000 { return "\(Int(distance))米" }
|
||||
let km = distance / 1000
|
||||
let tenths = Int(km * 10)
|
||||
return "约\(tenths / 10).\(tenths % 10)公里"
|
||||
}
|
||||
}
|
||||
|
||||
struct MultiTravelScenicSpotEntity: Equatable {
|
||||
let scenicSpotId: Int
|
||||
let name: String
|
||||
let coverUrl: String
|
||||
let scenicName: String
|
||||
let distanceText: String
|
||||
let address: String
|
||||
}
|
||||
|
||||
struct MultiTravelShootHistoryResponse: Decodable, Equatable {
|
||||
let projectName: String
|
||||
let projectType: Int
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
case projectTypeName = "project_type_name"
|
||||
case photogSpotList = "photog_spot_list"
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotogSpotItem: Decodable, Equatable {
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let photogNickname: String
|
||||
let photogName: String
|
||||
let files: [ShootHistoryFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case photogNickname = "photog_nickname"
|
||||
case photogName = "photog_name"
|
||||
case files
|
||||
}
|
||||
|
||||
var photographerDisplayName: String {
|
||||
photogNickname.isEmpty ? photogName : photogNickname
|
||||
}
|
||||
|
||||
var imageUrls: [String] {
|
||||
files.compactMap { file in
|
||||
if !file.coverUrl.isEmpty { return file.coverUrl }
|
||||
if file.fileType == 2, !file.fileUrl.isEmpty { return file.fileUrl }
|
||||
return file.fileUrl.isEmpty ? nil : file.fileUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShootHistoryFileItem: Decodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let uploadTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
case fileType = "file_type"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case uploadTime = "upload_time"
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreOrderDetailData: Decodable, Equatable {
|
||||
let orderNumber: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let createdAt: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let payTypeName: String
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let userId: Int
|
||||
let phone: String
|
||||
let projectId: Int
|
||||
let projectName: String
|
||||
let remark: String
|
||||
let multiTravel: StoreMultiTravelInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case createdAt = "created_at"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case userId = "user_id"
|
||||
case phone
|
||||
case projectId = "project_id"
|
||||
case projectName = "project_name"
|
||||
case remark
|
||||
case multiTravel = "multi_travel"
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreMultiTravelInfo: Decodable, Equatable {
|
||||
let projectInfo: StoreProjectInfo?
|
||||
let shootingList: [StoreShootingListItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectInfo = "project_info"
|
||||
case shootingList = "shooting_list"
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreProjectInfo: Decodable, Equatable {
|
||||
let settleSpotNum: Int
|
||||
let singleSpotMaterialNum: Int
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
case singleSpotPhotoNum = "single_spot_photo_num"
|
||||
case singleSpotVideoNum = "single_spot_video_num"
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreShootingListItem: Decodable, Equatable {
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let staffName: String
|
||||
let photogName: String
|
||||
let status: Int
|
||||
let startAvg: Float?
|
||||
let start: Float?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case staffName = "staff_name"
|
||||
case photogName = "photog_name"
|
||||
case status
|
||||
case startAvg = "start_avg"
|
||||
case start
|
||||
}
|
||||
|
||||
var displayStaffName: String {
|
||||
staffName.isEmpty ? photogName : staffName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Order Source
|
||||
|
||||
struct CooperativeSalerListResponse: Decodable {
|
||||
let total: Int
|
||||
let list: [CooperativeSalerEntity]
|
||||
}
|
||||
|
||||
struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
let saleUserId: Int
|
||||
let bindingId: Int
|
||||
let name: String
|
||||
let salerName: String
|
||||
let photogRemarkName: String
|
||||
let phone: String
|
||||
let salerPhone: String
|
||||
|
||||
var displayId: Int { saleUserId }
|
||||
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
|
||||
var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case saleUserId = "sale_user_id"
|
||||
case bindingId = "binding_id"
|
||||
case name
|
||||
case salerName = "saler_name"
|
||||
case photogRemarkName = "photog_remark_name"
|
||||
case phone
|
||||
case salerPhone = "saler_phone"
|
||||
}
|
||||
}
|
||||
|
||||
struct ReferralOrderListResponse: Decodable {
|
||||
let total: Int
|
||||
let list: [ReferralOrderEntity]
|
||||
}
|
||||
|
||||
struct ReferralOrderEntity: Decodable, Equatable {
|
||||
let id: Int
|
||||
let referralOrderNo: String
|
||||
let orderNo: String
|
||||
let userMobile: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
|
||||
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case referralOrderNo = "referral_order_no"
|
||||
case orderNo = "order_no"
|
||||
case userMobile = "user_mobile"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filters
|
||||
|
||||
enum PhotographerOrderFilter: CaseIterable {
|
||||
case all
|
||||
case paidDeposit
|
||||
case completed
|
||||
case refunded
|
||||
case needsRefinement
|
||||
case noRefinement
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .paidDeposit: "已付定金"
|
||||
case .completed: "已完成"
|
||||
case .refunded: "已退款"
|
||||
case .needsRefinement: "需要精修"
|
||||
case .noRefinement: "无需精修"
|
||||
}
|
||||
}
|
||||
|
||||
var tabIndex: Int {
|
||||
switch self {
|
||||
case .all, .needsRefinement, .noRefinement: 0
|
||||
case .paidDeposit: 18
|
||||
case .completed: 30
|
||||
case .refunded: 50
|
||||
}
|
||||
}
|
||||
|
||||
var rTabIndex: Int {
|
||||
switch self {
|
||||
case .needsRefinement: 1
|
||||
case .noRefinement: 2
|
||||
default: 0
|
||||
}
|
||||
}
|
||||
|
||||
static func from(tabIndex: Int, rTabIndex: Int) -> PhotographerOrderFilter {
|
||||
switch (tabIndex, rTabIndex) {
|
||||
case (18, 0): .paidDeposit
|
||||
case (30, 0): .completed
|
||||
case (50, 0): .refunded
|
||||
case (0, 1): .needsRefinement
|
||||
case (0, 2): .noRefinement
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreOrderFilter: CaseIterable {
|
||||
case all
|
||||
case paid
|
||||
case completed
|
||||
case refunded
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .paid: "已支付"
|
||||
case .completed: "已完成"
|
||||
case .refunded: "已退款"
|
||||
}
|
||||
}
|
||||
|
||||
var tabIndex: Int {
|
||||
switch self {
|
||||
case .all: 0
|
||||
case .paid: 18
|
||||
case .completed: 30
|
||||
case .refunded: 50
|
||||
}
|
||||
}
|
||||
|
||||
/// UI tab 18 映射 API order_status=20。
|
||||
var apiOrderStatus: Int {
|
||||
switch self {
|
||||
case .paid: 20
|
||||
default: tabIndex
|
||||
}
|
||||
}
|
||||
|
||||
static func from(tabIndex: Int) -> StoreOrderFilter {
|
||||
switch tabIndex {
|
||||
case 18: .paid
|
||||
case 30: .completed
|
||||
case 50: .refunded
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OrderTypeCode {
|
||||
static let photographerFollow = 11
|
||||
static let photographerFollowOfflineScan = 14
|
||||
static let multiTravel = 19
|
||||
}
|
||||
|
||||
enum OrderDisplayName {
|
||||
static func saleUser(remark: String, primary: String, fallback: String = "") -> String {
|
||||
if !remark.isEmpty { return remark }
|
||||
if !primary.isEmpty { return primary }
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
enum OrderListQueryBuilder {
|
||||
static let photographerPageSize = 10
|
||||
static let storePageSize = 20
|
||||
|
||||
static func orderStatusQuery(tabIndex: Int) -> String? {
|
||||
tabIndex == 0 ? nil : String(tabIndex)
|
||||
}
|
||||
|
||||
static func isRefinedQuery(rTabIndex: Int) -> Int? {
|
||||
rTabIndex == 0 ? nil : rTabIndex
|
||||
}
|
||||
|
||||
static func storeOrderStatusQuery(tabIndex: Int) -> Int? {
|
||||
let status = StoreOrderFilter.from(tabIndex: tabIndex).apiOrderStatus
|
||||
return status == 0 ? nil : status
|
||||
}
|
||||
}
|
||||
73
suixinkan/Features/Orders/Models/QRCodeScanResult.swift
Normal file
73
suixinkan/Features/Orders/Models/QRCodeScanResult.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// QRCodeScanResult.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 扫码 JSON 解析结果,对齐 Android QR 分流。
|
||||
struct QRCodeScanResult: Equatable {
|
||||
let rawJSON: String
|
||||
let method: String
|
||||
let orderNumber: String
|
||||
let userId: Int
|
||||
let time: String
|
||||
let timeLong: Int64
|
||||
let checkSum: String
|
||||
|
||||
static func parse(_ content: String) -> QRCodeScanResult? {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
|
||||
let method = (json["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let orderNumber = (json["order_number"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let userId = Self.parseInt(json["user_id"])
|
||||
let timeLong = Self.parseTimeLong(json["time"])
|
||||
let time = Self.parseTimeString(json["time"])
|
||||
let checkSum = (json["check_sum"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
return QRCodeScanResult(
|
||||
rawJSON: trimmed,
|
||||
method: method,
|
||||
orderNumber: orderNumber,
|
||||
userId: userId,
|
||||
time: time,
|
||||
timeLong: timeLong,
|
||||
checkSum: checkSum
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: Any?) -> Int {
|
||||
if let intValue = value as? Int { return intValue }
|
||||
if let stringValue = value as? String, let intValue = Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = value as? Double { return Int(doubleValue) }
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func parseTimeLong(_ value: Any?) -> Int64 {
|
||||
if let intValue = value as? Int64 { return intValue }
|
||||
if let intValue = value as? Int { return Int64(intValue) }
|
||||
if let doubleValue = value as? Double { return Int64(doubleValue) }
|
||||
if let stringValue = value as? String, let intValue = Int64(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return intValue
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func parseTimeString(_ value: Any?) -> String {
|
||||
if let stringValue = value as? String {
|
||||
return stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let longValue = parseTimeLong(value)
|
||||
guard longValue > 0 else { return "" }
|
||||
let seconds = longValue > 1_000_000_000_000 ? TimeInterval(longValue) / 1000 : TimeInterval(longValue)
|
||||
let date = Date(timeIntervalSince1970: seconds)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
203
suixinkan/Features/Orders/Utils/MainTabScanHandler.swift
Normal file
203
suixinkan/Features/Orders/Utils/MainTabScanHandler.swift
Normal file
@ -0,0 +1,203 @@
|
||||
//
|
||||
// MainTabScanHandler.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
|
||||
@MainActor
|
||||
final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onShowSignIn: ((OrderSignInResponse) -> Void)?
|
||||
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
|
||||
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
|
||||
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
|
||||
|
||||
private let api: OrderAPI
|
||||
private let locationManager = CLLocationManager()
|
||||
private var locationContinuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
|
||||
private(set) var pendingMultiVerify: MultiVerifyParseData?
|
||||
|
||||
init(api: OrderAPI) {
|
||||
self.api = api
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func handleScanResult(_ scanResult: String) async {
|
||||
guard !scanResult.isEmpty else {
|
||||
onShowMessage?("请扫描正确的二维码")
|
||||
return
|
||||
}
|
||||
|
||||
guard let parsed = QRCodeScanResult.parse(scanResult) else {
|
||||
onShowMessage?("请扫描正确的二维码")
|
||||
return
|
||||
}
|
||||
|
||||
switch parsed.method {
|
||||
case "check_in":
|
||||
await performCheckIn(scanResult: scanResult)
|
||||
case "order_verify":
|
||||
if AppStore.shared.currentAppRole == .storeAdmin {
|
||||
await performStoreVerify(parsed: parsed)
|
||||
} else {
|
||||
await performPhotographerVerify(jsonBody: parsed.rawJSON)
|
||||
}
|
||||
case "multi_verify":
|
||||
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
|
||||
onShowMessage?("请扫描正确的多点位核销码")
|
||||
return
|
||||
}
|
||||
let pending = MultiVerifyParseData(
|
||||
orderNumber: parsed.orderNumber,
|
||||
userId: parsed.userId,
|
||||
time: parsed.timeLong,
|
||||
checkSum: parsed.checkSum
|
||||
)
|
||||
pendingMultiVerify = pending
|
||||
onMultiVerifyPending?(pending)
|
||||
await fetchSpotsForMultiVerify(pending: pending)
|
||||
case SaleUserQrParser.methodSaleUserBind:
|
||||
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
|
||||
onShowMessage?("获客员绑定功能待接入")
|
||||
} else {
|
||||
onShowMessage?("请扫描正确的获客员二维码")
|
||||
}
|
||||
default:
|
||||
onShowMessage?("未知二维码,请检查后重试")
|
||||
}
|
||||
}
|
||||
|
||||
func confirmMultiVerify(pending: MultiVerifyParseData, spot: MultiTravelScenicSpotEntity) async {
|
||||
do {
|
||||
let response = try await api.multiTravelOrderVerify(
|
||||
MultiTravelOrderVerifyRequest(
|
||||
orderNumber: pending.orderNumber,
|
||||
userId: pending.userId,
|
||||
time: pending.time,
|
||||
checkSum: pending.checkSum,
|
||||
scenicSpotId: spot.scenicSpotId
|
||||
)
|
||||
)
|
||||
onShowSignIn?(response)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func performCheckIn(scanResult: String) async {
|
||||
guard var json = parseJSONObject(scanResult) else {
|
||||
onShowMessage?("请扫描正确的二维码")
|
||||
return
|
||||
}
|
||||
json["scenic_id"] = String(AppStore.shared.currentScenicId)
|
||||
guard let body = serializeJSON(json) else { return }
|
||||
do {
|
||||
let response = try await api.orderCheckIn(jsonBody: body)
|
||||
onShowSignIn?(response)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func performPhotographerVerify(jsonBody: String) async {
|
||||
do {
|
||||
let response = try await api.orderVerify(jsonBody: jsonBody)
|
||||
onShowWriteOff?(response)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func performStoreVerify(parsed: QRCodeScanResult) async {
|
||||
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
|
||||
onShowMessage?("请扫描正确的核销码")
|
||||
return
|
||||
}
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
guard storeId > 0 else {
|
||||
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let response = try await api.storeOrderVerify(
|
||||
StoreOrderVerifyRequest(
|
||||
userId: parsed.userId,
|
||||
orderNumber: parsed.orderNumber,
|
||||
time: parsed.time,
|
||||
storeId: storeId
|
||||
)
|
||||
)
|
||||
onShowWriteOff?(response)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchSpotsForMultiVerify(pending: MultiVerifyParseData) async {
|
||||
let status = locationManager.authorizationStatus
|
||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
onShowMessage?("缺少定位权限,功能无法进行")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let coordinate = try await requestLocation()
|
||||
let response = try await api.multiTravelScenicSpotList(
|
||||
orderNumber: pending.orderNumber,
|
||||
lat: coordinate.latitude,
|
||||
lng: coordinate.longitude
|
||||
)
|
||||
let spots = response.entityList
|
||||
if spots.isEmpty {
|
||||
onShowMessage?("暂无可核销的打卡点")
|
||||
} else {
|
||||
onShowMultiVerifySheet?(spots, spots.first)
|
||||
}
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = { result in
|
||||
switch result {
|
||||
case .success(let coordinate):
|
||||
continuation.resume(returning: coordinate)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
locationManager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
locationContinuation?(.success(location.coordinate))
|
||||
locationContinuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
locationContinuation?(.failure(error))
|
||||
locationContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainTabScanHandler {
|
||||
func parseJSONObject(_ text: String) -> [String: Any]? {
|
||||
guard let data = text.data(using: .utf8) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
|
||||
func serializeJSON(_ json: [String: Any]) -> String? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: json) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
55
suixinkan/Features/Orders/Utils/OrderLocationProvider.swift
Normal file
55
suixinkan/Features/Orders/Utils/OrderLocationProvider.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// OrderLocationProvider.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
|
||||
/// 一次性定位,用于多点位核销拉取打卡点。
|
||||
final class OrderLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private var continuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
let status = manager.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
guard status == .authorizedWhenInUse || status == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else {
|
||||
throw OrderLocationError.permissionDenied
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
continuation?(.success(location.coordinate))
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
continuation?(.failure(error))
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum OrderLocationError: LocalizedError {
|
||||
case permissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied: "缺少定位权限,功能无法进行"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
suixinkan/Features/Orders/Utils/SaleUserQrParser.swift
Normal file
55
suixinkan/Features/Orders/Utils/SaleUserQrParser.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// SaleUserQrParser.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员二维码解析,对齐 Android `SaleUserQrParser`。
|
||||
enum SaleUserQrParser {
|
||||
static let methodSaleUserBind = "sale_user_bind"
|
||||
|
||||
static func parseSaleUserId(_ content: String) -> Int? {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let fromMethod = parseFromJSON(json) { return fromMethod }
|
||||
}
|
||||
|
||||
if let intValue = Int(trimmed), intValue > 0 { return intValue }
|
||||
|
||||
if trimmed.contains("://") || trimmed.contains("?"),
|
||||
let components = URLComponents(string: trimmed) {
|
||||
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
|
||||
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
|
||||
let intValue = Int(value), intValue > 0 {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseFromJSON(_ json: [String: Any]) -> Int? {
|
||||
if let method = json["method"] as? String, method == methodSaleUserBind,
|
||||
let id = readInt(json["sale_user_id"]), id > 0 {
|
||||
return id
|
||||
}
|
||||
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
|
||||
if let id = readInt(json[key]), id > 0 { return id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readInt(_ value: Any?) -> Int? {
|
||||
if let intValue = value as? Int { return intValue }
|
||||
if let stringValue = value as? String, let intValue = Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = value as? Double { return Int(doubleValue) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
//
|
||||
// DepositOrderListViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 店铺管理员押金单列表 ViewModel,对齐 Android `DepositOrderListViewModel`。
|
||||
final class DepositOrderListViewModel {
|
||||
|
||||
private(set) var searchText = ""
|
||||
private(set) var filterLabel = "全部"
|
||||
private(set) var tabIndex = 0
|
||||
private(set) var startTime: Date?
|
||||
private(set) var endTime: Date?
|
||||
private(set) var orderList: [StoreOrderItem] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var canLoadMore = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var writeOffResponse: WriteOffOrderResponse?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private var searchDebounceTask: Task<Void, Never>?
|
||||
|
||||
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
|
||||
}()
|
||||
|
||||
func refreshOrderList(api: OrderAPI) async {
|
||||
currentPage = 1
|
||||
isRefreshing = true
|
||||
isLoadingMore = false
|
||||
notifyStateChange()
|
||||
await loadOrderList(refresh: true, api: api)
|
||||
}
|
||||
|
||||
func loadMore(api: OrderAPI) async {
|
||||
guard !isLoadingMore, !isRefreshing, orderList.count < totalCount else { return }
|
||||
isLoadingMore = true
|
||||
notifyStateChange()
|
||||
await loadOrderList(refresh: false, api: api)
|
||||
}
|
||||
|
||||
func shouldLoadMore(at index: Int) -> Bool {
|
||||
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
|
||||
return index >= max(0, orderList.count - 3)
|
||||
}
|
||||
|
||||
func onSearchTextChange(_ text: String, api: OrderAPI) {
|
||||
searchText = text
|
||||
notifyStateChange()
|
||||
searchDebounceTask?.cancel()
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
Task { await refreshOrderList(api: api) }
|
||||
return
|
||||
}
|
||||
if Self.isValidPhone(trimmed) {
|
||||
searchDebounceTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await refreshOrderList(api: api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onFilterChange(tab: Int) {
|
||||
tabIndex = tab
|
||||
filterLabel = StoreOrderFilter.from(tabIndex: tab).label
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func onDateRangeSelected(start: Date?, end: Date?) {
|
||||
startTime = start
|
||||
endTime = end
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func dismissWriteOffDialog() {
|
||||
writeOffResponse = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func processScanResult(_ scanResult: String, api: OrderAPI) async {
|
||||
guard let parsed = QRCodeScanResult.parse(scanResult) else {
|
||||
onShowMessage?("请扫描正确的二维码")
|
||||
return
|
||||
}
|
||||
guard parsed.method == "order_verify" else {
|
||||
onShowMessage?("请扫描订单核销码")
|
||||
return
|
||||
}
|
||||
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
|
||||
onShowMessage?("核销码内容不完整")
|
||||
return
|
||||
}
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
guard storeId > 0 else {
|
||||
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||
return
|
||||
}
|
||||
await executeStoreOrderVerify(
|
||||
StoreOrderVerifyRequest(
|
||||
userId: parsed.userId,
|
||||
orderNumber: parsed.orderNumber,
|
||||
time: parsed.time,
|
||||
storeId: storeId
|
||||
),
|
||||
api: api
|
||||
)
|
||||
}
|
||||
|
||||
func requestRefund(
|
||||
orderNumber: String,
|
||||
refundType: Int,
|
||||
refundAmount: String,
|
||||
refundReason: String,
|
||||
api: OrderAPI
|
||||
) async {
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
guard storeId > 0 else {
|
||||
onShowMessage?("店铺信息异常")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await api.storeOrderRefund(
|
||||
StoreOrderRefundRequest(
|
||||
storeId: storeId,
|
||||
orderNumber: orderNumber,
|
||||
refundType: refundType,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: refundReason
|
||||
)
|
||||
)
|
||||
onShowMessage?("退款成功")
|
||||
await refreshOrderList(api: api)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func canOpenDetail(for item: StoreOrderItem) -> Bool {
|
||||
item.orderType == StoreOrderType.multiTravelShoot
|
||||
}
|
||||
|
||||
static func isValidPhone(_ text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count == 11, trimmed.first == "1" else { return false }
|
||||
guard let second = trimmed.dropFirst().first, ("3" ... "9").contains(second) else { return false }
|
||||
return trimmed.allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
static func apiOrderStatus(forTabIndex tabIndex: Int) -> Int? {
|
||||
OrderListQueryBuilder.storeOrderStatusQuery(tabIndex: tabIndex)
|
||||
}
|
||||
|
||||
private func loadOrderList(refresh: Bool, api: OrderAPI) async {
|
||||
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
|
||||
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
if storeId <= 0 {
|
||||
finishLoading()
|
||||
orderList = []
|
||||
canLoadMore = false
|
||||
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||
return
|
||||
}
|
||||
|
||||
let orderStatus: Int?
|
||||
let start: String?
|
||||
let end: String?
|
||||
let userPhone: String?
|
||||
|
||||
if isPhoneSearch {
|
||||
orderStatus = 0
|
||||
start = nil
|
||||
end = nil
|
||||
userPhone = phone
|
||||
} else {
|
||||
orderStatus = Self.apiOrderStatus(forTabIndex: tabIndex)
|
||||
start = startTime.map { apiDateFormatter.string(from: $0) }
|
||||
end = endTime.map { apiDateFormatter.string(from: $0) }
|
||||
userPhone = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.storeOrderList(
|
||||
storeId: storeId,
|
||||
page: currentPage,
|
||||
userPhone: userPhone,
|
||||
orderStatus: orderStatus,
|
||||
startTime: start,
|
||||
endTime: end
|
||||
)
|
||||
totalCount = response.total
|
||||
if refresh || currentPage == 1 {
|
||||
orderList = response.list
|
||||
currentPage = 2
|
||||
} else {
|
||||
orderList.append(contentsOf: response.list)
|
||||
currentPage += 1
|
||||
}
|
||||
canLoadMore = orderList.count < totalCount
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
finishLoading()
|
||||
}
|
||||
|
||||
private func executeStoreOrderVerify(_ request: StoreOrderVerifyRequest, api: OrderAPI) async {
|
||||
do {
|
||||
let response = try await api.storeOrderVerify(request)
|
||||
writeOffResponse = response
|
||||
if response.orderVerify {
|
||||
await refreshOrderList(api: api)
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishLoading() {
|
||||
isRefreshing = false
|
||||
isLoadingMore = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
473
suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift
Normal file
473
suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift
Normal file
@ -0,0 +1,473 @@
|
||||
//
|
||||
// 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 displayText: String
|
||||
let userMobile: String
|
||||
let createdAt: String
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private var loadingOrderSourcePeople = false
|
||||
|
||||
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 {
|
||||
onShowMessage?("获客员绑定功能待接入")
|
||||
} 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? {
|
||||
if let selection = orderSourceSelections[order.orderNumber] { return selection }
|
||||
guard let referral = order.referralOrder else { return nil }
|
||||
let person = OrderSourcePerson(
|
||||
key: String(referral.saleUserId),
|
||||
name: referral.displaySalerName,
|
||||
phone: referral.salerPhone
|
||||
)
|
||||
let lead = OrderSourceLead(
|
||||
key: referral.orderNo,
|
||||
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText,
|
||||
userMobile: referral.userMobile,
|
||||
createdAt: referral.createdAt
|
||||
)
|
||||
return OrderSourceSelection(person: person, lead: lead)
|
||||
}
|
||||
|
||||
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
||||
orderSourceLeads = [:]
|
||||
guard !loadingOrderSourcePeople else { return }
|
||||
loadingOrderSourcePeople = true
|
||||
defer { loadingOrderSourcePeople = false }
|
||||
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)
|
||||
let leads = response.list.map {
|
||||
OrderSourceLead(
|
||||
key: $0.displayReferralOrderNo,
|
||||
displayText: $0.displayReferralOrderNo,
|
||||
userMobile: $0.userMobile,
|
||||
createdAt: $0.createdAt
|
||||
)
|
||||
}
|
||||
orderSourceLeads[person.key] = leads
|
||||
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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user