新增线下收款记录和 ai 修图优化
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//
|
||||
// OfflineCollectionModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 线下收款方式,与线上订单支付方式保持独立。
|
||||
enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable {
|
||||
case wechat = "WECHAT"
|
||||
case alipay = "ALIPAY"
|
||||
case cash = "CASH"
|
||||
|
||||
/// 页面展示的中文名称。
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .wechat: "微信"
|
||||
case .alipay: "支付宝"
|
||||
case .cash: "现金"
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面使用的本地矢量图标资源名称。
|
||||
var assetName: String {
|
||||
switch self {
|
||||
case .wechat: "payment_method_wechat"
|
||||
case .alipay: "payment_method_alipay"
|
||||
case .cash: "payment_method_cash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 线下收款记录的补缴状态。
|
||||
enum OfflineCollectionStatus: String, Codable, Sendable, Hashable {
|
||||
case pending = "PENDING"
|
||||
case settled = "SETTLED"
|
||||
case overdue = "OVERDUE"
|
||||
|
||||
/// 状态对应的中文文案。
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .pending: "待补缴"
|
||||
case .settled: "已补缴"
|
||||
case .overdue: "逾期未补缴"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前线下收款数据所属的用户、店铺与景区上下文。
|
||||
struct OfflineCollectionContext: Codable, Sendable, Hashable {
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
|
||||
/// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。
|
||||
var storageScope: String {
|
||||
[collectorId, storeId, scenicId]
|
||||
.map { value in
|
||||
value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined()
|
||||
}
|
||||
.joined(separator: "_")
|
||||
}
|
||||
|
||||
/// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。
|
||||
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
|
||||
let session = appStore.session
|
||||
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
|
||||
let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
|
||||
let collectorName = [session.realName, session.userName, session.accountDisplayName]
|
||||
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
return OfflineCollectionContext(
|
||||
collectorId: session.userId.nonEmptyValue ?? "U10086",
|
||||
collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三",
|
||||
storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"),
|
||||
storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店",
|
||||
scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001",
|
||||
scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 独立的线下收款记录,不包含任何订单字段。
|
||||
struct OfflineCollectionRecord: Codable, Sendable, Hashable {
|
||||
let id: String
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
let amountFen: Int
|
||||
let paymentMethod: OfflineCollectionPaymentMethod
|
||||
let registeredAt: Date
|
||||
let receivedAt: Date
|
||||
let businessDate: String
|
||||
var status: OfflineCollectionStatus
|
||||
var settlementBatchId: String?
|
||||
var settledAt: Date?
|
||||
}
|
||||
|
||||
/// 线下收款的独立补缴流水,仅关联本批次收款记录快照。
|
||||
struct OfflineSettlementBatch: Codable, Sendable, Hashable {
|
||||
let id: String
|
||||
let businessDate: String
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
let recordIds: [String]
|
||||
let recordCount: Int
|
||||
let amountFen: Int
|
||||
let paidAt: Date
|
||||
let payerId: String
|
||||
let payerName: String
|
||||
let clientRequestId: String
|
||||
|
||||
/// 流水类型固定为线下收款补缴。
|
||||
var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" }
|
||||
}
|
||||
|
||||
/// 某一营业日的线下收款汇总。
|
||||
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
|
||||
let hasOverdue: Bool
|
||||
|
||||
/// 按记录状态用整数“分”生成日汇总。
|
||||
static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary {
|
||||
let settled = records.filter { $0.status == .settled }
|
||||
let pending = records.filter { $0.status == .pending || $0.status == .overdue }
|
||||
return OfflineDailySummary(
|
||||
businessDate: businessDate,
|
||||
totalCount: records.count,
|
||||
totalAmountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
settledCount: settled.count,
|
||||
settledAmountFen: settled.reduce(0) { $0 + $1.amountFen },
|
||||
pendingCount: pending.count,
|
||||
pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen },
|
||||
hasOverdue: pending.contains { $0.status == .overdue }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。
|
||||
struct OfflineSettlementRequest: Sendable, Hashable {
|
||||
let businessDate: String
|
||||
let recordIds: [String]
|
||||
let amountFen: Int
|
||||
let clientRequestId: String
|
||||
}
|
||||
|
||||
/// 登记成功后页面反馈所需的最小数据。
|
||||
struct OfflineCollectionRegistrationReceipt: Sendable, Hashable {
|
||||
let record: OfflineCollectionRecord
|
||||
let summary: OfflineDailySummary
|
||||
}
|
||||
|
||||
/// 线下收款 Mock 流程可识别的业务异常。
|
||||
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
|
||||
case invalidAmount
|
||||
case createFailed
|
||||
case noPendingRecords
|
||||
case summaryMismatch
|
||||
case recordsChanged
|
||||
case settlementFailed
|
||||
case persistenceFailed
|
||||
|
||||
/// 供页面展示的中文错误提示。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
|
||||
case .createFailed: "登记失败,请重试"
|
||||
case .noPendingRecords: "当前营业日无待补缴记录"
|
||||
case .summaryMismatch: "金额汇总异常,请刷新数据"
|
||||
case .recordsChanged: "待补缴记录已变化,请重新确认"
|
||||
case .settlementFailed: "补缴失败,请重试"
|
||||
case .persistenceFailed: "本地数据保存失败,请重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 金额输入、整数分换算和展示的统一工具。
|
||||
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 }
|
||||
let wholeCount = parts.first?.count ?? 0
|
||||
let fractionCount = parts.count == 2 ? parts[1].count : 0
|
||||
return wholeCount <= 5 && fractionCount <= 2
|
||||
}
|
||||
|
||||
/// 将用户输入精确转换为整数“分”,不经过浮点计算。
|
||||
static func parseFen(_ rawValue: String) -> Int? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil }
|
||||
|
||||
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
|
||||
let fractionText = parts.count == 2 ? String(parts[1]) : ""
|
||||
guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber),
|
||||
let whole = Int(wholeText) else { return nil }
|
||||
|
||||
let cents: Int
|
||||
switch fractionText.count {
|
||||
case 0: cents = 0
|
||||
case 1: cents = (Int(fractionText) ?? 0) * 10
|
||||
case 2: cents = Int(fractionText) ?? 0
|
||||
default: return nil
|
||||
}
|
||||
let amountFen = whole * 100 + cents
|
||||
guard amountFen > 0, amountFen <= maximumFen else { return nil }
|
||||
return amountFen
|
||||
}
|
||||
|
||||
/// 将整数分格式化为带人民币符号且固定两位小数的文本。
|
||||
static func display(_ amountFen: Int) -> String {
|
||||
let sign = amountFen < 0 ? "-" : ""
|
||||
let absolute = abs(amountFen)
|
||||
return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))"
|
||||
}
|
||||
}
|
||||
|
||||
/// 营业日与页面时间文案的统一格式化工具。
|
||||
enum OfflineCollectionDate {
|
||||
static let dailyCutoffTime = "23:59:59"
|
||||
|
||||
/// 按指定日历生成 YYYY-MM-DD 营业日。
|
||||
static func businessDate(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(
|
||||
format: "%04d-%02d-%02d",
|
||||
components.year ?? 0,
|
||||
components.month ?? 0,
|
||||
components.day ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
/// 将营业日文本解析为当地日期。
|
||||
static func date(from businessDate: String, calendar: Calendar = .current) -> 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: businessDate)
|
||||
}
|
||||
|
||||
/// 将时间格式化为当日明细所需的 HH:mm。
|
||||
static func timeText(_ date: Date, calendar: Calendar = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// 将时间格式化为补缴流水的完整时间。
|
||||
static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除首尾空白后的非空文本。
|
||||
var nonEmptyValue: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// OfflineCollectionViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款首页线下收款区域的 ViewModel。
|
||||
final class OfflineCollectionHomeViewModel {
|
||||
private(set) var todaySummary: OfflineDailySummary
|
||||
private(set) var overdueSummaries: [OfflineDailySummary] = []
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 使用当前账号上下文创建首页 ViewModel。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.context = context
|
||||
self.service = service
|
||||
todaySummary = OfflineDailySummary.make(businessDate: service.todayBusinessDate, records: [])
|
||||
load()
|
||||
}
|
||||
|
||||
/// 当前今日营业日。
|
||||
var todayBusinessDate: String { service.todayBusinessDate }
|
||||
|
||||
/// 最早一个需要处理的逾期营业日。
|
||||
var earliestOverdueBusinessDate: String? { overdueSummaries.first?.businessDate }
|
||||
|
||||
/// 历史逾期记录的合计笔数。
|
||||
var overdueRecordCount: Int { overdueSummaries.reduce(0) { $0 + $1.pendingCount } }
|
||||
|
||||
/// 历史逾期记录的合计待补缴金额。
|
||||
var overdueAmountFen: Int { overdueSummaries.reduce(0) { $0 + $1.pendingAmountFen } }
|
||||
|
||||
/// 从本地 Mock 服务重新读取今日和逾期汇总。
|
||||
func load() {
|
||||
todaySummary = service.getDailySummary(date: todayBusinessDate, context: context)
|
||||
overdueSummaries = service.getOverdueSummaries(context: context)
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 线下收款登记页 ViewModel,管理表单校验、提交防重和成功反馈。
|
||||
final class OfflineCollectionRegistrationViewModel {
|
||||
private(set) var amountText = ""
|
||||
private(set) var paymentMethod: OfflineCollectionPaymentMethod? = .wechat
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var receipt: OfflineCollectionRegistrationReceipt?
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
|
||||
|
||||
/// 使用指定收款上下文和 Mock 服务创建登记 ViewModel。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.context = context
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// 当前金额和收款方式是否允许提交。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting && OfflineCollectionMoney.parseFen(amountText) != nil && paymentMethod != nil
|
||||
}
|
||||
|
||||
/// 更新金额输入,仅接受数字、单个小数点和最多两位小数。
|
||||
func updateAmount(_ value: String) {
|
||||
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
|
||||
amountText = value
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 选择线下收款方式。
|
||||
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
|
||||
paymentMethod = method
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 提交一笔线下收款登记,提交中的重复调用会被忽略。
|
||||
func submit() async {
|
||||
guard !isSubmitting else { return }
|
||||
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
|
||||
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let paymentMethod else {
|
||||
onShowMessage?("请选择收款方式")
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
onStateChange?()
|
||||
defer {
|
||||
isSubmitting = false
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await service.createOfflineCollection(
|
||||
amountFen: amountFen,
|
||||
paymentMethod: paymentMethod,
|
||||
context: context
|
||||
)
|
||||
receipt = result
|
||||
onRegistrationSuccess?(result)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记成功后开始下一笔,清空金额并保留本次收款方式。
|
||||
func startAnotherRegistration() {
|
||||
amountText = ""
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 日清页的补缴处理状态。
|
||||
enum OfflineSettlementPresentationState: Sendable, Equatable {
|
||||
case idle
|
||||
case processing
|
||||
case success(OfflineSettlementBatch)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// 线下收款日清页 ViewModel,负责单营业日汇总、快照冻结和全额补缴。
|
||||
final class OfflineCollectionDailyViewModel {
|
||||
private(set) var businessDate: String
|
||||
private(set) var summary: OfflineDailySummary
|
||||
private(set) var records: [OfflineCollectionRecord] = []
|
||||
private(set) var batches: [OfflineSettlementBatch] = []
|
||||
private(set) var availableBusinessDates: [String] = []
|
||||
private(set) var settlementState: OfflineSettlementPresentationState = .idle
|
||||
private(set) var preparedRequest: OfflineSettlementRequest?
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 使用指定营业日、收款上下文和 Mock 服务创建日清 ViewModel。
|
||||
init(
|
||||
businessDate: String,
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.businessDate = businessDate
|
||||
self.context = context
|
||||
self.service = service
|
||||
summary = OfflineDailySummary.make(businessDate: businessDate, records: [])
|
||||
load()
|
||||
}
|
||||
|
||||
/// 当前查看的是否为今日营业日。
|
||||
var isToday: Bool { businessDate == service.todayBusinessDate }
|
||||
|
||||
/// 当前是否可以发起补缴。
|
||||
var canSettle: Bool {
|
||||
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing
|
||||
}
|
||||
|
||||
/// 从本地 Mock 服务加载当前营业日的全部数据。
|
||||
func load() {
|
||||
records = service.getDailyRecords(date: businessDate, context: context)
|
||||
summary = service.getDailySummary(date: businessDate, context: context)
|
||||
batches = service.getSettlementBatches(date: businessDate, context: context)
|
||||
availableBusinessDates = service.getAvailableBusinessDates(context: context)
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 切换至已存在数据或今日的营业日。
|
||||
func selectBusinessDate(_ date: String) {
|
||||
guard availableBusinessDates.contains(date), settlementState != .processing else { return }
|
||||
businessDate = date
|
||||
preparedRequest = nil
|
||||
settlementState = .idle
|
||||
load()
|
||||
}
|
||||
|
||||
/// 按页面当前待补缴记录冻结一份不可编辑的补缴快照。
|
||||
func prepareSettlement() throws -> OfflineSettlementRequest {
|
||||
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
|
||||
let pendingRecords = records.filter { $0.status == .pending || $0.status == .overdue }
|
||||
let amountFen = pendingRecords.reduce(0) { $0 + $1.amountFen }
|
||||
guard pendingRecords.count == summary.pendingCount, amountFen == summary.pendingAmountFen else {
|
||||
throw OfflineCollectionError.summaryMismatch
|
||||
}
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: businessDate,
|
||||
recordIds: pendingRecords.map(\.id),
|
||||
amountFen: amountFen,
|
||||
clientRequestId: "LOCAL-\(UUID().uuidString.uppercased())"
|
||||
)
|
||||
preparedRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
/// 取消未提交的补缴确认快照。
|
||||
func cancelPreparedSettlement() {
|
||||
guard settlementState != .processing else { return }
|
||||
preparedRequest = nil
|
||||
}
|
||||
|
||||
/// 提交已冻结的补缴快照,失败后保留快照以便原样重试。
|
||||
func confirmSettlement() async {
|
||||
guard settlementState != .processing else { return }
|
||||
guard let request = preparedRequest else {
|
||||
settlementState = .failed(OfflineCollectionError.noPendingRecords.localizedDescription)
|
||||
onStateChange?()
|
||||
return
|
||||
}
|
||||
|
||||
settlementState = .processing
|
||||
onStateChange?()
|
||||
do {
|
||||
let batch = try await service.settleDailyCollections(request: request, context: context)
|
||||
preparedRequest = nil
|
||||
settlementState = .success(batch)
|
||||
load()
|
||||
} catch {
|
||||
settlementState = .failed(error.localizedDescription)
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
/// 将成功或失败反馈恢复为可操作状态。
|
||||
func clearSettlementFeedback() {
|
||||
guard settlementState != .processing else { return }
|
||||
settlementState = .idle
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 开发环境下将下一次 Mock 补缴设为失败。
|
||||
func simulateNextSettlementFailure() {
|
||||
service.simulateNextSettlementFailure()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user