Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
159
GasTankAR/Data/Leads/LeadARProjectsService.swift
Normal file
@ -0,0 +1,159 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user