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( predicate: #Predicate { $0.leadID == leadID })) let legacy = try context.fetch(FetchDescriptor( 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( 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( 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( 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." } } } }