// // OperatingAreaModels.swift // suixinkan // // Created by Codex on 2026/6/25. // import Foundation /// 运营区域入口模式,区分店铺管理员与景区管理员两条接口链路。 enum OperatingAreaEntryMode: Equatable { case storeAdmin(storeId: Int) case scenicAdmin(scenicId: Int) var title: String { switch self { case .storeAdmin: "店铺运营区域" case .scenicAdmin: "景区运营区域" } } } /// 运营区域接口单项,表示一个门店或区域围栏。 struct OperatingAreaItem: Decodable, Equatable, Identifiable { let id: Int let name: String let businessMapArea: OperatingMapArea? let statusText: String let typeText: String let auditStatusText: String enum CodingKeys: String, CodingKey { case id case name case businessMapArea = "business_map_area" case statusText = "status_text" case typeText = "type_text" case auditStatusText = "audit_status_text" } init( id: Int = 0, name: String = "", businessMapArea: OperatingMapArea? = nil, statusText: String = "", typeText: String = "", auditStatusText: String = "" ) { self.id = id self.name = name self.businessMapArea = businessMapArea self.statusText = statusText self.typeText = typeText self.auditStatusText = auditStatusText } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 name = try container.operatingDecodeLossyString(forKey: .name) businessMapArea = try container.decodeIfPresent(OperatingMapArea.self, forKey: .businessMapArea) statusText = try container.operatingDecodeLossyString(forKey: .statusText) typeText = try container.operatingDecodeLossyString(forKey: .typeText) auditStatusText = try container.operatingDecodeLossyString(forKey: .auditStatusText) } } /// 运营区域原始 JSON 值,用于保留并解析后端不稳定的 `business_map_area`。 enum OperatingMapArea: Decodable, Equatable { case null case bool(Bool) case number(Double) case string(String) case array([OperatingMapArea]) case object([String: OperatingMapArea]) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null } else if let value = try? container.decode(Bool.self) { self = .bool(value) } else if let value = try? container.decode(Double.self) { self = .number(value) } else if let value = try? container.decode(String.self) { self = .string(value) } else if let value = try? container.decode([OperatingMapArea].self) { self = .array(value) } else { let keyed = try decoder.container(keyedBy: OperatingDynamicCodingKey.self) var object: [String: OperatingMapArea] = [:] for key in keyed.allKeys { object[key.stringValue] = try keyed.decode(OperatingMapArea.self, forKey: key) } self = .object(object) } } /// 将 JSON 字符串解析为运营区域 JSON 值。 static func parseString(_ text: String) -> OperatingMapArea? { guard let data = text.data(using: .utf8), let raw = try? JSONSerialization.jsonObject(with: data) else { return nil } return fromJSONObject(raw) } private static func fromJSONObject(_ value: Any) -> OperatingMapArea { switch value { case is NSNull: return .null case let value as Bool: return .bool(value) case let value as NSNumber: return .number(value.doubleValue) case let value as String: return .string(value) case let value as [Any]: return .array(value.map(fromJSONObject)) case let value as [String: Any]: return .object(value.mapValues(fromJSONObject)) default: return .null } } } /// 运营区域地理坐标点。 struct OperatingGeoPoint: Equatable, Hashable { let latitude: Double let longitude: Double } /// 运营区域围栏环,用于地图绘制和列表展示。 struct OperatingFenceRing: Identifiable, Equatable { let id: String let itemId: Int let regionName: String let points: [OperatingGeoPoint] let isCurrentStore: Bool init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { self.id = "\(itemId)-\(ringIndex)" self.itemId = itemId self.regionName = regionName self.points = points self.isCurrentStore = isCurrentStore } } /// 运营区域阻断原因,用于缺少上下文或数据不可展示时给出稳定提示。 enum OperatingAreaBlockReason: Equatable { case missingStore case missingScenic case unsupportedRole case emptyAreaList case noParsableFenceData case backendMessage(String) var message: String { switch self { case .missingStore: "请先选择店铺" case .missingScenic: "请先选择景区" case .unsupportedRole: "当前账号暂不支持运营区域" case .emptyAreaList: "当前景区无区域数据" case .noParsableFenceData: "有区域记录,但未解析出有效围栏,请检查坐标数据" case .backendMessage(let message): message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "运营区域加载失败" : message } } } /// 运营区域围栏解析器,兼容 GeoJSON、数组、字符串 JSON 和常见路径字段。 enum OperatingAreaParser { /// 将后端 `business_map_area` 解析为若干闭合多边形环。 static func parseToRings(_ area: OperatingMapArea?) -> [[OperatingGeoPoint]] { guard let area else { return [] } return parseElement(area) } private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { switch value { case .null, .bool, .number: return [] case .string(let text): let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let parsed = OperatingMapArea.parseString(trimmed) else { return [] } return parseElement(parsed) case .array(let array): return parseArrayRoot(array) case .object(let object): return parseObject(object) } } private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { let type: String? if case let .string(rawType)? = object["type"] { type = rawType.lowercased() } else { type = nil } switch type { case "polygon": guard case let .array(coordinates)? = object["coordinates"], case let .array(outer)? = coordinates.first else { return [] } return parseRing(outer).map { [$0] } ?? [] case "multipolygon": guard case let .array(polygons)? = object["coordinates"] else { return [] } return polygons.compactMap { polygon -> [OperatingGeoPoint]? in guard case let .array(rings) = polygon, case let .array(firstRing)? = rings.first else { return nil } return parseRing(firstRing) } default: for key in ["path", "paths", "points", "coordinates"] { if let nested = object[key] { return parseElement(nested) } } return [] } } private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { guard let first = array.first else { return [] } switch first { case .object: return parseObjectRing(array).map { [$0] } ?? [] case .array(let nested): guard let sample = nested.first else { return [] } switch sample { case .array(let pointCandidate): if pointCandidate.count >= 2, pointCandidate[0].numberValue != nil, pointCandidate[1].numberValue != nil { return parseRing(nested).map { [$0] } ?? [] } return array.compactMap { element -> [OperatingGeoPoint]? in guard case let .array(segment) = element else { return nil } return parseRing(segment) } case .object: return parseObjectRing(nested).map { [$0] } ?? [] default: return parseRing(nested).map { [$0] } ?? [] } default: return [] } } private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in guard case let .object(object) = element else { return nil } return pointFromObject(object) } return points.count >= 3 ? points : nil } private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { let points = array.compactMap { element -> OperatingGeoPoint? in switch element { case .array(let pair): guard pair.count >= 2, let a = pair[0].numberValue, let b = pair[1].numberValue else { return nil } return normalizePair(a, b) case .object(let object): return pointFromObject(object) default: return nil } } return points.count >= 3 ? points : nil } private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue guard let lat, let lng else { return nil } return OperatingGeoPoint(latitude: lat, longitude: lng) } private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { if looksLikeLngLatPair(lng: a, lat: b) { return OperatingGeoPoint(latitude: b, longitude: a) } if looksLikeLatLngPair(lat: a, lng: b) { return OperatingGeoPoint(latitude: a, longitude: b) } if abs(a) > 90 { return OperatingGeoPoint(latitude: b, longitude: a) } return OperatingGeoPoint(latitude: a, longitude: b) } private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) } private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) } } private struct OperatingDynamicCodingKey: CodingKey { let stringValue: String let intValue: Int? init?(stringValue: String) { self.stringValue = stringValue intValue = nil } init?(intValue: Int) { stringValue = String(intValue) self.intValue = intValue } } private extension OperatingMapArea { var numberValue: Double? { switch self { case .number(let value): return value case .string(let text): return Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) default: return nil } } } private extension KeyedDecodingContainer { func operatingDecodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "true" : "false" } return "" } func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) if let intValue = Int(text) { return intValue } if let doubleValue = Double(text) { return Int(doubleValue) } } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } return nil } }