新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
518 lines
18 KiB
Swift
518 lines
18 KiB
Swift
//
|
||
// CooperationOrderModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/29.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
extension String {
|
||
/// 非空时返回自身,否则返回 fallback。
|
||
func cooperationNonEmptyOrDefault(_ fallback: String) -> String {
|
||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? fallback : trimmed
|
||
}
|
||
}
|
||
|
||
/// 带客单状态更新请求体。
|
||
struct ReferralOrderStatusUpdateRequest: Encodable {
|
||
let status: Int
|
||
}
|
||
|
||
/// 合作获客员列表响应。
|
||
struct CooperativeSalerListPayload: Decodable {
|
||
let total: Int
|
||
let list: [CooperativeSaler]
|
||
let page: Int
|
||
let pageSize: Int
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case total
|
||
case list
|
||
case page
|
||
case pageSize = "page_size"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||
list = try container.decodeIfPresent([CooperativeSaler].self, forKey: .list) ?? []
|
||
page = try container.decodeLossyInt(forKey: .page) ?? 0
|
||
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
|
||
}
|
||
}
|
||
|
||
/// 合作获客员实体。
|
||
struct CooperativeSaler: Decodable, Identifiable {
|
||
let saleUserId: Int
|
||
let bindingId: Int
|
||
let name: String
|
||
let salerName: String
|
||
let phone: String
|
||
let phoneMasked: String
|
||
let salerPhone: String
|
||
let shareCode: String
|
||
let storeId: Int
|
||
let bindTime: String
|
||
let boundAt: String
|
||
|
||
var id: Int { saleUserId }
|
||
|
||
var displayId: Int { saleUserId }
|
||
|
||
var displayName: String {
|
||
name.cooperationNonEmptyOrDefault(salerName)
|
||
}
|
||
|
||
var displayPhone: String {
|
||
salerPhone.cooperationNonEmptyOrDefault(phone)
|
||
}
|
||
|
||
var displayBindTime: String {
|
||
bindTime.cooperationNonEmptyOrDefault(boundAt)
|
||
}
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case saleUserId = "sale_user_id"
|
||
case bindingId = "binding_id"
|
||
case name
|
||
case salerName = "saler_name"
|
||
case phone
|
||
case phoneMasked = "phone_masked"
|
||
case salerPhone = "saler_phone"
|
||
case shareCode = "share_code"
|
||
case storeId = "store_id"
|
||
case bindTime = "bind_time"
|
||
case boundAt = "bound_at"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
|
||
name = try container.decodeLossyString(forKey: .name)
|
||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||
phone = try container.decodeLossyString(forKey: .phone)
|
||
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
|
||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||
shareCode = try container.decodeLossyString(forKey: .shareCode)
|
||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||
bindTime = try container.decodeLossyString(forKey: .bindTime)
|
||
boundAt = try container.decodeLossyString(forKey: .boundAt)
|
||
}
|
||
}
|
||
|
||
/// 获客员邀请信息,用于绑定确认页展示。
|
||
struct SaleUserInviteInfo: Decodable {
|
||
let saleUserId: Int
|
||
let isBound: Bool
|
||
let photographerName: String
|
||
let photographerPhone: String
|
||
let salerName: String
|
||
let salerPhone: String
|
||
let shareCode: String
|
||
let subtitle: String
|
||
let title: String
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case saleUserId = "sale_user_id"
|
||
case isBound = "is_bound"
|
||
case photographerName = "photographer_name"
|
||
case photographerPhone = "photographer_phone"
|
||
case salerName = "saler_name"
|
||
case salerPhone = "saler_phone"
|
||
case shareCode = "share_code"
|
||
case subtitle
|
||
case title
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||
isBound = try container.decodeLossyBool(forKey: .isBound) ?? false
|
||
photographerName = try container.decodeLossyString(forKey: .photographerName)
|
||
photographerPhone = try container.decodeLossyString(forKey: .photographerPhone)
|
||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||
shareCode = try container.decodeLossyString(forKey: .shareCode)
|
||
subtitle = try container.decodeLossyString(forKey: .subtitle)
|
||
title = try container.decodeLossyString(forKey: .title)
|
||
}
|
||
|
||
init(
|
||
saleUserId: Int,
|
||
isBound: Bool = false,
|
||
photographerName: String = "",
|
||
photographerPhone: String = "",
|
||
salerName: String = "",
|
||
salerPhone: String = "",
|
||
shareCode: String = "",
|
||
subtitle: String = "",
|
||
title: String = ""
|
||
) {
|
||
self.saleUserId = saleUserId
|
||
self.isBound = isBound
|
||
self.photographerName = photographerName
|
||
self.photographerPhone = photographerPhone
|
||
self.salerName = salerName
|
||
self.salerPhone = salerPhone
|
||
self.shareCode = shareCode
|
||
self.subtitle = subtitle
|
||
self.title = title
|
||
}
|
||
}
|
||
|
||
/// 获客员线索实体。
|
||
struct ReferralLead: Decodable, Identifiable {
|
||
let id: Int
|
||
let leadId: Int
|
||
let userPhone: String
|
||
let userMobileMasked: String
|
||
let phone: String
|
||
let remark: String
|
||
let createdAt: String
|
||
let imageUrls: [String]
|
||
let images: [String]
|
||
|
||
var displayId: String {
|
||
let value = leadId > 0 ? leadId : id
|
||
return "\(value)"
|
||
}
|
||
|
||
var displayPhone: String {
|
||
userMobileMasked.cooperationNonEmptyOrDefault(userPhone.cooperationNonEmptyOrDefault(phone))
|
||
}
|
||
|
||
var displayImages: [String] {
|
||
imageUrls.isEmpty ? images : imageUrls
|
||
}
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case leadId = "lead_id"
|
||
case userPhone = "user_phone"
|
||
case userMobileMasked = "user_mobile_masked"
|
||
case phone
|
||
case remark
|
||
case createdAt = "created_at"
|
||
case imageUrls = "image_urls"
|
||
case images
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||
leadId = try container.decodeLossyInt(forKey: .leadId) ?? 0
|
||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
|
||
phone = try container.decodeLossyString(forKey: .phone)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
|
||
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
|
||
}
|
||
}
|
||
|
||
/// 获客订单实体。
|
||
struct AcquisitionOrder: Decodable, Identifiable {
|
||
let id: Int
|
||
let orderNumber: String
|
||
let projectName: String
|
||
let orderType: Int
|
||
let orderTypeLabel: String
|
||
let orderAmount: String
|
||
let amount: String
|
||
let actualPayAmount: String
|
||
let createdAt: String
|
||
let customerPhone: String
|
||
let userMobileMasked: String
|
||
let phone: String
|
||
let orderStatusName: String
|
||
let status: String
|
||
let saleUserName: String
|
||
let partnerName: String
|
||
let partnerRole: String
|
||
let remark: String
|
||
let imageUrls: [String]
|
||
|
||
var displayAmount: String {
|
||
orderAmount.cooperationNonEmptyOrDefault(amount.cooperationNonEmptyOrDefault(actualPayAmount))
|
||
}
|
||
|
||
var displayCustomerPhone: String {
|
||
userMobileMasked.cooperationNonEmptyOrDefault(customerPhone.cooperationNonEmptyOrDefault(phone))
|
||
}
|
||
|
||
var displayStatus: String {
|
||
orderStatusName.cooperationNonEmptyOrDefault(status)
|
||
}
|
||
|
||
var displayPartner: String {
|
||
if !saleUserName.isEmpty { return saleUserName }
|
||
if !partnerName.isEmpty { return partnerName }
|
||
return ""
|
||
}
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case orderNumber = "order_number"
|
||
case projectName = "project_name"
|
||
case orderType = "order_type"
|
||
case orderTypeLabel = "order_type_label"
|
||
case orderAmount = "order_amount"
|
||
case amount
|
||
case actualPayAmount = "actual_pay_amount"
|
||
case createdAt = "created_at"
|
||
case customerPhone = "customer_phone"
|
||
case userMobileMasked = "user_mobile_masked"
|
||
case phone
|
||
case orderStatusName = "order_status_name"
|
||
case status
|
||
case saleUserName = "sale_user_name"
|
||
case partnerName = "partner_name"
|
||
case partnerRole = "partner_role"
|
||
case remark
|
||
case imageUrls = "image_urls"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||
amount = try container.decodeLossyString(forKey: .amount)
|
||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||
customerPhone = try container.decodeLossyString(forKey: .customerPhone)
|
||
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
|
||
phone = try container.decodeLossyString(forKey: .phone)
|
||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||
status = try container.decodeLossyString(forKey: .status)
|
||
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
|
||
partnerName = try container.decodeLossyString(forKey: .partnerName)
|
||
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
|
||
}
|
||
}
|
||
|
||
/// 带客单列表中的获客员摘要。
|
||
struct ReferralOrderSaler: Decodable {
|
||
let saleUserId: Int
|
||
let salerName: String
|
||
let salerPhone: String
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case saleUserId = "sale_user_id"
|
||
case salerName = "saler_name"
|
||
case salerPhone = "saler_phone"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||
}
|
||
}
|
||
|
||
/// 可绑定带客单列表响应。
|
||
struct ReferralOrderListPayload: Decodable {
|
||
let total: Int
|
||
let list: [ReferralOrder]
|
||
let page: Int
|
||
let pageSize: Int
|
||
let saler: ReferralOrderSaler?
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case total
|
||
case list
|
||
case page
|
||
case pageSize = "page_size"
|
||
case saler
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||
list = try container.decodeIfPresent([ReferralOrder].self, forKey: .list) ?? []
|
||
page = try container.decodeLossyInt(forKey: .page) ?? 0
|
||
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
|
||
saler = try container.decodeIfPresent(ReferralOrderSaler.self, forKey: .saler)
|
||
}
|
||
}
|
||
|
||
/// 可绑定带客单实体。
|
||
struct ReferralOrder: Decodable, Identifiable {
|
||
let id: Int
|
||
let referralOrderNo: String
|
||
let orderNo: String
|
||
let userMobile: String
|
||
let remark: String
|
||
let createdAt: String
|
||
let status: Int
|
||
let statusLabel: String
|
||
let images: [String]
|
||
|
||
var displayReferralOrderNo: String {
|
||
referralOrderNo.cooperationNonEmptyOrDefault(orderNo)
|
||
}
|
||
|
||
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"
|
||
case images
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
||
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
||
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
|
||
}
|
||
}
|
||
|
||
/// listv2 已绑定订单来源 referral_order。
|
||
struct BoundReferralOrder: Decodable, Equatable, Hashable {
|
||
let id: Int
|
||
let orderNo: String
|
||
let saleUserId: Int
|
||
let salerName: 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
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case orderNo = "order_no"
|
||
case saleUserId = "sale_user_id"
|
||
case salerName = "saler_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"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||
displayText = try container.decodeLossyString(forKey: .displayText)
|
||
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
||
remark = try container.decodeLossyString(forKey: .remark)
|
||
bindOrderNumber = try container.decodeLossyString(forKey: .bindOrderNumber)
|
||
sourceType = try container.decodeLossyInt(forKey: .sourceType) ?? 0
|
||
sourceTypeLabel = try container.decodeLossyString(forKey: .sourceTypeLabel)
|
||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||
}
|
||
}
|
||
|
||
/// 扫码绑定获客员 Sheet 标识。
|
||
struct BindAcquirerSheetItem: Identifiable {
|
||
let saleUserId: Int
|
||
var id: Int { saleUserId }
|
||
}
|
||
|
||
/// 合作订单模块内部导航路由。
|
||
enum CooperationOrderRoute: Hashable {
|
||
case acquirer
|
||
case bind(saleUserId: Int)
|
||
}
|
||
|
||
private extension KeyedDecodingContainer {
|
||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||
func decodeLossyString(forKey key: Key) throws -> String {
|
||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||
return value
|
||
}
|
||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||
return String(value)
|
||
}
|
||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||
return String(value)
|
||
}
|
||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||
return value ? "1" : "0"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||
return value
|
||
}
|
||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if let intValue = Int(text) {
|
||
return intValue
|
||
}
|
||
if let doubleValue = Double(text) {
|
||
return Int(doubleValue)
|
||
}
|
||
}
|
||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||
return Int(value)
|
||
}
|
||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||
return value ? 1 : 0
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||
return value
|
||
}
|
||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||
return value != 0
|
||
}
|
||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||
case "1", "true", "yes":
|
||
return true
|
||
case "0", "false", "no":
|
||
return false
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|