feat: 完成9.7线下收款登记与日清
|
After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
After Width: | Height: | Size: 905 KiB |
@@ -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`、补缴金额快照或补缴笔数快照,也不依赖新增错误码。
|
||||||
@@ -17,6 +17,7 @@ final class NetworkServices {
|
|||||||
let orderAPI: OrderAPI
|
let orderAPI: OrderAPI
|
||||||
let homeAPI: HomeAPI
|
let homeAPI: HomeAPI
|
||||||
let paymentAPI: PaymentAPI
|
let paymentAPI: PaymentAPI
|
||||||
|
let offlineCollectionAPI: OfflineCollectionAPI
|
||||||
let taskAPI: TaskAPI
|
let taskAPI: TaskAPI
|
||||||
let inviteAPI: InviteAPI
|
let inviteAPI: InviteAPI
|
||||||
let walletAPI: WalletAPI
|
let walletAPI: WalletAPI
|
||||||
@@ -43,6 +44,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: client)
|
orderAPI = OrderAPI(client: client)
|
||||||
homeAPI = HomeAPI(client: client)
|
homeAPI = HomeAPI(client: client)
|
||||||
paymentAPI = PaymentAPI(client: client)
|
paymentAPI = PaymentAPI(client: client)
|
||||||
|
offlineCollectionAPI = OfflineCollectionAPI(client: client)
|
||||||
taskAPI = TaskAPI(client: client)
|
taskAPI = TaskAPI(client: client)
|
||||||
inviteAPI = InviteAPI(client: client)
|
inviteAPI = InviteAPI(client: client)
|
||||||
walletAPI = WalletAPI(client: client)
|
walletAPI = WalletAPI(client: client)
|
||||||
@@ -74,6 +76,7 @@ final class NetworkServices {
|
|||||||
orderAPI = OrderAPI(client: apiClient)
|
orderAPI = OrderAPI(client: apiClient)
|
||||||
homeAPI = HomeAPI(client: apiClient)
|
homeAPI = HomeAPI(client: apiClient)
|
||||||
paymentAPI = PaymentAPI(client: apiClient)
|
paymentAPI = PaymentAPI(client: apiClient)
|
||||||
|
offlineCollectionAPI = OfflineCollectionAPI(client: apiClient)
|
||||||
taskAPI = TaskAPI(client: apiClient)
|
taskAPI = TaskAPI(client: apiClient)
|
||||||
inviteAPI = InviteAPI(client: apiClient)
|
inviteAPI = InviteAPI(client: apiClient)
|
||||||
walletAPI = WalletAPI(client: apiClient)
|
walletAPI = WalletAPI(client: apiClient)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "offline_security_shield_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_alipay_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 221 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_cash_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "payment_method_wechat_generated.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 268 KiB |
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 日清日历的展示模式。
|
||||||
|
enum OfflineCollectionCalendarMode: Sendable, Equatable {
|
||||||
|
case week
|
||||||
|
case month
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 周/月日历日期运算状态,不依赖 UIKit,便于单元测试。
|
||||||
|
struct OfflineCollectionCalendarState: Sendable, Equatable {
|
||||||
|
private let calendar: Calendar
|
||||||
|
private(set) var selectedDate: Date
|
||||||
|
private(set) var maximumDate: Date
|
||||||
|
private(set) var mode: OfflineCollectionCalendarMode
|
||||||
|
|
||||||
|
init(
|
||||||
|
selectedDate: Date,
|
||||||
|
maximumDate: Date,
|
||||||
|
mode: OfflineCollectionCalendarMode = .week,
|
||||||
|
calendar: Calendar = OfflineCollectionDate.calendar
|
||||||
|
) {
|
||||||
|
self.calendar = calendar
|
||||||
|
self.maximumDate = calendar.startOfDay(for: maximumDate)
|
||||||
|
self.selectedDate = min(calendar.startOfDay(for: selectedDate), self.maximumDate)
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换周历和月历并保留选中日期。
|
||||||
|
mutating func toggleMode() {
|
||||||
|
mode = mode == .week ? .month : .week
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择不晚于服务端今日的日期。
|
||||||
|
mutating func select(_ date: Date) -> Bool {
|
||||||
|
let value = calendar.startOfDay(for: date)
|
||||||
|
guard value <= maximumDate else { return false }
|
||||||
|
selectedDate = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横滑一个周或一个月,并返回新的选中日期。
|
||||||
|
@discardableResult
|
||||||
|
mutating func movePage(_ offset: Int) -> Date {
|
||||||
|
let candidate: Date
|
||||||
|
switch mode {
|
||||||
|
case .week:
|
||||||
|
candidate = calendar.date(byAdding: .day, value: offset * 7, to: selectedDate) ?? selectedDate
|
||||||
|
case .month:
|
||||||
|
let current = calendar.dateComponents([.year, .month, .day], from: selectedDate)
|
||||||
|
let monthStart = calendar.date(from: DateComponents(year: current.year, month: current.month, day: 1)) ?? selectedDate
|
||||||
|
let targetMonth = calendar.date(byAdding: .month, value: offset, to: monthStart) ?? monthStart
|
||||||
|
let days = calendar.range(of: .day, in: .month, for: targetMonth)?.count ?? 1
|
||||||
|
candidate = calendar.date(byAdding: .day, value: min(current.day ?? 1, days) - 1, to: targetMonth) ?? targetMonth
|
||||||
|
}
|
||||||
|
selectedDate = min(calendar.startOfDay(for: candidate), maximumDate)
|
||||||
|
return selectedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前周的周一至周日。
|
||||||
|
var weekDates: [Date] {
|
||||||
|
let weekday = calendar.component(.weekday, from: selectedDate)
|
||||||
|
let daysFromMonday = (weekday + 5) % 7
|
||||||
|
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: selectedDate) ?? selectedDate
|
||||||
|
return (0 ..< 7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前月份固定六行、周一开周的 42 个日期。
|
||||||
|
var monthDates: [Date] {
|
||||||
|
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
let first = calendar.date(from: parts) ?? selectedDate
|
||||||
|
let weekday = calendar.component(.weekday, from: first)
|
||||||
|
let leading = (weekday + 5) % 7
|
||||||
|
let start = calendar.date(byAdding: .day, value: -leading, to: first) ?? first
|
||||||
|
return (0 ..< 42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中月份标题。
|
||||||
|
var monthTitle: String {
|
||||||
|
let parts = calendar.dateComponents([.year, .month], from: selectedDate)
|
||||||
|
return "\(parts.year ?? 0)年\(parts.month ?? 0)月"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 线下收款方式,对应后端 `pay_method`。
|
||||||
|
enum OfflineCollectionPaymentMethod: Int, Codable, CaseIterable, Sendable, Hashable {
|
||||||
|
case wechat = 2
|
||||||
|
case alipay = 1
|
||||||
|
case cash = 3
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self { case .wechat: "微信"; case .alipay: "支付宝"; case .cash: "现金" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImageName: String {
|
||||||
|
switch self { case .wechat: "message.fill"; case .alipay: "a.circle.fill"; case .cash: "banknote.fill" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetName: String {
|
||||||
|
switch self { case .wechat: "payment_method_wechat"; case .alipay: "payment_method_alipay"; case .cash: "payment_method_cash" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款记录的补缴展示状态。
|
||||||
|
enum OfflineCollectionStatus: Int, Codable, Sendable, Hashable {
|
||||||
|
case pending = 0
|
||||||
|
case settled = 1
|
||||||
|
case overdue = 2
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self { case .pending: "待补缴"; case .settled: "已补缴"; case .overdue: "逾期未补缴" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前登录账号在线下收款页面中的展示和请求上下文。
|
||||||
|
struct OfflineCollectionContext: Sendable, Hashable {
|
||||||
|
let collectorName: String
|
||||||
|
let storeName: String
|
||||||
|
let scenicId: Int
|
||||||
|
let scenicName: String
|
||||||
|
|
||||||
|
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
|
||||||
|
let session = appStore.session
|
||||||
|
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
|
||||||
|
let store = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
|
||||||
|
let name = [session.realName, session.userName, session.accountDisplayName]
|
||||||
|
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? "-"
|
||||||
|
return OfflineCollectionContext(
|
||||||
|
collectorName: name,
|
||||||
|
storeName: store?.name.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-",
|
||||||
|
scenicId: session.currentScenicId,
|
||||||
|
scenicName: session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页展示的一笔线下收款记录。
|
||||||
|
struct OfflineCollectionRecord: Sendable, Hashable {
|
||||||
|
let collectNo: String
|
||||||
|
let amountFen: Int
|
||||||
|
let paymentMethod: OfflineCollectionPaymentMethod
|
||||||
|
let timeText: String
|
||||||
|
let status: OfflineCollectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某一营业日的线下收款汇总。
|
||||||
|
struct OfflineDailySummary: Sendable, Hashable {
|
||||||
|
let businessDate: String
|
||||||
|
let totalCount: Int
|
||||||
|
let totalAmountFen: Int
|
||||||
|
let settledCount: Int
|
||||||
|
let settledAmountFen: Int
|
||||||
|
let pendingCount: Int
|
||||||
|
let pendingAmountFen: Int
|
||||||
|
|
||||||
|
static func empty(_ date: String) -> OfflineDailySummary {
|
||||||
|
OfflineDailySummary(
|
||||||
|
businessDate: date, totalCount: 0, totalAmountFen: 0,
|
||||||
|
settledCount: 0, settledAmountFen: 0, pendingCount: 0, pendingAmountFen: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计接口中的一个待补缴日期标记。
|
||||||
|
struct OfflinePendingDate: Decodable, Sendable, Hashable {
|
||||||
|
let date: String
|
||||||
|
let unpaidAmount: String
|
||||||
|
let unpaidCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case unpaidCount = "unpaid_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
var unpaidAmountFen: Int { OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款统计接口响应。
|
||||||
|
struct OfflineCollectionStatisticsResponse: Decodable, Sendable {
|
||||||
|
/// 服务端今日营业日及金额汇总。
|
||||||
|
struct Today: Decodable, Sendable {
|
||||||
|
let date: String
|
||||||
|
let totalAmount: String
|
||||||
|
let paidAmount: String
|
||||||
|
let unpaidAmount: String
|
||||||
|
let collectCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case totalAmount = "total_amount"
|
||||||
|
case paidAmount = "paid_amount"
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前账号在所选景区内的全部待补缴汇总。
|
||||||
|
struct Pending: Decodable, Sendable {
|
||||||
|
let amount: String
|
||||||
|
let collectCount: Int
|
||||||
|
let dateCount: Int
|
||||||
|
let dates: [OfflinePendingDate]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case amount
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case dateCount = "date_count"
|
||||||
|
case dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let today: Today
|
||||||
|
let pending: Pending
|
||||||
|
|
||||||
|
var todaySummary: OfflineDailySummary {
|
||||||
|
let pendingCount = pending.dates.first(where: { $0.date == today.date })?.unpaidCount ?? 0
|
||||||
|
return OfflineDailySummary(
|
||||||
|
businessDate: today.date,
|
||||||
|
totalCount: today.collectCount,
|
||||||
|
totalAmountFen: OfflineCollectionMoney.responseFen(today.totalAmount) ?? 0,
|
||||||
|
settledCount: max(0, today.collectCount - pendingCount),
|
||||||
|
settledAmountFen: OfflineCollectionMoney.responseFen(today.paidAmount) ?? 0,
|
||||||
|
pendingCount: pendingCount,
|
||||||
|
pendingAmountFen: OfflineCollectionMoney.responseFen(today.unpaidAmount) ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单日详情接口响应。
|
||||||
|
struct OfflineCollectionDetailsResponse: Decodable, Sendable {
|
||||||
|
/// 单日详情中的一笔登记记录。
|
||||||
|
struct Collect: Decodable, Sendable {
|
||||||
|
let collectNo: String
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
let status: Int
|
||||||
|
let time: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case collectNo = "collect_no"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
case status
|
||||||
|
case time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let date: String
|
||||||
|
let totalAmount: String
|
||||||
|
let collectCount: Int
|
||||||
|
let paidAmount: String
|
||||||
|
let paidCount: Int
|
||||||
|
let unpaidAmount: String
|
||||||
|
let unpaidCount: Int
|
||||||
|
let status: Int?
|
||||||
|
let statusText: String?
|
||||||
|
let collects: [Collect]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case totalAmount = "total_amount"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case paidAmount = "paid_amount"
|
||||||
|
case paidCount = "paid_count"
|
||||||
|
case unpaidAmount = "unpaid_amount"
|
||||||
|
case unpaidCount = "unpaid_count"
|
||||||
|
case status
|
||||||
|
case statusText = "status_text"
|
||||||
|
case collects
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: OfflineDailySummary {
|
||||||
|
OfflineDailySummary(
|
||||||
|
businessDate: date,
|
||||||
|
totalCount: collectCount,
|
||||||
|
totalAmountFen: OfflineCollectionMoney.responseFen(totalAmount) ?? 0,
|
||||||
|
settledCount: paidCount,
|
||||||
|
settledAmountFen: OfflineCollectionMoney.responseFen(paidAmount) ?? 0,
|
||||||
|
pendingCount: unpaidCount,
|
||||||
|
pendingAmountFen: OfflineCollectionMoney.responseFen(unpaidAmount) ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func records(serverToday: String) -> [OfflineCollectionRecord] {
|
||||||
|
collects.compactMap { item in
|
||||||
|
guard let method = OfflineCollectionPaymentMethod(rawValue: item.payMethod) else { return nil }
|
||||||
|
let rawStatus = OfflineCollectionStatus(rawValue: item.status) ?? .pending
|
||||||
|
let status: OfflineCollectionStatus = rawStatus == .pending && date < serverToday ? .overdue : rawStatus
|
||||||
|
return OfflineCollectionRecord(
|
||||||
|
collectNo: item.collectNo,
|
||||||
|
amountFen: OfflineCollectionMoney.responseFen(item.amount) ?? 0,
|
||||||
|
paymentMethod: method,
|
||||||
|
timeText: String(item.time.prefix(5)),
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记一笔线下收款的请求。
|
||||||
|
struct OfflineCollectionRegisterRequest: Encodable, Sendable {
|
||||||
|
let scenicId: Int
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记线下收款响应。
|
||||||
|
struct OfflineCollectionRegisterResponse: Decodable, Sendable {
|
||||||
|
let collectNo: String
|
||||||
|
let amount: String
|
||||||
|
let payMethod: Int
|
||||||
|
let status: Int
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case collectNo = "collect_no"
|
||||||
|
case amount
|
||||||
|
case payMethod = "pay_method"
|
||||||
|
case status
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var record: OfflineCollectionRecord {
|
||||||
|
OfflineCollectionRecord(
|
||||||
|
collectNo: collectNo,
|
||||||
|
amountFen: OfflineCollectionMoney.responseFen(amount) ?? 0,
|
||||||
|
paymentMethod: OfflineCollectionPaymentMethod(rawValue: payMethod) ?? .wechat,
|
||||||
|
timeText: OfflineCollectionDate.timePart(createdAt),
|
||||||
|
status: OfflineCollectionStatus(rawValue: status) ?? .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 补缴确认数据;金额和笔数仅用于客户端确认,后端只接收景区和日期。
|
||||||
|
struct OfflineSettlementRequest: Encodable, Sendable, Hashable {
|
||||||
|
let scenicId: Int
|
||||||
|
let businessDate: String
|
||||||
|
let amountFen: Int
|
||||||
|
let pendingCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case businessDate = "date"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(scenicId, forKey: .scenicId)
|
||||||
|
try container.encode(businessDate, forKey: .businessDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次成功补缴的返回结果。
|
||||||
|
struct OfflineSettlementResult: Decodable, Sendable, Hashable {
|
||||||
|
let date: String
|
||||||
|
let updatedCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case updatedCount = "updated_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记成功弹窗所需数据。
|
||||||
|
struct OfflineCollectionRegistrationReceipt: Sendable {
|
||||||
|
let record: OfflineCollectionRecord
|
||||||
|
let businessDate: String
|
||||||
|
let summary: OfflineDailySummary?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款业务的客户端异常。
|
||||||
|
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
|
||||||
|
case missingScenic
|
||||||
|
case invalidAmount
|
||||||
|
case noPendingRecords
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingScenic: "请先选择景区"
|
||||||
|
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
|
||||||
|
case .noPendingRecords: "当前营业日无待补缴记录"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款金额的精确解析与格式化工具。
|
||||||
|
enum OfflineCollectionMoney {
|
||||||
|
static let maximumFen = 9_999_999
|
||||||
|
|
||||||
|
static func acceptsEditingText(_ text: String) -> Bool {
|
||||||
|
guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false }
|
||||||
|
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard parts.count <= 2 else { return false }
|
||||||
|
return (parts.first?.count ?? 0) <= 5 && (parts.count == 2 ? parts[1].count : 0) <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseFen(_ rawValue: String) -> Int? {
|
||||||
|
guard !rawValue.isEmpty, rawValue == rawValue.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
acceptsEditingText(rawValue), rawValue != "." else { return nil }
|
||||||
|
let parts = rawValue.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard let whole = Int(parts[0].isEmpty ? "0" : String(parts[0])) else { return nil }
|
||||||
|
let fraction = parts.count == 2 ? String(parts[1]) : ""
|
||||||
|
let cents = fraction.isEmpty ? 0 : (fraction.count == 1 ? (Int(fraction) ?? 0) * 10 : (Int(fraction) ?? 0))
|
||||||
|
let value = whole * 100 + cents
|
||||||
|
return (1 ... maximumFen).contains(value) ? value : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func responseFen(_ value: String) -> Int? { parseFen(value) }
|
||||||
|
static func apiAmount(_ fen: Int) -> String { "\(fen / 100).\(String(format: "%02d", fen % 100))" }
|
||||||
|
static func display(_ fen: Int) -> String { "¥\(apiAmount(fen))" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款统一使用的营业日工具。
|
||||||
|
enum OfflineCollectionDate {
|
||||||
|
static var calendar: Calendar {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.locale = Locale(identifier: "zh_CN")
|
||||||
|
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
calendar.firstWeekday = 2
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
static func businessDate(for date: Date, calendar: Calendar = calendar) -> String {
|
||||||
|
let values = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d", values.year ?? 0, values.month ?? 0, values.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from value: String, calendar: Calendar = calendar) -> Date? {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = calendar
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = calendar.timeZone
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func timePart(_ value: String) -> String {
|
||||||
|
String((value.split(separator: " ").last ?? Substring(value)).prefix(5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nonEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@@ -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<String> = []
|
||||||
|
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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int, OfflineDailyItem>!
|
||||||
|
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<Int, OfflineDailyItem>()
|
||||||
|
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<Int, OfflineDailyItem> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = []
|
||||||
|
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<String>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel = PaymentCollectionDetailsViewModel()
|
private let viewModel = PaymentCollectionDetailsViewModel()
|
||||||
private let paymentAPI = NetworkServices.shared.paymentAPI
|
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 scrollView = UIScrollView()
|
||||||
private let contentContainerView = UIView()
|
private let contentContainerView = UIView()
|
||||||
@@ -48,10 +53,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
private let voiceCardView = UIView()
|
private let voiceCardView = UIView()
|
||||||
private let voiceTitleLabel = UILabel()
|
private let voiceTitleLabel = UILabel()
|
||||||
private let voiceSwitch = UISwitch()
|
private let voiceSwitch = UISwitch()
|
||||||
|
private let offlineCollectionView = OfflineCollectionHomeView()
|
||||||
|
|
||||||
private var amountDialog: PaymentSetAmountDialogView?
|
private var amountDialog: PaymentSetAmountDialogView?
|
||||||
private var appliedBrandConfig: PayPageConfig?
|
private var appliedBrandConfig: PayPageConfig?
|
||||||
private var appliedBrandingRefreshVersion = -1
|
private var appliedBrandingRefreshVersion = -1
|
||||||
|
private var isShowingOfflineCollectionLoading = false
|
||||||
|
|
||||||
private var previousStandardAppearance: UINavigationBarAppearance?
|
private var previousStandardAppearance: UINavigationBarAppearance?
|
||||||
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
||||||
@@ -101,8 +108,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
let usesBranding = viewModel.usesNalatiBranding
|
let usesBranding = viewModel.usesNalatiBranding
|
||||||
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
||||||
scrollView.showsVerticalScrollIndicator = !usesBranding
|
scrollView.showsVerticalScrollIndicator = !usesBranding
|
||||||
scrollView.isScrollEnabled = !usesBranding
|
scrollView.isScrollEnabled = true
|
||||||
scrollView.alwaysBounceVertical = false
|
scrollView.alwaysBounceVertical = true
|
||||||
|
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
||||||
@@ -201,6 +208,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
contentStack.addArrangedSubview(recordRow)
|
contentStack.addArrangedSubview(recordRow)
|
||||||
contentStack.addArrangedSubview(voiceCardView)
|
contentStack.addArrangedSubview(voiceCardView)
|
||||||
}
|
}
|
||||||
|
contentStack.addArrangedSubview(offlineCollectionView)
|
||||||
|
|
||||||
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
|
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
|
||||||
let qrContentStack = UIStackView(arrangedSubviews: [
|
let qrContentStack = UIStackView(arrangedSubviews: [
|
||||||
@@ -330,15 +338,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
make.width.equalTo(scrollView.snp.width)
|
make.width.equalTo(scrollView.snp.width)
|
||||||
if viewModel.usesNalatiBranding {
|
if viewModel.usesNalatiBranding {
|
||||||
make.height.equalTo(scrollView.snp.height)
|
make.height.greaterThanOrEqualTo(scrollView.snp.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||||
if viewModel.usesNalatiBranding {
|
if viewModel.usesNalatiBranding {
|
||||||
make.centerX.centerY.equalToSuperview()
|
make.centerX.equalToSuperview()
|
||||||
make.top.greaterThanOrEqualToSuperview()
|
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
make.bottom.lessThanOrEqualToSuperview()
|
|
||||||
} else {
|
} else {
|
||||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
}
|
}
|
||||||
@@ -358,6 +365,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||||
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
|
recordRow.addTarget(self, action: #selector(recordTapped), for: .touchUpInside)
|
||||||
voiceSwitch.addTarget(self, action: #selector(voiceSwitchChanged), for: .valueChanged)
|
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() {
|
override func viewDidLoad() {
|
||||||
@@ -369,10 +392,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
applyBrandNavigationAppearanceIfNeeded()
|
applyBrandNavigationAppearanceIfNeeded()
|
||||||
|
applyOfflineCollection()
|
||||||
|
Task { await offlineCollectionViewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
setOfflineCollectionLoadingVisible(false)
|
||||||
restoreNavigationAppearanceIfNeeded()
|
restoreNavigationAppearanceIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +458,26 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
amountDialog?.dismiss()
|
amountDialog?.dismiss()
|
||||||
amountDialog = nil
|
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) {
|
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||||
@@ -668,6 +714,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
@objc private func voiceSwitchChanged() {
|
@objc private func voiceSwitchChanged() {
|
||||||
viewModel.toggleReceiveVoice()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收款详情信息行。
|
/// 收款详情信息行。
|
||||||
|
|||||||
@@ -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<T: Decodable>(_ json: String) throws -> T {
|
||||||
|
try JSONDecoder().decode(T.self, from: Data(json.utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||