194 lines
9.7 KiB
Swift
194 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
/// 注销条件中的单个服务端阻断项;保留未知 code/action,不能据此绕过服务端限制。
|
|
struct StoreAccountDeregistrationBlocker: Decodable, Equatable, Sendable {
|
|
/// 稳定业务错误码。
|
|
let code: String
|
|
/// 供用户阅读的阻断说明。
|
|
let message: String
|
|
/// 后端建议动作;客户端仅对已确认的动作提供说明。
|
|
let action: String
|
|
|
|
/// 已知资产确认问题集中展示在资产卡,其余阻断仍逐条展示并阻止继续。
|
|
var isAssetConfirmation: Bool {
|
|
["WALLET_WAIVER_MISSING", "POINTS_WAIVER_MISSING", "WAIVER_STALE"].contains(code)
|
|
}
|
|
|
|
/// 未实现跳转的动作仍展示处理建议,不悄悄丢弃该阻断项。
|
|
var guidance: String {
|
|
switch action {
|
|
case "complete_orders": "请先处理未履约订单或带单,然后刷新条件。"
|
|
case "wait_risk_window": "请等待业务风险期结束,然后刷新条件。"
|
|
case "confirm_wallet_waiver": "请单独确认放弃现金余额。"
|
|
case "confirm_points_waiver": "请单独确认放弃积分。"
|
|
default: "请按上述说明处理;如不清楚如何操作,请联系管理员或客服。"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 2026-08-28 真实 eligibility 响应;必需的金额、确认及权限字段缺失时解码失败。
|
|
struct StoreAccountDeregistrationEligibility: Decodable, Equatable, Sendable {
|
|
/// 服务端是否允许申请,不能单独替代完整阻断检查。
|
|
let canApply: Bool
|
|
/// 当前门店用户身份 ID。
|
|
let storeUserID: Int
|
|
/// 对应的财务身份 ID。
|
|
let financeIdentityID: Int
|
|
/// 现金余额,单位分,避免浮点运算。
|
|
let walletBalanceFen: Int64
|
|
/// 服务端金额展示字符串。
|
|
let walletBalance: String
|
|
/// 积分余额。
|
|
let pointsBalance: Int64
|
|
/// 服务端当前余额快照是否已有现金放弃确认。
|
|
let walletWaived: Bool
|
|
/// 服务端当前余额快照是否已有积分放弃确认。
|
|
let pointsWaived: Bool
|
|
/// 未履约主订单及带单数量。
|
|
let unfulfilledCount: Int
|
|
/// 尚在处理的交付任务数量。
|
|
let fulfillmentInProgressCount: Int
|
|
/// 最后业务风险结束时间;尚未确认时区,按服务端原文展示。
|
|
let riskEndAt: String?
|
|
/// 业务风险等待截止时间,不是注销冷静期截止时间。
|
|
let eligibleAt: String?
|
|
/// 服务端业务风险等待时长。
|
|
let riskWindowHours: Int
|
|
/// 全部阻断原因,包含客户端尚不认识的新错误码。
|
|
let blockers: [StoreAccountDeregistrationBlocker]
|
|
/// 当前注销记录;已实测草稿、冷静期和主动撤销,其他状态保留原始结构。
|
|
let deregister: StoreAccountDeregistrationJSON
|
|
|
|
/// 资产之外尚需处理的业务条件,包含客户端不认识的阻断代码。
|
|
var businessBlockers: [StoreAccountDeregistrationBlocker] {
|
|
blockers.filter { !$0.isAssetConfirmation }
|
|
}
|
|
|
|
/// 两步页面可以开始资产确认的前提,不替代提交前的完整服务端核验。
|
|
var permitsAssetConfirmation: Bool {
|
|
permitsPreparation && businessBlockers.isEmpty && storeUserID > 0 && financeIdentityID > 0
|
|
&& walletBalanceFen >= 0 && pointsBalance >= 0
|
|
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
|
|
}
|
|
|
|
/// 字段名对齐真实响应,不接受缺失字段的乐观默认值。
|
|
enum CodingKeys: String, CodingKey {
|
|
case canApply = "can_apply", storeUserID = "store_user_id", financeIdentityID = "finance_identity_id"
|
|
case walletBalanceFen = "wallet_balance_fen", walletBalance = "wallet_balance", pointsBalance = "points_balance"
|
|
case walletWaived = "wallet_waived", pointsWaived = "points_waived"
|
|
case unfulfilledCount = "unfulfilled_count", fulfillmentInProgressCount = "fulfillment_in_progress_count"
|
|
case riskEndAt = "risk_end_at", eligibleAt = "eligible_at", riskWindowHours = "risk_window_hours"
|
|
case blockers, deregister
|
|
}
|
|
|
|
/// 没有记录、未提交草稿或已撤销时可准备申请,其他条件仍以服务端核验为准。
|
|
var permitsPreparation: Bool {
|
|
StoreAccountDeregistrationStatus(deregister: deregister).permitsPreparation
|
|
}
|
|
|
|
/// 完整条件满足才允许提交;有矛盾或未知状态则拒绝,截止时间不由本机推断。
|
|
var permitsApplication: Bool {
|
|
canApply && blockers.isEmpty && walletWaived && pointsWaived
|
|
&& walletBalanceFen >= 0 && pointsBalance >= 0
|
|
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
|
|
&& storeUserID > 0 && permitsPreparation
|
|
}
|
|
|
|
/// 确认弹窗期间两种资产或财务身份发生变化,必须重新展示并确认。
|
|
func hasSameAssets(as other: Self) -> Bool {
|
|
storeUserID == other.storeUserID && financeIdentityID == other.financeIdentityID
|
|
&& walletBalanceFen == other.walletBalanceFen && pointsBalance == other.pointsBalance
|
|
}
|
|
}
|
|
|
|
/// status 的实测结构:0 草稿、1 冷静期、9 已撤销;缺失或未知状态不默认放行。
|
|
struct StoreAccountDeregistrationStatus: Decodable, Equatable, Sendable {
|
|
/// 保留原始记录,尚不推断未观测的阻断和完成状态值。
|
|
let deregister: StoreAccountDeregistrationJSON
|
|
|
|
/// 2026-08-28 两次资产确认后的真实草稿形态;不能仅凭 status=0 忽略矛盾的提交或终态字段。
|
|
var isUnsubmittedDraft: Bool {
|
|
guard case let .object(record) = deregister,
|
|
case let .number(id) = record["id"], id > 0,
|
|
id <= Decimal(Int64.max), id == Decimal(NSDecimalNumber(decimal: id).int64Value),
|
|
record["status"] == .number(0),
|
|
case let .string(label) = record["status_label"], !label.isEmpty,
|
|
case .string = record["reason"],
|
|
record["apply_time"] == .null, record["cooling_until"] == .null,
|
|
record["remaining_seconds"] == .number(0), record["cancel_time"] == .null,
|
|
record["blocked_code"] == .string(""), record["blocked_reason"] == .string(""),
|
|
record["completed_at"] == .null else { return false }
|
|
return true
|
|
}
|
|
|
|
/// 实测冷静期;即使剩余秒数为 0,也须等待服务端复核,不能视为完成。
|
|
var isCooling: Bool {
|
|
guard let record = validatedRecord else { return false }
|
|
return record["status"] == .number(1) && nonemptyString(record["apply_time"]) != nil
|
|
&& nonemptyString(record["cooling_until"]) != nil && record["cancel_time"] == .null
|
|
&& record["completed_at"] == .null && record["blocked_code"] == .string("")
|
|
&& record["blocked_reason"] == .string("")
|
|
}
|
|
|
|
/// 已撤销必须同时有撤销时间,不能只因剩余秒数为零或仍保留冷静期截止时间而推断。
|
|
var isCancelled: Bool {
|
|
guard let record = validatedRecord else { return false }
|
|
return record["status"] == .number(9) && nonemptyString(record["apply_time"]) != nil
|
|
&& nonemptyString(record["cancel_time"]) != nil && record["completed_at"] == .null
|
|
&& record["remaining_seconds"] == .number(0)
|
|
}
|
|
|
|
/// 用于核对取消前后是否为同一申请,不包含账号凭证。
|
|
var recordID: String? {
|
|
guard let record = validatedRecord, case let .number(id) = record["id"] else { return nil }
|
|
return NSDecimalNumber(decimal: id).stringValue
|
|
}
|
|
|
|
/// 识别同一次已撤销记录;避免重新申请时用旧撤销响应清除新的提交意图。
|
|
var cancellationFingerprint: String? {
|
|
guard isCancelled, let recordID, let time = nonemptyString(validatedRecord?["cancel_time"]) else { return nil }
|
|
return recordID + "|" + time
|
|
}
|
|
|
|
/// 按服务端原文展示,不推断时间字符串的时区。
|
|
var coolingUntil: String? { nonemptyString(validatedRecord?["cooling_until"]) }
|
|
|
|
/// 服务端查询时的剩余秒数;客户端不据此宣布注销完成。
|
|
var remainingSeconds: Int64? {
|
|
guard let record = validatedRecord, case let .number(seconds) = record["remaining_seconds"] else { return nil }
|
|
return NSDecimalNumber(decimal: seconds).int64Value
|
|
}
|
|
|
|
/// 没有申请、未提交草稿或已撤销允许恢复业务;准备新申请仍须重新核验资产及条件。
|
|
var permitsPreparation: Bool { deregister == .null || isUnsubmittedDraft || isCancelled }
|
|
|
|
private var validatedRecord: [String: StoreAccountDeregistrationJSON]? {
|
|
guard case let .object(record) = deregister,
|
|
case let .number(id) = record["id"], id > 0, isInteger(id),
|
|
case let .number(seconds) = record["remaining_seconds"], seconds >= 0, isInteger(seconds),
|
|
case let .string(label) = record["status_label"], !label.isEmpty,
|
|
case .string = record["reason"], case .string = record["blocked_code"],
|
|
case .string = record["blocked_reason"] else { return nil }
|
|
for key in ["apply_time", "cooling_until", "cancel_time", "completed_at"] {
|
|
guard record[key] == .null || nonemptyString(record[key]) != nil else { return nil }
|
|
}
|
|
return record
|
|
}
|
|
|
|
private func nonemptyString(_ value: StoreAccountDeregistrationJSON?) -> String? {
|
|
guard case let .string(text) = value, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
return text
|
|
}
|
|
|
|
private func isInteger(_ value: Decimal) -> Bool {
|
|
value <= Decimal(Int64.max) && value >= Decimal(Int64.min)
|
|
&& value == Decimal(NSDecimalNumber(decimal: value).int64Value)
|
|
}
|
|
}
|
|
|
|
/// 必须分别确认的两种资产。
|
|
enum StoreAccountDeregistrationAsset: Sendable {
|
|
case wallet
|
|
case points
|
|
}
|