feat: 完成9.7线下收款登记与日清
This commit is contained in:
@@ -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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user