370 lines
13 KiB
Swift
370 lines
13 KiB
Swift
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 }
|
||
}
|