Files
Gas-Tank-AR-App/GasTankAR/Data/Leads/LeadARProjectsService.swift

160 lines
7.2 KiB
Swift

import Foundation
import SwiftData
/// Translates sales data into AR configurations and owns the persistent association.
/// A separate context makes a failed save atomic without rolling back unrelated UI edits.
@MainActor
final class LeadARProjectsService: ARProjectsProviding {
private let detail: LeadDetail
private let context: ModelContext
private let catalog: any TankCatalogProviding
init(detail: LeadDetail, container: ModelContainer,
catalog: any TankCatalogProviding = TankCatalog.shared) {
self.detail = detail
self.context = ModelContext(container)
self.context.autosaveEnabled = false
self.catalog = catalog
}
func loadProjects() throws -> [ARProject] {
do {
let leadID = detail.lead.id
var links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
predicate: #Predicate { $0.leadID == leadID }))
let legacy = try context.fetch(FetchDescriptor<Project>(
predicate: #Predicate { $0.leadID == leadID },
sortBy: [SortDescriptor(\.creationDate)]))
var linkedIDs = Set(links.compactMap { $0.project?.projectID })
for offer in detail.offers {
guard let tank = offer.tank else { continue }
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
guard !links.contains(where: { $0.offerID == offer.id && $0.project != nil }) else { continue }
// Reuse an unlinked legacy session once, preserving world maps, photos and routes.
let project = legacy.first { !linkedIDs.contains($0.projectID) && $0.tank == tank }
?? makeProject(name: offer.title, tank: tank)
if project.projectName.isEmpty { project.projectName = offer.title }
let link = LeadARProjectLink(leadID: leadID, offerID: offer.id, project: project)
context.insert(link)
links.append(link)
linkedIDs.insert(project.projectID)
}
for project in legacy where !linkedIDs.contains(project.projectID) {
if project.projectName.isEmpty { project.projectName = "Mein AR-Projekt" }
let link = LeadARProjectLink(leadID: leadID, project: project)
context.insert(link)
links.append(link)
}
try context.save()
return links.compactMap(projectValue).sorted {
if $0.source != $1.source { return $0.source == .supplied }
return $0.createdAt > $1.createdAt
}
} catch {
context.rollback()
throw error
}
}
func createProject(name: String, tank: Tank) throws -> ARProject {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ProjectError.missingName }
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
do {
let project = makeProject(name: trimmed, tank: tank)
let link = LeadARProjectLink(leadID: detail.lead.id, project: project)
context.insert(link)
try context.save()
return ARProject(id: project.projectID, name: trimmed, tank: tank,
createdAt: project.creationDate, source: .custom, reference: nil)
} catch {
context.rollback()
throw error
}
}
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ProjectError.missingName }
guard catalog.definition(for: tank) != nil else { throw ProjectError.unavailableTank }
do {
let leadID = detail.lead.id
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
predicate: #Predicate { $0.leadID == leadID }))
let matching = links.filter { $0.project?.projectID == id }
guard let project = matching.first?.project else { throw ProjectError.notFound }
guard matching.allSatisfy({ $0.offerID == nil }) else { throw ProjectError.editingNotAllowed }
project.projectName = trimmed
project.tank = tank
try context.save()
return ARProject(id: project.projectID, name: trimmed, tank: tank,
createdAt: project.creationDate, source: .custom, reference: nil)
} catch {
context.rollback()
throw error
}
}
func deleteProject(id: UUID) throws {
do {
let leadID = detail.lead.id
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
predicate: #Predicate { $0.leadID == leadID }))
let matching = links.filter { $0.project?.projectID == id }
guard let project = matching.first?.project else { throw ProjectError.notFound }
guard matching.allSatisfy({ $0.offerID == nil }) else {
throw ProjectError.deletionNotAllowed
}
for link in matching {
link.project = nil
context.delete(link)
}
context.delete(project)
try context.save()
} catch {
context.rollback()
throw error
}
}
func resolveProject(id: UUID) throws -> Project {
let leadID = detail.lead.id
let links = try context.fetch(FetchDescriptor<LeadARProjectLink>(
predicate: #Predicate { $0.leadID == leadID }))
guard let project = links.compactMap(\.project).first(where: { $0.projectID == id })
else { throw ProjectError.notFound }
return project
}
private func makeProject(name: String, tank: Tank) -> Project {
// These compatibility fields are still used by the existing AR photo/export flow.
let project = Project(leadID: detail.lead.id, customerName: detail.lead.displayName,
creationDate: .now, tank: tank)
project.projectName = name
context.insert(project)
return project
}
private func projectValue(_ link: LeadARProjectLink) -> ARProject? {
guard let project = link.project else { return nil }
return ARProject(id: project.projectID, name: project.projectName,
tank: project.tank, createdAt: project.creationDate,
source: link.offerID == nil ? .custom : .supplied, reference: link.offerID)
}
enum ProjectError: LocalizedError {
case unavailableTank, missingName, notFound, deletionNotAllowed, editingNotAllowed
var errorDescription: String? {
switch self {
case .unavailableTank: "Für diese Tankkonfiguration ist kein Modell verfügbar."
case .missingName: "Bitte gib einen Projektnamen ein."
case .deletionNotAllowed: "Bereitgestellte Projekte können nicht gelöscht werden."
case .editingNotAllowed: "Bereitgestellte Projekte können nicht bearbeitet werden."
case .notFound: "Das Projekt konnte nicht gefunden werden."
}
}
}
}