import Foundation /// 线下收款方式,对应后端 `pay_method`。 enum OfflineCollectionPaymentMethod: Int, Codable, CaseIterable, Sendable, Hashable { case wechat = 2 case alipay = 1 case cash = 3 var displayName: String { switch self { case .wechat: "微信"; case .alipay: "支付宝"; case .cash: "现金" } } var systemImageName: String { switch self { case .wechat: "message.fill"; case .alipay: "a.circle.fill"; case .cash: "banknote.fill" } } var assetName: String { switch self { case .wechat: "payment_method_wechat"; case .alipay: "payment_method_alipay"; case .cash: "payment_method_cash" } } } /// 线下收款记录的补缴展示状态。 enum OfflineCollectionStatus: Int, Codable, Sendable, Hashable { case pending = 0 case settled = 1 case overdue = 2 var displayName: String { switch self { case .pending: "待补缴"; case .settled: "已补缴"; case .overdue: "逾期未补缴" } } } /// 当前登录账号在线下收款页面中的展示和请求上下文。 struct OfflineCollectionContext: Sendable, Hashable { let collectorName: String let storeName: String let scenicId: Int let scenicName: String static func current(appStore: AppStore = .shared) -> OfflineCollectionContext { let session = appStore.session let stores = appStore.permissions.rolePermissionList().flatMap(\.store) let store = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first let name = [session.realName, session.userName, session.accountDisplayName] .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? "-" return OfflineCollectionContext( collectorName: name, storeName: store?.name.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-", scenicId: session.currentScenicId, scenicName: session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" ) } } /// 日清页展示的一笔线下收款记录。 struct OfflineCollectionRecord: Sendable, Hashable { let collectNo: String let amountFen: Int let paymentMethod: OfflineCollectionPaymentMethod let timeText: String let status: OfflineCollectionStatus } /// 某一营业日的线下收款汇总。 struct OfflineDailySummary: Sendable, Hashable { let businessDate: String let totalCount: Int let totalAmountFen: Int let settledCount: Int let settledAmountFen: Int let pendingCount: Int let pendingAmountFen: Int static func empty(_ date: String) -> OfflineDailySummary { OfflineDailySummary( businessDate: date, totalCount: 0, totalAmountFen: 0, settledCount: 0, settledAmountFen: 0, pendingCount: 0, pendingAmountFen: 0 ) } } /// 统计接口中的一个待补缴日期标记。 struct OfflinePendingDate: Decodable, Sendable, Hashable { let date: String let unpaidAmount: String let unpaidCount: Int enum CodingKeys: String, CodingKey { case date case unpaidAmount = "unpaid_amount" case unpaidCount = "unpaid_count" } var unpaidAmountFen: Int { OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0 } } /// 线下收款统计接口响应。 struct OfflineCollectionStatisticsResponse: Decodable, Sendable { /// 服务端今日营业日及金额汇总。 struct Today: Decodable, Sendable { let date: String let totalAmount: String let paidAmount: String let unpaidAmount: String let collectCount: Int enum CodingKeys: String, CodingKey { case date case totalAmount = "total_amount" case paidAmount = "paid_amount" case unpaidAmount = "unpaid_amount" case collectCount = "collect_count" } } /// 当前账号在所选景区内的全部待补缴汇总。 struct Pending: Decodable, Sendable { let amount: String let collectCount: Int let dateCount: Int let dates: [OfflinePendingDate] enum CodingKeys: String, CodingKey { case amount case collectCount = "collect_count" case dateCount = "date_count" case dates } } let today: Today let pending: Pending var todaySummary: OfflineDailySummary { let pendingCount = pending.dates.first(where: { $0.date == today.date })?.unpaidCount ?? 0 return OfflineDailySummary( businessDate: today.date, totalCount: today.collectCount, totalAmountFen: OfflineCollectionMoney.responseFen(today.totalAmount) ?? 0, settledCount: max(0, today.collectCount - pendingCount), settledAmountFen: OfflineCollectionMoney.responseFen(today.paidAmount) ?? 0, pendingCount: pendingCount, pendingAmountFen: OfflineCollectionMoney.responseFen(today.unpaidAmount) ?? 0 ) } } /// 单日详情接口响应。 struct OfflineCollectionDetailsResponse: Decodable, Sendable { /// 单日详情中的一笔登记记录。 struct Collect: Decodable, Sendable { let collectNo: String let amount: String let payMethod: Int let status: Int let time: String enum CodingKeys: String, CodingKey { case collectNo = "collect_no" case amount case payMethod = "pay_method" case status case time } } let date: String let totalAmount: String let collectCount: Int let paidAmount: String let paidCount: Int let unpaidAmount: String let unpaidCount: Int let status: Int? let statusText: String? let collects: [Collect] enum CodingKeys: String, CodingKey { case date case totalAmount = "total_amount" case collectCount = "collect_count" case paidAmount = "paid_amount" case paidCount = "paid_count" case unpaidAmount = "unpaid_amount" case unpaidCount = "unpaid_count" case status case statusText = "status_text" case collects } var summary: OfflineDailySummary { OfflineDailySummary( businessDate: date, totalCount: collectCount, totalAmountFen: OfflineCollectionMoney.responseFen(totalAmount) ?? 0, settledCount: paidCount, settledAmountFen: OfflineCollectionMoney.responseFen(paidAmount) ?? 0, pendingCount: unpaidCount, pendingAmountFen: OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0 ) } func records(serverToday: String) -> [OfflineCollectionRecord] { collects.compactMap { item in guard let method = OfflineCollectionPaymentMethod(rawValue: item.payMethod) else { return nil } let rawStatus = OfflineCollectionStatus(rawValue: item.status) ?? .pending let status: OfflineCollectionStatus = rawStatus == .pending && date < serverToday ? .overdue : rawStatus return OfflineCollectionRecord( collectNo: item.collectNo, amountFen: OfflineCollectionMoney.responseFen(item.amount) ?? 0, paymentMethod: method, timeText: String(item.time.prefix(5)), status: status ) } } } /// 登记一笔线下收款的请求。 struct OfflineCollectionRegisterRequest: Encodable, Sendable { let scenicId: Int let amount: String let payMethod: Int enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case amount case payMethod = "pay_method" } } /// 登记线下收款响应。 struct OfflineCollectionRegisterResponse: Decodable, Sendable { let collectNo: String let amount: String let payMethod: Int let status: Int let createdAt: String enum CodingKeys: String, CodingKey { case collectNo = "collect_no" case amount case payMethod = "pay_method" case status case createdAt = "created_at" } var record: OfflineCollectionRecord { OfflineCollectionRecord( collectNo: collectNo, amountFen: OfflineCollectionMoney.responseFen(amount) ?? 0, paymentMethod: OfflineCollectionPaymentMethod(rawValue: payMethod) ?? .wechat, timeText: OfflineCollectionDate.timePart(createdAt), status: OfflineCollectionStatus(rawValue: status) ?? .pending ) } } /// 补缴确认数据;金额和笔数仅用于客户端确认,后端只接收景区和日期。 struct OfflineSettlementRequest: Encodable, Sendable, Hashable { let scenicId: Int let businessDate: String let amountFen: Int let pendingCount: Int enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case businessDate = "date" } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(scenicId, forKey: .scenicId) try container.encode(businessDate, forKey: .businessDate) } } /// 一次成功补缴的返回结果。 struct OfflineSettlementResult: Decodable, Sendable, Hashable { let date: String let updatedCount: Int enum CodingKeys: String, CodingKey { case date case updatedCount = "updated_count" } } /// 登记成功弹窗所需数据。 struct OfflineCollectionRegistrationReceipt: Sendable { let record: OfflineCollectionRecord let businessDate: String let summary: OfflineDailySummary? } /// 线下收款业务的客户端异常。 enum OfflineCollectionError: LocalizedError, Sendable, Equatable { case missingScenic case invalidAmount case noPendingRecords var errorDescription: String? { switch self { case .missingScenic: "请先选择景区" case .invalidAmount: "请输入0.01~99,999.99元的有效金额" case .noPendingRecords: "当前营业日无待补缴记录" } } } /// 线下收款金额的精确解析与格式化工具。 enum OfflineCollectionMoney { static let maximumFen = 9_999_999 static func acceptsEditingText(_ text: String) -> Bool { guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false } let parts = text.split(separator: ".", omittingEmptySubsequences: false) guard parts.count <= 2 else { return false } return (parts.first?.count ?? 0) <= 5 && (parts.count == 2 ? parts[1].count : 0) <= 2 } static func parseFen(_ rawValue: String) -> Int? { guard !rawValue.isEmpty, rawValue == rawValue.trimmingCharacters(in: .whitespacesAndNewlines), acceptsEditingText(rawValue), rawValue != "." else { return nil } let parts = rawValue.split(separator: ".", omittingEmptySubsequences: false) guard let whole = Int(parts[0].isEmpty ? "0" : String(parts[0])) else { return nil } let fraction = parts.count == 2 ? String(parts[1]) : "" let cents = fraction.isEmpty ? 0 : (fraction.count == 1 ? (Int(fraction) ?? 0) * 10 : (Int(fraction) ?? 0)) let value = whole * 100 + cents return (1 ... maximumFen).contains(value) ? value : nil } static func responseFen(_ value: String) -> Int? { parseFen(value) } static func apiAmount(_ fen: Int) -> String { "\(fen / 100).\(String(format: "%02d", fen % 100))" } static func display(_ fen: Int) -> String { "¥\(apiAmount(fen))" } } /// 线下收款统一使用的营业日工具。 enum OfflineCollectionDate { static var calendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "zh_CN") calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! calendar.firstWeekday = 2 return calendar } static func businessDate(for date: Date, calendar: Calendar = calendar) -> String { let values = calendar.dateComponents([.year, .month, .day], from: date) return String(format: "%04d-%02d-%02d", values.year ?? 0, values.month ?? 0, values.day ?? 0) } static func date(from value: String, calendar: Calendar = calendar) -> Date? { let formatter = DateFormatter() formatter.calendar = calendar formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: value) } static func timePart(_ value: String) -> String { String((value.split(separator: " ").last ?? Substring(value)).prefix(5)) } } private extension String { var nonEmpty: String? { isEmpty ? nil : self } }