184 lines
7.4 KiB
Swift
184 lines
7.4 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?
|
|
|
|
// ── 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 {
|
|
// 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
|
|
}
|
|
}
|