360 lines
16 KiB
Swift
360 lines
16 KiB
Swift
//
|
|
// 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<String> = 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)
|
|
}
|
|
}
|