feat: 完成9.7线下收款登记与日清
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user