import Foundation /// 收款首页线下收款区域的加载状态与业务数据。 final class OfflineCollectionHomeViewModel { private(set) var statistics: OfflineCollectionStatisticsResponse? private(set) var isLoading = false private(set) var errorMessage: String? let context: OfflineCollectionContext private let api: any OfflineCollectionServing var onStateChange: (() -> Void)? init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { self.context = context self.api = api } var todayBusinessDate: String? { statistics?.today.date } var todaySummary: OfflineDailySummary { statistics?.todaySummary ?? .empty(todayBusinessDate ?? "") } var overdueDates: [OfflinePendingDate] { guard let statistics else { return [] } return statistics.pending.dates.filter { $0.date < statistics.today.date } } var earliestOverdueBusinessDate: String? { overdueDates.first?.date } var overdueRecordCount: Int { overdueDates.reduce(0) { $0 + $1.unpaidCount } } var overdueAmountFen: Int { overdueDates.reduce(0) { $0 + $1.unpaidAmountFen } } /// 从真实统计接口刷新首页卡片。 func load() async { guard context.scenicId > 0 else { errorMessage = OfflineCollectionError.missingScenic.localizedDescription onStateChange?() return } isLoading = true errorMessage = nil onStateChange?() do { statistics = try await api.statistics(scenicId: context.scenicId) } catch { errorMessage = error.localizedDescription } isLoading = false onStateChange?() } } /// 线下收款登记页 ViewModel,负责表单校验和登记后的统计刷新。 final class OfflineCollectionRegistrationViewModel { private(set) var amountText = "" private(set) var paymentMethod: OfflineCollectionPaymentMethod = .wechat private(set) var isSubmitting = false let context: OfflineCollectionContext let api: any OfflineCollectionServing var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)? init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { self.context = context self.api = api } var canSubmit: Bool { !isSubmitting && context.scenicId > 0 && OfflineCollectionMoney.parseFen(amountText) != nil } func updateAmount(_ value: String) { guard OfflineCollectionMoney.acceptsEditingText(value) else { return } guard value != amountText else { return } amountText = value onStateChange?() } func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) { guard method != paymentMethod else { return } paymentMethod = method onStateChange?() } /// 按后端现有契约提交一笔线下收款。 func submit() async { guard !isSubmitting else { return } guard context.scenicId > 0 else { onShowMessage?(OfflineCollectionError.missingScenic.localizedDescription) return } guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else { onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription) return } isSubmitting = true onStateChange?() defer { isSubmitting = false onStateChange?() } do { let response = try await api.register(OfflineCollectionRegisterRequest( scenicId: context.scenicId, amount: OfflineCollectionMoney.apiAmount(amountFen), payMethod: paymentMethod.rawValue )) let statistics = try? await api.statistics(scenicId: context.scenicId) let responseDate = String(response.createdAt.prefix(10)) onRegistrationSuccess?(OfflineCollectionRegistrationReceipt( record: response.record, businessDate: statistics?.today.date ?? responseDate, summary: statistics?.todaySummary )) } catch { onShowMessage?(error.localizedDescription) } } func startAnotherRegistration() { amountText = "" onStateChange?() } } /// 日清页的补缴呈现状态。 enum OfflineSettlementPresentationState: Sendable, Equatable { case idle case processing case success(OfflineSettlementResult) case failed(String, canRetry: Bool) } /// 日清页 ViewModel,管理服务端营业日、日期标记、详情请求与补缴快照。 final class OfflineCollectionDailyViewModel { private(set) var businessDate: String private(set) var serverToday: String private(set) var summary: OfflineDailySummary private(set) var records: [OfflineCollectionRecord] = [] private(set) var pendingDates: Set = [] private(set) var isLoading = false private(set) var errorMessage: String? private(set) var settlementState: OfflineSettlementPresentationState = .idle private(set) var preparedRequest: OfflineSettlementRequest? let context: OfflineCollectionContext private let api: any OfflineCollectionServing private var detailRequestVersion = 0 var onStateChange: (() -> Void)? init(businessDate: String, context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { self.businessDate = businessDate serverToday = businessDate summary = .empty(businessDate) self.context = context self.api = api } var isToday: Bool { businessDate == serverToday } var canSettle: Bool { summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing && !isLoading } /// 首次进入或页面重新出现时刷新统计和当前详情。 func refresh() async { guard context.scenicId > 0 else { errorMessage = OfflineCollectionError.missingScenic.localizedDescription onStateChange?() return } isLoading = true errorMessage = nil onStateChange?() do { let statistics = try await api.statistics(scenicId: context.scenicId) serverToday = statistics.today.date pendingDates = Set(statistics.pending.dates.map(\.date)) if businessDate.isEmpty || businessDate > serverToday { businessDate = serverToday } await loadDetails(for: businessDate) } catch { isLoading = false errorMessage = error.localizedDescription onStateChange?() } } /// 选择任意不晚于服务端今日的日期,并只接受最后一次请求结果。 func selectBusinessDate(_ date: String) async { guard date <= serverToday, settlementState != .processing else { return } businessDate = date preparedRequest = nil settlementState = .idle await loadDetails(for: date) } private func loadDetails(for date: String) async { detailRequestVersion += 1 let version = detailRequestVersion isLoading = true errorMessage = nil records = [] summary = .empty(date) onStateChange?() do { let details = try await api.details(scenicId: context.scenicId, date: date) guard version == detailRequestVersion, date == businessDate else { return } summary = details.summary records = details.records(serverToday: serverToday) isLoading = false onStateChange?() } catch is CancellationError { return } catch { guard version == detailRequestVersion, date == businessDate else { return } isLoading = false errorMessage = error.localizedDescription onStateChange?() } } func prepareSettlement() throws -> OfflineSettlementRequest { guard canSettle else { throw OfflineCollectionError.noPendingRecords } let request = OfflineSettlementRequest( scenicId: context.scenicId, businessDate: businessDate, amountFen: summary.pendingAmountFen, pendingCount: summary.pendingCount ) preparedRequest = request return request } func cancelPreparedSettlement() { guard settlementState != .processing else { return } preparedRequest = nil } /// 按后端现有契约补缴指定日期;失败时允许用户再次提交。 func confirmSettlement() async { guard settlementState != .processing, let request = preparedRequest else { return } settlementState = .processing onStateChange?() do { let result = try await api.supplement(request) preparedRequest = nil settlementState = .success(result) await refreshAfterSettlement() } catch { settlementState = .failed(error.localizedDescription, canRetry: true) onStateChange?() } } private func refreshAfterSettlement() async { if let statistics = try? await api.statistics(scenicId: context.scenicId) { serverToday = statistics.today.date pendingDates = Set(statistics.pending.dates.map(\.date)) } detailRequestVersion += 1 let version = detailRequestVersion if let details = try? await api.details(scenicId: context.scenicId, date: businessDate), version == detailRequestVersion { summary = details.summary records = details.records(serverToday: serverToday) } isLoading = false onStateChange?() } func clearSettlementFeedback() { guard settlementState != .processing else { return } settlementState = .idle onStateChange?() } }