新增线下收款记录和 ai 修图优化

This commit is contained in:
han xin
2026-08-21 15:51:52 +08:00
parent 6fe9928b49
commit 5cf4409bad
40 changed files with 5482 additions and 56 deletions
@@ -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
}
}