feat: 完成9.7线下收款登记与日清

This commit is contained in:
2026-08-26 09:53:48 +08:00
parent dca4bd5a20
commit 825a0448cc
24 changed files with 2663 additions and 6 deletions
@@ -0,0 +1,48 @@
import Foundation
/// 线下收款接口抽象,供生产 API 与测试替身共用。
@MainActor
protocol OfflineCollectionServing: AnyObject {
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult
}
/// 线下收款真实网络 API。
@MainActor
final class OfflineCollectionAPI: OfflineCollectionServing {
private let client: APIClient
private let prefix = "/api/yf-handset-app/photog/offline-pay-collect"
init(client: APIClient) {
self.client = client
}
func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse {
try await client.send(APIRequest(
method: .get,
path: "\(prefix)/statistics",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
))
}
func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse {
try await client.send(APIRequest(
method: .get,
path: "\(prefix)/details",
queryItems: [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "date", value: date),
]
))
}
func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse {
try await client.send(APIRequest(method: .post, path: "\(prefix)/register", body: request))
}
func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult {
try await client.send(APIRequest(method: .post, path: "\(prefix)/supplement", body: request))
}
}
@@ -0,0 +1,82 @@
import Foundation
/// 日清日历的展示模式。
enum OfflineCollectionCalendarMode: Sendable, Equatable {
case week
case month
}
/// 周/月日历日期运算状态,不依赖 UIKit,便于单元测试。
struct OfflineCollectionCalendarState: Sendable, Equatable {
private let calendar: Calendar
private(set) var selectedDate: Date
private(set) var maximumDate: Date
private(set) var mode: OfflineCollectionCalendarMode
init(
selectedDate: Date,
maximumDate: Date,
mode: OfflineCollectionCalendarMode = .week,
calendar: Calendar = OfflineCollectionDate.calendar
) {
self.calendar = calendar
self.maximumDate = calendar.startOfDay(for: maximumDate)
self.selectedDate = min(calendar.startOfDay(for: selectedDate), self.maximumDate)
self.mode = mode
}
/// 切换周历和月历并保留选中日期。
mutating func toggleMode() {
mode = mode == .week ? .month : .week
}
/// 选择不晚于服务端今日的日期。
mutating func select(_ date: Date) -> Bool {
let value = calendar.startOfDay(for: date)
guard value <= maximumDate else { return false }
selectedDate = value
return true
}
/// 横滑一个周或一个月,并返回新的选中日期。
@discardableResult
mutating func movePage(_ offset: Int) -> Date {
let candidate: Date
switch mode {
case .week:
candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
case .month:
let current = calendar.dateComponents([.year, .month, .day], from: selectedDate)
let monthStart = calendar.date(from: DateComponents(year: current.year, month: current.month, day: 1)) ?? selectedDate
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) ?? monthStart
let days = calendar.range(of: .day, in: .month, for: targetMonth)?.count ?? 1
candidate = calendar.date(byAdding: .day, value: min(current.day ?? 1, days) - 1, to: targetMonth) ?? targetMonth
}
selectedDate = min(calendar.startOfDay(for: candidate), maximumDate)
return selectedDate
}
/// 当前周的周一至周日。
var weekDates: [Date] {
let weekday = calendar.component(.weekday, from: selectedDate)
let daysFromMonday = (weekday + 5) % 7
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: selectedDate) ?? selectedDate
return (0 ..< 7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
}
/// 当前月份固定六行、周一开周的 42 个日期。
var monthDates: [Date] {
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
let first = calendar.date(from: parts) ?? selectedDate
let weekday = calendar.component(.weekday, from: first)
let leading = (weekday + 5) % 7
let start = calendar.date(byAdding: .day, value: -leading, to: first) ?? first
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
}
/// 当前选中月份标题。
var monthTitle: String {
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
return "\(parts.year ?? 0)年\(parts.month ?? 0)月"
}
}
@@ -0,0 +1,369 @@
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 }
}
@@ -0,0 +1,275 @@
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<String> = []
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?()
}
}