05/08 Updated code: fix some issues 2
This commit is contained in:
@@ -148,11 +148,26 @@ actor APIClient {
|
||||
// ── Submit Inspection ─────────────────────────────────────────────────
|
||||
|
||||
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] = [
|
||||
"template_id": inspection.templateServerId,
|
||||
"facility_id": inspection.facilityServerId,
|
||||
"status": "completed",
|
||||
"form_data": inspection.formData,
|
||||
"form_data": sanitisedFormData,
|
||||
"mobile_local_id": inspection.localId,
|
||||
]
|
||||
if let score = inspection.overallScore { body["overall_score"] = score }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user