diff --git a/docs/9.7/design/offline-collection-daily-redesign-v2.png b/docs/9.7/design/offline-collection-daily-redesign-v2.png new file mode 100644 index 0000000..dd3151b Binary files /dev/null and b/docs/9.7/design/offline-collection-daily-redesign-v2.png differ diff --git a/docs/9.7/design/offline-collection-daily-redesign.png b/docs/9.7/design/offline-collection-daily-redesign.png new file mode 100644 index 0000000..acdb588 Binary files /dev/null and b/docs/9.7/design/offline-collection-daily-redesign.png differ diff --git a/docs/9.7/design/offline-collection-registration-redesign.png b/docs/9.7/design/offline-collection-registration-redesign.png new file mode 100644 index 0000000..201735a Binary files /dev/null and b/docs/9.7/design/offline-collection-registration-redesign.png differ diff --git a/docs/9.7线下收款后端接口调整.md b/docs/9.7线下收款后端接口调整.md new file mode 100644 index 0000000..f176e19 --- /dev/null +++ b/docs/9.7线下收款后端接口调整.md @@ -0,0 +1,53 @@ +# 9.7 线下收款接口调整 + +本次仅调整以下两个接口,路径保持不变: + +- `GET /api/yf-handset-app/photog/offline-pay-collect/statistics` +- `GET /api/yf-handset-app/photog/offline-pay-collect/details` + +## 1. statistics + +请求:`scenic_id` + +在现有 `pending` 中增加所有未补缴日期,按日期升序: + +```json +"dates": [ + { "date": "2026-08-23", "unpaid_amount": "230.00", "unpaid_count": 2 } +] +``` + +- 包含今天及历史所有仍有待补缴数据的日期。 +- `date_count`、待补缴总金额和总笔数必须与 `dates` 汇总一致。 +- `today.date` 按 `Asia/Shanghai` 返回当前营业日,客户端以此限制未来日期。 + +## 2. details + +请求改为:`scenic_id + date`,删除 `page/page_size`。 + +`data` 直接返回单日对象: + +```json +{ + "date": "2026-08-24", + "total_amount": "350.00", + "collect_count": 3, + "paid_amount": "100.00", + "paid_count": 1, + "unpaid_amount": "250.00", + "unpaid_count": 2, + "status": 0, + "status_text": "本日未结清", + "collects": [] +} +``` + +- 无数据日期也返回成功、零值汇总和空 `collects`。 +- 明细按登记时间倒序,再按 `collect_no` 保证稳定顺序。 + +## 不调整的接口 + +- `POST /register`:继续只接收 `scenic_id/amount/pay_method`,响应保持现状。 +- `POST /supplement`:继续只接收 `scenic_id/date`,响应保持现状。 + +客户端不会向这两个接口传入 `request_id`、补缴金额快照或补缴笔数快照,也不依赖新增错误码。 diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 686962b..bf0b929 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -17,6 +17,7 @@ final class NetworkServices { let orderAPI: OrderAPI let homeAPI: HomeAPI let paymentAPI: PaymentAPI + let offlineCollectionAPI: OfflineCollectionAPI let taskAPI: TaskAPI let inviteAPI: InviteAPI let walletAPI: WalletAPI @@ -43,6 +44,7 @@ final class NetworkServices { orderAPI = OrderAPI(client: client) homeAPI = HomeAPI(client: client) paymentAPI = PaymentAPI(client: client) + offlineCollectionAPI = OfflineCollectionAPI(client: client) taskAPI = TaskAPI(client: client) inviteAPI = InviteAPI(client: client) walletAPI = WalletAPI(client: client) @@ -74,6 +76,7 @@ final class NetworkServices { orderAPI = OrderAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient) paymentAPI = PaymentAPI(client: apiClient) + offlineCollectionAPI = OfflineCollectionAPI(client: apiClient) taskAPI = TaskAPI(client: apiClient) inviteAPI = InviteAPI(client: apiClient) walletAPI = WalletAPI(client: apiClient) diff --git a/suixinkan/Assets.xcassets/OfflineCollection/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/Contents.json new file mode 100644 index 0000000..e4ab8e0 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "offline_security_shield_generated.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/offline_security_shield_generated.png b/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/offline_security_shield_generated.png new file mode 100644 index 0000000..f8d97bb Binary files /dev/null and b/suixinkan/Assets.xcassets/OfflineCollection/offline_security_shield.imageset/offline_security_shield_generated.png differ diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json new file mode 100644 index 0000000..06c8a8f --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "payment_method_alipay_generated.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay_generated.png b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay_generated.png new file mode 100644 index 0000000..d798fd4 Binary files /dev/null and b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay_generated.png differ diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json new file mode 100644 index 0000000..e6e3920 --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "payment_method_cash_generated.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash_generated.png b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash_generated.png new file mode 100644 index 0000000..cd4f5fb Binary files /dev/null and b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_cash.imageset/payment_method_cash_generated.png differ diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json new file mode 100644 index 0000000..643a79c --- /dev/null +++ b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "payment_method_wechat_generated.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat_generated.png b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat_generated.png new file mode 100644 index 0000000..8fdffd7 Binary files /dev/null and b/suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat_generated.png differ diff --git a/suixinkan/Features/OfflineCollection/API/OfflineCollectionAPI.swift b/suixinkan/Features/OfflineCollection/API/OfflineCollectionAPI.swift new file mode 100644 index 0000000..7aa00c9 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/API/OfflineCollectionAPI.swift @@ -0,0 +1,48 @@ +import Foundation + +/// 线下收款接口抽象,供生产 API 与测试替身共用。 +@MainActor +protocol OfflineCollectionServing: AnyObject { + func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse + func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse + func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse + func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult +} + +/// 线下收款真实网络 API。 +@MainActor +final class OfflineCollectionAPI: OfflineCollectionServing { + private let client: APIClient + private let prefix = "/api/yf-handset-app/photog/offline-pay-collect" + + init(client: APIClient) { + self.client = client + } + + func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse { + try await client.send(APIRequest( + method: .get, + path: "\(prefix)/statistics", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + )) + } + + func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse { + try await client.send(APIRequest( + method: .get, + path: "\(prefix)/details", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "date", value: date), + ] + )) + } + + func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse { + try await client.send(APIRequest(method: .post, path: "\(prefix)/register", body: request)) + } + + func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult { + try await client.send(APIRequest(method: .post, path: "\(prefix)/supplement", body: request)) + } +} diff --git a/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift new file mode 100644 index 0000000..05245cb --- /dev/null +++ b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionCalendarModel.swift @@ -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)月" + } +} diff --git a/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift new file mode 100644 index 0000000..cf544a1 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/Models/OfflineCollectionModels.swift @@ -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 } +} diff --git a/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift b/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift new file mode 100644 index 0000000..0af36b9 --- /dev/null +++ b/suixinkan/Features/OfflineCollection/ViewModels/OfflineCollectionViewModels.swift @@ -0,0 +1,275 @@ +import Foundation + +/// 收款首页线下收款区域的加载状态与业务数据。 +final class OfflineCollectionHomeViewModel { + private(set) var statistics: OfflineCollectionStatisticsResponse? + private(set) var isLoading = false + private(set) var errorMessage: String? + + let context: OfflineCollectionContext + private let api: any OfflineCollectionServing + var onStateChange: (() -> Void)? + + init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { + self.context = context + self.api = api + } + + var todayBusinessDate: String? { statistics?.today.date } + var todaySummary: OfflineDailySummary { + statistics?.todaySummary ?? .empty(todayBusinessDate ?? "") + } + var overdueDates: [OfflinePendingDate] { + guard let statistics else { return [] } + return statistics.pending.dates.filter { $0.date < statistics.today.date } + } + var earliestOverdueBusinessDate: String? { overdueDates.first?.date } + var overdueRecordCount: Int { overdueDates.reduce(0) { $0 + $1.unpaidCount } } + var overdueAmountFen: Int { overdueDates.reduce(0) { $0 + $1.unpaidAmountFen } } + + /// 从真实统计接口刷新首页卡片。 + func load() async { + guard context.scenicId > 0 else { + errorMessage = OfflineCollectionError.missingScenic.localizedDescription + onStateChange?() + return + } + isLoading = true + errorMessage = nil + onStateChange?() + do { + statistics = try await api.statistics(scenicId: context.scenicId) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + onStateChange?() + } +} + +/// 线下收款登记页 ViewModel,负责表单校验和登记后的统计刷新。 +final class OfflineCollectionRegistrationViewModel { + private(set) var amountText = "" + private(set) var paymentMethod: OfflineCollectionPaymentMethod = .wechat + private(set) var isSubmitting = false + + let context: OfflineCollectionContext + let api: any OfflineCollectionServing + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)? + + init(context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { + self.context = context + self.api = api + } + + var canSubmit: Bool { + !isSubmitting && context.scenicId > 0 && OfflineCollectionMoney.parseFen(amountText) != nil + } + + func updateAmount(_ value: String) { + guard OfflineCollectionMoney.acceptsEditingText(value) else { return } + guard value != amountText else { return } + amountText = value + onStateChange?() + } + + func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) { + guard method != paymentMethod else { return } + paymentMethod = method + onStateChange?() + } + + /// 按后端现有契约提交一笔线下收款。 + func submit() async { + guard !isSubmitting else { return } + guard context.scenicId > 0 else { + onShowMessage?(OfflineCollectionError.missingScenic.localizedDescription) + return + } + guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else { + onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription) + return + } + + isSubmitting = true + onStateChange?() + defer { + isSubmitting = false + onStateChange?() + } + + do { + let response = try await api.register(OfflineCollectionRegisterRequest( + scenicId: context.scenicId, + amount: OfflineCollectionMoney.apiAmount(amountFen), + payMethod: paymentMethod.rawValue + )) + let statistics = try? await api.statistics(scenicId: context.scenicId) + let responseDate = String(response.createdAt.prefix(10)) + onRegistrationSuccess?(OfflineCollectionRegistrationReceipt( + record: response.record, + businessDate: statistics?.today.date ?? responseDate, + summary: statistics?.todaySummary + )) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func startAnotherRegistration() { + amountText = "" + onStateChange?() + } +} + +/// 日清页的补缴呈现状态。 +enum OfflineSettlementPresentationState: Sendable, Equatable { + case idle + case processing + case success(OfflineSettlementResult) + case failed(String, canRetry: Bool) +} + +/// 日清页 ViewModel,管理服务端营业日、日期标记、详情请求与补缴快照。 +final class OfflineCollectionDailyViewModel { + private(set) var businessDate: String + private(set) var serverToday: String + private(set) var summary: OfflineDailySummary + private(set) var records: [OfflineCollectionRecord] = [] + private(set) var pendingDates: Set = [] + private(set) var isLoading = false + private(set) var errorMessage: String? + private(set) var settlementState: OfflineSettlementPresentationState = .idle + private(set) var preparedRequest: OfflineSettlementRequest? + + let context: OfflineCollectionContext + private let api: any OfflineCollectionServing + private var detailRequestVersion = 0 + var onStateChange: (() -> Void)? + + init(businessDate: String, context: OfflineCollectionContext = .current(), api: any OfflineCollectionServing) { + self.businessDate = businessDate + serverToday = businessDate + summary = .empty(businessDate) + self.context = context + self.api = api + } + + var isToday: Bool { businessDate == serverToday } + var canSettle: Bool { + summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing && !isLoading + } + + /// 首次进入或页面重新出现时刷新统计和当前详情。 + func refresh() async { + guard context.scenicId > 0 else { + errorMessage = OfflineCollectionError.missingScenic.localizedDescription + onStateChange?() + return + } + isLoading = true + errorMessage = nil + onStateChange?() + do { + let statistics = try await api.statistics(scenicId: context.scenicId) + serverToday = statistics.today.date + pendingDates = Set(statistics.pending.dates.map(\.date)) + if businessDate.isEmpty || businessDate > serverToday { businessDate = serverToday } + await loadDetails(for: businessDate) + } catch { + isLoading = false + errorMessage = error.localizedDescription + onStateChange?() + } + } + + /// 选择任意不晚于服务端今日的日期,并只接受最后一次请求结果。 + func selectBusinessDate(_ date: String) async { + guard date <= serverToday, settlementState != .processing else { return } + businessDate = date + preparedRequest = nil + settlementState = .idle + await loadDetails(for: date) + } + + private func loadDetails(for date: String) async { + detailRequestVersion += 1 + let version = detailRequestVersion + isLoading = true + errorMessage = nil + records = [] + summary = .empty(date) + onStateChange?() + do { + let details = try await api.details(scenicId: context.scenicId, date: date) + guard version == detailRequestVersion, date == businessDate else { return } + summary = details.summary + records = details.records(serverToday: serverToday) + isLoading = false + onStateChange?() + } catch is CancellationError { + return + } catch { + guard version == detailRequestVersion, date == businessDate else { return } + isLoading = false + errorMessage = error.localizedDescription + onStateChange?() + } + } + + func prepareSettlement() throws -> OfflineSettlementRequest { + guard canSettle else { throw OfflineCollectionError.noPendingRecords } + let request = OfflineSettlementRequest( + scenicId: context.scenicId, + businessDate: businessDate, + amountFen: summary.pendingAmountFen, + pendingCount: summary.pendingCount + ) + preparedRequest = request + return request + } + + func cancelPreparedSettlement() { + guard settlementState != .processing else { return } + preparedRequest = nil + } + + /// 按后端现有契约补缴指定日期;失败时允许用户再次提交。 + func confirmSettlement() async { + guard settlementState != .processing, let request = preparedRequest else { return } + settlementState = .processing + onStateChange?() + do { + let result = try await api.supplement(request) + preparedRequest = nil + settlementState = .success(result) + await refreshAfterSettlement() + } catch { + settlementState = .failed(error.localizedDescription, canRetry: true) + onStateChange?() + } + } + + private func refreshAfterSettlement() async { + if let statistics = try? await api.statistics(scenicId: context.scenicId) { + serverToday = statistics.today.date + pendingDates = Set(statistics.pending.dates.map(\.date)) + } + detailRequestVersion += 1 + let version = detailRequestVersion + if let details = try? await api.details(scenicId: context.scenicId, date: businessDate), version == detailRequestVersion { + summary = details.summary + records = details.records(serverToday: serverToday) + } + isLoading = false + onStateChange?() + } + + func clearSettlementFeedback() { + guard settlementState != .processing else { return } + settlementState = .idle + onStateChange?() + } +} diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift new file mode 100644 index 0000000..cfe4f56 --- /dev/null +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionDailyViewController.swift @@ -0,0 +1,606 @@ +import SnapKit +import UIKit + +/// 日清列表条目。 +private enum OfflineDailyItem: Hashable { + case record(OfflineCollectionRecord) + case empty(String) +} + +/// 日清记录在白色列表卡中的圆角位置。 +private enum OfflineRecordPosition { + case single + case first + case middle + case last +} + +/// 线下收款日清页,按 9.7 视觉稿展示日历、2×2 汇总、明细和补缴操作。 +@MainActor +final class OfflineCollectionDailyViewController: BaseViewController { + private let viewModel: OfflineCollectionDailyViewModel + private let tableView = UITableView(frame: .zero, style: .plain) + private var dataSource: UITableViewDiffableDataSource! + private let headerContainer = UIView() + private let headerStack = UIStackView() + private let calendarView = OfflineCollectionCalendarView() + private let summaryCard = UIView() + private let summaryDateLabel = UILabel() + private let totalValue = UILabel() + private let countValue = UILabel() + private let settledValue = UILabel() + private let pendingValue = UILabel() + private let pendingStatusLabel = UILabel() + private let statusButton = UIButton(type: .system) + private let bottomContainer = UIView() + private let settlementButton = OfflineCollectionGradientButton( + startColor: UIColor(hex: 0xFF8B00), + endColor: UIColor(hex: 0xFF7200) + ) + private var feedbackPresented = false + private var isShowingGlobalLoading = false + + init( + businessDate: String, + context: OfflineCollectionContext = .current(), + api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI + ) { + viewModel = OfflineCollectionDailyViewModel(businessDate: businessDate, context: context, api: api) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func setupNavigationBar() { + title = "线下收款日清" + } + + override func setupUI() { + view.backgroundColor = UIColor(hex: 0xF7FAFF) + configureTableView() + configureHeader() + configureBottomBar() + + view.addSubview(tableView) + view.addSubview(bottomContainer) + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomContainer.snp.top) + } + bottomContainer.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } + } + + override func bindActions() { + calendarView.onDateSelected = { [weak self] date in + guard let self else { return } + Task { await self.viewModel.selectBusinessDate(date) } + } + calendarView.onModeChanged = { [weak self] in self?.resizeHeader() } + viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + Task { await viewModel.refresh() } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + setGlobalLoadingVisible(false) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + resizeHeader() + } + + private func configureTableView() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 82 + tableView.showsVerticalScrollIndicator = false + tableView.contentInset.bottom = 12 + tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier) + tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier) + dataSource = makeDataSource() + } + + private func configureHeader() { + headerStack.axis = .vertical + headerStack.spacing = 16 + headerContainer.addSubview(headerStack) + headerStack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.bottom.equalToSuperview().inset(10) + make.leading.trailing.equalToSuperview().inset(16) + } + headerStack.addArrangedSubview(calendarView) + configureSummaryCard() + headerStack.addArrangedSubview(summaryCard) + + let listTitle = UILabel() + listTitle.text = "收款明细" + listTitle.font = .systemFont(ofSize: 18, weight: .bold) + listTitle.textColor = UIColor(hex: 0x081739) + let listTitleContainer = UIView() + listTitleContainer.addSubview(listTitle) + listTitle.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(8) + make.trailing.centerY.equalToSuperview() + } + listTitleContainer.snp.makeConstraints { make in make.height.equalTo(42) } + headerStack.setCustomSpacing(18, after: summaryCard) + headerStack.addArrangedSubview(listTitleContainer) + + statusButton.configuration = .plain() + statusButton.configuration?.baseForegroundColor = AppColor.primary + statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + statusButton.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } + statusButton.isHidden = true + headerStack.addArrangedSubview(statusButton) + tableView.tableHeaderView = headerContainer + } + + private func configureSummaryCard() { + summaryCard.backgroundColor = .white + summaryCard.layer.cornerRadius = 12 + summaryCard.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor + summaryCard.layer.shadowOpacity = 0.12 + summaryCard.layer.shadowRadius = 10 + summaryCard.layer.shadowOffset = CGSize(width: 0, height: 4) + + summaryDateLabel.font = .systemFont(ofSize: 18, weight: .bold) + summaryDateLabel.textColor = UIColor(hex: 0x081739) + + let topLeft = metric(title: "线下收款总额", value: totalValue) + let topRight = metric(title: "登记笔数", value: countValue) + let bottomLeft = metric(title: "已补缴", value: settledValue) + let bottomRight = metric(title: "待补缴", value: pendingValue, badge: pendingStatusLabel) + let grid = UIView() + [topLeft, topRight, bottomLeft, bottomRight].forEach(grid.addSubview) + topLeft.snp.makeConstraints { make in + make.top.leading.equalToSuperview() + make.width.equalToSuperview().multipliedBy(0.5) + make.height.equalToSuperview().multipliedBy(0.5) + } + topRight.snp.makeConstraints { make in + make.top.trailing.equalToSuperview() + make.width.height.equalTo(topLeft) + } + bottomLeft.snp.makeConstraints { make in + make.bottom.leading.equalToSuperview() + make.width.height.equalTo(topLeft) + } + bottomRight.snp.makeConstraints { make in + make.bottom.trailing.equalToSuperview() + make.width.height.equalTo(topLeft) + } + + let horizontalDivider = UIView() + let verticalDivider = UIView() + [horizontalDivider, verticalDivider].forEach { + $0.backgroundColor = UIColor(hex: 0xE7EBF1) + grid.addSubview($0) + } + horizontalDivider.snp.makeConstraints { make in + make.leading.trailing.centerY.equalToSuperview() + make.height.equalTo(1) + } + verticalDivider.snp.makeConstraints { make in + make.top.bottom.centerX.equalToSuperview() + make.width.equalTo(1) + } + + summaryCard.addSubview(summaryDateLabel) + summaryCard.addSubview(grid) + summaryDateLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(24) + } + grid.snp.makeConstraints { make in + make.top.equalTo(summaryDateLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(10) + make.bottom.equalToSuperview().inset(10) + } + summaryCard.snp.makeConstraints { make in make.height.equalTo(238) } + } + + private func metric(title: String, value: UILabel, badge: UILabel? = nil) -> UIView { + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = UIColor(hex: 0x7B8494) + titleLabel.textAlignment = .center + + value.font = .systemFont(ofSize: 22, weight: .bold) + value.textColor = UIColor(hex: 0x081739) + value.textAlignment = .center + value.adjustsFontSizeToFitWidth = true + value.minimumScaleFactor = 0.72 + + let stack = UIStackView(arrangedSubviews: [titleLabel, value]) + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 8 + if let badge { + badge.text = "未结清" + badge.font = .systemFont(ofSize: 12, weight: .medium) + badge.textColor = UIColor(hex: 0xFF7600) + badge.textAlignment = .center + badge.backgroundColor = UIColor(hex: 0xFFF0E2) + badge.layer.cornerRadius = 7 + badge.clipsToBounds = true + badge.snp.makeConstraints { make in make.width.equalTo(72); make.height.equalTo(26) } + let badgeContainer = UIView() + badgeContainer.addSubview(badge) + badge.snp.makeConstraints { make in make.center.equalToSuperview() } + stack.addArrangedSubview(badgeContainer) + badgeContainer.snp.makeConstraints { make in make.height.equalTo(26) } + stack.spacing = 5 + } + + let container = UIView() + container.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(12) + } + return container + } + + private func configureBottomBar() { + bottomContainer.backgroundColor = .white + bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor + bottomContainer.layer.shadowOpacity = 0.12 + bottomContainer.layer.shadowRadius = 12 + bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3) + settlementButton.setTitle("当日暂无待补缴", for: .normal) + settlementButton.setTitleColor(.white, for: .normal) + settlementButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + settlementButton.layer.cornerRadius = 10 + settlementButton.clipsToBounds = true + settlementButton.accessibilityIdentifier = "offlineCollection.settle" + settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside) + bottomContainer.addSubview(settlementButton) + settlementButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(54) + make.bottom.equalTo(bottomContainer.safeAreaLayoutGuide).inset(14) + } + } + + @MainActor private func applyState() { + let processing = viewModel.settlementState == .processing + setGlobalLoadingVisible(viewModel.isLoading || processing) + calendarView.apply( + selectedDate: viewModel.businessDate, + maximumDate: viewModel.serverToday, + pendingDates: viewModel.pendingDates + ) + summaryDateLabel.text = formattedSummaryDate() + totalValue.text = OfflineCollectionMoney.display(viewModel.summary.totalAmountFen) + countValue.text = "\(viewModel.summary.totalCount)笔" + settledValue.text = OfflineCollectionMoney.display(viewModel.summary.settledAmountFen) + pendingValue.text = OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen) + pendingValue.textColor = viewModel.summary.pendingCount > 0 ? UIColor(hex: 0xFF7600) : UIColor(hex: 0x081739) + pendingStatusLabel.isHidden = viewModel.summary.pendingCount == 0 + + if let error = viewModel.errorMessage, !viewModel.isLoading { + statusButton.isHidden = false + statusButton.isUserInteractionEnabled = true + statusButton.configuration?.title = "\(error) 点击重试" + UIAccessibility.post(notification: .announcement, argument: error) + } else { + statusButton.isHidden = true + } + + settlementButton.isEnabled = viewModel.canSettle && !processing + if processing { + settlementButton.setTitle("补缴中", for: .normal) + } else if viewModel.summary.totalCount > 0 && viewModel.summary.pendingCount == 0 { + settlementButton.setTitle("本日已全部补缴", for: .normal) + } else if viewModel.summary.pendingCount == 0 { + settlementButton.setTitle("当日暂无待补缴", for: .normal) + } else { + settlementButton.setTitle( + "补缴本日全部 \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))", + for: .normal + ) + } + settlementButton.alpha = viewModel.canSettle || processing ? 1 : 0.45 + applySnapshot() + resizeHeader() + presentFeedbackIfNeeded() + } + + private func formattedSummaryDate() -> String { + guard let date = OfflineCollectionDate.date(from: viewModel.businessDate) else { return viewModel.businessDate } + let calendar = OfflineCollectionDate.calendar + let text = "\(calendar.component(.month, from: date))月\(calendar.component(.day, from: date))日" + return viewModel.businessDate == viewModel.serverToday ? "\(text) · 今日" : text + } + + private func setGlobalLoadingVisible(_ visible: Bool) { + guard visible != isShowingGlobalLoading else { return } + isShowingGlobalLoading = visible + visible ? showLoading() : hideLoading() + } + + @MainActor private func applySnapshot() { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + if viewModel.records.isEmpty { + let text = viewModel.errorMessage == nil && !viewModel.isLoading ? "本日暂无线下收款记录" : "" + snapshot.appendItems([.empty(text)]) + } else { + snapshot.appendItems(viewModel.records.map(OfflineDailyItem.record)) + } + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func makeDataSource() -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + switch item { + case let .record(record): + let cell = tableView.dequeueReusableCell( + withIdentifier: OfflineCollectionRecordCell.reuseIdentifier, + for: indexPath + ) as! OfflineCollectionRecordCell + let count = self?.viewModel.records.count ?? 1 + let position: OfflineRecordPosition + if count == 1 { position = .single } + else if indexPath.row == 0 { position = .first } + else if indexPath.row == count - 1 { position = .last } + else { position = .middle } + cell.apply(record, position: position) + return cell + case let .empty(text): + let cell = tableView.dequeueReusableCell( + withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier, + for: indexPath + ) as! OfflineCollectionEmptyCell + cell.apply(text) + return cell + } + } + } + + private func resizeHeader() { + guard tableView.bounds.width > 0 else { return } + headerContainer.frame.size.width = tableView.bounds.width + headerContainer.setNeedsLayout() + headerContainer.layoutIfNeeded() + let height = headerContainer.systemLayoutSizeFitting( + CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + guard abs(headerContainer.frame.height - height) > 0.5 else { return } + headerContainer.frame.size.height = height + tableView.tableHeaderView = headerContainer + } + + @MainActor private func presentFeedbackIfNeeded() { + guard !feedbackPresented else { return } + switch viewModel.settlementState { + case .idle, .processing: + return + case let .success(result): + feedbackPresented = true + let alert = UIAlertController( + title: "补缴成功", + message: "营业日:\(result.date)\n共结清 \(result.updatedCount) 笔", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in self?.finishFeedback() }) + present(alert, animated: true) + case let .failed(message, canRetry): + feedbackPresented = true + let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in + self?.viewModel.cancelPreparedSettlement() + self?.finishFeedback() + }) + if canRetry { + alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in + guard let self else { return } + self.feedbackPresented = false + Task { await self.viewModel.confirmSettlement() } + }) + } + present(alert, animated: true) + } + } + + private func finishFeedback() { + feedbackPresented = false + viewModel.clearSettlementFeedback() + } + + @objc private func retryTapped() { + Task { await viewModel.refresh() } + } + + @objc private func settlementTapped() { + do { + let request = try viewModel.prepareSettlement() + let message = "营业日:\(request.businessDate)\n待补缴记录:\(request.pendingCount) 笔\n本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))" + let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.cancelPreparedSettlement() + }) + alert.addAction(UIAlertAction(title: "确认补缴", style: .default) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.confirmSettlement() } + }) + present(alert, animated: true) + } catch { + showToast(error.localizedDescription) + } + } +} + +/// 日清页的一笔收款记录行,纯展示支付方式、编号、金额、状态与时间。 +private final class OfflineCollectionRecordCell: UITableViewCell { + static let reuseIdentifier = "OfflineCollectionRecordCell" + + private let card = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let idLabel = UILabel() + private let amountLabel = UILabel() + private let statusLabel = UILabel() + private let timeLabel = UILabel() + private let separator = UIView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + card.backgroundColor = .white + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.textColor = UIColor(hex: 0x081739) + + idLabel.font = .systemFont(ofSize: 9.5, weight: .medium) + idLabel.textColor = UIColor(hex: 0x6F7B8F) + idLabel.backgroundColor = UIColor(hex: 0xF0F2F6) + idLabel.layer.cornerRadius = 5 + idLabel.clipsToBounds = true + idLabel.textAlignment = .center + idLabel.adjustsFontSizeToFitWidth = true + idLabel.minimumScaleFactor = 0.7 + + amountLabel.font = .systemFont(ofSize: 16, weight: .bold) + amountLabel.textColor = UIColor(hex: 0x081739) + amountLabel.textAlignment = .right + amountLabel.adjustsFontSizeToFitWidth = true + amountLabel.minimumScaleFactor = 0.72 + + statusLabel.font = .systemFont(ofSize: 11, weight: .medium) + statusLabel.textAlignment = .center + statusLabel.layer.cornerRadius = 7 + statusLabel.clipsToBounds = true + + timeLabel.font = .systemFont(ofSize: 12) + timeLabel.textColor = UIColor(hex: 0x7B8494) + timeLabel.textAlignment = .right + separator.backgroundColor = UIColor(hex: 0xE7EBF1) + + let idContainer = UIView() + idContainer.addSubview(idLabel) + idLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } + idContainer.snp.makeConstraints { make in make.height.equalTo(20) } + let leftStack = UIStackView(arrangedSubviews: [titleLabel, idContainer]) + leftStack.axis = .vertical + leftStack.spacing = 4 + + card.addSubview(iconView) + card.addSubview(leftStack) + card.addSubview(amountLabel) + card.addSubview(statusLabel) + card.addSubview(timeLabel) + card.addSubview(separator) + contentView.addSubview(card) + + card.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(16) + make.height.greaterThanOrEqualTo(78) + } + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.width.height.equalTo(38) + } + leftStack.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(10) + make.centerY.equalToSuperview() + make.width.equalTo(106) + } + amountLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(leftStack.snp.trailing).offset(4) + make.centerY.equalToSuperview() + make.width.equalTo(68) + } + statusLabel.snp.makeConstraints { make in + make.leading.equalTo(amountLabel.snp.trailing).offset(4) + make.centerY.equalToSuperview() + make.width.equalTo(50) + make.height.equalTo(27) + } + timeLabel.snp.makeConstraints { make in + make.leading.equalTo(statusLabel.snp.trailing).offset(2) + make.trailing.equalToSuperview().inset(10) + make.centerY.equalToSuperview() + make.width.greaterThanOrEqualTo(34) + } + separator.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.trailing.equalToSuperview().inset(8) + make.bottom.equalToSuperview() + make.height.equalTo(1 / UIScreen.main.scale) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ record: OfflineCollectionRecord, position: OfflineRecordPosition) { + iconView.image = UIImage(named: record.paymentMethod.assetName) + titleLabel.text = record.paymentMethod == .wechat ? "微信收款" : record.paymentMethod.displayName + idLabel.text = "编号 \(record.collectNo)" + amountLabel.text = OfflineCollectionMoney.display(record.amountFen) + let settled = record.status == .settled + statusLabel.text = settled ? "已补缴" : "未补缴" + statusLabel.textColor = settled ? UIColor(hex: 0x16A34A) : UIColor(hex: 0xFF7600) + statusLabel.backgroundColor = settled ? UIColor(hex: 0xEAF8EF) : UIColor(hex: 0xFFF0E2) + timeLabel.text = record.timeText + separator.isHidden = position == .single || position == .last + + card.layer.cornerRadius = 12 + switch position { + case .single: + card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + case .first: + card.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + case .middle: + card.layer.maskedCorners = [] + case .last: + card.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + } + accessibilityLabel = "\(titleLabel.text ?? ""),编号 \(record.collectNo),\(amountLabel.text ?? ""),\(statusLabel.text ?? ""),\(record.timeText)" + } +} + +/// 日清页空数据占位。 +private final class OfflineCollectionEmptyCell: UITableViewCell { + static let reuseIdentifier = "OfflineCollectionEmptyCell" + private let label = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + label.font = .systemFont(ofSize: 14) + label.textColor = UIColor(hex: 0x7B8494) + label.textAlignment = .center + contentView.addSubview(label) + label.snp.makeConstraints { make in make.edges.equalToSuperview().inset(36) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ text: String) { + label.text = text + } +} diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift new file mode 100644 index 0000000..b4b9b91 --- /dev/null +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift @@ -0,0 +1,451 @@ +import SnapKit +import UIKit + +/// 线下收款登记页,按 9.7 视觉稿展示金额、支付方式和登记说明。 +@MainActor +final class OfflineCollectionRegistrationViewController: BaseViewController { + private let viewModel: OfflineCollectionRegistrationViewModel + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let amountCard = UIView() + private let amountField = UITextField() + private let methodCard = UIView() + private let methodStack = UIStackView() + private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:] + private let explanationCard = UIView() + private let contextLabel = UILabel() + private let bottomContainer = UIView() + private let submitButton = OfflineCollectionGradientButton( + startColor: UIColor(hex: 0x087BFF), + endColor: UIColor(hex: 0x0067F4) + ) + private var hasFocusedAmountField = false + private var isShowingGlobalLoading = false + + init( + context: OfflineCollectionContext = .current(), + api: any OfflineCollectionServing = NetworkServices.shared.offlineCollectionAPI + ) { + viewModel = OfflineCollectionRegistrationViewModel(context: context, api: api) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func setupNavigationBar() { + title = "线下收款登记" + } + + override func setupUI() { + view.backgroundColor = UIColor(hex: 0xF7FAFF) + configureScrollContent() + configureAmountCard() + configureMethodCard() + configureExplanationCard() + configureContextLabel() + configureBottomBar() + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + [amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview) + view.addSubview(bottomContainer) + bottomContainer.addSubview(submitButton) + } + + override func setupConstraints() { + bottomContainer.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.keyboardLayoutGuide.snp.top) + } + submitButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(18) + make.height.equalTo(56) + make.bottom.equalToSuperview().inset(16) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomContainer.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 24, right: 16)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-32) + } + } + + override func bindActions() { + amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged) + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } } + viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } + viewModel.onRegistrationSuccess = { [weak self] receipt in Task { @MainActor in self?.showSuccess(receipt) } } + } + + override func viewDidLoad() { + super.viewDidLoad() + applyState() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !hasFocusedAmountField else { return } + hasFocusedAmountField = true + amountField.becomeFirstResponder() + moveAmountCursorToEnd() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + setGlobalLoadingVisible(false) + } + + private func configureScrollContent() { + scrollView.keyboardDismissMode = .interactive + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + contentStack.axis = .vertical + contentStack.spacing = 16 + } + + private func configureAmountCard() { + configureCard(amountCard) + + let titleLabel = makeTitleLabel("收款金额") + let helperLabel = UILabel() + helperLabel.text = "单笔金额 0.01~99,999.99 元" + helperLabel.font = .systemFont(ofSize: 13) + helperLabel.textColor = UIColor(hex: 0x7B8494) + helperLabel.adjustsFontSizeToFitWidth = true + helperLabel.minimumScaleFactor = 0.82 + + let currencyLabel = UILabel() + currencyLabel.text = "¥" + currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold) + currencyLabel.textColor = UIColor(hex: 0x081739) + + amountField.placeholder = "0.00" + amountField.font = .systemFont(ofSize: 42, weight: .bold) + amountField.textColor = UIColor(hex: 0x081739) + amountField.tintColor = AppColor.primary + amountField.textAlignment = .right + amountField.keyboardType = .decimalPad + amountField.adjustsFontSizeToFitWidth = true + amountField.minimumFontSize = 30 + amountField.delegate = self + amountField.accessibilityLabel = "收款金额" + amountField.accessibilityIdentifier = "offlineCollection.amount" + + let amountInputStack = UIStackView(arrangedSubviews: [currencyLabel, amountField]) + amountInputStack.axis = .horizontal + amountInputStack.alignment = .center + amountInputStack.spacing = 6 + + let amountRow = UIView() + amountRow.addSubview(helperLabel) + amountRow.addSubview(amountInputStack) + helperLabel.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(amountInputStack.snp.leading).offset(-8) + } + amountInputStack.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.width.lessThanOrEqualTo(205) + } + amountField.snp.makeConstraints { make in + make.height.equalTo(56) + make.width.greaterThanOrEqualTo(98) + } + + let underline = UIView() + underline.backgroundColor = UIColor(hex: 0x1684FC) + underline.snp.makeConstraints { make in make.height.equalTo(1) } + + let quickAmounts: [(String, Int)] = [("¥50", 5_000), ("¥100", 10_000), ("¥200", 20_000), ("¥500", 50_000)] + let quickStack = UIStackView() + quickStack.axis = .horizontal + quickStack.distribution = .fillEqually + quickStack.spacing = 12 + quickAmounts.forEach { title, fen in + let button = UIButton(type: .system) + button.tag = fen + button.setTitle(title, for: .normal) + button.setTitleColor(UIColor(hex: 0x1677FF), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + button.backgroundColor = UIColor(hex: 0xF5F8FD) + button.layer.cornerRadius = 8 + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor(hex: 0xE3E8F0).cgColor + button.addTarget(self, action: #selector(quickAmountTapped(_:)), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(36) } + quickStack.addArrangedSubview(button) + } + + let stack = UIStackView(arrangedSubviews: [titleLabel, amountRow, underline, quickStack]) + stack.axis = .vertical + stack.setCustomSpacing(8, after: titleLabel) + stack.setCustomSpacing(2, after: amountRow) + stack.setCustomSpacing(22, after: underline) + amountCard.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) } + amountRow.snp.makeConstraints { make in make.height.equalTo(56) } + } + + private func configureMethodCard() { + configureCard(methodCard) + let titleLabel = makeTitleLabel("收款方式") + methodStack.axis = .horizontal + methodStack.distribution = .fillEqually + methodStack.spacing = 12 + for method in OfflineCollectionPaymentMethod.allCases { + let button = OfflinePaymentMethodButton(method: method) + button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside) + methodButtons[method] = button + methodStack.addArrangedSubview(button) + } + let stack = UIStackView(arrangedSubviews: [titleLabel, methodStack]) + stack.axis = .vertical + stack.spacing = 20 + methodCard.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(20) } + methodStack.snp.makeConstraints { make in make.height.equalTo(120) } + } + + private func configureExplanationCard() { + configureCard(explanationCard) + let iconView = UIImageView(image: UIImage(named: "offline_security_shield")) + iconView.contentMode = .scaleAspectFit + iconView.accessibilityLabel = "安全说明" + + let label = UILabel() + label.text = "登记后计入今日待补缴,不生成订单" + label.font = .systemFont(ofSize: 15) + label.textColor = UIColor(hex: 0x22304D) + label.numberOfLines = 0 + + explanationCard.addSubview(iconView) + explanationCard.addSubview(label) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(20) + make.centerY.equalToSuperview() + make.width.height.equalTo(44) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(14) + make.trailing.equalToSuperview().inset(18) + make.top.bottom.equalToSuperview().inset(20) + } + explanationCard.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) } + } + + private func configureContextLabel() { + contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)" + contextLabel.font = .systemFont(ofSize: 13) + contextLabel.textColor = UIColor(hex: 0x7B8494) + contextLabel.numberOfLines = 0 + contextLabel.textAlignment = .center + contextLabel.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } + } + + private func configureBottomBar() { + bottomContainer.backgroundColor = .white + bottomContainer.layer.shadowColor = UIColor(hex: 0x8090A8).cgColor + bottomContainer.layer.shadowOpacity = 0.12 + bottomContainer.layer.shadowRadius = 12 + bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -3) + submitButton.setTitle("确认登记", for: .normal) + submitButton.setTitleColor(.white, for: .normal) + submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .semibold) + submitButton.layer.cornerRadius = 10 + submitButton.clipsToBounds = true + submitButton.accessibilityIdentifier = "offlineCollection.submit" + } + + @MainActor private func applyState() { + setGlobalLoadingVisible(viewModel.isSubmitting) + if amountField.text != viewModel.amountText { + amountField.text = viewModel.amountText + moveAmountCursorToEnd() + } + methodButtons.forEach { method, button in button.setSelected(method == viewModel.paymentMethod) } + submitButton.isEnabled = viewModel.canSubmit && !viewModel.isSubmitting + submitButton.alpha = viewModel.canSubmit || viewModel.isSubmitting ? 1 : 0.45 + } + + @MainActor private func showSuccess(_ receipt: OfflineCollectionRegistrationReceipt) { + setGlobalLoadingVisible(false) + amountField.resignFirstResponder() + let totalText = receipt.summary.map { OfflineCollectionMoney.display($0.totalAmountFen) } ?? "—" + let pendingText = receipt.summary.map { OfflineCollectionMoney.display($0.pendingAmountFen) } ?? "—" + let message = """ + 本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen)) + 今日累计线下收款 \(totalText) + 今日待补缴 \(pendingText) + """ + let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in + self?.viewModel.startAnotherRegistration() + self?.amountField.becomeFirstResponder() + self?.moveAmountCursorToEnd() + }) + alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in + guard let self else { return } + self.navigationController?.pushViewController( + OfflineCollectionDailyViewController( + businessDate: receipt.businessDate, + context: self.viewModel.context, + api: self.viewModel.api + ), + animated: true + ) + }) + present(alert, animated: true) + } + + private func setGlobalLoadingVisible(_ visible: Bool) { + guard visible != isShowingGlobalLoading else { return } + isShowingGlobalLoading = visible + visible ? showLoading() : hideLoading() + } + + private func makeTitleLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = UIColor(hex: 0x081739) + return label + } + + private func configureCard(_ card: UIView) { + card.backgroundColor = .white + card.layer.cornerRadius = 12 + card.layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor + card.layer.shadowOpacity = 0.12 + card.layer.shadowRadius = 10 + card.layer.shadowOffset = CGSize(width: 0, height: 4) + } + + private func moveAmountCursorToEnd() { + guard amountField.isFirstResponder else { return } + let end = amountField.endOfDocument + amountField.selectedTextRange = amountField.textRange(from: end, to: end) + } + + @objc private func amountChanged() { + viewModel.updateAmount(amountField.text ?? "") + moveAmountCursorToEnd() + } + + @objc private func quickAmountTapped(_ sender: UIButton) { + viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag)) + amountField.becomeFirstResponder() + moveAmountCursorToEnd() + } + + @objc private func methodTapped(_ sender: OfflinePaymentMethodButton) { + viewModel.selectPaymentMethod(sender.method) + } + + @objc private func submitTapped() { + Task { await viewModel.submit() } + } +} + +extension OfflineCollectionRegistrationViewController: UITextFieldDelegate { + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard let current = textField.text, let swiftRange = Range(range, in: current) else { return false } + return OfflineCollectionMoney.acceptsEditingText(current.replacingCharacters(in: swiftRange, with: string)) + } +} + +/// 登记页的收款方式单选卡,展示生成的品牌图标和右上角选中标记。 +@MainActor +final class OfflinePaymentMethodButton: UIControl { + let method: OfflineCollectionPaymentMethod + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) + + init(method: OfflineCollectionPaymentMethod) { + self.method = method + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func setSelected(_ selected: Bool) { + isSelected = selected + backgroundColor = selected ? UIColor(hex: 0xF1F7FF) : .white + layer.borderColor = (selected ? UIColor(hex: 0x1684FC) : UIColor(hex: 0xE2E7EF)).cgColor + layer.borderWidth = selected ? 1.5 : 1 + checkmarkView.isHidden = !selected + titleLabel.textColor = UIColor(hex: 0x081739) + accessibilityTraits = selected ? [.button, .selected] : .button + } + + private func setupUI() { + layer.cornerRadius = 10 + clipsToBounds = false + accessibilityLabel = method.displayName + iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal) + iconView.contentMode = .scaleAspectFit + titleLabel.text = method.displayName + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.textAlignment = .center + checkmarkView.tintColor = .white + checkmarkView.backgroundColor = UIColor(hex: 0x1684FC) + checkmarkView.layer.cornerRadius = 11 + checkmarkView.contentMode = .center + checkmarkView.isHidden = true + + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 12 + stack.isUserInteractionEnabled = false + addSubview(stack) + addSubview(checkmarkView) + stack.snp.makeConstraints { make in make.center.equalToSuperview() } + iconView.snp.makeConstraints { make in make.width.height.equalTo(46) } + checkmarkView.snp.makeConstraints { make in + make.width.height.equalTo(22) + make.top.trailing.equalToSuperview().inset(-2) + } + setSelected(false) + } +} + +/// 线下收款页面共用的双端色渐变按钮。 +@MainActor +final class OfflineCollectionGradientButton: UIButton { + private let gradientLayer = CAGradientLayer() + + init(startColor: UIColor, endColor: UIColor) { + super.init(frame: .zero) + gradientLayer.colors = [startColor.cgColor, endColor.cgColor] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + gradientLayer.cornerRadius = layer.cornerRadius + } + + override var isEnabled: Bool { + didSet { gradientLayer.opacity = isEnabled ? 1 : 0.45 } + } +} diff --git a/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift new file mode 100644 index 0000000..567eb0f --- /dev/null +++ b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionCalendarView.swift @@ -0,0 +1,252 @@ +import SnapKit +import UIKit + +/// 日清页顶部可横滑的周/月日历组件。 +@MainActor +final class OfflineCollectionCalendarView: UIView { + var onDateSelected: ((String) -> Void)? + var onModeChanged: (() -> Void)? + + private let titleLabel = UILabel() + private let toggleButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + private let rowsStack = UIStackView() + private var rowStacks: [UIStackView] = [] + private var dayButtons: [OfflineCollectionDayButton] = [] + private var pendingDates: Set = [] + private var state = OfflineCollectionCalendarState(selectedDate: Date(), maximumDate: Date()) + private let calendar = OfflineCollectionDate.calendar + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: state.mode == .week ? 160 : 378) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// 使用选中日、服务端今日和待补缴日期刷新组件。 + func apply(selectedDate: String, maximumDate: String, pendingDates: Set) { + guard let selected = OfflineCollectionDate.date(from: selectedDate), + let maximum = OfflineCollectionDate.date(from: maximumDate) else { return } + self.pendingDates = pendingDates + state = OfflineCollectionCalendarState( + selectedDate: selected, + maximumDate: maximum, + mode: state.mode, + calendar: calendar + ) + reloadDates() + } + + private func setupUI() { + backgroundColor = .white + layer.cornerRadius = AppRadius.lg + layer.shadowColor = UIColor(hex: 0x8FA0B8).cgColor + layer.shadowOpacity = 0.12 + layer.shadowRadius = 10 + layer.shadowOffset = CGSize(width: 0, height: 4) + + titleLabel.font = .systemFont(ofSize: 20, weight: .bold) + titleLabel.textColor = UIColor(hex: 0x081739) + toggleButton.configuration = .plain() + toggleButton.configuration?.baseForegroundColor = UIColor(hex: 0x081739) + toggleButton.configuration?.imagePlacement = .trailing + toggleButton.configuration?.imagePadding = 8 + toggleButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 12) + toggleButton.layer.cornerRadius = 9 + toggleButton.layer.borderWidth = 1 + toggleButton.layer.borderColor = UIColor(hex: 0xE2E7EF).cgColor + toggleButton.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) + toggleButton.accessibilityIdentifier = "offlineCollection.calendar.toggle" + + nextButton.setImage(UIImage(systemName: "chevron.right"), for: .normal) + nextButton.tintColor = UIColor(hex: 0x081739) + nextButton.accessibilityLabel = "下一页" + nextButton.addTarget(self, action: #selector(nextPage), for: .touchUpInside) + nextButton.snp.makeConstraints { make in make.width.height.equalTo(44) } + + let spacer = UIView() + let header = UIStackView(arrangedSubviews: [titleLabel, spacer, toggleButton, nextButton]) + header.axis = .horizontal + header.alignment = .center + header.spacing = 8 + + let weekdayStack = UIStackView() + weekdayStack.axis = .horizontal + weekdayStack.distribution = .fillEqually + ["一", "二", "三", "四", "五", "六", "日"].forEach { value in + let label = UILabel() + label.text = value + label.font = .systemFont(ofSize: 13, weight: .medium) + label.textColor = UIColor(hex: 0x657084) + label.textAlignment = .center + weekdayStack.addArrangedSubview(label) + } + + rowsStack.axis = .vertical + rowsStack.distribution = .fillEqually + rowsStack.spacing = 1 + for _ in 0 ..< 6 { + let row = UIStackView() + row.axis = .horizontal + row.distribution = .fillEqually + for _ in 0 ..< 7 { + let button = OfflineCollectionDayButton() + button.addTarget(self, action: #selector(dayTapped(_:)), for: .touchUpInside) + dayButtons.append(button) + row.addArrangedSubview(button) + } + rowStacks.append(row) + rowsStack.addArrangedSubview(row) + } + + addSubview(header) + addSubview(weekdayStack) + addSubview(rowsStack) + header.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().inset(10) + make.height.equalTo(44) + } + weekdayStack.snp.makeConstraints { make in + make.top.equalTo(header.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(10) + make.height.equalTo(24) + } + rowsStack.snp.makeConstraints { make in + make.top.equalTo(weekdayStack.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(10) + make.bottom.equalToSuperview().inset(12) + } + + let left = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) + left.direction = .left + let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))) + right.direction = .right + addGestureRecognizer(left) + addGestureRecognizer(right) + reloadDates() + } + + private func reloadDates() { + titleLabel.text = state.monthTitle + let isMonth = state.mode == .month + toggleButton.configuration?.title = isMonth ? "周" : "月" + toggleButton.configuration?.image = UIImage(systemName: isMonth ? "chevron.up" : "chevron.down") + rowStacks.enumerated().forEach { $0.element.isHidden = !isMonth && $0.offset > 0 } + + let dates = isMonth ? state.monthDates : state.weekDates + let selectedMonth = calendar.component(.month, from: state.selectedDate) + for (index, button) in dayButtons.enumerated() { + guard index < dates.count else { + button.isHidden = true + continue + } + let date = dates[index] + let key = OfflineCollectionDate.businessDate(for: date, calendar: calendar) + button.isHidden = false + button.apply( + date: date, + key: key, + selected: calendar.isDate(date, inSameDayAs: state.selectedDate), + pending: pendingDates.contains(key), + today: calendar.isDate(date, inSameDayAs: state.maximumDate), + enabled: date <= state.maximumDate, + inCurrentMonth: !isMonth || calendar.component(.month, from: date) == selectedMonth, + calendar: calendar + ) + } + invalidateIntrinsicContentSize() + } + + @objc private func toggleMode() { + state.toggleMode() + let animations = { [weak self] in + self?.reloadDates() + self?.superview?.layoutIfNeeded() + } + if UIAccessibility.isReduceMotionEnabled { animations() } else { + UIView.animate(withDuration: 0.2, animations: animations) + } + onModeChanged?() + } + + @objc private func dayTapped(_ sender: OfflineCollectionDayButton) { + guard let date = sender.date, state.select(date) else { return } + reloadDates() + onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) + } + + @objc private func swiped(_ gesture: UISwipeGestureRecognizer) { + let oldDate = state.selectedDate + let offset = gesture.direction == .left ? 1 : -1 + let date = state.movePage(offset) + guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } + let transition: UIView.AnimationOptions = gesture.direction == .left ? .transitionCrossDissolve : .transitionCrossDissolve + if UIAccessibility.isReduceMotionEnabled { reloadDates() } else { + UIView.transition(with: rowsStack, duration: 0.18, options: transition) { [weak self] in self?.reloadDates() } + } + onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) + } + + @objc private func nextPage() { + let oldDate = state.selectedDate + let date = state.movePage(1) + guard !calendar.isDate(oldDate, inSameDayAs: date) else { return } + reloadDates() + onDateSelected?(OfflineCollectionDate.businessDate(for: date, calendar: calendar)) + } +} + +/// 日历中的单个日期按钮,同时表达选中、今日和待补缴状态。 +private final class OfflineCollectionDayButton: UIControl { + private let dayLabel = UILabel() + private let pendingDot = UIView() + private(set) var date: Date? + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 10 + dayLabel.font = .systemFont(ofSize: 17, weight: .medium) + dayLabel.textAlignment = .center + pendingDot.layer.cornerRadius = 2.5 + addSubview(dayLabel) + addSubview(pendingDot) + dayLabel.snp.makeConstraints { make in make.centerX.equalToSuperview(); make.centerY.equalToSuperview().offset(-2) } + pendingDot.snp.makeConstraints { make in make.top.equalTo(dayLabel.snp.bottom).offset(2); make.centerX.equalToSuperview(); make.width.height.equalTo(5) } + snp.makeConstraints { make in make.height.greaterThanOrEqualTo(44) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply( + date: Date, + key: String, + selected: Bool, + pending: Bool, + today: Bool, + enabled: Bool, + inCurrentMonth: Bool, + calendar: Calendar + ) { + self.date = date + isEnabled = enabled + dayLabel.text = String(calendar.component(.day, from: date)) + pendingDot.isHidden = !pending + pendingDot.backgroundColor = selected ? .white : AppColor.danger + backgroundColor = selected ? AppColor.primary : (pending ? UIColor(hex: 0xFFF1F0) : .clear) + layer.borderWidth = pending && selected ? 2 : (today && !selected ? 1 : 0) + layer.borderColor = (pending && selected ? AppColor.danger : AppColor.primary).cgColor + dayLabel.textColor = selected ? .white : (enabled ? (inCurrentMonth ? AppColor.textPrimary : AppColor.textTertiary) : AppColor.textTertiary) + alpha = enabled ? 1 : 0.35 + accessibilityLabel = "\(key)\(today ? ",今天" : "")\(pending ? ",待补缴" : "")\(selected ? ",已选中" : "")" + accessibilityTraits = selected ? [.button, .selected] : .button + } +} diff --git a/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift new file mode 100644 index 0000000..2f9ce36 --- /dev/null +++ b/suixinkan/UI/OfflineCollection/Views/OfflineCollectionHomeView.swift @@ -0,0 +1,170 @@ +import SnapKit +import UIKit + +/// 收款页中的线下收款入口、今日汇总和逾期提醒区域。 +@MainActor +final class OfflineCollectionHomeView: UIView { + var onRegister: (() -> Void)? + var onOpenToday: (() -> Void)? + var onOpenOverdue: (() -> Void)? + var onRetry: (() -> Void)? + + private let stack = UIStackView() + private let statusButton = UIButton(type: .system) + private let overdueCard = OfflineCollectionHomeCard() + private let registerCard = OfflineCollectionHomeCard() + private let todayCard = OfflineCollectionHomeCard() + + override init(frame: CGRect) { + super.init(frame: frame) + stack.axis = .vertical + stack.spacing = AppSpacing.md + let title = UILabel() + title.text = "线下收款" + title.font = .systemFont(ofSize: 16, weight: .semibold) + title.textColor = AppColor.textPrimary + stack.addArrangedSubview(title) + stack.addArrangedSubview(statusButton) + stack.addArrangedSubview(overdueCard) + stack.addArrangedSubview(registerCard) + stack.addArrangedSubview(todayCard) + addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + title.snp.makeConstraints { make in make.height.equalTo(24) } + + statusButton.configuration = .plain() + statusButton.configuration?.baseForegroundColor = AppColor.primary + statusButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + statusButton.isHidden = true + + registerCard.apply( + title: "线下收款登记", + detail: "线下收款后,请及时登记并在当日完成补缴", + action: "去登记", + image: "plus.circle.fill", + tint: AppColor.primary, + background: .white + ) + registerCard.addTarget(self, action: #selector(registerTapped), for: .touchUpInside) + todayCard.addTarget(self, action: #selector(todayTapped), for: .touchUpInside) + overdueCard.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// 根据真实统计接口状态刷新区域。 + func apply( + today: OfflineDailySummary, + overdueDayCount: Int, + overdueRecordCount: Int, + overdueAmountFen: Int, + errorMessage: String? + ) { + if let errorMessage { + statusButton.configuration?.title = "\(errorMessage) 点击重试" + statusButton.configuration?.showsActivityIndicator = false + statusButton.isUserInteractionEnabled = true + statusButton.isHidden = false + statusButton.accessibilityTraits.insert(.button) + } else { + statusButton.isHidden = true + } + + let hasOverdue = overdueDayCount > 0 + overdueCard.isHidden = !hasOverdue + if hasOverdue { + overdueCard.apply( + title: "存在逾期未补缴", + detail: "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))", + action: "立即处理", + image: "exclamationmark.circle.fill", + tint: AppColor.danger, + background: UIColor(hex: 0xFFF1F0) + ) + } + + let settled = today.totalCount > 0 && today.pendingCount == 0 + todayCard.apply( + title: "今日待补缴 \(OfflineCollectionMoney.display(today.pendingAmountFen))", + detail: "\(today.pendingCount) 笔 · 今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))\(settled ? " · 已结清" : "")", + action: today.pendingCount > 0 ? "去补缴" : "查看明细", + image: "calendar", + tint: today.pendingCount > 0 ? UIColor(hex: 0xD97706) : AppColor.primary, + background: .white + ) + } + + @objc private func retryTapped() { onRetry?() } + @objc private func registerTapped() { onRegister?() } + @objc private func todayTapped() { onOpenToday?() } + @objc private func overdueTapped() { onOpenOverdue?() } +} + +/// 线下收款首页区域的统一可点击卡片。 +private final class OfflineCollectionHomeCard: UIControl { + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let actionLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = AppRadius.lg + clipsToBounds = true + isAccessibilityElement = true + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + detailLabel.font = .systemFont(ofSize: 13) + detailLabel.textColor = AppColor.textSecondary + detailLabel.numberOfLines = 0 + actionLabel.font = .systemFont(ofSize: 13, weight: .semibold) + actionLabel.textColor = AppColor.primary + actionLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel]) + textStack.axis = .vertical + textStack.spacing = 5 + [iconView, titleLabel, detailLabel, actionLabel, textStack].forEach { + $0.isUserInteractionEnabled = false + } + addSubview(iconView) + addSubview(textStack) + addSubview(actionLabel) + iconView.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview(); make.width.height.equalTo(30) } + textStack.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm); make.top.bottom.equalToSuperview().inset(AppSpacing.md); make.trailing.lessThanOrEqualTo(actionLabel.snp.leading).offset(-AppSpacing.sm) } + actionLabel.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.md); make.centerY.equalToSuperview() } + snp.makeConstraints { make in make.height.greaterThanOrEqualTo(86) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard isEnabled, + isUserInteractionEnabled, + !isHidden, + alpha > 0.01, + self.point(inside: point, with: event) else { return nil } + return self + } + + override var isHighlighted: Bool { + didSet { + UIView.animate(withDuration: 0.12) { + self.alpha = self.isHighlighted ? 0.78 : 1 + } + } + } + + func apply(title: String, detail: String, action: String, image: String, tint: UIColor, background: UIColor) { + titleLabel.text = title + detailLabel.text = detail + actionLabel.text = "\(action) ›" + iconView.image = UIImage(systemName: image) + iconView.tintColor = tint + backgroundColor = background + accessibilityLabel = "\(title),\(detail),\(action)" + accessibilityTraits = .button + } +} diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift index a2b791f..69f5dc3 100644 --- a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -12,6 +12,11 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let viewModel = PaymentCollectionDetailsViewModel() private let paymentAPI = NetworkServices.shared.paymentAPI + private let offlineCollectionAPI = NetworkServices.shared.offlineCollectionAPI + private lazy var offlineCollectionViewModel = OfflineCollectionHomeViewModel( + context: .current(), + api: offlineCollectionAPI + ) private let scrollView = UIScrollView() private let contentContainerView = UIView() @@ -48,10 +53,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let voiceCardView = UIView() private let voiceTitleLabel = UILabel() private let voiceSwitch = UISwitch() + private let offlineCollectionView = OfflineCollectionHomeView() private var amountDialog: PaymentSetAmountDialogView? private var appliedBrandConfig: PayPageConfig? private var appliedBrandingRefreshVersion = -1 + private var isShowingOfflineCollectionLoading = false private var previousStandardAppearance: UINavigationBarAppearance? private var previousScrollEdgeAppearance: UINavigationBarAppearance? @@ -101,8 +108,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController { let usesBranding = viewModel.usesNalatiBranding view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground scrollView.showsVerticalScrollIndicator = !usesBranding - scrollView.isScrollEnabled = !usesBranding - scrollView.alwaysBounceVertical = false + scrollView.isScrollEnabled = true + scrollView.alwaysBounceVertical = true contentStack.axis = .vertical contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md @@ -201,6 +208,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(voiceCardView) } + contentStack.addArrangedSubview(offlineCollectionView) let qrDisplayView = usesBranding ? qrContainerView : qrImageView let qrContentStack = UIStackView(arrangedSubviews: [ @@ -330,15 +338,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController { make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) if viewModel.usesNalatiBranding { - make.height.equalTo(scrollView.snp.height) + make.height.greaterThanOrEqualTo(scrollView.snp.height) } } contentStack.snp.makeConstraints { make in make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) if viewModel.usesNalatiBranding { - make.centerX.centerY.equalToSuperview() - make.top.greaterThanOrEqualToSuperview() - make.bottom.lessThanOrEqualToSuperview() + make.centerX.equalToSuperview() + make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } else { make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) } @@ -358,6 +365,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController { refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside) voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged) + + offlineCollectionViewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyOfflineCollection() } + } + offlineCollectionView.onRetry = { [weak self] in + Task { await self?.offlineCollectionViewModel.load() } + } + offlineCollectionView.onRegister = { [weak self] in self?.openOfflineRegistration() } + offlineCollectionView.onOpenToday = { [weak self] in + guard let self, let date = self.offlineCollectionViewModel.todayBusinessDate else { return } + self.openOfflineDaily(date: date) + } + offlineCollectionView.onOpenOverdue = { [weak self] in + guard let self, let date = self.offlineCollectionViewModel.earliestOverdueBusinessDate else { return } + self.openOfflineDaily(date: date) + } } override func viewDidLoad() { @@ -369,10 +392,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyBrandNavigationAppearanceIfNeeded() + applyOfflineCollection() + Task { await offlineCollectionViewModel.load() } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + setOfflineCollectionLoadingVisible(false) restoreNavigationAppearanceIfNeeded() } @@ -432,6 +458,26 @@ final class PaymentCollectionDetailsViewController: BaseViewController { amountDialog?.dismiss() amountDialog = nil } + applyOfflineCollection() + } + + @MainActor + private func applyOfflineCollection() { + setOfflineCollectionLoadingVisible(offlineCollectionViewModel.isLoading) + offlineCollectionView.apply( + today: offlineCollectionViewModel.todaySummary, + overdueDayCount: offlineCollectionViewModel.overdueDates.count, + overdueRecordCount: offlineCollectionViewModel.overdueRecordCount, + overdueAmountFen: offlineCollectionViewModel.overdueAmountFen, + errorMessage: offlineCollectionViewModel.errorMessage + ) + } + + @MainActor + private func setOfflineCollectionLoadingVisible(_ visible: Bool) { + guard visible != isShowingOfflineCollectionLoading else { return } + isShowingOfflineCollectionLoading = visible + visible ? showLoading() : hideLoading() } private func setActionRowAvailable(_ isAvailable: Bool) { @@ -668,6 +714,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController { @objc private func voiceSwitchChanged() { viewModel.toggleReceiveVoice() } + + private func openOfflineRegistration() { + let controller = OfflineCollectionRegistrationViewController( + context: offlineCollectionViewModel.context, + api: offlineCollectionAPI + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func openOfflineDaily(date: String) { + let controller = OfflineCollectionDailyViewController( + businessDate: date, + context: offlineCollectionViewModel.context, + api: offlineCollectionAPI + ) + navigationController?.pushViewController(controller, animated: true) + } } /// 收款详情信息行。 diff --git a/suixinkanTests/OfflineCollectionFeatureTests.swift b/suixinkanTests/OfflineCollectionFeatureTests.swift new file mode 100644 index 0000000..87ed02a --- /dev/null +++ b/suixinkanTests/OfflineCollectionFeatureTests.swift @@ -0,0 +1,231 @@ +import XCTest +import UIKit +@testable import suixinkan + +/// 线下收款接口契约与精确金额测试。 +@MainActor +final class OfflineCollectionAPITests: XCTestCase { + func testStatisticsAndDetailsUseRequiredQueriesWithoutPagination() async throws { + let statistics = Data(#"{"code":100000,"msg":"success","data":{"today":{"date":"2026-08-25","total_amount":"350.00","paid_amount":"100.00","unpaid_amount":"250.00","collect_count":3},"pending":{"amount":"250.00","collect_count":2,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"250.00","unpaid_count":2}]}}}"#.utf8) + let details = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-24","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}}"#.utf8) + let session = MockURLSession(responses: [statistics, details]) + let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.statistics(scenicId: 100) + let empty = try await api.details(scenicId: 100, date: "2026-08-24") + + XCTAssertEqual(response.today.date, "2026-08-25") + XCTAssertEqual(response.todaySummary.pendingAmountFen, 25_000) + XCTAssertEqual(empty.collects.count, 0) + let statisticsQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(statisticsQuery, [URLQueryItem(name: "scenic_id", value: "100")]) + let detailQuery = try XCTUnwrap(URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems) + XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "scenic_id", value: "100"))) + XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "date", value: "2026-08-24"))) + XCTAssertFalse(detailQuery.contains { $0.name == "page" || $0.name == "page_size" }) + } + + func testRegisterAndSupplementBodiesMatchBackendContract() async throws { + let registered = Data(#"{"code":100000,"msg":"success","data":{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}}"#.utf8) + let supplemented = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-25","updated_count":1}}"#.utf8) + let session = MockURLSession(responses: [registered, supplemented]) + let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.register(.init(scenicId: 100, amount: "12.30", payMethod: 2)) + _ = try await api.supplement(.init(scenicId: 100, businessDate: "2026-08-25", amountFen: 1_230, pendingCount: 1)) + + let registerBody = try jsonBody(session.requests[0]) + XCTAssertNil(registerBody["request_id"]) + XCTAssertEqual(registerBody["scenic_id"] as? Int, 100) + XCTAssertEqual(registerBody["amount"] as? String, "12.30") + XCTAssertNil(registerBody["status"]) + let supplementBody = try jsonBody(session.requests[1]) + XCTAssertEqual(supplementBody["date"] as? String, "2026-08-25") + XCTAssertEqual(supplementBody["scenic_id"] as? Int, 100) + XCTAssertNil(supplementBody["request_id"]) + XCTAssertNil(supplementBody["expected_unpaid_amount"]) + XCTAssertNil(supplementBody["expected_unpaid_count"]) + } + + func testMoneyNeverUsesFloatingPoint() { + XCTAssertEqual(OfflineCollectionMoney.parseFen("0.01"), 1) + XCTAssertEqual(OfflineCollectionMoney.parseFen("12.3"), 1_230) + XCTAssertEqual(OfflineCollectionMoney.apiAmount(9_999_999), "99999.99") + XCTAssertNil(OfflineCollectionMoney.parseFen("0")) + XCTAssertNil(OfflineCollectionMoney.parseFen("100000.00")) + XCTAssertNil(OfflineCollectionMoney.parseFen("1.001")) + } + + private func jsonBody(_ request: URLRequest) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]) + } +} + +/// 线下收款周/月日历边界测试。 +final class OfflineCollectionCalendarTests: XCTestCase { + func testMondayFirstWeekAndSixRowMonth() throws { + let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25")) + var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected) + XCTAssertEqual(state.weekDates.map { OfflineCollectionDate.businessDate(for: $0) }, [ + "2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", "2026-08-28", "2026-08-29", "2026-08-30", + ]) + state.toggleMode() + XCTAssertEqual(state.monthDates.count, 42) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.monthDates[0]), "2026-07-27") + } + + func testMonthSwipeClampsDayAndFutureDate() throws { + let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25")) + let january = try XCTUnwrap(OfflineCollectionDate.date(from: "2024-01-31")) + var state = OfflineCollectionCalendarState(selectedDate: january, maximumDate: maximum, mode: .month) + state.movePage(1) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2024-02-29") + XCTAssertFalse(state.select(try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26")))) + state.movePage(40) + XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25") + } +} + +/// 线下收款 ViewModel 的并发与幂等行为测试。 +@MainActor +final class OfflineCollectionViewModelTests: XCTestCase { + func testRegistrationViewMatchesReferenceStructure() throws { + let controller = OfflineCollectionRegistrationViewController( + context: .init(collectorName: "张三", storeName: "那拉提旅拍一店", scenicId: 100, scenicName: "那拉提景区"), + api: OfflineCollectionFakeAPI() + ) + + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + let subviews = allSubviews(in: controller.view) + let labels = subviews.compactMap { ($0 as? UILabel)?.text } + let amountField = try XCTUnwrap(subviews.compactMap { $0 as? UITextField }.first { $0.accessibilityLabel == "收款金额" }) + let methodButtons = subviews.compactMap { $0 as? OfflinePaymentMethodButton } + let submitButton = try XCTUnwrap(subviews.compactMap { $0 as? UIButton }.first { $0.accessibilityIdentifier == "offlineCollection.submit" }) + + XCTAssertEqual(controller.title, "线下收款登记") + XCTAssertTrue(labels.contains("收款金额")) + XCTAssertTrue(labels.contains("收款方式")) + XCTAssertTrue(labels.contains("登记后将计入今日待补缴,不生成订单。")) + XCTAssertTrue(labels.contains("当前:张三 · 那拉提旅拍一店 · 那拉提景区")) + XCTAssertEqual(amountField.font?.pointSize, 38) + XCTAssertEqual(amountField.textAlignment, .right) + XCTAssertEqual(methodButtons.count, 3) + XCTAssertEqual(submitButton.configuration?.title, "确认登记") + XCTAssertFalse(submitButton.configuration?.showsActivityIndicator ?? false) + } + + func testDailyViewControllerLoadsWithoutConstraintCrash() { + let controller = OfflineCollectionDailyViewController( + businessDate: "2026-08-25", + context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), + api: OfflineCollectionFakeAPI() + ) + + controller.loadViewIfNeeded() + + XCTAssertNotNil(controller.view) + } + + func testOfflineCollectionHomeCardsUseTheirEntireSurfaceForNavigation() throws { + let homeView = OfflineCollectionHomeView(frame: CGRect(x: 0, y: 0, width: 390, height: 300)) + var openedRegistration = false + var openedToday = false + homeView.onRegister = { openedRegistration = true } + homeView.onOpenToday = { openedToday = true } + homeView.apply( + today: .init(businessDate: "2026-08-25", totalCount: 1, totalAmountFen: 1_230, settledCount: 0, settledAmountFen: 0, pendingCount: 1, pendingAmountFen: 1_230), + overdueDayCount: 0, + overdueRecordCount: 0, + overdueAmountFen: 0, + errorMessage: nil + ) + homeView.setNeedsLayout() + homeView.layoutIfNeeded() + + let controls = allSubviews(in: homeView).compactMap { $0 as? UIControl } + let registrationCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("线下收款登记") == true }) + let todayCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("今日待补缴") == true }) + + XCTAssertTrue(registrationCard.hitTest(CGPoint(x: registrationCard.bounds.midX, y: registrationCard.bounds.midY), with: nil) === registrationCard) + XCTAssertTrue(todayCard.hitTest(CGPoint(x: todayCard.bounds.midX, y: todayCard.bounds.midY), with: nil) === todayCard) + registrationCard.sendActions(for: .touchUpInside) + todayCard.sendActions(for: .touchUpInside) + XCTAssertTrue(openedRegistration) + XCTAssertTrue(openedToday) + } + + func testRapidDateChangesDiscardOlderResponse() async throws { + let api = OfflineCollectionFakeAPI() + api.detailDelays["2026-08-24"] = 150_000_000 + let viewModel = OfflineCollectionDailyViewModel( + businessDate: "2026-08-25", + context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), + api: api + ) + + let older = Task { await viewModel.selectBusinessDate("2026-08-24") } + try await Task.sleep(nanoseconds: 20_000_000) + await viewModel.selectBusinessDate("2026-08-25") + await older.value + + XCTAssertEqual(viewModel.businessDate, "2026-08-25") + XCTAssertEqual(viewModel.summary.businessDate, "2026-08-25") + } + + func testRegistrationRetryUsesExistingBackendRequestShape() async { + let api = OfflineCollectionFakeAPI() + api.registerFailuresRemaining = 1 + let viewModel = OfflineCollectionRegistrationViewModel( + context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), + api: api + ) + viewModel.updateAmount("12.30") + + await viewModel.submit() + await viewModel.submit() + + XCTAssertEqual(api.registerRequests.count, 2) + XCTAssertEqual(api.registerRequests[0].amount, api.registerRequests[1].amount) + } + + private func allSubviews(in view: UIView) -> [UIView] { + view.subviews.flatMap { [$0] + allSubviews(in: $0) } + } +} + +/// 为 ViewModel 测试提供可控延时和失败次数的线下收款接口替身。 +@MainActor +private final class OfflineCollectionFakeAPI: OfflineCollectionServing { + var detailDelays: [String: UInt64] = [:] + var registerFailuresRemaining = 0 + private(set) var registerRequests: [OfflineCollectionRegisterRequest] = [] + + func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse { + try decode(#"{"today":{"date":"2026-08-25","total_amount":"12.30","paid_amount":"0.00","unpaid_amount":"12.30","collect_count":1},"pending":{"amount":"12.30","collect_count":1,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"12.30","unpaid_count":1}]}}"#) + } + + func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse { + if let delay = detailDelays[date] { try? await Task.sleep(nanoseconds: delay) } + return try decode(#"{"date":"\#(date)","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}"#) + } + + func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse { + registerRequests.append(request) + if registerFailuresRemaining > 0 { + registerFailuresRemaining -= 1 + throw URLError(.networkConnectionLost) + } + return try decode(#"{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}"#) + } + + func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult { + try decode(#"{"date":"2026-08-25","updated_count":1}"#) + } + + private func decode(_ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } +}