// // OfflineCollectionMockService.swift // suixinkan // import Foundation /// 线下收款本地 Mock 服务,负责演示数据、持久化、逾期流转与幂等补缴。 final class OfflineCollectionMockService: @unchecked Sendable { /// App 内共享的线下收款 Mock 数据源。 static let shared = OfflineCollectionMockService() /// 用于本地持久化的数据快照。 private struct Snapshot: Codable, Equatable { var records: [OfflineCollectionRecord] var batches: [OfflineSettlementBatch] } private let defaults: UserDefaults private let calendar: Calendar private let nowProvider: @Sendable () -> Date private let requestDelayNanoseconds: UInt64 private let lock = NSLock() private var shouldFailNextCreation = false private var shouldFailNextSettlement = false /// 创建 Mock 服务,测试可注入独立 UserDefaults、时间与请求延迟。 init( defaults: UserDefaults = .standard, calendar: Calendar = .current, nowProvider: @escaping @Sendable () -> Date = Date.init, requestDelayNanoseconds: UInt64 = 450_000_000 ) { self.defaults = defaults self.calendar = calendar self.nowProvider = nowProvider self.requestDelayNanoseconds = requestDelayNanoseconds } /// 当前设备时区下的今日营业日。 var todayBusinessDate: String { OfflineCollectionDate.businessDate(for: nowProvider(), calendar: calendar) } /// 获取某营业日汇总。 func getDailySummary(date: String, context: OfflineCollectionContext) -> OfflineDailySummary { lock.withLock { var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) let records = matchingRecords(snapshot.records, date: date, context: context) return OfflineDailySummary.make(businessDate: date, records: records) } } /// 获取某营业日明细,按登记时间倒序返回。 func getDailyRecords(date: String, context: OfflineCollectionContext) -> [OfflineCollectionRecord] { lock.withLock { var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) return matchingRecords(snapshot.records, date: date, context: context) .sorted { $0.registeredAt > $1.registeredAt } } } /// 获取某营业日的成功补缴流水。 func getSettlementBatches(date: String, context: OfflineCollectionContext) -> [OfflineSettlementBatch] { lock.withLock { let snapshot = loadSnapshotLocked(context: context) return snapshot.batches .filter { batch in batch.businessDate == date && batch.collectorId == context.collectorId && batch.storeId == context.storeId && batch.scenicId == context.scenicId } .sorted { $0.paidAt > $1.paidAt } } } /// 获取当前用户与店铺的历史逾期营业日,按日期升序方便先处理最早欠款。 func getOverdueSummaries(context: OfflineCollectionContext) -> [OfflineDailySummary] { lock.withLock { var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) let overdueDates: Set = Set(snapshot.records.compactMap { record -> String? in guard matches(record, context: context), record.status == .overdue else { return nil } return record.businessDate }) return overdueDates.sorted().map { date in OfflineDailySummary.make( businessDate: date, records: matchingRecords(snapshot.records, date: date, context: context) ) } } } /// 获取可查看的营业日,始终包含今日。 func getAvailableBusinessDates(context: OfflineCollectionContext) -> [String] { lock.withLock { var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) var dates = Set(snapshot.records.filter { matches($0, context: context) }.map(\.businessDate)) dates.insert(todayBusinessDate) return dates.sorted(by: >) } } /// 新建一条待补缴线下收款记录,不触发任何订单流程。 func createOfflineCollection( amountFen: Int, paymentMethod: OfflineCollectionPaymentMethod, context: OfflineCollectionContext ) async throws -> OfflineCollectionRegistrationReceipt { guard amountFen > 0, amountFen <= OfflineCollectionMoney.maximumFen else { throw OfflineCollectionError.invalidAmount } await simulateRequestDelay() return try lock.withLock { if shouldFailNextCreation { shouldFailNextCreation = false throw OfflineCollectionError.createFailed } var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) let now = nowProvider() let businessDate = OfflineCollectionDate.businessDate(for: now, calendar: calendar) let sequence = snapshot.records.filter { $0.businessDate == businessDate }.count + 1 let record = OfflineCollectionRecord( id: String(format: "OCR%@%04d", businessDate.replacingOccurrences(of: "-", with: ""), sequence), collectorId: context.collectorId, collectorName: context.collectorName, storeId: context.storeId, storeName: context.storeName, scenicId: context.scenicId, scenicName: context.scenicName, amountFen: amountFen, paymentMethod: paymentMethod, registeredAt: now, receivedAt: now, businessDate: businessDate, status: .pending, settlementBatchId: nil, settledAt: nil ) snapshot.records.append(record) try saveSnapshotLocked(snapshot, context: context) let summary = OfflineDailySummary.make( businessDate: businessDate, records: matchingRecords(snapshot.records, date: businessDate, context: context) ) return OfflineCollectionRegistrationReceipt(record: record, summary: summary) } } /// 按确认时冻结的记录快照执行全额补缴,同一请求标识只生成一条流水。 func settleDailyCollections( request: OfflineSettlementRequest, context: OfflineCollectionContext ) async throws -> OfflineSettlementBatch { await simulateRequestDelay() return try lock.withLock { var snapshot = loadSnapshotLocked(context: context) refreshOverdueLocked(snapshot: &snapshot, context: context) if let existing = snapshot.batches.first(where: { $0.clientRequestId == request.clientRequestId }) { return existing } if shouldFailNextSettlement { shouldFailNextSettlement = false throw OfflineCollectionError.settlementFailed } let requestedIds = Set(request.recordIds) guard !requestedIds.isEmpty, requestedIds.count == request.recordIds.count else { throw OfflineCollectionError.noPendingRecords } let pendingRecords = snapshot.records.filter { record in requestedIds.contains(record.id) && matches(record, context: context) && record.businessDate == request.businessDate && (record.status == .pending || record.status == .overdue) } guard pendingRecords.count == request.recordIds.count else { throw OfflineCollectionError.recordsChanged } let calculatedAmount = pendingRecords.reduce(0) { $0 + $1.amountFen } guard calculatedAmount == request.amountFen else { throw OfflineCollectionError.summaryMismatch } let now = nowProvider() let batchSequence = snapshot.batches.filter { $0.businessDate == request.businessDate }.count + 1 let batchId = String( format: "OCS%@%04d", request.businessDate.replacingOccurrences(of: "-", with: ""), batchSequence ) let batch = OfflineSettlementBatch( id: batchId, businessDate: request.businessDate, collectorId: context.collectorId, collectorName: context.collectorName, storeId: context.storeId, storeName: context.storeName, scenicId: context.scenicId, scenicName: context.scenicName, recordIds: request.recordIds, recordCount: request.recordIds.count, amountFen: request.amountFen, paidAt: now, payerId: context.collectorId, payerName: context.collectorName, clientRequestId: request.clientRequestId ) // 流水与记录在同一份本地快照内一次性更新,避免出现金额已减但流水缺失。 snapshot.batches.append(batch) for index in snapshot.records.indices where requestedIds.contains(snapshot.records[index].id) { snapshot.records[index].status = .settled snapshot.records[index].settlementBatchId = batch.id snapshot.records[index].settledAt = now } try saveSnapshotLocked(snapshot, context: context) return batch } } /// 开发环境中使下一次登记失败,用于验收输入保留和重试。 func simulateNextCreationFailure() { lock.withLock { shouldFailNextCreation = true } } /// 开发环境中使下一次补缴失败,失败时不改变记录和流水。 func simulateNextSettlementFailure() { lock.withLock { shouldFailNextSettlement = true } } /// 将当前上下文恢复为文档约定的默认演示数据。 func resetOfflineCollectionMockData(context: OfflineCollectionContext) { lock.withLock { let snapshot = makeDefaultSnapshot(context: context) try? saveSnapshotLocked(snapshot, context: context) shouldFailNextCreation = false shouldFailNextSettlement = false } } private func matchingRecords( _ records: [OfflineCollectionRecord], date: String, context: OfflineCollectionContext ) -> [OfflineCollectionRecord] { records.filter { $0.businessDate == date && matches($0, context: context) } } private func matches(_ record: OfflineCollectionRecord, context: OfflineCollectionContext) -> Bool { record.collectorId == context.collectorId && record.storeId == context.storeId && record.scenicId == context.scenicId } private func refreshOverdueLocked(snapshot: inout Snapshot, context: OfflineCollectionContext) { let today = todayBusinessDate var changed = false for index in snapshot.records.indices where matches(snapshot.records[index], context: context) { guard snapshot.records[index].status != .settled else { continue } let expected: OfflineCollectionStatus = snapshot.records[index].businessDate < today ? .overdue : .pending if snapshot.records[index].status != expected { snapshot.records[index].status = expected changed = true } } if changed { try? saveSnapshotLocked(snapshot, context: context) } } private func loadSnapshotLocked(context: OfflineCollectionContext) -> Snapshot { let key = storageKey(context: context) guard let data = defaults.data(forKey: key) else { let snapshot = makeDefaultSnapshot(context: context) try? saveSnapshotLocked(snapshot, context: context) return snapshot } do { return try JSONDecoder().decode(Snapshot.self, from: data) } catch { #if DEBUG print("线下收款 Mock 数据损坏,已恢复默认数据:\(error)") #endif let snapshot = makeDefaultSnapshot(context: context) try? saveSnapshotLocked(snapshot, context: context) return snapshot } } private func saveSnapshotLocked(_ snapshot: Snapshot, context: OfflineCollectionContext) throws { do { defaults.set(try JSONEncoder().encode(snapshot), forKey: storageKey(context: context)) } catch { throw OfflineCollectionError.persistenceFailed } } private func storageKey(context: OfflineCollectionContext) -> String { "offline_collection_mock_v1_\(context.storageScope)" } private func makeDefaultSnapshot(context: OfflineCollectionContext) -> Snapshot { let todayDate = nowProvider() let today = OfflineCollectionDate.businessDate(for: todayDate, calendar: calendar) let previousDate = calendar.date(byAdding: .day, value: -1, to: todayDate) ?? todayDate let previousBusinessDate = OfflineCollectionDate.businessDate(for: previousDate, calendar: calendar) let samples: [(String, Int, OfflineCollectionPaymentMethod, Date, OfflineCollectionStatus)] = [ ("OCR\(today.replacingOccurrences(of: "-", with: ""))0001", 29_900, .wechat, time(on: todayDate, hour: 10, minute: 21), .pending), ("OCR\(today.replacingOccurrences(of: "-", with: ""))0002", 39_900, .alipay, time(on: todayDate, hour: 14, minute: 16), .pending), ("OCR\(today.replacingOccurrences(of: "-", with: ""))0003", 19_900, .wechat, time(on: todayDate, hour: 17, minute: 42), .pending), ("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0001", 30_000, .cash, time(on: previousDate, hour: 16, minute: 10), .overdue), ("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0002", 20_000, .wechat, time(on: previousDate, hour: 19, minute: 32), .overdue), ] let records = samples.map { sample in let businessDate = sample.3 < calendar.startOfDay(for: todayDate) ? previousBusinessDate : today return OfflineCollectionRecord( id: sample.0, collectorId: context.collectorId, collectorName: context.collectorName, storeId: context.storeId, storeName: context.storeName, scenicId: context.scenicId, scenicName: context.scenicName, amountFen: sample.1, paymentMethod: sample.2, registeredAt: sample.3, receivedAt: sample.3, businessDate: businessDate, status: sample.4, settlementBatchId: nil, settledAt: nil ) } return Snapshot(records: records, batches: []) } private func time(on date: Date, hour: Int, minute: Int) -> Date { calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date) ?? date } private func simulateRequestDelay() async { guard requestDelayNanoseconds > 0 else { return } try? await Task.sleep(nanoseconds: requestDelayNanoseconds) } }