// Models/LocalInspection.swift // ---------------------------- // SwiftData model for locally stored inspections. // Created immediately when the inspector starts a new inspection. // Written entirely offline; synced to server when connectivity returns. import Foundation import SwiftData @Model final class LocalInspection { // ── Identity ────────────────────────────────────────────────────────── /// UUID generated on device — stable local identity and idempotency key @Attribute(.unique) var localId: String /// Set by server after successful sync; nil until then var serverId: Int? // ── Foreign keys (server IDs, from cached reference data) ───────────── var templateServerId: Int var facilityServerId: Int var areaServerId: Int? var inspectorUserId: Int // ── Inspection data ──────────────────────────────────────────────────── /// "draft" | "completed" | "synced" | "sync_failed" var status: String /// JSON dict of { field_id: value } — same shape as server form_data var formDataJSON: String var inspectorNotes: String var overallScore: Double? var inspectionDate: Date var completedAt: Date? var createdAt: Date var lastModifiedAt: Date // ── Sync ────────────────────────────────────────────────────────────── var syncStatus: String // "pending" | "synced" | "failed" var syncErrorMessage: String? var syncRetryCount: Int // ── Follow-up / re-inspection (populated from server after sync) ─────── /// Set by director/admin on the web app; signals this inspection needs a follow-up. @Attribute var followUpRequired: Bool = false /// Optional note explaining what the follow-up should address. var followUpNote: String? /// Server ID of the parent inspection this record is a re-inspection of. var parentServerId: Int? /// Local UUID of the parent LocalInspection — set at creation, always available /// regardless of whether the parent has synced. Used to clear the parent's /// followUpRequired badge without relying on parentServerId being non-nil. var parentLocalId: String? // ── Relationships ────────────────────────────────────────────────────── @Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto] @Relationship(deleteRule: .cascade) var localIssues: [LocalIssue] init( templateServerId: Int, facilityServerId: Int, areaServerId: Int?, inspectorUserId: Int ) { self.localId = UUID().uuidString self.serverId = nil self.templateServerId = templateServerId self.facilityServerId = facilityServerId self.areaServerId = areaServerId self.inspectorUserId = inspectorUserId self.status = "draft" self.formDataJSON = "{}" self.inspectorNotes = "" self.overallScore = nil self.inspectionDate = Date() self.completedAt = nil self.createdAt = Date() self.lastModifiedAt = Date() self.syncStatus = "pending" self.syncErrorMessage = nil self.syncRetryCount = 0 self.followUpRequired = false self.followUpNote = nil self.parentServerId = nil self.parentLocalId = nil self.pendingPhotos = [] self.localIssues = [] } // ── Form data helpers ────────────────────────────────────────────────── var formData: [String: Any] { get { guard let data = formDataJSON.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return dict } set { if let data = try? JSONSerialization.data(withJSONObject: newValue), let str = String(data: data, encoding: .utf8) { formDataJSON = str lastModifiedAt = Date() } } } func setValue(_ value: Any, forFieldId fieldId: String) { var current = formData current[fieldId] = value formData = current } func getValue(forFieldId fieldId: String) -> Any? { formData[fieldId] } // ── Score calculation (mirrors Python _compute_score_from_form) ──────── func computeScore(fromSchema schema: [[String: Any]]) -> Double? { let scoreable = schema.filter { ["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "") } guard !scoreable.isEmpty else { return nil } var total = 0 var earned = 0 for field in scoreable { // Resolve field ID to String regardless of whether the JSON encoded // it as a String or an Int. The web app stores IDs as integers in // form_schema JSON (e.g. "id": 5); JSONSerialization decodes these // as Int, not String. The formData dict is keyed by String (the // ID is always stringified before storage), so the lookup key must // also be a String. The previous guard-let with Optional.map was // producing Optional("5") rather than "5", causing all lookups to // miss when field IDs were integers. let fid: String if let s = field["id"] as? String { fid = s } else if let n = field["id"] as? Int { fid = String(n) } else { continue // field has no usable id — skip } guard let ftype = field["type"] as? String else { continue } let val = formData[fid].map { "\($0)" } ?? "" switch ftype { case "rating": if let v = Int(val), v > 0 { earned += v total += 5 } case "checkbox": total += 1 if val == "true" { earned += 1 } case "radio": total += 1 if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 } case "pass_fail": if val.isEmpty { continue } total += 1 if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 } default: break } } guard total > 0 else { return nil } return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2) } } // Helper for rounding extension Double { func rounded(toPlaces places: Int) -> Double { let divisor = pow(10.0, Double(places)) return (self * divisor).rounded() / divisor } }