Files

377 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 displayAmount(_ fen: Int) -> String {
let whole = fen / 100
let cents = fen % 100
if cents == 0 { return "\(whole)" }
if cents.isMultiple(of: 10) { return "\(whole).\(cents / 10)" }
return "\(whole).\(String(format: "%02d", cents))"
}
static func display(_ fen: Int) -> String { "¥\(displayAmount(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 }
}