迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。

新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:33:00 +08:00
parent d310d26293
commit 1970572edd
66 changed files with 4247 additions and 201 deletions

View File

@ -0,0 +1,517 @@
//
// 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 ""
}
/// IntDoubleBool
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
}
/// BoolInt
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
}
}