291 lines
11 KiB
Swift
291 lines
11 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|