feat: 完成9.7线下收款登记与日清
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
/// 日清日历的展示模式。
|
||||
enum OfflineCollectionCalendarMode: Sendable, Equatable {
|
||||
case week
|
||||
case month
|
||||
}
|
||||
|
||||
/// 周/月日历日期运算状态,不依赖 UIKit,便于单元测试。
|
||||
struct OfflineCollectionCalendarState: Sendable, Equatable {
|
||||
private let calendar: Calendar
|
||||
private(set) var selectedDate: Date
|
||||
private(set) var maximumDate: Date
|
||||
private(set) var mode: OfflineCollectionCalendarMode
|
||||
|
||||
init(
|
||||
selectedDate: Date,
|
||||
maximumDate: Date,
|
||||
mode: OfflineCollectionCalendarMode = .week,
|
||||
calendar: Calendar = OfflineCollectionDate.calendar
|
||||
) {
|
||||
self.calendar = calendar
|
||||
self.maximumDate = calendar.startOfDay(for: maximumDate)
|
||||
self.selectedDate = min(calendar.startOfDay(for: selectedDate), self.maximumDate)
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
/// 切换周历和月历并保留选中日期。
|
||||
mutating func toggleMode() {
|
||||
mode = mode == .week ? .month : .week
|
||||
}
|
||||
|
||||
/// 选择不晚于服务端今日的日期。
|
||||
mutating func select(_ date: Date) -> Bool {
|
||||
let value = calendar.startOfDay(for: date)
|
||||
guard value <= maximumDate else { return false }
|
||||
selectedDate = value
|
||||
return true
|
||||
}
|
||||
|
||||
/// 横滑一个周或一个月,并返回新的选中日期。
|
||||
@discardableResult
|
||||
mutating func movePage(_ offset: Int) -> Date {
|
||||
let candidate: Date
|
||||
switch mode {
|
||||
case .week:
|
||||
candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
|
||||
case .month:
|
||||
let current = calendar.dateComponents([.year, .month, .day], from: selectedDate)
|
||||
let monthStart = calendar.date(from: DateComponents(year: current.year, month: current.month, day: 1)) ?? selectedDate
|
||||
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) ?? monthStart
|
||||
let days = calendar.range(of: .day, in: .month, for: targetMonth)?.count ?? 1
|
||||
candidate = calendar.date(byAdding: .day, value: min(current.day ?? 1, days) - 1, to: targetMonth) ?? targetMonth
|
||||
}
|
||||
selectedDate = min(calendar.startOfDay(for: candidate), maximumDate)
|
||||
return selectedDate
|
||||
}
|
||||
|
||||
/// 当前周的周一至周日。
|
||||
var weekDates: [Date] {
|
||||
let weekday = calendar.component(.weekday, from: selectedDate)
|
||||
let daysFromMonday = (weekday + 5) % 7
|
||||
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: selectedDate) ?? selectedDate
|
||||
return (0 ..< 7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||
}
|
||||
|
||||
/// 当前月份固定六行、周一开周的 42 个日期。
|
||||
var monthDates: [Date] {
|
||||
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||
let first = calendar.date(from: parts) ?? selectedDate
|
||||
let weekday = calendar.component(.weekday, from: first)
|
||||
let leading = (weekday + 5) % 7
|
||||
let start = calendar.date(byAdding: .day, value: -leading, to: first) ?? first
|
||||
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
||||
}
|
||||
|
||||
/// 当前选中月份标题。
|
||||
var monthTitle: String {
|
||||
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||
return "\(parts.year ?? 0)年\(parts.month ?? 0)月"
|
||||
}
|
||||
}
|
||||
@@ -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