05/08 Updated code: fix some issues 2
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
@@ -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