Files
JQC_iOS_App/JanitorialQC/Models/LocalInspection.swift
T

193 lines
7.9 KiB
Swift

// 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?
// ── GPS (captured at submit time via CoreLocation) ─────────────────────
/// Device latitude at the moment the inspector tapped Submit. Nil if
/// location permission was denied or a fix could not be obtained in time.
var submitLatitude: Double?
/// Device longitude at the moment the inspector tapped Submit.
var submitLongitude: Double?
// ── 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.submitLatitude = nil
self.submitLongitude = 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 {
// Explicit cast required — field["id"] arrives as Int from JSONSerialization.
// Using Optional.map { "\($0)" } on Any? wraps in a second Optional,
// producing "Optional(5)" instead of "5", so all formData lookups miss
// and scores silently return 0 (CLAUDE.md rule 34).
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 }
guard let ftype = field["type"] as? String else { continue }
// Same cast-first pattern for the stored value — formData values may be
// String, Int, or Bool depending on field type.
let rawVal = formData[fid]
let val: String
if let s = rawVal as? String { val = s }
else if let n = rawVal as? Int { val = String(n) }
else if let b = rawVal as? Bool { val = b ? "true" : "false" }
else { val = "" }
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
}
}