// // OfflineCollectionModels.swift // suixinkan // import Foundation /// 线下收款方式,与线上订单支付方式保持独立。 enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable { case wechat = "WECHAT" case alipay = "ALIPAY" case cash = "CASH" /// 页面展示的中文名称。 var displayName: String { switch self { case .wechat: "微信" case .alipay: "支付宝" case .cash: "现金" } } /// 页面使用的本地矢量图标资源名称。 var assetName: String { switch self { case .wechat: "payment_method_wechat" case .alipay: "payment_method_alipay" case .cash: "payment_method_cash" } } } /// 线下收款记录的补缴状态。 enum OfflineCollectionStatus: String, Codable, Sendable, Hashable { case pending = "PENDING" case settled = "SETTLED" case overdue = "OVERDUE" /// 状态对应的中文文案。 var displayName: String { switch self { case .pending: "待补缴" case .settled: "已补缴" case .overdue: "逾期未补缴" } } } /// 当前线下收款数据所属的用户、店铺与景区上下文。 struct OfflineCollectionContext: Codable, Sendable, Hashable { let collectorId: String let collectorName: String let storeId: String let storeName: String let scenicId: String let scenicName: String /// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。 var storageScope: String { [collectorId, storeId, scenicId] .map { value in value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined() } .joined(separator: "_") } /// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。 static func current(appStore: AppStore = .shared) -> OfflineCollectionContext { let session = appStore.session let stores = appStore.permissions.rolePermissionList().flatMap(\.store) let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first let collectorName = [session.realName, session.userName, session.accountDisplayName] .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return OfflineCollectionContext( collectorId: session.userId.nonEmptyValue ?? "U10086", collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三", storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"), storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店", scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001", scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区" ) } } /// 独立的线下收款记录,不包含任何订单字段。 struct OfflineCollectionRecord: Codable, Sendable, Hashable { let id: String let collectorId: String let collectorName: String let storeId: String let storeName: String let scenicId: String let scenicName: String let amountFen: Int let paymentMethod: OfflineCollectionPaymentMethod let registeredAt: Date let receivedAt: Date let businessDate: String var status: OfflineCollectionStatus var settlementBatchId: String? var settledAt: Date? } /// 线下收款的独立补缴流水,仅关联本批次收款记录快照。 struct OfflineSettlementBatch: Codable, Sendable, Hashable { let id: String let businessDate: String let collectorId: String let collectorName: String let storeId: String let storeName: String let scenicId: String let scenicName: String let recordIds: [String] let recordCount: Int let amountFen: Int let paidAt: Date let payerId: String let payerName: String let clientRequestId: String /// 流水类型固定为线下收款补缴。 var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" } } /// 某一营业日的线下收款汇总。 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 let hasOverdue: Bool /// 按记录状态用整数“分”生成日汇总。 static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary { let settled = records.filter { $0.status == .settled } let pending = records.filter { $0.status == .pending || $0.status == .overdue } return OfflineDailySummary( businessDate: businessDate, totalCount: records.count, totalAmountFen: records.reduce(0) { $0 + $1.amountFen }, settledCount: settled.count, settledAmountFen: settled.reduce(0) { $0 + $1.amountFen }, pendingCount: pending.count, pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen }, hasOverdue: pending.contains { $0.status == .overdue } ) } } /// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。 struct OfflineSettlementRequest: Sendable, Hashable { let businessDate: String let recordIds: [String] let amountFen: Int let clientRequestId: String } /// 登记成功后页面反馈所需的最小数据。 struct OfflineCollectionRegistrationReceipt: Sendable, Hashable { let record: OfflineCollectionRecord let summary: OfflineDailySummary } /// 线下收款 Mock 流程可识别的业务异常。 enum OfflineCollectionError: LocalizedError, Sendable, Equatable { case invalidAmount case createFailed case noPendingRecords case summaryMismatch case recordsChanged case settlementFailed case persistenceFailed /// 供页面展示的中文错误提示。 var errorDescription: String? { switch self { case .invalidAmount: "请输入0.01~99,999.99元的有效金额" case .createFailed: "登记失败,请重试" case .noPendingRecords: "当前营业日无待补缴记录" case .summaryMismatch: "金额汇总异常,请刷新数据" case .recordsChanged: "待补缴记录已变化,请重新确认" case .settlementFailed: "补缴失败,请重试" case .persistenceFailed: "本地数据保存失败,请重试" } } } /// 金额输入、整数分换算和展示的统一工具。 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 } let wholeCount = parts.first?.count ?? 0 let fractionCount = parts.count == 2 ? parts[1].count : 0 return wholeCount <= 5 && fractionCount <= 2 } /// 将用户输入精确转换为整数“分”,不经过浮点计算。 static func parseFen(_ rawValue: String) -> Int? { let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil } let parts = text.split(separator: ".", omittingEmptySubsequences: false) let wholeText = parts[0].isEmpty ? "0" : String(parts[0]) let fractionText = parts.count == 2 ? String(parts[1]) : "" guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber), let whole = Int(wholeText) else { return nil } let cents: Int switch fractionText.count { case 0: cents = 0 case 1: cents = (Int(fractionText) ?? 0) * 10 case 2: cents = Int(fractionText) ?? 0 default: return nil } let amountFen = whole * 100 + cents guard amountFen > 0, amountFen <= maximumFen else { return nil } return amountFen } /// 将整数分格式化为带人民币符号且固定两位小数的文本。 static func display(_ amountFen: Int) -> String { let sign = amountFen < 0 ? "-" : "" let absolute = abs(amountFen) return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))" } } /// 营业日与页面时间文案的统一格式化工具。 enum OfflineCollectionDate { static let dailyCutoffTime = "23:59:59" /// 按指定日历生成 YYYY-MM-DD 营业日。 static func businessDate(for date: Date, calendar: Calendar = .current) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) return String( format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0 ) } /// 将营业日文本解析为当地日期。 static func date(from businessDate: String, calendar: Calendar = .current) -> 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: businessDate) } /// 将时间格式化为当日明细所需的 HH:mm。 static func timeText(_ date: Date, calendar: Calendar = .current) -> String { let formatter = DateFormatter() formatter.calendar = calendar formatter.locale = Locale(identifier: "zh_CN") formatter.timeZone = calendar.timeZone formatter.dateFormat = "HH:mm" return formatter.string(from: date) } /// 将时间格式化为补缴流水的完整时间。 static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String { let formatter = DateFormatter() formatter.calendar = calendar formatter.locale = Locale(identifier: "zh_CN") formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd HH:mm" return formatter.string(from: date) } } private extension String { /// 去除首尾空白后的非空文本。 var nonEmptyValue: String? { let value = trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } }