05/08 Updated code: fix some issues 2

This commit is contained in:
Nguyen Ngo
2026-05-08 13:37:09 -04:00
parent 304422cf6f
commit 86969e5fb3
2 changed files with 201 additions and 1 deletions
+16 -1
View File
@@ -148,11 +148,26 @@ actor APIClient {
// Submit Inspection // Submit Inspection
func submitInspection(_ inspection: LocalInspection) async throws -> Int { func submitInspection(_ inspection: LocalInspection) async throws -> Int {
// Sanitise form_data: replace any field values that are still a local
// file reference ("local://...") with an empty string. This happens
// when a photo upload failed but the inspection was submitted anyway.
// JSONSerialization silently drops non-serialisable values, so without
// this guard the server would receive a dict missing those fields
// entirely worse than receiving an empty string.
var sanitisedFormData: [String: Any] = [:]
for (k, v) in inspection.formData {
if let s = v as? String, s.hasPrefix("local://") {
sanitisedFormData[k] = "" // upload failed; clear the field
} else {
sanitisedFormData[k] = v
}
}
var body: [String: Any] = [ var body: [String: Any] = [
"template_id": inspection.templateServerId, "template_id": inspection.templateServerId,
"facility_id": inspection.facilityServerId, "facility_id": inspection.facilityServerId,
"status": "completed", "status": "completed",
"form_data": inspection.formData, "form_data": sanitisedFormData,
"mobile_local_id": inspection.localId, "mobile_local_id": inspection.localId,
] ]
if let score = inspection.overallScore { body["overall_score"] = score } if let score = inspection.overallScore { body["overall_score"] = score }
+185
View File
@@ -0,0 +1,185 @@
// 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
}
}