Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9322e7c4ca | |||
| 82ac7df0ec | |||
| 2a56ba53ba | |||
| eb187cdc15 | |||
| bec0410ef4 | |||
| 83a400a90e | |||
| 0d3bd47f7a | |||
| ca91af9682 | |||
| a25e5900c7 | |||
| d02f4abb57 | |||
| 264e64bbf5 | |||
| 7891dfb3dd | |||
| 90133cd0e2 | |||
| 59e195af71 | |||
| 36a22202bf | |||
| df90a4a172 | |||
| cf20bd94d0 | |||
| 9a93920b71 | |||
| 474d2215a2 | |||
| e1259b9ca8 | |||
| 2b9b40af93 | |||
| eb427ac608 | |||
| 97e598fe3b | |||
| 9406843988 | |||
| ec827a4ce8 | |||
| c4a93a7f15 | |||
| 0d11315848 | |||
| c336c1c7f8 | |||
| 3b6f66d0fb | |||
| af40d87213 | |||
| efcf7b180c | |||
| f916c26fb4 | |||
| c198d362b1 | |||
| 8524631508 | |||
| 2f56082adc | |||
| 673bba7298 | |||
| 8d8b62f1f5 | |||
| 99f40dd7ea | |||
| bfcf018d33 | |||
| 6c56306873 | |||
| 2cc77f5405 | |||
| 47487c7bab | |||
| fe502fc4b3 | |||
| df86a5f568 | |||
| 3f0662c49a | |||
| 3b6d0c4db5 | |||
| 0605aee88d | |||
| 6fba9d938a | |||
| 5d9ebbbc3e | |||
| 282d8b70fc | |||
| 814494f812 | |||
| 75e5566532 | |||
| 5d382db42e | |||
| 054717fff1 | |||
| 5218c064cb | |||
| 6a24b564a4 | |||
| cc85523c9c | |||
| bb27cb151e | |||
| 16673b91d3 | |||
| ac77e27089 | |||
| 7bd62b3527 | |||
| 402604b4cc | |||
| 4acdf89588 | |||
| 20c0569731 | |||
| 144c813acf | |||
| 328b5b5b15 | |||
| 6472bbf162 | |||
| c66a5b9f64 | |||
| 7e468d51ca | |||
| 7b3f99e837 | |||
| 20704b3cd8 | |||
| 6fa64baa35 | |||
| 6bca898488 | |||
| 8bd90629f9 | |||
| 7eda2fbbe8 | |||
| 5b8200a4ff | |||
| ac6632e24f | |||
| 0ce527c69e | |||
| 8f46ffb695 | |||
| 36558e97cb | |||
| 1cf30a0c8b | |||
| ff4bda1e53 | |||
| af87ef329b | |||
| 9c1d820876 | |||
| cbe215cfc2 | |||
| 12d64bf009 | |||
| 0871068ff8 | |||
| 3d7cee81da | |||
| 0aa5a62754 | |||
| f487713a92 | |||
| 5eb27d1c28 | |||
| 4060115749 | |||
| b98a6761c2 | |||
| 6a8e879898 | |||
| b9407a9f13 | |||
| 53ae1ba76f | |||
| 9a418aa213 | |||
| 9a88b5cad2 | |||
| 3e6a584f4f | |||
| 6ad6167c52 | |||
| f9b84e4d3c | |||
| 2e6fd332ac | |||
| b5376f46e5 | |||
| 98f1fcae14 | |||
| 14c064de64 | |||
| 313c2108a9 | |||
| c12ac16557 | |||
| b686d4506c | |||
| c38bd130e5 | |||
| de343364ad | |||
| e8385adb87 | |||
| f7843e5e35 | |||
| 3a78d09399 | |||
| 28d2032f23 | |||
| 1ec8d734ee | |||
| 60dab1e9df | |||
| 9e175fdf56 | |||
| 13024c5ae8 | |||
| 42b12c5c36 | |||
| a033d15912 | |||
| 1f9bc08682 | |||
| 191381ece4 | |||
| fabdfb121a | |||
| 4454adca59 | |||
| 9ae417cb03 | |||
| 367aa4c67b | |||
| cac3768885 | |||
| b953908f58 | |||
| 0ffb1c771e | |||
| 9363a2dd99 |
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Ignore Visual Studio + build artifacts
|
||||||
|
.vs/
|
||||||
|
TrafagSalesExporter/.vs/
|
||||||
|
TrafagSalesExporter/bin/
|
||||||
|
TrafagSalesExporter/obj/
|
||||||
|
TrafagSalesExporter/*.user
|
||||||
|
TrafagSalesExporter/*.suo
|
||||||
|
TrafagSalesExporter/*.db
|
||||||
|
TrafagSalesExporter/*.db-shm
|
||||||
|
TrafagSalesExporter/*.db-wal
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HealthBridgeApp: App {
|
||||||
|
@StateObject private var appState = AppState()
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerBackgroundTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(healthKitManager)
|
||||||
|
.environmentObject(syncCoordinator)
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await requestHealthKitAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerBackgroundTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: "com.healthbridge.sync",
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let bgTask = task as? BGAppRefreshTask else { return }
|
||||||
|
handleBackgroundSync(task: bgTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundSync(task: BGAppRefreshTask) {
|
||||||
|
scheduleNextBackgroundSync()
|
||||||
|
|
||||||
|
let syncTask = Task {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
syncTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleNextBackgroundSync() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule background sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestHealthKitAuthorization() async {
|
||||||
|
do {
|
||||||
|
try await healthKitManager.requestAuthorization()
|
||||||
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App State
|
||||||
|
@MainActor
|
||||||
|
class AppState: ObservableObject {
|
||||||
|
@Published var selectedTab: Tab = .dashboard
|
||||||
|
@Published var showingConflictDetail: Conflict?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case dashboard
|
||||||
|
case conflicts
|
||||||
|
case rules
|
||||||
|
case sources
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conflict
|
||||||
|
struct Conflict: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var status: ConflictStatus
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var appliedStrategy: MergeStrategy?
|
||||||
|
let detectedAt: Date
|
||||||
|
var resolvedAt: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
status: ConflictStatus = .pending,
|
||||||
|
resolution: ConflictResolution? = nil,
|
||||||
|
appliedStrategy: MergeStrategy? = nil,
|
||||||
|
detectedAt: Date = Date(),
|
||||||
|
resolvedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.status = status
|
||||||
|
self.resolution = resolution
|
||||||
|
self.appliedStrategy = appliedStrategy
|
||||||
|
self.detectedAt = detectedAt
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
return (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentageDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
guard let min = values.min(), min > 0 else { return 0 }
|
||||||
|
guard let max = values.max() else { return 0 }
|
||||||
|
return ((max - min) / min) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var severity: ConflictSeverity {
|
||||||
|
let pctDiff = percentageDifference
|
||||||
|
if pctDiff < 5 { return .minor }
|
||||||
|
if pctDiff < 20 { return .moderate }
|
||||||
|
if pctDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
var highestValueReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var lowestValueReading: SourceReading? {
|
||||||
|
readings.min(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var primarySourceReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Status
|
||||||
|
enum ConflictStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case resolved = "resolved"
|
||||||
|
case manualReview = "manual_review"
|
||||||
|
case ignored = "ignored"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "Offen"
|
||||||
|
case .resolved: return "Gelöst"
|
||||||
|
case .manualReview: return "Manuelle Prüfung"
|
||||||
|
case .ignored: return "Ignoriert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "clock.fill"
|
||||||
|
case .resolved: return "checkmark.circle.fill"
|
||||||
|
case .manualReview: return "hand.raised.fill"
|
||||||
|
case .ignored: return "eye.slash.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Severity
|
||||||
|
enum ConflictSeverity: String, Codable {
|
||||||
|
case minor = "minor"
|
||||||
|
case moderate = "moderate"
|
||||||
|
case significant = "significant"
|
||||||
|
case major = "major"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "Gering"
|
||||||
|
case .moderate: return "Moderat"
|
||||||
|
case .significant: return "Erheblich"
|
||||||
|
case .major: return "Gross"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "green"
|
||||||
|
case .moderate: return "yellow"
|
||||||
|
case .significant: return "orange"
|
||||||
|
case .major: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Resolution
|
||||||
|
struct ConflictResolution: Codable {
|
||||||
|
let resolvedValue: Double
|
||||||
|
let secondaryResolvedValue: Double? // For blood pressure
|
||||||
|
let winningSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let isManual: Bool
|
||||||
|
let resolvedAt: Date
|
||||||
|
let notes: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
resolvedValue: Double,
|
||||||
|
secondaryResolvedValue: Double? = nil,
|
||||||
|
winningSourceId: String,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
isManual: Bool = false,
|
||||||
|
resolvedAt: Date = Date(),
|
||||||
|
notes: String? = nil
|
||||||
|
) {
|
||||||
|
self.resolvedValue = resolvedValue
|
||||||
|
self.secondaryResolvedValue = secondaryResolvedValue
|
||||||
|
self.winningSourceId = winningSourceId
|
||||||
|
self.strategy = strategy
|
||||||
|
self.isManual = isManual
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Strategy
|
||||||
|
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case exclusive = "exclusive"
|
||||||
|
case priority = "priority"
|
||||||
|
case higherWins = "higher_wins"
|
||||||
|
case lowerWins = "lower_wins"
|
||||||
|
case average = "average"
|
||||||
|
case coverage = "coverage"
|
||||||
|
case coverageThenHigher = "coverage_then_higher"
|
||||||
|
case manual = "manual"
|
||||||
|
case mostRecent = "most_recent"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "Exklusiv"
|
||||||
|
case .priority: return "Priorität"
|
||||||
|
case .higherWins: return "Höherer Wert"
|
||||||
|
case .lowerWins: return "Niedrigerer Wert"
|
||||||
|
case .average: return "Durchschnitt"
|
||||||
|
case .coverage: return "Abdeckung"
|
||||||
|
case .coverageThenHigher: return "Abdeckung + Höher"
|
||||||
|
case .manual: return "Manuell"
|
||||||
|
case .mostRecent: return "Neuester"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive:
|
||||||
|
return "Nur eine Quelle kann diesen Datentyp liefern"
|
||||||
|
case .priority:
|
||||||
|
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
|
||||||
|
case .higherWins:
|
||||||
|
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
|
||||||
|
case .lowerWins:
|
||||||
|
return "Der kleinere Wert wird verwendet"
|
||||||
|
case .average:
|
||||||
|
return "Durchschnitt aller Quellen"
|
||||||
|
case .coverage:
|
||||||
|
return "Quelle mit Daten für dieses Zeitfenster"
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
|
||||||
|
case .manual:
|
||||||
|
return "Benutzer entscheidet bei jedem Konflikt"
|
||||||
|
case .mostRecent:
|
||||||
|
return "Zuletzt erfasster Wert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "1.circle.fill"
|
||||||
|
case .priority: return "list.number"
|
||||||
|
case .higherWins: return "arrow.up.circle.fill"
|
||||||
|
case .lowerWins: return "arrow.down.circle.fill"
|
||||||
|
case .average: return "divide.circle.fill"
|
||||||
|
case .coverage: return "square.fill.on.square.fill"
|
||||||
|
case .coverageThenHigher: return "square.stack.3d.up.fill"
|
||||||
|
case .manual: return "hand.raised.fill"
|
||||||
|
case .mostRecent: return "clock.arrow.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Rule
|
||||||
|
struct MergeRule: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var primarySourceId: String?
|
||||||
|
var fallbackSourceId: String?
|
||||||
|
var sourcePriorities: [String: Int]
|
||||||
|
var autoApply: Bool
|
||||||
|
var thresholdForManualReview: Double? // Percentage difference threshold
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
primarySourceId: String? = nil,
|
||||||
|
fallbackSourceId: String? = nil,
|
||||||
|
sourcePriorities: [String: Int] = [:],
|
||||||
|
autoApply: Bool = true,
|
||||||
|
thresholdForManualReview: Double? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.strategy = strategy
|
||||||
|
self.primarySourceId = primarySourceId
|
||||||
|
self.fallbackSourceId = fallbackSourceId
|
||||||
|
self.sourcePriorities = sourcePriorities
|
||||||
|
self.autoApply = autoApply
|
||||||
|
self.thresholdForManualReview = thresholdForManualReview
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
switch dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .steps, .distance, .activeEnergy:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
|
||||||
|
case .sleep:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Record
|
||||||
|
struct SyncRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var mergedValue: Double?
|
||||||
|
var secondaryMergedValue: Double? // For blood pressure
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var status: SyncStatus
|
||||||
|
var hasConflict: Bool
|
||||||
|
var conflictId: UUID?
|
||||||
|
let createdAt: Date
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case processing = "processing"
|
||||||
|
case completed = "completed"
|
||||||
|
case failed = "failed"
|
||||||
|
case requiresManualReview = "requires_manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
mergedValue: Double? = nil,
|
||||||
|
secondaryMergedValue: Double? = nil,
|
||||||
|
strategy: MergeStrategy = .priority,
|
||||||
|
status: SyncStatus = .pending,
|
||||||
|
hasConflict: Bool = false,
|
||||||
|
conflictId: UUID? = nil,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
processedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.mergedValue = mergedValue
|
||||||
|
self.secondaryMergedValue = secondaryMergedValue
|
||||||
|
self.strategy = strategy
|
||||||
|
self.status = status
|
||||||
|
self.hasConflict = hasConflict
|
||||||
|
self.conflictId = conflictId
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.processedAt = processedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Data Type
|
||||||
|
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case steps = "steps"
|
||||||
|
case heartRate = "heart_rate"
|
||||||
|
case bloodPressureSystolic = "blood_pressure_systolic"
|
||||||
|
case bloodPressureDiastolic = "blood_pressure_diastolic"
|
||||||
|
case bloodOxygen = "blood_oxygen"
|
||||||
|
case sleep = "sleep"
|
||||||
|
case distance = "distance"
|
||||||
|
case floorsClimbed = "floors_climbed"
|
||||||
|
case activeEnergy = "active_energy"
|
||||||
|
case restingHeartRate = "resting_heart_rate"
|
||||||
|
case heartRateVariability = "hrv"
|
||||||
|
case respiratoryRate = "respiratory_rate"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate: return "Herzfrequenz"
|
||||||
|
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
|
||||||
|
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
|
||||||
|
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
|
||||||
|
case .sleep: return "Schlaf"
|
||||||
|
case .distance: return "Distanz"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "Aktive Energie"
|
||||||
|
case .restingHeartRate: return "Ruhepuls"
|
||||||
|
case .heartRateVariability: return "HRV"
|
||||||
|
case .respiratoryRate: return "Atemfrequenz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "figure.walk"
|
||||||
|
case .heartRate, .restingHeartRate: return "heart.fill"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
|
||||||
|
case .bloodOxygen: return "lungs.fill"
|
||||||
|
case .sleep: return "bed.double.fill"
|
||||||
|
case .distance: return "map.fill"
|
||||||
|
case .floorsClimbed: return "stairs"
|
||||||
|
case .activeEnergy: return "flame.fill"
|
||||||
|
case .heartRateVariability: return "waveform.path.ecg"
|
||||||
|
case .respiratoryRate: return "wind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate, .restingHeartRate: return "bpm"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
|
||||||
|
case .bloodOxygen: return "%"
|
||||||
|
case .sleep: return "h"
|
||||||
|
case .distance: return "km"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "kcal"
|
||||||
|
case .heartRateVariability: return "ms"
|
||||||
|
case .respiratoryRate: return "/min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkQuantityType: HKQuantityType? {
|
||||||
|
switch self {
|
||||||
|
case .steps:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||||
|
case .heartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRate)
|
||||||
|
case .bloodPressureSystolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
|
||||||
|
case .bloodPressureDiastolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
|
||||||
|
case .distance:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
|
||||||
|
case .activeEnergy:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
||||||
|
case .restingHeartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
|
||||||
|
case .sleep:
|
||||||
|
return nil // Sleep uses category type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkCategoryType: HKCategoryType? {
|
||||||
|
switch self {
|
||||||
|
case .sleep:
|
||||||
|
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkUnit: HKUnit {
|
||||||
|
switch self {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return .count()
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return HKUnit.count().unitDivided(by: .minute())
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return .millimeterOfMercury()
|
||||||
|
case .bloodOxygen:
|
||||||
|
return .percent()
|
||||||
|
case .sleep:
|
||||||
|
return .hour()
|
||||||
|
case .distance:
|
||||||
|
return .meterUnit(with: .kilo)
|
||||||
|
case .activeEnergy:
|
||||||
|
return .kilocalorie()
|
||||||
|
case .heartRateVariability:
|
||||||
|
return .secondUnit(with: .milli)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default primary source for this data type
|
||||||
|
var defaultPrimarySource: SourceCategory {
|
||||||
|
switch self {
|
||||||
|
case .floorsClimbed:
|
||||||
|
return .iPhone
|
||||||
|
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
|
||||||
|
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
|
||||||
|
.heartRateVariability, .respiratoryRate:
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this data type typically has only one source
|
||||||
|
var isExclusive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Category
|
||||||
|
enum SourceCategory: String, Codable, CaseIterable {
|
||||||
|
case iPhone = "iphone"
|
||||||
|
case watch = "watch"
|
||||||
|
case thirdPartyWatch = "third_party_watch"
|
||||||
|
case thirdPartyApp = "third_party_app"
|
||||||
|
case healthBridge = "health_bridge"
|
||||||
|
case unknown = "unknown"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iPhone"
|
||||||
|
case .watch: return "Apple Watch"
|
||||||
|
case .thirdPartyWatch: return "Drittanbieter-Watch"
|
||||||
|
case .thirdPartyApp: return "Drittanbieter-App"
|
||||||
|
case .healthBridge: return "HealthBridge"
|
||||||
|
case .unknown: return "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iphone"
|
||||||
|
case .watch: return "applewatch"
|
||||||
|
case .thirdPartyWatch: return "applewatch.side.right"
|
||||||
|
case .thirdPartyApp: return "app.badge"
|
||||||
|
case .healthBridge: return "arrow.triangle.2.circlepath"
|
||||||
|
case .unknown: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var priority: Int {
|
||||||
|
switch self {
|
||||||
|
case .healthBridge: return 100
|
||||||
|
case .watch: return 80
|
||||||
|
case .thirdPartyWatch: return 70
|
||||||
|
case .iPhone: return 50
|
||||||
|
case .thirdPartyApp: return 30
|
||||||
|
case .unknown: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Quality
|
||||||
|
enum DataQuality: String, Codable {
|
||||||
|
case complete = "complete"
|
||||||
|
case partial = "partial"
|
||||||
|
case missing = "missing"
|
||||||
|
case invalid = "invalid"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "checkmark.circle.fill"
|
||||||
|
case .partial: return "circle.lefthalf.filled"
|
||||||
|
case .missing: return "circle.dashed"
|
||||||
|
case .invalid: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "green"
|
||||||
|
case .partial: return "yellow"
|
||||||
|
case .missing: return "gray"
|
||||||
|
case .invalid: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Window
|
||||||
|
struct TimeWindow: Codable, Hashable, Identifiable {
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
|
||||||
|
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
|
||||||
|
|
||||||
|
var interval: DateInterval {
|
||||||
|
DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedRange: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = startOfDay
|
||||||
|
|
||||||
|
while current < endOfDay {
|
||||||
|
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
|
||||||
|
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hourlyWindows(for date: Date) -> [TimeWindow] {
|
||||||
|
windows(for: date, intervalMinutes: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Source
|
||||||
|
struct HealthSource: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let name: String
|
||||||
|
let category: SourceCategory
|
||||||
|
var supportedDataTypes: Set<HealthDataType>
|
||||||
|
var lastActivityDate: Date?
|
||||||
|
var userPriorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
name: String,
|
||||||
|
category: SourceCategory,
|
||||||
|
supportedDataTypes: Set<HealthDataType> = [],
|
||||||
|
lastActivityDate: Date? = nil,
|
||||||
|
userPriorities: [HealthDataType: Int] = [:],
|
||||||
|
isEnabled: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.supportedDataTypes = supportedDataTypes
|
||||||
|
self.lastActivityDate = lastActivityDate
|
||||||
|
self.userPriorities = userPriorities
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
if name.isEmpty {
|
||||||
|
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHealthBridge: Bool {
|
||||||
|
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(for dataType: HealthDataType) -> Int {
|
||||||
|
userPriorities[dataType] ?? category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(hkSource: HKSource) -> HealthSource {
|
||||||
|
let category = classifySource(bundleId: hkSource.bundleIdentifier)
|
||||||
|
return HealthSource(
|
||||||
|
id: hkSource.bundleIdentifier,
|
||||||
|
bundleIdentifier: hkSource.bundleIdentifier,
|
||||||
|
name: hkSource.name,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifySource(bundleId: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleId.lowercased()
|
||||||
|
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if lowercased.contains("apple.health") {
|
||||||
|
return .iPhone
|
||||||
|
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
|
||||||
|
return .watch
|
||||||
|
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
|
||||||
|
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
|
||||||
|
lowercased.contains("polar") || lowercased.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Reading
|
||||||
|
struct SourceReading: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let sourceId: String
|
||||||
|
let sourceName: String
|
||||||
|
let sourceCategory: SourceCategory
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double? // For blood pressure (diastolic)
|
||||||
|
let timestamp: Date
|
||||||
|
let originalRecordId: String?
|
||||||
|
let quality: DataQuality
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
sourceId: String,
|
||||||
|
sourceName: String,
|
||||||
|
sourceCategory: SourceCategory,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
timestamp: Date,
|
||||||
|
originalRecordId: String? = nil,
|
||||||
|
quality: DataQuality = .complete
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.sourceId = sourceId
|
||||||
|
self.sourceName = sourceName
|
||||||
|
self.sourceCategory = sourceCategory
|
||||||
|
self.value = value
|
||||||
|
self.secondaryValue = secondaryValue
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.originalRecordId = originalRecordId
|
||||||
|
self.quality = quality
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Status
|
||||||
|
struct SourceHealthStatus: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let source: HealthSource
|
||||||
|
let lastSync: Date?
|
||||||
|
let recordCount: Int
|
||||||
|
let dataGaps: [TimeWindow]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
guard let lastSync = lastSync else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .recentlySynced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .syncedToday
|
||||||
|
} else if hoursSinceSync < 72 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case recentlySynced
|
||||||
|
case syncedToday
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "checkmark.circle.fill"
|
||||||
|
case .syncedToday: return "checkmark.circle"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "Kürzlich synchronisiert"
|
||||||
|
case .syncedToday: return "Heute synchronisiert"
|
||||||
|
case .stale: return "Sync überfällig"
|
||||||
|
case .veryStale: return "Lange nicht synchronisiert"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
enum HealthBridgeConstants {
|
||||||
|
static let bundleIdentifier = "com.healthbridge.merged"
|
||||||
|
static let displayName = "HealthBridge"
|
||||||
|
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
|
||||||
|
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# HealthBridge
|
||||||
|
|
||||||
|
Intelligente Health-Daten-Synchronisation für iOS – Eine "Single Source of Truth" für Gesundheitsdaten.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Source Discovery
|
||||||
|
- Automatische Erkennung aller verbundenen Datenquellen
|
||||||
|
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
|
||||||
|
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
|
||||||
|
|
||||||
|
### Konflikt-Erkennung
|
||||||
|
- Automatische Erkennung von Datenkonflikten zwischen Quellen
|
||||||
|
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
|
||||||
|
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
|
||||||
|
|
||||||
|
### Merge-Strategien
|
||||||
|
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
|
||||||
|
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
|
||||||
|
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
|
||||||
|
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
|
||||||
|
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
|
||||||
|
- **Average**: Durchschnitt aller Quellen
|
||||||
|
- **Manual**: Benutzer entscheidet bei jedem Konflikt
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
|
||||||
|
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
|
||||||
|
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
|
||||||
|
- **Quellen**: Übersicht aller erkannten Datenquellen
|
||||||
|
|
||||||
|
### Background Sync
|
||||||
|
- Automatische Synchronisierung im Hintergrund
|
||||||
|
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
|
||||||
|
- Push-Benachrichtigungen bei neuen Konflikten
|
||||||
|
|
||||||
|
## Unterstützte Datentypen
|
||||||
|
|
||||||
|
| Datentyp | Primärquelle | Strategie |
|
||||||
|
|----------|--------------|-----------|
|
||||||
|
| Schritte | Watch | Coverage + Higher |
|
||||||
|
| Herzfrequenz | Watch | Exclusive |
|
||||||
|
| Blutdruck | Watch D2 | Exclusive |
|
||||||
|
| SpO2 | Watch | Exclusive |
|
||||||
|
| Schlaf | Watch | Priority |
|
||||||
|
| Distanz | Watch/iPhone | Coverage + Higher |
|
||||||
|
| Stockwerke | iPhone | Exclusive |
|
||||||
|
| Aktive Energie | Watch | Coverage + Higher |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Apple Health │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HealthBridge App │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │SourceManager│ │ RuleEngine │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SyncCoordinator │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
HealthBridge/
|
||||||
|
├── App/
|
||||||
|
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
|
||||||
|
├── Models/
|
||||||
|
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
|
||||||
|
│ ├── Source.swift # HealthSource, SourceReading
|
||||||
|
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
|
||||||
|
├── Services/
|
||||||
|
│ ├── HealthKitManager.swift # HealthKit-Integration
|
||||||
|
│ ├── SourceManager.swift # Source Discovery & Management
|
||||||
|
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
|
||||||
|
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
|
||||||
|
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
|
||||||
|
│ ├── DataWriter.swift # Daten zurückschreiben
|
||||||
|
│ └── SyncCoordinator.swift # Orchestrierung aller Services
|
||||||
|
├── Views/
|
||||||
|
│ ├── ContentView.swift # Tab-Navigation
|
||||||
|
│ ├── DashboardView.swift # Hauptübersicht
|
||||||
|
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
|
||||||
|
│ ├── RulesView.swift # Regelwerk-Editor
|
||||||
|
│ ├── SourcesView.swift # Quellen-Übersicht
|
||||||
|
│ ├── SettingsView.swift # Einstellungen
|
||||||
|
│ └── Components/
|
||||||
|
│ └── HealthChart.swift # Diagramm-Komponenten
|
||||||
|
├── ViewModels/
|
||||||
|
│ └── DashboardViewModel.swift # Dashboard-Logik
|
||||||
|
├── Utils/
|
||||||
|
│ ├── Extensions.swift # Swift-Erweiterungen
|
||||||
|
│ └── NotificationManager.swift # Push-Benachrichtigungen
|
||||||
|
└── Resources/
|
||||||
|
├── Info.plist # App-Konfiguration
|
||||||
|
└── HealthBridge.entitlements # HealthKit-Berechtigungen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- iOS 16.0+
|
||||||
|
- Xcode 15.0+
|
||||||
|
- Apple Developer Account (für HealthKit-Entitlements)
|
||||||
|
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Projekt in Xcode öffnen
|
||||||
|
2. Team für Code Signing auswählen
|
||||||
|
3. HealthKit-Capability aktivieren
|
||||||
|
4. Auf physischem Gerät ausführen
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
Die App benötigt folgende Berechtigungen:
|
||||||
|
- **HealthKit Read**: Lesen aller Gesundheitsdaten
|
||||||
|
- **HealthKit Write**: Schreiben gemergter Daten
|
||||||
|
- **Background App Refresh**: Für automatische Synchronisierung
|
||||||
|
- **Notifications**: Für Konflikt-Benachrichtigungen
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
|
<true/>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HealthBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchScreenBackground</string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>LaunchIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>healthkit</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- HealthKit -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
|
||||||
|
|
||||||
|
<!-- Background Modes -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.healthbridge.sync</string>
|
||||||
|
<string>com.healthbridge.cleanup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Reader
|
||||||
|
@MainActor
|
||||||
|
class DataReader: ObservableObject {
|
||||||
|
static let shared = DataReader()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var isReading = false
|
||||||
|
@Published var lastReadDate: Date?
|
||||||
|
@Published var detectedConflicts: [Conflict] = []
|
||||||
|
@Published var readingProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Fetch Data by Type and Date Range
|
||||||
|
|
||||||
|
func fetchData(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
groupByWindow intervalMinutes: Int = 15
|
||||||
|
) async throws -> [TimeWindowData] {
|
||||||
|
isReading = true
|
||||||
|
defer { isReading = false }
|
||||||
|
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group samples by source
|
||||||
|
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
|
||||||
|
|
||||||
|
// Create time windows
|
||||||
|
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
|
||||||
|
|
||||||
|
// Assign samples to windows
|
||||||
|
var windowDataList: [TimeWindowData] = []
|
||||||
|
|
||||||
|
for window in windows {
|
||||||
|
let windowData = createWindowData(
|
||||||
|
window: window,
|
||||||
|
dataType: dataType,
|
||||||
|
groupedBySource: groupedBySource
|
||||||
|
)
|
||||||
|
windowDataList.append(windowData)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReadDate = Date()
|
||||||
|
return windowDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
|
||||||
|
var grouped: [String: [HKSample]] = [:]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
let sourceId = sample.sourceRevision.source.bundleIdentifier
|
||||||
|
if grouped[sourceId] == nil {
|
||||||
|
grouped[sourceId] = []
|
||||||
|
}
|
||||||
|
grouped[sourceId]?.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = start
|
||||||
|
|
||||||
|
while current < end {
|
||||||
|
let windowEnd = min(
|
||||||
|
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
|
||||||
|
end
|
||||||
|
)
|
||||||
|
windows.append(TimeWindow(start: current, end: windowEnd))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindowData(
|
||||||
|
window: TimeWindow,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
groupedBySource: [String: [HKSample]]
|
||||||
|
) -> TimeWindowData {
|
||||||
|
var readings: [SourceReading] = []
|
||||||
|
|
||||||
|
for (sourceId, samples) in groupedBySource {
|
||||||
|
let windowSamples = samples.filter { sample in
|
||||||
|
sample.startDate < window.end && sample.endDate > window.start
|
||||||
|
}
|
||||||
|
|
||||||
|
if !windowSamples.isEmpty {
|
||||||
|
let reading = createReading(
|
||||||
|
from: windowSamples,
|
||||||
|
sourceId: sourceId,
|
||||||
|
dataType: dataType,
|
||||||
|
window: window
|
||||||
|
)
|
||||||
|
readings.append(reading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasConflict = detectConflict(in: readings, dataType: dataType)
|
||||||
|
|
||||||
|
return TimeWindowData(
|
||||||
|
timeWindow: window,
|
||||||
|
dataType: dataType,
|
||||||
|
readings: readings,
|
||||||
|
hasConflict: hasConflict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createReading(
|
||||||
|
from samples: [HKSample],
|
||||||
|
sourceId: String,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
window: TimeWindow
|
||||||
|
) -> SourceReading {
|
||||||
|
let value: Double
|
||||||
|
var secondaryValue: Double? = nil
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
// Sum up values for cumulative types
|
||||||
|
value = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
|
||||||
|
// Average for rate-based types
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// For blood pressure, we need to handle correlations
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
// Sum up sleep duration
|
||||||
|
value = samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
|
||||||
|
let category = source?.category ?? sourceManager.classifySource(sourceId)
|
||||||
|
|
||||||
|
return SourceReading(
|
||||||
|
sourceId: sourceId,
|
||||||
|
sourceName: source?.name ?? sourceId,
|
||||||
|
sourceCategory: category,
|
||||||
|
value: value,
|
||||||
|
secondaryValue: secondaryValue,
|
||||||
|
timestamp: window.start,
|
||||||
|
originalRecordId: samples.first?.uuid.uuidString,
|
||||||
|
quality: samples.isEmpty ? .missing : .complete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detection
|
||||||
|
|
||||||
|
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
|
||||||
|
// No conflict if less than 2 readings
|
||||||
|
guard readings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Filter out zero values (device wasn't tracking)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
guard nonZeroReadings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Check if values differ significantly
|
||||||
|
let values = nonZeroReadings.map { $0.value }
|
||||||
|
guard let minVal = values.min(), let maxVal = values.max() else { return false }
|
||||||
|
|
||||||
|
// Threshold varies by data type
|
||||||
|
let threshold = conflictThreshold(for: dataType)
|
||||||
|
|
||||||
|
if minVal == 0 {
|
||||||
|
return maxVal > threshold.absoluteThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (maxVal - minVal) / minVal * 100
|
||||||
|
return percentDiff > threshold.percentageThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
|
||||||
|
switch dataType {
|
||||||
|
case .steps:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
|
||||||
|
case .distance:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
|
||||||
|
case .heartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
|
||||||
|
case .activeEnergy:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
|
||||||
|
case .sleep:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
|
||||||
|
case .restingHeartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detect All Conflicts
|
||||||
|
|
||||||
|
func detectConflicts(
|
||||||
|
for date: Date,
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws -> [Conflict] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var allConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
for (index, dataType) in dataTypes.enumerated() {
|
||||||
|
readingProgress = Double(index) / Double(dataTypes.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflicts = windowData
|
||||||
|
.filter { $0.hasConflict }
|
||||||
|
.map { data in
|
||||||
|
Conflict(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: data.timeWindow,
|
||||||
|
readings: data.readings,
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
allConflicts.append(contentsOf: conflicts)
|
||||||
|
} catch {
|
||||||
|
print("Failed to detect conflicts for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgress = 1.0
|
||||||
|
detectedConflicts = allConflicts
|
||||||
|
return allConflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Gaps Detection
|
||||||
|
|
||||||
|
func detectGaps(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
expectedIntervalMinutes: Int = 15
|
||||||
|
) async throws -> [DataGap] {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: endDate),
|
||||||
|
expectedRecordCount: 0,
|
||||||
|
actualRecordCount: 0
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
var gaps: [DataGap] = []
|
||||||
|
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
|
||||||
|
|
||||||
|
// Check gap at start
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
|
||||||
|
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let current = sortedSamples[i]
|
||||||
|
let next = sortedSamples[i + 1]
|
||||||
|
let gap = next.startDate.timeIntervalSince(current.endDate)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
|
||||||
|
expectedRecordCount: Int(gap / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
|
||||||
|
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aggregated Data
|
||||||
|
|
||||||
|
func fetchDailySummary(for date: Date) async throws -> DailySummary {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var summary = DailySummary(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = aggregateValue(samples: samples, dataType: dataType)
|
||||||
|
summary.values[dataType] = value
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
let conflictCount = windowData.filter { $0.hasConflict }.count
|
||||||
|
summary.conflictCounts[dataType] = conflictCount
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch \(dataType) for summary: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
return samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
|
||||||
|
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
return samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct TimeWindowData: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let readings: [SourceReading]
|
||||||
|
let hasConflict: Bool
|
||||||
|
|
||||||
|
var primaryReading: SourceReading? {
|
||||||
|
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictSeverity: ConflictSeverity? {
|
||||||
|
guard hasConflict, readings.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
|
||||||
|
if percentDiff < 5 { return .minor }
|
||||||
|
if percentDiff < 20 { return .moderate }
|
||||||
|
if percentDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictThreshold {
|
||||||
|
let percentageThreshold: Double
|
||||||
|
let absoluteThreshold: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataGap: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let expectedRecordCount: Int
|
||||||
|
let actualRecordCount: Int
|
||||||
|
|
||||||
|
var severity: GapSeverity {
|
||||||
|
let duration = timeWindow.duration
|
||||||
|
if duration < 3600 { return .minor } // < 1 hour
|
||||||
|
if duration < 4 * 3600 { return .moderate } // < 4 hours
|
||||||
|
if duration < 12 * 3600 { return .significant } // < 12 hours
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GapSeverity {
|
||||||
|
case minor, moderate, significant, major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailySummary {
|
||||||
|
let date: Date
|
||||||
|
var values: [HealthDataType: Double] = [:]
|
||||||
|
var conflictCounts: [HealthDataType: Int] = [:]
|
||||||
|
var lastUpdated = Date()
|
||||||
|
|
||||||
|
var totalConflicts: Int {
|
||||||
|
conflictCounts.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedValue(for dataType: HealthDataType) -> String {
|
||||||
|
guard let value = values[dataType] else { return "–" }
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
case .distance:
|
||||||
|
return String(format: "%.2f km", value)
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return String(format: "%.0f %@", value, dataType.unit)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return String(format: "%.0f mmHg", value)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return String(format: "%.0f%%", value * 100)
|
||||||
|
case .activeEnergy:
|
||||||
|
return String(format: "%.0f kcal", value)
|
||||||
|
case .sleep:
|
||||||
|
let hours = Int(value)
|
||||||
|
let minutes = Int((value - Double(hours)) * 60)
|
||||||
|
return "\(hours)h \(minutes)min"
|
||||||
|
case .heartRateVariability:
|
||||||
|
return String(format: "%.0f ms", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Writer
|
||||||
|
@MainActor
|
||||||
|
class DataWriter: ObservableObject {
|
||||||
|
static let shared = DataWriter()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isWriting = false
|
||||||
|
@Published var writeProgress: Double = 0
|
||||||
|
@Published var lastWriteDate: Date?
|
||||||
|
@Published var writtenRecords: [WrittenRecord] = []
|
||||||
|
@Published var failedWrites: [FailedWrite] = []
|
||||||
|
|
||||||
|
private let processedRecordsKey = "healthbridge.processed.records"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Single Record
|
||||||
|
|
||||||
|
func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
// Check if already written
|
||||||
|
if isAlreadyWritten(mergedRecord) {
|
||||||
|
throw DataWriterError.duplicateRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = createMetadata(from: mergedRecord)
|
||||||
|
|
||||||
|
switch mergedRecord.dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// Blood pressure needs special handling
|
||||||
|
guard let diastolic = mergedRecord.secondaryValue else {
|
||||||
|
throw DataWriterError.missingSecondaryValue
|
||||||
|
}
|
||||||
|
try await writeBloodPressure(
|
||||||
|
systolic: mergedRecord.value,
|
||||||
|
diastolic: diastolic,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
try await writeSample(
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
date: mergedRecord.timeWindow.start,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let writtenRecord = WrittenRecord(
|
||||||
|
id: UUID(),
|
||||||
|
mergedRecordId: mergedRecord.id,
|
||||||
|
dataType: mergedRecord.dataType,
|
||||||
|
value: mergedRecord.value,
|
||||||
|
secondaryValue: mergedRecord.secondaryValue,
|
||||||
|
writtenAt: Date(),
|
||||||
|
timeWindow: mergedRecord.timeWindow
|
||||||
|
)
|
||||||
|
|
||||||
|
writtenRecords.append(writtenRecord)
|
||||||
|
markAsProcessed(mergedRecord)
|
||||||
|
lastWriteDate = Date()
|
||||||
|
|
||||||
|
return writtenRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Batch
|
||||||
|
|
||||||
|
func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult {
|
||||||
|
isWriting = true
|
||||||
|
defer { isWriting = false }
|
||||||
|
|
||||||
|
var successful: [WrittenRecord] = []
|
||||||
|
var failed: [FailedWrite] = []
|
||||||
|
|
||||||
|
for (index, record) in mergedRecords.enumerated() {
|
||||||
|
writeProgress = Double(index) / Double(mergedRecords.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let writtenRecord = try await writeRecord(record)
|
||||||
|
successful.append(writtenRecord)
|
||||||
|
} catch {
|
||||||
|
let failedWrite = FailedWrite(
|
||||||
|
mergedRecord: record,
|
||||||
|
error: error,
|
||||||
|
attemptedAt: Date()
|
||||||
|
)
|
||||||
|
failed.append(failedWrite)
|
||||||
|
failedWrites.append(failedWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeProgress = 1.0
|
||||||
|
|
||||||
|
return BatchWriteResult(
|
||||||
|
successful: successful,
|
||||||
|
failed: failed,
|
||||||
|
completedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Write Methods
|
||||||
|
|
||||||
|
private func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]
|
||||||
|
) async throws {
|
||||||
|
// Validate blood pressure values
|
||||||
|
let validation = BloodPressureHandler.shared.validate(
|
||||||
|
systolic: systolic,
|
||||||
|
diastolic: diastolic
|
||||||
|
)
|
||||||
|
|
||||||
|
if !validation.isValid {
|
||||||
|
throw DataWriterError.invalidValue(validation.issues.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
|
||||||
|
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
private func createMetadata(from record: MergedRecord) -> [String: Any] {
|
||||||
|
var metadata: [String: Any] = [
|
||||||
|
HKMetadataKeyWasUserEntered: false,
|
||||||
|
"HealthBridgeSource": HealthBridgeConstants.bundleIdentifier,
|
||||||
|
"OriginalSourceId": record.originalSourceId,
|
||||||
|
"MergeStrategy": record.strategy.rawValue,
|
||||||
|
"MergedRecordId": record.id.uuidString,
|
||||||
|
"MergedAt": ISO8601DateFormatter().string(from: record.createdAt)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (key, value) in record.metadata {
|
||||||
|
metadata["HB_\(key)"] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Duplicate Prevention
|
||||||
|
|
||||||
|
private var processedRecordIds: Set<String> = []
|
||||||
|
|
||||||
|
private func loadProcessedRecords() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
|
||||||
|
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||||
|
processedRecordIds = ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProcessedRecords() {
|
||||||
|
if let data = try? JSONEncoder().encode(processedRecordIds) {
|
||||||
|
UserDefaults.standard.set(data, forKey: processedRecordsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAlreadyWritten(_ record: MergedRecord) -> Bool {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
return processedRecordIds.contains(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markAsProcessed(_ record: MergedRecord) {
|
||||||
|
let identifier = createRecordIdentifier(record)
|
||||||
|
processedRecordIds.insert(identifier)
|
||||||
|
saveProcessedRecords()
|
||||||
|
|
||||||
|
// Cleanup old records (keep last 7 days)
|
||||||
|
cleanupOldRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRecordIdentifier(_ record: MergedRecord) -> String {
|
||||||
|
let components = [
|
||||||
|
record.dataType.rawValue,
|
||||||
|
String(record.timeWindow.start.timeIntervalSince1970),
|
||||||
|
String(record.value)
|
||||||
|
]
|
||||||
|
return components.joined(separator: "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupOldRecords() {
|
||||||
|
// Keep only identifiers that contain recent timestamps
|
||||||
|
let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
||||||
|
let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970
|
||||||
|
|
||||||
|
processedRecordIds = processedRecordIds.filter { identifier in
|
||||||
|
guard let parts = identifier.split(separator: "-").dropFirst().first,
|
||||||
|
let timestamp = Double(parts) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return timestamp > cutoffTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProcessedRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Records
|
||||||
|
|
||||||
|
func deleteHealthBridgeRecords(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> Int {
|
||||||
|
guard let sampleType = dataType.hkQuantityType else {
|
||||||
|
throw DataWriterError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { [weak self] _, samplesOrNil, errorOrNil in
|
||||||
|
guard let self = self else {
|
||||||
|
continuation.resume(throwing: DataWriterError.unknownError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let samples = samplesOrNil else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only HealthBridge records
|
||||||
|
let healthBridgeSamples = samples.filter { sample in
|
||||||
|
if let metadata = sample.metadata,
|
||||||
|
let source = metadata["HealthBridgeSource"] as? String {
|
||||||
|
return source == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !healthBridgeSamples.isEmpty else {
|
||||||
|
continuation.resume(returning: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await self.healthStore.delete(healthBridgeSamples)
|
||||||
|
continuation.resume(returning: healthBridgeSamples.count)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WrittenRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let mergedRecordId: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let writtenAt: Date
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailedWrite: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let mergedRecord: MergedRecord
|
||||||
|
let error: Error
|
||||||
|
let attemptedAt: Date
|
||||||
|
|
||||||
|
var errorMessage: String {
|
||||||
|
error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BatchWriteResult {
|
||||||
|
let successful: [WrittenRecord]
|
||||||
|
let failed: [FailedWrite]
|
||||||
|
let completedAt: Date
|
||||||
|
|
||||||
|
var successCount: Int { successful.count }
|
||||||
|
var failureCount: Int { failed.count }
|
||||||
|
var totalCount: Int { successCount + failureCount }
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalCount > 0 else { return 1.0 }
|
||||||
|
return Double(successCount) / Double(totalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum DataWriterError: LocalizedError {
|
||||||
|
case unsupportedDataType
|
||||||
|
case duplicateRecord
|
||||||
|
case missingSecondaryValue
|
||||||
|
case invalidValue(String)
|
||||||
|
case writeFailed(String)
|
||||||
|
case unknownError
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .duplicateRecord:
|
||||||
|
return "Dieser Datensatz wurde bereits geschrieben"
|
||||||
|
case .missingSecondaryValue:
|
||||||
|
return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)"
|
||||||
|
case .invalidValue(let message):
|
||||||
|
return "Ungültiger Wert: \(message)"
|
||||||
|
case .writeFailed(let message):
|
||||||
|
return "Schreiben fehlgeschlagen: \(message)"
|
||||||
|
case .unknownError:
|
||||||
|
return "Unbekannter Fehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - HealthKit Manager
|
||||||
|
@MainActor
|
||||||
|
class HealthKitManager: ObservableObject {
|
||||||
|
static let shared = HealthKitManager()
|
||||||
|
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
|
||||||
|
@Published var discoveredSources: [HealthSource] = []
|
||||||
|
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
|
||||||
|
@Published var lastError: Error?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var allQuantityTypes: Set<HKQuantityType> {
|
||||||
|
var types = Set<HKQuantityType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
types.insert(quantityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCategoryTypes: Set<HKCategoryType> {
|
||||||
|
var types = Set<HKCategoryType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let categoryType = dataType.hkCategoryType {
|
||||||
|
types.insert(categoryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSampleTypes: Set<HKSampleType> {
|
||||||
|
var types = Set<HKSampleType>()
|
||||||
|
allQuantityTypes.forEach { types.insert($0) }
|
||||||
|
allCategoryTypes.forEach { types.insert($0) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async throws {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
throw HealthKitError.healthDataNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let typesToRead = allSampleTypes
|
||||||
|
let typesToWrite = allQuantityTypes
|
||||||
|
|
||||||
|
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
|
||||||
|
isAuthorized = true
|
||||||
|
|
||||||
|
await updateAuthorizationStatus()
|
||||||
|
await discoverSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAuthorizationStatus() async {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let status = healthStore.authorizationStatus(for: quantityType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
} else if let categoryType = dataType.hkCategoryType {
|
||||||
|
let status = healthStore.authorizationStatus(for: categoryType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
var allSources: [String: HealthSource] = [:]
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sources = try await fetchSources(for: sampleType)
|
||||||
|
for source in sources {
|
||||||
|
if var existingSource = allSources[source.bundleIdentifier] {
|
||||||
|
existingSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = existingSource
|
||||||
|
} else {
|
||||||
|
var newSource = source
|
||||||
|
newSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = newSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch sources for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
|
||||||
|
|
||||||
|
// Update source health status
|
||||||
|
for source in discoveredSources {
|
||||||
|
await updateSourceHealth(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
// Handled via continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sources = sourcesOrNil else {
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
|
||||||
|
continuation.resume(returning: healthSources)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSourceHealth(_ source: HealthSource) async {
|
||||||
|
var recordCount = 0
|
||||||
|
var lastActivity: Date?
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
|
||||||
|
// Simplified: just get count
|
||||||
|
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
|
||||||
|
recordCount += count
|
||||||
|
}
|
||||||
|
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
|
||||||
|
if lastActivity == nil || date > lastActivity! {
|
||||||
|
lastActivity = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = SourceHealthStatus(
|
||||||
|
id: source.id,
|
||||||
|
source: source,
|
||||||
|
lastSync: lastActivity,
|
||||||
|
recordCount: recordCount,
|
||||||
|
dataGaps: [], // TODO: Implement gap detection
|
||||||
|
overallQuality: recordCount > 0 ? .complete : .missing
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceHealthStatus[source.id] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let startOfDay = calendar.startOfDay(for: now)
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = samplesOrNil ?? []
|
||||||
|
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSamples.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
|
||||||
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: nil,
|
||||||
|
limit: 1,
|
||||||
|
sortDescriptors: [sortDescriptor]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSample?.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ source: HKSource) -> SourceCategory {
|
||||||
|
let bundleId = source.bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
if bundleId.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
|
||||||
|
return .iPhone
|
||||||
|
} else if bundleId.contains("apple") && bundleId.contains("watch") {
|
||||||
|
return .watch
|
||||||
|
} else if bundleId.contains("huawei") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("fitbit") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("garmin") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("polar") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
|
||||||
|
return source.supportedDataTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Fetching (Basic)
|
||||||
|
|
||||||
|
func fetchSamples(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> [HKSample] {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(
|
||||||
|
withStart: startDate,
|
||||||
|
end: endDate,
|
||||||
|
options: .strictStartDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: samplesOrNil ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Writing
|
||||||
|
|
||||||
|
func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
|
||||||
|
let sample = HKQuantitySample(
|
||||||
|
type: quantityType,
|
||||||
|
quantity: quantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
|
||||||
|
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
|
||||||
|
|
||||||
|
let systolicSample = HKQuantitySample(
|
||||||
|
type: systolicType,
|
||||||
|
quantity: systolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
let diastolicSample = HKQuantitySample(
|
||||||
|
type: diastolicType,
|
||||||
|
quantity: diastolicQuantity,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create correlation for blood pressure
|
||||||
|
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit Errors
|
||||||
|
enum HealthKitError: LocalizedError {
|
||||||
|
case healthDataNotAvailable
|
||||||
|
case authorizationDenied
|
||||||
|
case unsupportedDataType
|
||||||
|
case noDataFound
|
||||||
|
case writeFailed(Error)
|
||||||
|
case queryFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .healthDataNotAvailable:
|
||||||
|
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
|
||||||
|
case .authorizationDenied:
|
||||||
|
return "Zugriff auf Health-Daten wurde verweigert"
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .noDataFound:
|
||||||
|
return "Keine Daten gefunden"
|
||||||
|
case .writeFailed(let error):
|
||||||
|
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
case .queryFailed(let error):
|
||||||
|
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HKSource Extension
|
||||||
|
extension HKSource {
|
||||||
|
convenience init(bundleIdentifier: String, name: String) {
|
||||||
|
// Note: This is a workaround since HKSource doesn't have a public initializer
|
||||||
|
// In production, sources come from HealthKit queries
|
||||||
|
fatalError("HKSource cannot be initialized directly - use source from HKSample")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Merge Engine
|
||||||
|
@MainActor
|
||||||
|
class MergeEngine: ObservableObject {
|
||||||
|
static let shared = MergeEngine()
|
||||||
|
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@Published var pendingMerges: [MergeOperation] = []
|
||||||
|
@Published var completedMerges: [MergeOperation] = []
|
||||||
|
@Published var isMerging = false
|
||||||
|
@Published var mergeProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Analyze Window
|
||||||
|
|
||||||
|
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
|
||||||
|
let readings = windowData.readings
|
||||||
|
let dataType = windowData.dataType
|
||||||
|
|
||||||
|
// No analysis needed for single reading
|
||||||
|
if readings.count <= 1 {
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: false,
|
||||||
|
conflictSeverity: nil,
|
||||||
|
recommendedReading: readings.first,
|
||||||
|
alternativeReadings: [],
|
||||||
|
confidence: .high,
|
||||||
|
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rule to get recommendation
|
||||||
|
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
|
||||||
|
|
||||||
|
// Calculate conflict severity
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
var severity: ConflictSeverity? = nil
|
||||||
|
|
||||||
|
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
if percentDiff >= 5 {
|
||||||
|
if percentDiff < 10 { severity = .minor }
|
||||||
|
else if percentDiff < 25 { severity = .moderate }
|
||||||
|
else if percentDiff < 50 { severity = .significant }
|
||||||
|
else { severity = .major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
|
||||||
|
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: windowData.hasConflict,
|
||||||
|
conflictSeverity: severity,
|
||||||
|
recommendedReading: result.selectedReading,
|
||||||
|
alternativeReadings: alternativeReadings,
|
||||||
|
confidence: result.confidence,
|
||||||
|
analysisNotes: result.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolve Conflict
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = result.selectedReading else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: result.strategy,
|
||||||
|
isManual: result.strategy == .manual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflictManually(
|
||||||
|
_ conflict: Conflict,
|
||||||
|
selectedReadingId: UUID
|
||||||
|
) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: .manual,
|
||||||
|
isManual: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create Merged Record
|
||||||
|
|
||||||
|
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
|
||||||
|
return MergedRecord(
|
||||||
|
id: UUID(),
|
||||||
|
dataType: conflict.dataType,
|
||||||
|
timeWindow: conflict.timeWindow,
|
||||||
|
value: resolution.resolvedValue,
|
||||||
|
secondaryValue: resolution.secondaryResolvedValue,
|
||||||
|
originalSourceId: resolution.winningSourceId,
|
||||||
|
strategy: resolution.strategy,
|
||||||
|
createdAt: Date(),
|
||||||
|
metadata: [
|
||||||
|
"conflictId": conflict.id.uuidString,
|
||||||
|
"originalSourceCount": String(conflict.readings.count),
|
||||||
|
"isManualResolution": String(resolution.isManual)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch Processing
|
||||||
|
|
||||||
|
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
|
||||||
|
isMerging = true
|
||||||
|
defer { isMerging = false }
|
||||||
|
|
||||||
|
var operations: [MergeOperation] = []
|
||||||
|
|
||||||
|
for (index, conflict) in conflicts.enumerated() {
|
||||||
|
mergeProgress = Double(index) / Double(conflicts.count)
|
||||||
|
|
||||||
|
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
|
||||||
|
|
||||||
|
if result.confidence == .requiresManual ||
|
||||||
|
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
|
||||||
|
// Add to pending for manual review
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .pendingManualReview,
|
||||||
|
result: result
|
||||||
|
)
|
||||||
|
pendingMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
} else if let resolution = resolveConflict(conflict, using: result) {
|
||||||
|
// Auto-resolve
|
||||||
|
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .resolved,
|
||||||
|
result: result,
|
||||||
|
resolution: resolution,
|
||||||
|
mergedRecord: mergedRecord
|
||||||
|
)
|
||||||
|
completedMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeProgress = 1.0
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Merge
|
||||||
|
|
||||||
|
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var report = DailyMergeReport(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let windowData = try await dataReader.fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictWindows = windowData.filter { $0.hasConflict }
|
||||||
|
report.totalConflicts += conflictWindows.count
|
||||||
|
|
||||||
|
for window in conflictWindows {
|
||||||
|
let analysis = analyze(windowData: window)
|
||||||
|
|
||||||
|
if analysis.confidence == .requiresManual {
|
||||||
|
report.pendingManualReview += 1
|
||||||
|
} else {
|
||||||
|
report.autoResolved += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report.analysesByType[dataType, default: []].append(analysis)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WindowAnalysis {
|
||||||
|
let windowData: TimeWindowData
|
||||||
|
let hasConflict: Bool
|
||||||
|
let conflictSeverity: ConflictSeverity?
|
||||||
|
let recommendedReading: SourceReading?
|
||||||
|
let alternativeReadings: [SourceReading]
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let analysisNotes: String
|
||||||
|
|
||||||
|
var recommendedValue: Double? {
|
||||||
|
recommendedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double? {
|
||||||
|
guard let recommended = recommendedReading?.value,
|
||||||
|
let alternative = alternativeReadings.first?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return abs(recommended - alternative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergeOperation: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let conflict: Conflict
|
||||||
|
var status: MergeStatus
|
||||||
|
let result: RuleApplicationResult
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var mergedRecord: MergedRecord?
|
||||||
|
let createdAt = Date()
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum MergeStatus {
|
||||||
|
case pending
|
||||||
|
case pendingManualReview
|
||||||
|
case resolved
|
||||||
|
case written
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergedRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let originalSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let createdAt: Date
|
||||||
|
var writtenAt: Date?
|
||||||
|
var healthKitRecordId: String?
|
||||||
|
var metadata: [String: String]
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailyMergeReport {
|
||||||
|
let date: Date
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
|
||||||
|
var errors: [String] = []
|
||||||
|
let generatedAt = Date()
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
if totalConflicts == 0 {
|
||||||
|
return "Keine Konflikte gefunden"
|
||||||
|
}
|
||||||
|
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Rule Engine
|
||||||
|
@MainActor
|
||||||
|
class RuleEngine: ObservableObject {
|
||||||
|
static let shared = RuleEngine()
|
||||||
|
|
||||||
|
@Published var rules: [HealthDataType: MergeRule] = [:]
|
||||||
|
@Published var isLoaded = false
|
||||||
|
|
||||||
|
private let storage = RuleStorageManager()
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Loading
|
||||||
|
|
||||||
|
func loadRules() {
|
||||||
|
let savedRules = storage.loadRules()
|
||||||
|
|
||||||
|
if savedRules.isEmpty {
|
||||||
|
// Initialize with defaults
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
} else {
|
||||||
|
rules = savedRules
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRules() {
|
||||||
|
storage.saveRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Access
|
||||||
|
|
||||||
|
func getRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = rule
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefault(for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllToDefaults() {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application
|
||||||
|
|
||||||
|
func applyRule(
|
||||||
|
to readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
// Filter out empty/zero readings for most strategies
|
||||||
|
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
|
||||||
|
|
||||||
|
guard !validReadings.isEmpty else {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine gültigen Werte vorhanden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one valid reading, no conflict
|
||||||
|
if validReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: validReadings[0],
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Nur eine Quelle verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply strategy
|
||||||
|
switch rule.strategy {
|
||||||
|
case .exclusive:
|
||||||
|
return applyExclusiveStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .priority:
|
||||||
|
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
|
||||||
|
|
||||||
|
case .higherWins:
|
||||||
|
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .lowerWins:
|
||||||
|
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .average:
|
||||||
|
return applyAverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverage:
|
||||||
|
return applyCoverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .mostRecent:
|
||||||
|
return applyMostRecentStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .manual:
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .manual,
|
||||||
|
confidence: .requiresManual,
|
||||||
|
reason: "Manuelle Entscheidung erforderlich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Strategy Implementations
|
||||||
|
|
||||||
|
private func applyExclusiveStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// If primary source is specified, use it
|
||||||
|
if let primaryId = rule.primarySourceId,
|
||||||
|
let reading = readings.first(where: { $0.sourceId == primaryId }) {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: reading,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Exklusive Quelle: \(reading.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use highest priority source
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine geeignete Quelle gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPriorityStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule,
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Sort by user-defined priority, then by category priority
|
||||||
|
let sorted = readings.sorted { r1, r2 in
|
||||||
|
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
|
||||||
|
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
|
||||||
|
return p1 > p2
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Priorität gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHigherWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value > $1.value }
|
||||||
|
|
||||||
|
if let highest = sorted.first {
|
||||||
|
// Check if there's a significant difference
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let spread = (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
let avgValue = values.reduce(0, +) / Double(values.count)
|
||||||
|
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
|
||||||
|
|
||||||
|
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLowerWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value < $1.value }
|
||||||
|
|
||||||
|
if let lowest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: lowest,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let average = values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
// Create a synthetic reading for the average
|
||||||
|
let syntheticReading = SourceReading(
|
||||||
|
sourceId: HealthBridgeConstants.bundleIdentifier,
|
||||||
|
sourceName: "Durchschnitt",
|
||||||
|
sourceCategory: .healthBridge,
|
||||||
|
value: average,
|
||||||
|
timestamp: readings.first?.timestamp ?? Date(),
|
||||||
|
quality: .complete
|
||||||
|
)
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: syntheticReading,
|
||||||
|
strategy: .average,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Durchschnitt aus \(readings.count) Quellen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Prefer readings with complete quality
|
||||||
|
let completeReadings = readings.filter { $0.quality == .complete }
|
||||||
|
|
||||||
|
if completeReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: completeReadings[0],
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple complete readings, fall back to priority
|
||||||
|
if !completeReadings.isEmpty {
|
||||||
|
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No complete readings, use any reading with highest priority
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Daten gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageThenHigherStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// First check if one source has data and others don't (coverage)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
let zeroReadings = readings.filter { $0.value == 0 }
|
||||||
|
|
||||||
|
// If only one source has data, it wins on coverage
|
||||||
|
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nonZeroReadings[0],
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple sources have data, use higher wins
|
||||||
|
if nonZeroReadings.count > 1 {
|
||||||
|
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
|
||||||
|
if let highest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
if let first = readings.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Fallback auf erste Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Daten verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMostRecentStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
if let mostRecent = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: mostRecent,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Neuester Wert von \(mostRecent.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Zeitstempel verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Threshold Check
|
||||||
|
|
||||||
|
func shouldRequestManualReview(
|
||||||
|
readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> Bool {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
guard let threshold = rule.thresholdForManualReview else {
|
||||||
|
return rule.strategy == .manual
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard values.count >= 2,
|
||||||
|
let min = values.min(),
|
||||||
|
let max = values.max(),
|
||||||
|
min > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
return percentDiff > threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application Result
|
||||||
|
struct RuleApplicationResult {
|
||||||
|
let selectedReading: SourceReading?
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
var resolvedValue: Double? {
|
||||||
|
selectedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var winningSourceId: String? {
|
||||||
|
selectedReading?.sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleConfidence: String, Codable {
|
||||||
|
case high = "high"
|
||||||
|
case medium = "medium"
|
||||||
|
case low = "low"
|
||||||
|
case requiresManual = "requires_manual"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "Hohe Sicherheit"
|
||||||
|
case .medium: return "Mittlere Sicherheit"
|
||||||
|
case .low: return "Geringe Sicherheit"
|
||||||
|
case .requiresManual: return "Manuelle Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "checkmark.seal.fill"
|
||||||
|
case .medium: return "checkmark.seal"
|
||||||
|
case .low: return "questionmark.circle"
|
||||||
|
case .requiresManual: return "hand.raised.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Storage Manager
|
||||||
|
class RuleStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let rulesKey = "healthbridge.merge.rules"
|
||||||
|
|
||||||
|
func saveRules(_ rules: [HealthDataType: MergeRule]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(rules)
|
||||||
|
userDefaults.set(data, forKey: rulesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save rules: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRules() -> [HealthDataType: MergeRule] {
|
||||||
|
guard let data = userDefaults.data(forKey: rulesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load rules: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Handler
|
||||||
|
class BloodPressureHandler {
|
||||||
|
static let shared = BloodPressureHandler()
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let issues: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
|
||||||
|
var issues: [String] = []
|
||||||
|
|
||||||
|
// Range validation
|
||||||
|
if systolic < 70 || systolic > 200 {
|
||||||
|
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if diastolic < 40 || diastolic > 130 {
|
||||||
|
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausibility check
|
||||||
|
if diastolic >= systolic {
|
||||||
|
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic < 20 {
|
||||||
|
issues.append("Pulsdruck zu gering (< 20 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic > 100 {
|
||||||
|
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult(isValid: issues.isEmpty, issues: issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
|
||||||
|
if systolic < 120 && diastolic < 80 {
|
||||||
|
return .normal
|
||||||
|
} else if systolic < 130 && diastolic < 80 {
|
||||||
|
return .elevated
|
||||||
|
} else if systolic < 140 || diastolic < 90 {
|
||||||
|
return .hypertensionStage1
|
||||||
|
} else if systolic < 180 || diastolic < 120 {
|
||||||
|
return .hypertensionStage2
|
||||||
|
} else {
|
||||||
|
return .hypertensiveCrisis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BloodPressureClassification: String {
|
||||||
|
case normal = "Normal"
|
||||||
|
case elevated = "Erhöht"
|
||||||
|
case hypertensionStage1 = "Bluthochdruck Stufe 1"
|
||||||
|
case hypertensionStage2 = "Bluthochdruck Stufe 2"
|
||||||
|
case hypertensiveCrisis = "Hypertensive Krise"
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "green"
|
||||||
|
case .elevated: return "yellow"
|
||||||
|
case .hypertensionStage1: return "orange"
|
||||||
|
case .hypertensionStage2: return "red"
|
||||||
|
case .hypertensiveCrisis: return "purple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recommendation: String {
|
||||||
|
switch self {
|
||||||
|
case .normal:
|
||||||
|
return "Weiter so! Regelmässige Kontrolle empfohlen."
|
||||||
|
case .elevated:
|
||||||
|
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
|
||||||
|
case .hypertensionStage1:
|
||||||
|
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
|
||||||
|
case .hypertensionStage2:
|
||||||
|
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
|
||||||
|
case .hypertensiveCrisis:
|
||||||
|
return "SOFORT medizinische Hilfe aufsuchen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Source Manager
|
||||||
|
@MainActor
|
||||||
|
class SourceManager: ObservableObject {
|
||||||
|
static let shared = SourceManager()
|
||||||
|
|
||||||
|
@Published var sources: [HealthSource] = []
|
||||||
|
@Published var sourceProfiles: [String: SourceProfile] = [:]
|
||||||
|
@Published var isDiscovering = false
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let storage = SourceStorageManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
isDiscovering = true
|
||||||
|
defer { isDiscovering = false }
|
||||||
|
|
||||||
|
await healthKitManager.discoverSources()
|
||||||
|
sources = healthKitManager.discoveredSources
|
||||||
|
|
||||||
|
// Load saved source profiles and merge with discovered sources
|
||||||
|
let savedProfiles = storage.loadSourceProfiles()
|
||||||
|
for source in sources {
|
||||||
|
if let savedProfile = savedProfiles[source.bundleIdentifier] {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = savedProfile
|
||||||
|
} else {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
// HealthBridge
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Devices
|
||||||
|
if lowercased.contains("com.apple") {
|
||||||
|
if lowercased.contains("watch") || lowercased.contains("nano") {
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
if lowercased.contains("health") {
|
||||||
|
return .iPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Watch Brands
|
||||||
|
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
|
||||||
|
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
|
||||||
|
"coros", "whoop", "oura"]
|
||||||
|
for brand in watchBrands {
|
||||||
|
if lowercased.contains(brand) {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Health Apps
|
||||||
|
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
|
||||||
|
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
|
||||||
|
for app in healthApps {
|
||||||
|
if lowercased.contains(app) {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
|
||||||
|
let category = source.category
|
||||||
|
|
||||||
|
switch category {
|
||||||
|
case .iPhone:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: true,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: true,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .watch:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyWatch:
|
||||||
|
// Check for specific features based on name
|
||||||
|
let name = source.name.lowercased()
|
||||||
|
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
|
||||||
|
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyApp:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .healthBridge, .unknown:
|
||||||
|
return SourceCapabilities.empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health
|
||||||
|
|
||||||
|
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
|
||||||
|
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
|
||||||
|
var lastActivityByType: [HealthDataType: Date] = [:]
|
||||||
|
var recordCountByType: [HealthDataType: Int] = [:]
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: yesterday,
|
||||||
|
to: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let matchingSamples = samples.filter {
|
||||||
|
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCountByType[dataType] = matchingSamples.count
|
||||||
|
|
||||||
|
if let lastSample = matchingSamples.last {
|
||||||
|
lastActivityByType[dataType] = lastSample.endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect gaps
|
||||||
|
let gaps = detectDataGaps(
|
||||||
|
in: matchingSamples,
|
||||||
|
from: yesterday,
|
||||||
|
to: now,
|
||||||
|
expectedInterval: 15 * 60 // 15 minutes
|
||||||
|
)
|
||||||
|
if !gaps.isEmpty {
|
||||||
|
dataGaps[dataType] = gaps
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error checking health for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
if recordCountByType.values.reduce(0, +) == 0 {
|
||||||
|
overallQuality = .missing
|
||||||
|
} else if dataGaps.isEmpty {
|
||||||
|
overallQuality = .complete
|
||||||
|
} else {
|
||||||
|
overallQuality = .partial
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceHealthReport(
|
||||||
|
source: source,
|
||||||
|
lastActivityByType: lastActivityByType,
|
||||||
|
recordCountByType: recordCountByType,
|
||||||
|
dataGaps: dataGaps,
|
||||||
|
overallQuality: overallQuality,
|
||||||
|
checkedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectDataGaps(
|
||||||
|
in samples: [HKSample],
|
||||||
|
from start: Date,
|
||||||
|
to end: Date,
|
||||||
|
expectedInterval: TimeInterval
|
||||||
|
) -> [TimeWindow] {
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [TimeWindow(start: start, end: end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var gaps: [TimeWindow] = []
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
|
||||||
|
// Check gap at beginning
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let currentEnd = sortedSamples[i].endDate
|
||||||
|
let nextStart = sortedSamples[i + 1].startDate
|
||||||
|
let gap = nextStart.timeIntervalSince(currentEnd)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Management
|
||||||
|
|
||||||
|
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.priorities[dataType] = priority
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
|
||||||
|
if let profile = sourceProfiles[source.bundleIdentifier],
|
||||||
|
let priority = profile.priorities[dataType] {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
return source.category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
|
||||||
|
return sources
|
||||||
|
.filter { $0.supportedDataTypes.contains(dataType) }
|
||||||
|
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Enable/Disable
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool, for source: HealthSource) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.isEnabled = enabled
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEnabled(_ source: HealthSource) -> Bool {
|
||||||
|
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Profile
|
||||||
|
struct SourceProfile: Codable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var priorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
var customName: String?
|
||||||
|
var notes: String?
|
||||||
|
let addedAt: Date
|
||||||
|
|
||||||
|
init(source: HealthSource) {
|
||||||
|
self.bundleIdentifier = source.bundleIdentifier
|
||||||
|
self.priorities = [:]
|
||||||
|
self.isEnabled = true
|
||||||
|
self.customName = nil
|
||||||
|
self.notes = nil
|
||||||
|
self.addedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
struct SourceCapabilities {
|
||||||
|
let canMeasureSteps: Bool
|
||||||
|
let canMeasureDistance: Bool
|
||||||
|
let canMeasureFloors: Bool
|
||||||
|
let canMeasureHeartRate: Bool
|
||||||
|
let canMeasureBloodPressure: Bool
|
||||||
|
let canMeasureBloodOxygen: Bool
|
||||||
|
let canMeasureSleep: Bool
|
||||||
|
let canMeasureWorkouts: Bool
|
||||||
|
let hasGPS: Bool
|
||||||
|
let hasBarometer: Bool
|
||||||
|
let hasAccelerometer: Bool
|
||||||
|
|
||||||
|
static let empty = SourceCapabilities(
|
||||||
|
canMeasureSteps: false,
|
||||||
|
canMeasureDistance: false,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: false,
|
||||||
|
hasGPS: false,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedDataTypes: Set<HealthDataType> {
|
||||||
|
var types = Set<HealthDataType>()
|
||||||
|
if canMeasureSteps { types.insert(.steps) }
|
||||||
|
if canMeasureDistance { types.insert(.distance) }
|
||||||
|
if canMeasureFloors { types.insert(.floorsClimbed) }
|
||||||
|
if canMeasureHeartRate {
|
||||||
|
types.insert(.heartRate)
|
||||||
|
types.insert(.restingHeartRate)
|
||||||
|
}
|
||||||
|
if canMeasureBloodPressure {
|
||||||
|
types.insert(.bloodPressureSystolic)
|
||||||
|
types.insert(.bloodPressureDiastolic)
|
||||||
|
}
|
||||||
|
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
|
||||||
|
if canMeasureSleep { types.insert(.sleep) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Report
|
||||||
|
struct SourceHealthReport {
|
||||||
|
let source: HealthSource
|
||||||
|
let lastActivityByType: [HealthDataType: Date]
|
||||||
|
let recordCountByType: [HealthDataType: Int]
|
||||||
|
let dataGaps: [HealthDataType: [TimeWindow]]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
let checkedAt: Date
|
||||||
|
|
||||||
|
var lastOverallActivity: Date? {
|
||||||
|
lastActivityByType.values.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecordCount: Int {
|
||||||
|
recordCountByType.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSignificantGaps: Bool {
|
||||||
|
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Storage Manager
|
||||||
|
class SourceStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let profilesKey = "healthbridge.source.profiles"
|
||||||
|
|
||||||
|
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(profiles)
|
||||||
|
userDefaults.set(data, forKey: profilesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save source profiles: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSourceProfiles() -> [String: SourceProfile] {
|
||||||
|
guard let data = userDefaults.data(forKey: profilesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load source profiles: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Sync Coordinator
|
||||||
|
@MainActor
|
||||||
|
class SyncCoordinator: ObservableObject {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let mergeEngine = MergeEngine.shared
|
||||||
|
private let dataWriter = DataWriter.shared
|
||||||
|
|
||||||
|
@Published var isSyncing = false
|
||||||
|
@Published var syncProgress: Double = 0
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var lastSyncResult: SyncResult?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
@Published var syncHistory: [SyncResult] = []
|
||||||
|
|
||||||
|
private let syncHistoryKey = "healthbridge.sync.history"
|
||||||
|
private let maxHistoryItems = 50
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Sync
|
||||||
|
|
||||||
|
func performSync(
|
||||||
|
for date: Date = Date(),
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
syncProgress = 0
|
||||||
|
defer { isSyncing = false }
|
||||||
|
|
||||||
|
let startTime = Date()
|
||||||
|
var result = SyncResult(startedAt: startTime)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Step 1: Refresh sources (10%)
|
||||||
|
syncProgress = 0.05
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
syncProgress = 0.1
|
||||||
|
|
||||||
|
// Step 2: Detect conflicts (40%)
|
||||||
|
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
|
||||||
|
result.totalConflicts = conflicts.count
|
||||||
|
syncProgress = 0.4
|
||||||
|
|
||||||
|
// Step 3: Process conflicts with merge engine (70%)
|
||||||
|
let operations = await mergeEngine.processConflicts(conflicts)
|
||||||
|
syncProgress = 0.7
|
||||||
|
|
||||||
|
let autoResolved = operations.filter { $0.status == .resolved }
|
||||||
|
let pendingManual = operations.filter { $0.status == .pendingManualReview }
|
||||||
|
|
||||||
|
result.autoResolved = autoResolved.count
|
||||||
|
result.pendingManualReview = pendingManual.count
|
||||||
|
|
||||||
|
// Update pending conflicts
|
||||||
|
pendingConflicts = pendingManual.map { $0.conflict }
|
||||||
|
|
||||||
|
// Step 4: Write resolved records (90%)
|
||||||
|
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
|
||||||
|
if !recordsToWrite.isEmpty {
|
||||||
|
let writeResult = await dataWriter.writeBatch(recordsToWrite)
|
||||||
|
result.writtenRecords = writeResult.successCount
|
||||||
|
result.writeErrors = writeResult.failureCount
|
||||||
|
}
|
||||||
|
syncProgress = 0.9
|
||||||
|
|
||||||
|
// Step 5: Finalize (100%)
|
||||||
|
result.completedAt = Date()
|
||||||
|
result.status = .success
|
||||||
|
syncProgress = 1.0
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
result.status = .failed
|
||||||
|
result.error = error.localizedDescription
|
||||||
|
result.completedAt = Date()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncDate = Date()
|
||||||
|
lastSyncResult = result
|
||||||
|
addToHistory(result)
|
||||||
|
|
||||||
|
// Send notification if there are pending conflicts
|
||||||
|
if result.pendingManualReview > 0 {
|
||||||
|
await sendConflictNotification(count: result.pendingManualReview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Sync
|
||||||
|
|
||||||
|
func performQuickSync() async throws {
|
||||||
|
try await performSync(
|
||||||
|
for: Date(),
|
||||||
|
dataTypes: [.steps, .heartRate, .activeEnergy]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Specific Data Type
|
||||||
|
|
||||||
|
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
|
||||||
|
try await performSync(for: date, dataTypes: [dataType])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual Conflict Resolution
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
|
||||||
|
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
|
||||||
|
throw SyncError.resolutionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedConflict = conflict
|
||||||
|
resolvedConflict.status = .resolved
|
||||||
|
resolvedConflict.resolution = resolution
|
||||||
|
resolvedConflict.resolvedAt = Date()
|
||||||
|
|
||||||
|
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
|
||||||
|
|
||||||
|
// Write the record
|
||||||
|
_ = try await dataWriter.writeRecord(mergedRecord)
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ignoreConflict(_ conflict: Conflict) {
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync History
|
||||||
|
|
||||||
|
private func loadSyncHistory() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
|
||||||
|
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncHistory = history
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addToHistory(_ result: SyncResult) {
|
||||||
|
syncHistory.insert(result, at: 0)
|
||||||
|
if syncHistory.count > maxHistoryItems {
|
||||||
|
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
|
||||||
|
}
|
||||||
|
saveSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSyncHistory() {
|
||||||
|
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
syncHistory.removeAll()
|
||||||
|
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private func sendConflictNotification(count: Int) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
content.body = count == 1
|
||||||
|
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflicts",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermission() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
var todayStats: TodayStats {
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
let todaySyncs = syncHistory.filter {
|
||||||
|
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodayStats(
|
||||||
|
syncCount: todaySyncs.count,
|
||||||
|
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
|
||||||
|
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
|
||||||
|
pendingManual: pendingConflicts.count,
|
||||||
|
lastSync: lastSyncDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct SyncResult: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let startedAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var status: SyncStatus = .inProgress
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var writtenRecords = 0
|
||||||
|
var writeErrors = 0
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case success = "success"
|
||||||
|
case partialSuccess = "partial_success"
|
||||||
|
case failed = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval? {
|
||||||
|
guard let completed = completedAt else { return nil }
|
||||||
|
return completed.timeIntervalSince(startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
guard let duration = duration else { return "–" }
|
||||||
|
if duration < 1 {
|
||||||
|
return "< 1s"
|
||||||
|
}
|
||||||
|
return String(format: "%.1fs", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TodayStats {
|
||||||
|
let syncCount: Int
|
||||||
|
let totalConflicts: Int
|
||||||
|
let autoResolved: Int
|
||||||
|
let pendingManual: Int
|
||||||
|
let lastSync: Date?
|
||||||
|
|
||||||
|
var resolutionRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedLastSync: String {
|
||||||
|
guard let date = lastSync else { return "Nie" }
|
||||||
|
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncError: LocalizedError {
|
||||||
|
case notAuthorized
|
||||||
|
case syncInProgress
|
||||||
|
case resolutionFailed
|
||||||
|
case writeFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorized:
|
||||||
|
return "Keine Berechtigung für HealthKit"
|
||||||
|
case .syncInProgress:
|
||||||
|
return "Synchronisierung läuft bereits"
|
||||||
|
case .resolutionFailed:
|
||||||
|
return "Konfliktauflösung fehlgeschlagen"
|
||||||
|
case .writeFailed:
|
||||||
|
return "Schreiben der Daten fehlgeschlagen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Date Extensions
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedRelative() -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(hours: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(minutes: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Double Extensions
|
||||||
|
extension Double {
|
||||||
|
func formatted(decimals: Int = 1) -> String {
|
||||||
|
String(format: "%.\(decimals)f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsInteger: String {
|
||||||
|
String(format: "%.0f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsPercentage: String {
|
||||||
|
String(format: "%.1f%%", self * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array Extensions
|
||||||
|
extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Extensions
|
||||||
|
extension Color {
|
||||||
|
static let healthBridgePrimary = Color.blue
|
||||||
|
static let healthBridgeSecondary = Color.cyan
|
||||||
|
static let healthBridgeAccent = Color.orange
|
||||||
|
|
||||||
|
static func forSeverity(_ severity: ConflictSeverity) -> Color {
|
||||||
|
switch severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func forQuality(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Extensions
|
||||||
|
extension View {
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
self
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionHeader(_ title: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding Extensions
|
||||||
|
extension Binding {
|
||||||
|
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
|
||||||
|
Binding(
|
||||||
|
get: { self.wrappedValue },
|
||||||
|
set: { newValue in
|
||||||
|
self.wrappedValue = newValue
|
||||||
|
handler(newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional Extensions
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
var orEmpty: String {
|
||||||
|
self ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNilOrEmpty: Bool {
|
||||||
|
self?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection Extensions
|
||||||
|
extension Collection {
|
||||||
|
var isNotEmpty: Bool {
|
||||||
|
!isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class NotificationManager: ObservableObject {
|
||||||
|
static let shared = NotificationManager()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var pendingNotifications: [String] = []
|
||||||
|
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
Task {
|
||||||
|
await checkAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
isAuthorized = granted
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Notification authorization failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorization() async {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
isAuthorized = settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Notifications
|
||||||
|
|
||||||
|
func sendConflictNotification(count: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
} else {
|
||||||
|
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
content.categoryIdentifier = "CONFLICT"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Notifications
|
||||||
|
|
||||||
|
func sendSyncCompleteNotification(
|
||||||
|
conflictsResolved: Int,
|
||||||
|
pendingConflicts: Int
|
||||||
|
) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync abgeschlossen"
|
||||||
|
|
||||||
|
if pendingConflicts > 0 {
|
||||||
|
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
|
||||||
|
} else {
|
||||||
|
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = "SYNC_COMPLETE"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scheduled Notifications
|
||||||
|
|
||||||
|
func scheduleReminder(at hour: Int, minute: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge Erinnerung"
|
||||||
|
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var dateComponents = DateComponents()
|
||||||
|
dateComponents.hour = hour
|
||||||
|
dateComponents.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.reminder.daily",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule reminder: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelReminder() {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Management
|
||||||
|
|
||||||
|
func clearBadge() async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(0)
|
||||||
|
} catch {
|
||||||
|
print("Failed to clear badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBadge(count: Int) async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(count)
|
||||||
|
} catch {
|
||||||
|
print("Failed to update badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Categories
|
||||||
|
|
||||||
|
func registerCategories() {
|
||||||
|
// Conflict category with actions
|
||||||
|
let resolveAction = UNNotificationAction(
|
||||||
|
identifier: "RESOLVE_AUTO",
|
||||||
|
title: "Automatisch lösen",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let viewAction = UNNotificationAction(
|
||||||
|
identifier: "VIEW_CONFLICTS",
|
||||||
|
title: "Anzeigen",
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictCategory = UNNotificationCategory(
|
||||||
|
identifier: "CONFLICT",
|
||||||
|
actions: [resolveAction, viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync complete category
|
||||||
|
let syncCategory = UNNotificationCategory(
|
||||||
|
identifier: "SYNC_COMPLETE",
|
||||||
|
actions: [viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
center.setNotificationCategories([conflictCategory, syncCategory])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DashboardViewModel: ObservableObject {
|
||||||
|
private let syncCoordinator = SyncCoordinator.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var dailySummary: DailySummary?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var selectedDate = Date()
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
$selectedDate
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] date in
|
||||||
|
Task {
|
||||||
|
await self?.loadData(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData(for date: Date = Date()) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: date)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
return .syncing
|
||||||
|
} else if let lastSync = syncCoordinator.lastSyncDate {
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .synced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case syncing
|
||||||
|
case synced
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "Synchronisiere..."
|
||||||
|
case .synced: return "Synchronisiert"
|
||||||
|
case .stale: return "Sync empfohlen"
|
||||||
|
case .veryStale: return "Sync überfällig"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "arrow.triangle.2.circlepath"
|
||||||
|
case .synced: return "checkmark.circle.fill"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct HealthChart: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let data: [ChartDataPoint]
|
||||||
|
let showConflicts: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text(latest.formattedValue)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
PointMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Wert", point.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
|
||||||
|
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
|
||||||
|
AxisValueLabel(format: .dateTime.hour())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for iOS 15
|
||||||
|
simpleChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simpleChart: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let maxValue = data.map { $0.value }.max() ?? 1
|
||||||
|
let minValue = data.map { $0.value }.min() ?? 0
|
||||||
|
let range = maxValue - minValue
|
||||||
|
|
||||||
|
Path { path in
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
|
||||||
|
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
|
||||||
|
|
||||||
|
for (index, point) in data.enumerated() {
|
||||||
|
let x = CGFloat(index) * xStep
|
||||||
|
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
|
||||||
|
let y = geometry.size.height * (1 - normalizedY)
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartDataPoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let value: Double
|
||||||
|
let hasConflict: Bool
|
||||||
|
let sourceId: String?
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Chart
|
||||||
|
struct BloodPressureChart: View {
|
||||||
|
let data: [BloodPressurePoint]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Blutdruck")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let latest = data.last {
|
||||||
|
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text("mmHg")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Chart(data) { point in
|
||||||
|
// Systolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Systolisch", point.systolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
// Diastolic
|
||||||
|
LineMark(
|
||||||
|
x: .value("Zeit", point.date),
|
||||||
|
y: .value("Diastolisch", point.diastolic)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
.chartYScale(domain: 40...180)
|
||||||
|
.chartLegend(position: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BloodPressurePoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let systolic: Double
|
||||||
|
let diastolic: Double
|
||||||
|
let classification: BloodPressureHandler.BloodPressureClassification
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary Ring
|
||||||
|
struct SummaryRing: View {
|
||||||
|
let progress: Double
|
||||||
|
let color: Color
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.2), lineWidth: 8)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(progress, 1.0))
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(value)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
HealthChart(
|
||||||
|
dataType: .steps,
|
||||||
|
data: (0..<24).map { hour in
|
||||||
|
ChartDataPoint(
|
||||||
|
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
|
||||||
|
value: Double.random(in: 0...1000),
|
||||||
|
hasConflict: hour % 5 == 0,
|
||||||
|
sourceId: nil
|
||||||
|
)
|
||||||
|
},
|
||||||
|
showConflicts: true
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.75,
|
||||||
|
color: .blue,
|
||||||
|
icon: "figure.walk",
|
||||||
|
value: "7.5k",
|
||||||
|
label: "Schritte"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 0.5,
|
||||||
|
color: .red,
|
||||||
|
icon: "heart.fill",
|
||||||
|
value: "72",
|
||||||
|
label: "Puls"
|
||||||
|
)
|
||||||
|
|
||||||
|
SummaryRing(
|
||||||
|
progress: 1.0,
|
||||||
|
color: .green,
|
||||||
|
icon: "checkmark.circle",
|
||||||
|
value: "100%",
|
||||||
|
label: "Synced"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConflictsView: View {
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@State private var selectedConflict: Conflict?
|
||||||
|
@State private var showingDetail = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
conflictsList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikte")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle automatisch lösen") {
|
||||||
|
Task { await resolveAllAuto() }
|
||||||
|
}
|
||||||
|
Button("Alle ignorieren", role: .destructive) {
|
||||||
|
ignoreAll()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.pendingConflicts.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedConflict) { conflict in
|
||||||
|
ConflictDetailView(conflict: conflict) { selectedReadingId in
|
||||||
|
Task {
|
||||||
|
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
Text("Keine Konflikte")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflicts List
|
||||||
|
|
||||||
|
private var conflictsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
Section(dataType.displayName) {
|
||||||
|
ForEach(groupedConflicts[dataType] ?? []) { conflict in
|
||||||
|
ConflictRow(conflict: conflict)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedConflict = conflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedConflicts: [HealthDataType: [Conflict]] {
|
||||||
|
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
|
||||||
|
selectedConflict = nil
|
||||||
|
} catch {
|
||||||
|
print("Failed to resolve conflict: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAllAuto() async {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
if let primaryReading = conflict.primarySourceReading {
|
||||||
|
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ignoreAll() {
|
||||||
|
for conflict in syncCoordinator.pendingConflicts {
|
||||||
|
syncCoordinator.ignoreConflict(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Row
|
||||||
|
|
||||||
|
struct ConflictRow: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
severityBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(conflict.readings.prefix(3)) { reading in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflict.readings.count > 3 {
|
||||||
|
Text("+\(conflict.readings.count - 3) weitere")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityBadge: some View {
|
||||||
|
Text(conflict.severity.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(severityColor.opacity(0.2))
|
||||||
|
.foregroundStyle(severityColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch conflict.severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detail View
|
||||||
|
|
||||||
|
struct ConflictDetailView: View {
|
||||||
|
let conflict: Conflict
|
||||||
|
let onResolve: (UUID) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedReadingId: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Readings
|
||||||
|
readingsSection
|
||||||
|
|
||||||
|
// Difference Info
|
||||||
|
differenceSection
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Konflikt lösen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Auswählen") {
|
||||||
|
if let id = selectedReadingId {
|
||||||
|
onResolve(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(selectedReadingId == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: conflict.dataType.icon)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(conflict.dataType.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(conflict.timeWindow.formattedRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Readings
|
||||||
|
|
||||||
|
private var readingsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Quellen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(conflict.readings) { reading in
|
||||||
|
ReadingCard(
|
||||||
|
reading: reading,
|
||||||
|
isSelected: selectedReadingId == reading.id,
|
||||||
|
dataType: conflict.dataType
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedReadingId = reading.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Difference
|
||||||
|
|
||||||
|
private var differenceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Analyse")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Differenz")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Prozentual")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "%.1f%%", conflict.percentageDifference))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading Card
|
||||||
|
|
||||||
|
struct ReadingCard: View {
|
||||||
|
let reading: SourceReading
|
||||||
|
let isSelected: Bool
|
||||||
|
let dataType: HealthDataType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: reading.sourceCategory.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(reading.sourceName)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(reading.sourceCategory.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(reading.formattedValue)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ConflictsView()
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $appState.selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "chart.bar.fill")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.dashboard)
|
||||||
|
|
||||||
|
ConflictsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.conflicts)
|
||||||
|
.badge(syncCoordinator.pendingConflicts.count)
|
||||||
|
|
||||||
|
RulesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Regeln", systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.rules)
|
||||||
|
|
||||||
|
SourcesView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
|
}
|
||||||
|
.tag(AppState.Tab.sources)
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var syncCoordinator: SyncCoordinator
|
||||||
|
@StateObject private var dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@State private var dailySummary: DailySummary?
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var selectedDate = Date()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Sync Status Card
|
||||||
|
syncStatusCard
|
||||||
|
|
||||||
|
// Date Picker
|
||||||
|
datePicker
|
||||||
|
|
||||||
|
// Health Metrics
|
||||||
|
if let summary = dailySummary {
|
||||||
|
healthMetricsGrid(summary: summary)
|
||||||
|
} else if isLoading {
|
||||||
|
loadingView
|
||||||
|
} else {
|
||||||
|
emptyStateView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending Conflicts Alert
|
||||||
|
if !syncCoordinator.pendingConflicts.isEmpty {
|
||||||
|
pendingConflictsCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Sync History
|
||||||
|
recentSyncHistory
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("HealthBridge")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await performSync() }
|
||||||
|
} label: {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(syncCoordinator.isSyncing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedDate) {
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Card
|
||||||
|
|
||||||
|
private var syncStatusCard: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Heute")
|
||||||
|
.font(.headline)
|
||||||
|
Text(syncCoordinator.todayStats.formattedLastSync)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
ProgressView(value: syncCoordinator.syncProgress)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Synchronisiere...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.syncCount)",
|
||||||
|
label: "Syncs",
|
||||||
|
icon: "arrow.triangle.2.circlepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(syncCoordinator.todayStats.totalConflicts)",
|
||||||
|
label: "Konflikte",
|
||||||
|
icon: "exclamationmark.triangle"
|
||||||
|
)
|
||||||
|
|
||||||
|
StatItem(
|
||||||
|
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
|
||||||
|
label: "Gelöst",
|
||||||
|
icon: "checkmark.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Picker
|
||||||
|
|
||||||
|
private var datePicker: some View {
|
||||||
|
DatePicker(
|
||||||
|
"Datum",
|
||||||
|
selection: $selectedDate,
|
||||||
|
in: ...Date(),
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Metrics Grid
|
||||||
|
|
||||||
|
private func healthMetricsGrid(summary: DailySummary) -> some View {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 16) {
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
if let value = summary.values[dataType], value > 0 {
|
||||||
|
HealthMetricCard(
|
||||||
|
dataType: dataType,
|
||||||
|
value: summary.formattedValue(for: dataType),
|
||||||
|
conflictCount: summary.conflictCounts[dataType] ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Conflicts Card
|
||||||
|
|
||||||
|
private var pendingConflictsCard: some View {
|
||||||
|
Button {
|
||||||
|
appState.selectedTab = .conflicts
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Tippen zum Anzeigen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Sync History
|
||||||
|
|
||||||
|
private var recentSyncHistory: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Letzte Synchronisierungen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
|
||||||
|
SyncHistoryRow(result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncCoordinator.syncHistory.isEmpty {
|
||||||
|
Text("Keine Synchronisierungen")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading & Empty States
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Lade Daten...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Keine Daten verfügbar")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Synchronisieren Sie, um Daten zu laden")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
print("Sync failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
struct StatItem: View {
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HealthMetricCard: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let value: String
|
||||||
|
let conflictCount: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Spacer()
|
||||||
|
if conflictCount > 0 {
|
||||||
|
Text("\(conflictCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncHistoryRow: View {
|
||||||
|
let result: SyncResult
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(result.formattedDuration)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .partialSuccess: return "exclamationmark.circle.fill"
|
||||||
|
case .failed: return "xmark.circle.fill"
|
||||||
|
case .inProgress: return "arrow.clockwise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch result.status {
|
||||||
|
case .success: return .green
|
||||||
|
case .partialSuccess: return .orange
|
||||||
|
case .failed: return .red
|
||||||
|
case .inProgress: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: result.startedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DashboardView()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
.environmentObject(SyncCoordinator.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RulesView: View {
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedDataType: HealthDataType?
|
||||||
|
@State private var showingResetConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(HealthDataType.allCases) { dataType in
|
||||||
|
RuleRow(
|
||||||
|
dataType: dataType,
|
||||||
|
rule: ruleEngine.getRule(for: dataType)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedDataType = dataType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle("Regeln")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("Alle zurücksetzen", role: .destructive) {
|
||||||
|
showingResetConfirmation = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedDataType) { dataType in
|
||||||
|
RuleEditorView(dataType: dataType)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Alle Regeln zurücksetzen?",
|
||||||
|
isPresented: $showingResetConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Zurücksetzen", role: .destructive) {
|
||||||
|
ruleEngine.resetAllToDefaults()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Row
|
||||||
|
|
||||||
|
struct RuleRow: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let rule: MergeRule
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: rule.strategy.icon)
|
||||||
|
.font(.caption)
|
||||||
|
Text(rule.strategy.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !rule.autoApply {
|
||||||
|
Image(systemName: "hand.raised.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Editor View
|
||||||
|
|
||||||
|
struct RuleEditorView: View {
|
||||||
|
let dataType: HealthDataType
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var ruleEngine = RuleEngine.shared
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@State private var selectedStrategy: MergeStrategy
|
||||||
|
@State private var autoApply: Bool
|
||||||
|
@State private var primarySourceId: String?
|
||||||
|
@State private var thresholdForManualReview: Double?
|
||||||
|
@State private var useThreshold: Bool
|
||||||
|
|
||||||
|
init(dataType: HealthDataType) {
|
||||||
|
self.dataType = dataType
|
||||||
|
let rule = RuleEngine.shared.getRule(for: dataType)
|
||||||
|
_selectedStrategy = State(initialValue: rule.strategy)
|
||||||
|
_autoApply = State(initialValue: rule.autoApply)
|
||||||
|
_primarySourceId = State(initialValue: rule.primarySourceId)
|
||||||
|
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
|
||||||
|
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Data Type Info
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy Selection
|
||||||
|
Section("Strategie") {
|
||||||
|
Picker("Merge-Strategie", selection: $selectedStrategy) {
|
||||||
|
ForEach(MergeStrategy.allCases) { strategy in
|
||||||
|
Label(strategy.displayName, systemImage: strategy.icon)
|
||||||
|
.tag(strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
|
||||||
|
Text(selectedStrategy.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Source (for exclusive/priority)
|
||||||
|
if selectedStrategy == .exclusive || selectedStrategy == .priority {
|
||||||
|
Section("Primäre Quelle") {
|
||||||
|
let sources = sourceManager.sources.filter {
|
||||||
|
$0.supportedDataTypes.contains(dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sources.isEmpty {
|
||||||
|
Text("Keine Quellen für diesen Datentyp")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Picker("Quelle", selection: $primarySourceId) {
|
||||||
|
Text("Automatisch").tag(nil as String?)
|
||||||
|
ForEach(sources) { source in
|
||||||
|
Text(source.displayName).tag(source.bundleIdentifier as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Apply
|
||||||
|
Section("Automatisierung") {
|
||||||
|
Toggle("Automatisch anwenden", isOn: $autoApply)
|
||||||
|
|
||||||
|
if autoApply {
|
||||||
|
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
|
||||||
|
|
||||||
|
if useThreshold {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { thresholdForManualReview ?? 20 },
|
||||||
|
set: { thresholdForManualReview = $0 }
|
||||||
|
),
|
||||||
|
in: 5...50,
|
||||||
|
step: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
Section {
|
||||||
|
Button("Auf Standard zurücksetzen", role: .destructive) {
|
||||||
|
let defaultRule = MergeRule.defaultRule(for: dataType)
|
||||||
|
selectedStrategy = defaultRule.strategy
|
||||||
|
autoApply = defaultRule.autoApply
|
||||||
|
primarySourceId = defaultRule.primarySourceId
|
||||||
|
thresholdForManualReview = defaultRule.thresholdForManualReview
|
||||||
|
useThreshold = defaultRule.thresholdForManualReview != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Regel bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Speichern") {
|
||||||
|
saveRule()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveRule() {
|
||||||
|
let rule = MergeRule(
|
||||||
|
dataType: dataType,
|
||||||
|
strategy: selectedStrategy,
|
||||||
|
primarySourceId: primarySourceId,
|
||||||
|
autoApply: autoApply,
|
||||||
|
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
|
||||||
|
)
|
||||||
|
ruleEngine.setRule(rule, for: dataType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RulesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
|
||||||
|
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
|
||||||
|
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
|
||||||
|
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
|
||||||
|
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
|
||||||
|
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
|
||||||
|
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
|
||||||
|
@State private var showingClearDataConfirmation = false
|
||||||
|
@State private var showingExportSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Sync Settings
|
||||||
|
Section("Synchronisierung") {
|
||||||
|
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
|
||||||
|
|
||||||
|
if backgroundSyncEnabled {
|
||||||
|
Picker("Intervall", selection: $syncIntervalMinutes) {
|
||||||
|
Text("15 Minuten").tag(15)
|
||||||
|
Text("30 Minuten").tag(30)
|
||||||
|
Text("1 Stunde").tag(60)
|
||||||
|
Text("2 Stunden").tag(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Settings
|
||||||
|
Section("Benachrichtigungen") {
|
||||||
|
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
|
||||||
|
|
||||||
|
if notificationsEnabled {
|
||||||
|
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
|
||||||
|
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health Status
|
||||||
|
Section("HealthKit Status") {
|
||||||
|
HStack {
|
||||||
|
Text("Autorisierung")
|
||||||
|
Spacer()
|
||||||
|
if healthKitManager.isAuthorized {
|
||||||
|
Label("Erteilt", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Ausstehend", systemImage: "exclamationmark.circle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !healthKitManager.isAuthorized {
|
||||||
|
Button("HealthKit-Zugriff anfordern") {
|
||||||
|
Task {
|
||||||
|
try? await healthKitManager.requestAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
Section("Statistiken") {
|
||||||
|
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
|
||||||
|
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
|
||||||
|
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
|
||||||
|
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Management
|
||||||
|
Section("Daten") {
|
||||||
|
Button("Sync-Verlauf exportieren") {
|
||||||
|
showingExportSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Sync-Verlauf löschen", role: .destructive) {
|
||||||
|
showingClearDataConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// About
|
||||||
|
Section("Info") {
|
||||||
|
HStack {
|
||||||
|
Text("Version")
|
||||||
|
Spacer()
|
||||||
|
Text("1.0.0")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "https://apple.com/health")!) {
|
||||||
|
HStack {
|
||||||
|
Text("Apple Health")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einstellungen")
|
||||||
|
.confirmationDialog(
|
||||||
|
"Sync-Verlauf löschen?",
|
||||||
|
isPresented: $showingClearDataConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
syncCoordinator.clearHistory()
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingExportSheet) {
|
||||||
|
ExportView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stat Row
|
||||||
|
|
||||||
|
struct StatRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export View
|
||||||
|
|
||||||
|
struct ExportView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
@State private var exportFormat: ExportFormat = .json
|
||||||
|
@State private var isExporting = false
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable {
|
||||||
|
case json = "JSON"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("Daten exportieren")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Picker("Format", selection: $exportFormat) {
|
||||||
|
ForEach(ExportFormat.allCases, id: \.self) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
exportData()
|
||||||
|
} label: {
|
||||||
|
Label("Exportieren", systemImage: "square.and.arrow.up")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding()
|
||||||
|
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Export")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportData() {
|
||||||
|
isExporting = true
|
||||||
|
|
||||||
|
// In a real app, this would create and share a file
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
isExporting = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SourcesView: View {
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var selectedSource: HealthSource?
|
||||||
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
sourcesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Quellen")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedSource) { source in
|
||||||
|
SourceDetailView(source: source)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if sourceManager.sources.isEmpty {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Keine Quellen gefunden")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Verbinden Sie Geräte mit Apple Health")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await refreshSources() }
|
||||||
|
} label: {
|
||||||
|
Label("Aktualisieren", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sources List
|
||||||
|
|
||||||
|
private var sourcesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
|
||||||
|
Section(category.displayName) {
|
||||||
|
ForEach(groupedSources[category] ?? []) { source in
|
||||||
|
SourceRow(source: source)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.refreshable {
|
||||||
|
await refreshSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedSources: [SourceCategory: [HealthSource]] {
|
||||||
|
Dictionary(grouping: sourceManager.sources, by: { $0.category })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSources() async {
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Row
|
||||||
|
|
||||||
|
struct SourceRow: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("\(source.supportedDataTypes.count) Datentypen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let status = sourceManager.sourceHealthStatus[source.id] {
|
||||||
|
Image(systemName: status.syncStatus.icon)
|
||||||
|
.foregroundStyle(statusColor(for: status.syncStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .recentlySynced: return .green
|
||||||
|
case .syncedToday: return .blue
|
||||||
|
case .stale: return .orange
|
||||||
|
case .veryStale, .neverSynced: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Detail View
|
||||||
|
|
||||||
|
struct SourceDetailView: View {
|
||||||
|
let source: HealthSource
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var sourceManager = SourceManager.shared
|
||||||
|
@State private var healthReport: SourceHealthReport?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Capabilities
|
||||||
|
capabilitiesSection
|
||||||
|
|
||||||
|
// Data Types
|
||||||
|
dataTypesSection
|
||||||
|
|
||||||
|
// Health Report
|
||||||
|
if let report = healthReport {
|
||||||
|
healthReportSection(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority Settings
|
||||||
|
prioritySection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(source.displayName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Fertig") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadHealthReport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: source.category.icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(source.category.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(source.bundleIdentifier)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capabilities
|
||||||
|
|
||||||
|
private var capabilitiesSection: some View {
|
||||||
|
let capabilities = sourceManager.getSourceCapabilities(source)
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Fähigkeiten")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
|
||||||
|
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
|
||||||
|
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
|
||||||
|
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
|
||||||
|
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
|
||||||
|
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
|
||||||
|
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Types
|
||||||
|
|
||||||
|
private var dataTypesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Unterstützte Datentypen")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: dataType.icon)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(dataType.displayName)
|
||||||
|
Spacer()
|
||||||
|
Text(dataType.unit)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health Report
|
||||||
|
|
||||||
|
private func healthReportSection(_ report: SourceHealthReport) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Zustand")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Datensätze (24h)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(report.totalRecordCount)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Qualität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Image(systemName: report.overallQuality.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(qualityColor(report.overallQuality))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastActivity = report.lastOverallActivity {
|
||||||
|
HStack {
|
||||||
|
Text("Letzte Aktivität")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(formattedDate(lastActivity))
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.hasSignificantGaps {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Datenlücken erkannt")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Section
|
||||||
|
|
||||||
|
private var prioritySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Priorität")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
|
||||||
|
HStack {
|
||||||
|
Text(dataType.displayName)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Stepper(
|
||||||
|
"\(sourceManager.getPriority(for: source, dataType: dataType))",
|
||||||
|
value: Binding(
|
||||||
|
get: { sourceManager.getPriority(for: source, dataType: dataType) },
|
||||||
|
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
|
||||||
|
),
|
||||||
|
in: 0...100,
|
||||||
|
step: 10
|
||||||
|
)
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func loadHealthReport() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
healthReport = await sourceManager.getSourceHealth(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func qualityColor(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capability Badge
|
||||||
|
|
||||||
|
struct CapabilityBadge: View {
|
||||||
|
let name: String
|
||||||
|
let available: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
|
||||||
|
.foregroundStyle(available ? .green : .secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(available ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SourcesView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
|
||||||
|
|
||||||
|
<!-- SEO Meta Tags -->
|
||||||
|
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
|
||||||
|
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
|
||||||
|
<meta name="author" content="PromoMaster">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
|
||||||
|
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
<meta property="og:locale:alternate" content="en_US">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
|
||||||
|
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "PromoMaster",
|
||||||
|
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
|
||||||
|
"applicationCategory": "Marketing",
|
||||||
|
"operatingSystem": "Web Browser",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "EUR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Language Toggle -->
|
||||||
|
<div class="lang-toggle">
|
||||||
|
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
|
||||||
|
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-bg"></div>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="hero-title">
|
||||||
|
<span class="hero-icon">⚡</span>
|
||||||
|
PromoMaster
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<main class="container">
|
||||||
|
<!-- Step 1: Product Input -->
|
||||||
|
<section class="card step-card" id="step1">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
|
||||||
|
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
|
||||||
|
<select id="productCategory">
|
||||||
|
<option value="tech" data-de="Technologie" data-en="Technology"></option>
|
||||||
|
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
|
||||||
|
<option value="food" data-de="Lebensmittel & Getränke" data-en="Food & Beverages"></option>
|
||||||
|
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
|
||||||
|
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
|
||||||
|
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
|
||||||
|
<option value="beauty" data-de="Schönheit & Pflege" data-en="Beauty & Care"></option>
|
||||||
|
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
|
||||||
|
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
|
||||||
|
<option value="other" data-de="Sonstiges" data-en="Other"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
|
||||||
|
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
|
||||||
|
<span class="char-count"><span id="charCount">0</span>/200</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
|
||||||
|
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
|
||||||
|
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
|
||||||
|
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverlässig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
|
||||||
|
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Step 2: Promotion Style -->
|
||||||
|
<section class="card step-card" id="step2">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
<h2 data-de="Werbestil wählen" data-en="Choose Promotion Style"></h2>
|
||||||
|
</div>
|
||||||
|
<div class="style-grid">
|
||||||
|
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">💼</span>
|
||||||
|
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">👋</span>
|
||||||
|
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">⚡</span>
|
||||||
|
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">✨</span>
|
||||||
|
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">🎉</span>
|
||||||
|
<span class="style-label" data-de="Spaßig & Kreativ" data-en="Fun & Creative"></span>
|
||||||
|
</div>
|
||||||
|
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
|
||||||
|
<span class="style-icon">◯</span>
|
||||||
|
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<div class="generate-section">
|
||||||
|
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Section -->
|
||||||
|
<section id="results" class="results-section hidden">
|
||||||
|
<!-- Social Media Posts -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📱 Social Media Posts" data-en="📱 Social Media Posts"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-twitter">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="twitter-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('twitter-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="twitter-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('twitter-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-instagram">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="instagram-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('instagram-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="instagram-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('instagram-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-facebook">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="facebook-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('facebook-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="facebook-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('facebook-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-linkedin">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="linkedin-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('linkedin-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="linkedin-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('linkedin-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content hidden" id="tab-tiktok">
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="tiktok-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('tiktok-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="tiktok-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('tiktok-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Marketing -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="✉ E-Mail Marketing" data-en="✉ Email Marketing"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="email-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('email-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="email-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('email-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEO Texts -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="🔎 SEO-Texte & Metadaten" data-en="🔎 SEO Texts & Metadata"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="seo-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('seo-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="seo-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('seo-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Press Release -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📰 Pressemitteilung" data-en="📰 Press Release"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-results">
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge">DE</span>
|
||||||
|
<div class="result-text" id="press-de"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('press-de')">📋</button>
|
||||||
|
</div>
|
||||||
|
<div class="lang-result">
|
||||||
|
<span class="lang-badge en">EN</span>
|
||||||
|
<div class="result-text" id="press-en"></div>
|
||||||
|
<button class="btn-copy" onclick="copyText('press-en')">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slogan Generator -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="💡 Slogans & Taglines" data-en="💡 Slogans & Taglines"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="slogan-grid" id="sloganGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtag Cloud -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
|
||||||
|
</div>
|
||||||
|
<div class="hashtag-cloud" id="hashtagCloud"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Landing Page Preview -->
|
||||||
|
<div class="card result-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="🌐 Landing Page HTML" data-en="🌐 Landing Page HTML"></h3>
|
||||||
|
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
|
||||||
|
</div>
|
||||||
|
<div class="landing-preview-container">
|
||||||
|
<div class="preview-toggle">
|
||||||
|
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
|
||||||
|
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
|
||||||
|
</div>
|
||||||
|
<div id="landing-preview" class="landing-preview"></div>
|
||||||
|
<pre id="landing-code" class="landing-code hidden"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Section -->
|
||||||
|
<div class="card result-card export-card">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3 data-de="📥 Alles exportieren" data-en="📥 Export Everything"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="export-grid">
|
||||||
|
<button class="btn-export" onclick="exportAs('txt')">
|
||||||
|
<span class="export-icon">📄</span>
|
||||||
|
<span>TXT</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('html')">
|
||||||
|
<span class="export-icon">🌐</span>
|
||||||
|
<span>HTML</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('json')">
|
||||||
|
<span class="export-icon">📚</span>
|
||||||
|
<span>JSON</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-export" onclick="exportAs('csv')">
|
||||||
|
<span class="export-icon">📊</span>
|
||||||
|
<span>CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Toast Notification -->
|
||||||
|
<div class="toast hidden" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="promo.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
// === PromoMaster - Product Promotion Tool ===
|
||||||
|
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let currentLang = 'de';
|
||||||
|
let selectedStyle = 'professional';
|
||||||
|
let generatedData = null;
|
||||||
|
|
||||||
|
// --- Language System ---
|
||||||
|
function setLanguage(lang) {
|
||||||
|
currentLang = lang;
|
||||||
|
document.documentElement.setAttribute('data-lang', lang);
|
||||||
|
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
|
||||||
|
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-de]').forEach(function (el) {
|
||||||
|
el.textContent = el.getAttribute('data-' + lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
|
||||||
|
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('select option').forEach(function (opt) {
|
||||||
|
var val = opt.getAttribute('data-' + lang);
|
||||||
|
if (val) opt.textContent = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setLanguage = setLanguage;
|
||||||
|
|
||||||
|
// --- Style Selection ---
|
||||||
|
function selectStyle(el) {
|
||||||
|
document.querySelectorAll('.style-option').forEach(function (s) {
|
||||||
|
s.classList.remove('selected');
|
||||||
|
});
|
||||||
|
el.classList.add('selected');
|
||||||
|
selectedStyle = el.getAttribute('data-style');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectStyle = selectStyle;
|
||||||
|
|
||||||
|
// --- Character Counter ---
|
||||||
|
var descInput = document.getElementById('productDescription');
|
||||||
|
var charCount = document.getElementById('charCount');
|
||||||
|
if (descInput && charCount) {
|
||||||
|
descInput.addEventListener('input', function () {
|
||||||
|
charCount.textContent = descInput.value.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text Templates ---
|
||||||
|
var templates = {
|
||||||
|
professional: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) {
|
||||||
|
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) {
|
||||||
|
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
casual: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
|
||||||
|
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
|
||||||
|
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
urgent: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
|
||||||
|
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
|
||||||
|
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
luxury: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
|
||||||
|
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
|
||||||
|
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
|
||||||
|
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
|
||||||
|
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
|
||||||
|
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fun: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
|
||||||
|
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
|
||||||
|
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
|
||||||
|
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
twitter: {
|
||||||
|
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
|
||||||
|
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
|
||||||
|
},
|
||||||
|
instagram: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
|
||||||
|
},
|
||||||
|
facebook: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
|
||||||
|
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
|
||||||
|
},
|
||||||
|
tiktok: {
|
||||||
|
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
|
||||||
|
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Email Templates ---
|
||||||
|
var emailTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Sehr geehrte Damen und Herren,\n\n' +
|
||||||
|
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
|
||||||
|
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
|
||||||
|
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
|
||||||
|
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
|
||||||
|
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
|
||||||
|
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
|
||||||
|
'Ihr ' + p.name + ' Team\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
|
||||||
|
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
|
||||||
|
'Abmelden | Datenschutz | Impressum';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Dear Customer,\n\n' +
|
||||||
|
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Key benefits at a glance:\n\n' +
|
||||||
|
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
|
||||||
|
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
|
||||||
|
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
|
||||||
|
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
|
||||||
|
'Best regards,\n' +
|
||||||
|
'The ' + p.name + ' Team\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
|
||||||
|
'You received this email because you showed interest in ' + p.name + '.\n' +
|
||||||
|
'Unsubscribe | Privacy Policy | Legal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SEO Templates ---
|
||||||
|
var seoTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
|
||||||
|
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
|
||||||
|
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
|
||||||
|
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
|
||||||
|
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
|
||||||
|
'H2 \u00dcBERSCHRIFTEN:\n' +
|
||||||
|
'Warum ' + p.name + '?\n' +
|
||||||
|
'Funktionen & Vorteile\n' +
|
||||||
|
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
|
||||||
|
'Jetzt ' + p.name + ' bestellen\n\n' +
|
||||||
|
'ALT-TEXT F\u00dcR BILDER:\n' +
|
||||||
|
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
|
||||||
|
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
|
||||||
|
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
|
||||||
|
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
|
||||||
|
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
|
||||||
|
'H2 HEADINGS:\n' +
|
||||||
|
'Why ' + p.name + '?\n' +
|
||||||
|
'Features & Benefits\n' +
|
||||||
|
'Who is ' + p.name + ' for?\n' +
|
||||||
|
'Order ' + p.name + ' Now\n\n' +
|
||||||
|
'IMAGE ALT TEXT:\n' +
|
||||||
|
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Press Release Templates ---
|
||||||
|
var pressTemplates = {
|
||||||
|
de: function (p) {
|
||||||
|
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
return 'PRESSEMITTEILUNG\n' +
|
||||||
|
'Datum: ' + today + '\n' +
|
||||||
|
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
p.name + ': ' + p.desc + '\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Hauptmerkmale von ' + p.name + ':\n\n' +
|
||||||
|
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
|
||||||
|
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
|
||||||
|
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
|
||||||
|
'Pressekontakt:\n' +
|
||||||
|
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
|
||||||
|
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
|
||||||
|
'###';
|
||||||
|
},
|
||||||
|
en: function (p) {
|
||||||
|
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
return 'PRESS RELEASE\n' +
|
||||||
|
'Date: ' + today + '\n' +
|
||||||
|
'For Immediate Release\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
p.name + ': ' + p.descEn + '\n\n' +
|
||||||
|
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
|
||||||
|
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
|
||||||
|
(p.features.length ?
|
||||||
|
'Key Features of ' + p.name + ':\n\n' +
|
||||||
|
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
|
||||||
|
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
|
||||||
|
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
|
||||||
|
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
|
||||||
|
'Press Contact:\n' +
|
||||||
|
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
|
||||||
|
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
|
||||||
|
'###';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Category Mapping ---
|
||||||
|
var categoryNames = {
|
||||||
|
tech: { de: 'Technologie', en: 'Technology' },
|
||||||
|
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
|
||||||
|
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
|
||||||
|
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
|
||||||
|
home: { de: 'Haus & Garten', en: 'Home & Garden' },
|
||||||
|
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
|
||||||
|
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
|
||||||
|
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
|
||||||
|
software: { de: 'Software & Apps', en: 'Software & Apps' },
|
||||||
|
other: { de: 'Sonstiges', en: 'Other' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Gather Product Data ---
|
||||||
|
function getProductData() {
|
||||||
|
var name = document.getElementById('productName').value.trim();
|
||||||
|
var desc = document.getElementById('productDescription').value.trim();
|
||||||
|
var price = document.getElementById('productPrice').value.trim();
|
||||||
|
var url = document.getElementById('productUrl').value.trim();
|
||||||
|
var features = document.getElementById('productFeatures').value.trim();
|
||||||
|
var audience = document.getElementById('targetAudience').value.trim();
|
||||||
|
var category = document.getElementById('productCategory').value;
|
||||||
|
|
||||||
|
if (!name || !desc) {
|
||||||
|
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
|
||||||
|
var catInfo = categoryNames[category] || { de: '', en: '' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
desc: desc,
|
||||||
|
descEn: desc, // User provides in their language; used as-is
|
||||||
|
price: price,
|
||||||
|
url: url,
|
||||||
|
features: featureList,
|
||||||
|
featuresEn: featureList,
|
||||||
|
audience: audience,
|
||||||
|
audienceEn: audience,
|
||||||
|
category: category,
|
||||||
|
categoryDe: catInfo.de,
|
||||||
|
categoryEn: catInfo.en,
|
||||||
|
hashtags: generateHashtags(name, featureList, category, 'de'),
|
||||||
|
hashtagsEn: generateHashtags(name, featureList, category, 'en')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Hashtags ---
|
||||||
|
function generateHashtags(name, features, category, lang) {
|
||||||
|
var tags = [];
|
||||||
|
tags.push('#' + name.replace(/\s+/g, ''));
|
||||||
|
|
||||||
|
var catTags = {
|
||||||
|
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
|
||||||
|
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
|
||||||
|
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
|
||||||
|
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
|
||||||
|
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
|
||||||
|
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
|
||||||
|
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
|
||||||
|
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
|
||||||
|
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
|
||||||
|
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
var ct = catTags[category];
|
||||||
|
if (ct) {
|
||||||
|
tags = tags.concat(ct[lang] || ct.en);
|
||||||
|
}
|
||||||
|
|
||||||
|
features.slice(0, 2).forEach(function (f) {
|
||||||
|
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Slogans ---
|
||||||
|
function generateSlogans(p) {
|
||||||
|
var slogans = [];
|
||||||
|
|
||||||
|
var sloganTemplatesDe = [
|
||||||
|
p.name + ' \u2013 Weil du das Beste verdienst.',
|
||||||
|
p.name + '. Einfach. Besser. Anders.',
|
||||||
|
'Die Zukunft hei\u00dft ' + p.name + '.',
|
||||||
|
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
|
||||||
|
'Erlebe den Unterschied mit ' + p.name + '.',
|
||||||
|
p.name + '. Mehr als du erwartest.'
|
||||||
|
];
|
||||||
|
|
||||||
|
var sloganTemplatesEn = [
|
||||||
|
p.name + ' \u2013 Because you deserve the best.',
|
||||||
|
p.name + '. Simple. Better. Different.',
|
||||||
|
'The future is called ' + p.name + '.',
|
||||||
|
p.name + ' \u2013 Your next step forward.',
|
||||||
|
'Experience the difference with ' + p.name + '.',
|
||||||
|
p.name + '. More than you expect.'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < sloganTemplatesDe.length; i++) {
|
||||||
|
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return slogans;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate Landing Page HTML ---
|
||||||
|
function generateLandingPage(p) {
|
||||||
|
var accentColor = '#6C5CE7';
|
||||||
|
return '<!DOCTYPE html>\n' +
|
||||||
|
'<html lang="de">\n<head>\n' +
|
||||||
|
' <meta charset="UTF-8">\n' +
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
|
||||||
|
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
|
||||||
|
' <meta name="description" content="' + p.desc + '">\n' +
|
||||||
|
' <meta property="og:title" content="' + p.name + '">\n' +
|
||||||
|
' <meta property="og:description" content="' + p.desc + '">\n' +
|
||||||
|
' <style>\n' +
|
||||||
|
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
|
||||||
|
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
|
||||||
|
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
|
||||||
|
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
|
||||||
|
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
|
||||||
|
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
|
||||||
|
' .cta-btn:hover { transform: scale(1.05); }\n' +
|
||||||
|
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
|
||||||
|
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
|
||||||
|
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
|
||||||
|
' .feature { text-align: center; padding: 24px; }\n' +
|
||||||
|
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
|
||||||
|
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
|
||||||
|
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
|
||||||
|
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
|
||||||
|
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
|
||||||
|
' </style>\n' +
|
||||||
|
'</head>\n<body>\n' +
|
||||||
|
' <section class="hero">\n' +
|
||||||
|
' <h1>' + p.name + '</h1>\n' +
|
||||||
|
' <p>' + p.desc + '</p>\n' +
|
||||||
|
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
|
||||||
|
' </section>\n' +
|
||||||
|
(p.features.length ? ' <section class="features" id="features">\n' +
|
||||||
|
' <h2>Features</h2>\n' +
|
||||||
|
' <div class="feature-grid">\n' +
|
||||||
|
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
|
||||||
|
' </div>\n' +
|
||||||
|
' </section>\n' : '') +
|
||||||
|
(p.price ? ' <section class="pricing">\n' +
|
||||||
|
' <h2>Preis / Price</h2>\n' +
|
||||||
|
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
|
||||||
|
' </section>\n' : '') +
|
||||||
|
' <footer class="footer">\n' +
|
||||||
|
' © ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
|
||||||
|
' </footer>\n' +
|
||||||
|
'</body>\n</html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Generate Function ---
|
||||||
|
function generateAll() {
|
||||||
|
var p = getProductData();
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
var btn = document.getElementById('generateBtn');
|
||||||
|
btn.classList.add('loading');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
var style = templates[selectedStyle] || templates.professional;
|
||||||
|
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
|
||||||
|
|
||||||
|
platforms.forEach(function (platform) {
|
||||||
|
var tpl = style[platform];
|
||||||
|
if (tpl) {
|
||||||
|
setText(platform + '-de', tpl.de(p));
|
||||||
|
setText(platform + '-en', tpl.en(p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email
|
||||||
|
setText('email-de', emailTemplates.de(p));
|
||||||
|
setText('email-en', emailTemplates.en(p));
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
setText('seo-de', seoTemplates.de(p));
|
||||||
|
setText('seo-en', seoTemplates.en(p));
|
||||||
|
|
||||||
|
// Press Release
|
||||||
|
setText('press-de', pressTemplates.de(p));
|
||||||
|
setText('press-en', pressTemplates.en(p));
|
||||||
|
|
||||||
|
// Slogans
|
||||||
|
var slogans = generateSlogans(p);
|
||||||
|
var sloganGrid = document.getElementById('sloganGrid');
|
||||||
|
sloganGrid.innerHTML = '';
|
||||||
|
slogans.forEach(function (s) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'slogan-item';
|
||||||
|
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
|
||||||
|
div.onclick = function () { copyToClipboard(s.de); };
|
||||||
|
sloganGrid.appendChild(div);
|
||||||
|
|
||||||
|
var divEn = document.createElement('div');
|
||||||
|
divEn.className = 'slogan-item';
|
||||||
|
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
|
||||||
|
divEn.onclick = function () { copyToClipboard(s.en); };
|
||||||
|
sloganGrid.appendChild(divEn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hashtags
|
||||||
|
var hashtagCloud = document.getElementById('hashtagCloud');
|
||||||
|
hashtagCloud.innerHTML = '';
|
||||||
|
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
|
||||||
|
allTags.forEach(function (tag) {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'hashtag';
|
||||||
|
span.textContent = tag;
|
||||||
|
span.onclick = function () { copyToClipboard(tag); };
|
||||||
|
hashtagCloud.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landing Page
|
||||||
|
var landingHtml = generateLandingPage(p);
|
||||||
|
document.getElementById('landing-code').textContent = landingHtml;
|
||||||
|
var iframe = document.createElement('iframe');
|
||||||
|
iframe.srcdoc = landingHtml;
|
||||||
|
var previewDiv = document.getElementById('landing-preview');
|
||||||
|
previewDiv.innerHTML = '';
|
||||||
|
previewDiv.appendChild(iframe);
|
||||||
|
|
||||||
|
// Store data for export
|
||||||
|
generatedData = {
|
||||||
|
product: p,
|
||||||
|
style: selectedStyle,
|
||||||
|
social: {},
|
||||||
|
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
|
||||||
|
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
|
||||||
|
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
|
||||||
|
slogans: slogans,
|
||||||
|
hashtags: allTags,
|
||||||
|
landingPage: landingHtml
|
||||||
|
};
|
||||||
|
|
||||||
|
platforms.forEach(function (platform) {
|
||||||
|
var tpl = style[platform];
|
||||||
|
if (tpl) {
|
||||||
|
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show results
|
||||||
|
document.getElementById('results').classList.remove('hidden');
|
||||||
|
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.generateAll = generateAll;
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
function setText(id, text) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
|
||||||
|
}).catch(function () {
|
||||||
|
fallbackCopy(text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) copyToClipboard(el.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyText = copyText;
|
||||||
|
|
||||||
|
function copyAll(section) {
|
||||||
|
if (!generatedData) return;
|
||||||
|
var text = '';
|
||||||
|
if (section === 'social') {
|
||||||
|
Object.keys(generatedData.social).forEach(function (platform) {
|
||||||
|
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
|
||||||
|
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
|
||||||
|
});
|
||||||
|
} else if (section === 'email') {
|
||||||
|
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
|
||||||
|
} else if (section === 'seo') {
|
||||||
|
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
|
||||||
|
} else if (section === 'press') {
|
||||||
|
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
|
||||||
|
}
|
||||||
|
copyToClipboard(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyAll = copyAll;
|
||||||
|
|
||||||
|
function copyHashtags() {
|
||||||
|
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyHashtags = copyHashtags;
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
function switchTab(btn, tab) {
|
||||||
|
var parent = btn.closest('.result-card');
|
||||||
|
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
|
||||||
|
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.switchTab = switchTab;
|
||||||
|
|
||||||
|
// --- Landing Page Preview Toggle ---
|
||||||
|
function togglePreview(mode) {
|
||||||
|
var preview = document.getElementById('landing-preview');
|
||||||
|
var code = document.getElementById('landing-code');
|
||||||
|
var buttons = document.querySelectorAll('.preview-btn');
|
||||||
|
|
||||||
|
buttons.forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
|
||||||
|
if (mode === 'preview') {
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
code.classList.add('hidden');
|
||||||
|
buttons[0].classList.add('active');
|
||||||
|
} else {
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
code.classList.remove('hidden');
|
||||||
|
buttons[1].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.togglePreview = togglePreview;
|
||||||
|
|
||||||
|
// --- Export Functions ---
|
||||||
|
function exportAs(format) {
|
||||||
|
if (!generatedData) {
|
||||||
|
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = '';
|
||||||
|
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
|
||||||
|
var mimeType = 'text/plain';
|
||||||
|
|
||||||
|
if (format === 'txt') {
|
||||||
|
content = buildTextExport();
|
||||||
|
filename += '.txt';
|
||||||
|
} else if (format === 'html') {
|
||||||
|
content = generatedData.landingPage;
|
||||||
|
filename += '-landingpage.html';
|
||||||
|
mimeType = 'text/html';
|
||||||
|
} else if (format === 'json') {
|
||||||
|
content = JSON.stringify(generatedData, null, 2);
|
||||||
|
filename += '.json';
|
||||||
|
mimeType = 'application/json';
|
||||||
|
} else if (format === 'csv') {
|
||||||
|
content = buildCsvExport();
|
||||||
|
filename += '.csv';
|
||||||
|
mimeType = 'text/csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(content, filename, mimeType);
|
||||||
|
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.exportAs = exportAs;
|
||||||
|
|
||||||
|
function buildTextExport() {
|
||||||
|
var d = generatedData;
|
||||||
|
var lines = [];
|
||||||
|
lines.push('========================================');
|
||||||
|
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
|
||||||
|
lines.push('Produkt / Product: ' + d.product.name);
|
||||||
|
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
|
||||||
|
lines.push('========================================\n');
|
||||||
|
|
||||||
|
Object.keys(d.social).forEach(function (platform) {
|
||||||
|
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
|
||||||
|
lines.push(d.social[platform].de);
|
||||||
|
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
|
||||||
|
lines.push(d.social[platform].en);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
|
||||||
|
lines.push(d.email.de);
|
||||||
|
lines.push('\n--- E-MAIL MARKETING (EN) ---');
|
||||||
|
lines.push(d.email.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- SEO (DE) ---');
|
||||||
|
lines.push(d.seo.de);
|
||||||
|
lines.push('\n--- SEO (EN) ---');
|
||||||
|
lines.push(d.seo.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
|
||||||
|
lines.push(d.press.de);
|
||||||
|
lines.push('\n--- PRESS RELEASE (EN) ---');
|
||||||
|
lines.push(d.press.en);
|
||||||
|
|
||||||
|
lines.push('\n\n--- SLOGANS ---');
|
||||||
|
d.slogans.forEach(function (s) {
|
||||||
|
lines.push('DE: ' + s.de);
|
||||||
|
lines.push('EN: ' + s.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('\n\n--- HASHTAGS ---');
|
||||||
|
lines.push(d.hashtags.join(' '));
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvExport() {
|
||||||
|
var d = generatedData;
|
||||||
|
var rows = [['Platform', 'Language', 'Content']];
|
||||||
|
|
||||||
|
Object.keys(d.social).forEach(function (platform) {
|
||||||
|
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
|
||||||
|
|
||||||
|
d.slogans.forEach(function (s, i) {
|
||||||
|
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
|
||||||
|
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map(function (r) { return r.join(','); }).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(content, filename, mimeType) {
|
||||||
|
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toast ---
|
||||||
|
function showToast(msg) {
|
||||||
|
var toast = document.getElementById('toast');
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(function () {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(function () { toast.classList.add('hidden'); }, 400);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialize ---
|
||||||
|
setLanguage('de');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
/* === PromoMaster - Product Promotion Tool === */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #6C5CE7;
|
||||||
|
--primary-dark: #5A4BD1;
|
||||||
|
--primary-light: #A29BFE;
|
||||||
|
--accent: #00CEC9;
|
||||||
|
--accent-dark: #00B5B0;
|
||||||
|
--bg: #0F0F1A;
|
||||||
|
--bg-card: #1A1A2E;
|
||||||
|
--bg-card-hover: #222240;
|
||||||
|
--text: #EAEAEA;
|
||||||
|
--text-muted: #8B8BA3;
|
||||||
|
--border: #2D2D4A;
|
||||||
|
--success: #00E676;
|
||||||
|
--warning: #FFD93D;
|
||||||
|
--danger: #FF6B6B;
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Language Toggle === */
|
||||||
|
.lang-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover:not(.active) {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hero === */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 80px 0 50px;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
|
||||||
|
animation: heroPulse 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heroPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||||
|
50% { transform: scale(1.05); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, var(--primary-light), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon {
|
||||||
|
font-size: 0.8em;
|
||||||
|
-webkit-text-fill-color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.3rem);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 12px;
|
||||||
|
position: relative;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Cards === */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Step Header === */
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Form === */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 16px center;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Style Options === */
|
||||||
|
.style-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option:hover {
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option.selected .style-label {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Generate Button === */
|
||||||
|
.generate-section {
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
padding: 18px 48px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate.loading .btn-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Results === */
|
||||||
|
.results-section {
|
||||||
|
animation: fadeInUp 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-all {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--primary-light);
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-all:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Tabs === */
|
||||||
|
.result-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Language Results === */
|
||||||
|
.lang-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-result {
|
||||||
|
position: relative;
|
||||||
|
padding: 20px;
|
||||||
|
padding-left: 56px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 16px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #E74C3C, #C0392B);
|
||||||
|
border-radius: 6px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-badge.en {
|
||||||
|
background: linear-gradient(135deg, #2980B9, #2471A3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--text);
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Slogans === */
|
||||||
|
.slogan-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item {
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-item .slogan-lang {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hashtags === */
|
||||||
|
.hashtag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(108, 92, 231, 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Landing Page Preview === */
|
||||||
|
.landing-preview-container {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-preview {
|
||||||
|
min-height: 300px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-preview iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-code {
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Export === */
|
||||||
|
.export-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
padding: 14px 28px;
|
||||||
|
background: var(--success);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 30px;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Footer === */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Hidden === */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slogan-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 60px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle {
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tabs {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.style-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
padding: 16px 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
// Configure for macOS
|
||||||
|
configureMacOS()
|
||||||
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +37,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Resume game if needed
|
// Resume game if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
// MARK: - macOS Configuration
|
||||||
|
private func configureMacOS() {
|
||||||
|
// Set minimum window size for macOS
|
||||||
|
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
|
||||||
|
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
|
||||||
|
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 600, height: 900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func buildMenu(with builder: UIMenuBuilder) {
|
||||||
|
super.buildMenu(with: builder)
|
||||||
|
|
||||||
|
// Remove unnecessary menus for a game
|
||||||
|
builder.remove(menu: .format)
|
||||||
|
builder.remove(menu: .edit)
|
||||||
|
|
||||||
|
// Add Game menu
|
||||||
|
let pauseCommand = UIKeyCommand(
|
||||||
|
title: "Pause",
|
||||||
|
action: #selector(handlePauseCommand),
|
||||||
|
input: "p",
|
||||||
|
modifierFlags: .command
|
||||||
|
)
|
||||||
|
|
||||||
|
let restartCommand = UIKeyCommand(
|
||||||
|
title: "Neustart",
|
||||||
|
action: #selector(handleRestartCommand),
|
||||||
|
input: "r",
|
||||||
|
modifierFlags: .command
|
||||||
|
)
|
||||||
|
|
||||||
|
let gameMenu = UIMenu(
|
||||||
|
title: "Spiel",
|
||||||
|
children: [pauseCommand, restartCommand]
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.insertSibling(gameMenu, afterMenu: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handlePauseCommand() {
|
||||||
|
NotificationCenter.default.post(name: .pauseGame, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRestartCommand() {
|
||||||
|
NotificationCenter.default.post(name: .restartGame, object: nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notification Names
|
// MARK: - Notification Names
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let pauseGame = Notification.Name("pauseGame")
|
static let pauseGame = Notification.Name("pauseGame")
|
||||||
static let resumeGame = Notification.Name("resumeGame")
|
static let resumeGame = Notification.Name("resumeGame")
|
||||||
|
static let restartGame = Notification.Name("restartGame")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class GameViewController: UIViewController {
|
|||||||
|
|
||||||
// Setup notification observers
|
// Setup notification observers
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
setupMacCatalyst()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNotificationObservers() {
|
private func setupNotificationObservers() {
|
||||||
@@ -45,6 +49,13 @@ class GameViewController: UIViewController {
|
|||||||
name: .pauseGame,
|
name: .pauseGame,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleRestartNotification),
|
||||||
|
name: .restartGame,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handlePauseNotification() {
|
@objc private func handlePauseNotification() {
|
||||||
@@ -57,12 +68,46 @@ class GameViewController: UIViewController {
|
|||||||
// This is just a notification that the app is going to background
|
// This is just a notification that the app is going to background
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func handleRestartNotification() {
|
||||||
|
guard let skView = self.view as? SKView else { return }
|
||||||
|
|
||||||
|
let menuScene = MenuScene(size: skView.bounds.size)
|
||||||
|
menuScene.scaleMode = .aspectFill
|
||||||
|
|
||||||
|
let transition = SKTransition.fade(withDuration: 0.5)
|
||||||
|
skView.presentScene(menuScene, transition: transition)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private func setupMacCatalyst() {
|
||||||
|
// Configure window appearance for macOS
|
||||||
|
if let windowScene = view.window?.windowScene {
|
||||||
|
windowScene.title = "Rollkoffer Simulator"
|
||||||
|
|
||||||
|
// Set window style
|
||||||
|
if let titlebar = windowScene.titlebar {
|
||||||
|
titlebar.titleVisibility = .visible
|
||||||
|
titlebar.toolbarStyle = .unified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable keyboard input
|
||||||
|
override var canBecomeFirstResponder: Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
return .all
|
||||||
|
#else
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
return .portrait
|
return .portrait
|
||||||
} else {
|
} else {
|
||||||
return .all
|
return .all
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
override var prefersStatusBarHidden: Bool {
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
|||||||
@@ -50,5 +50,9 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright 2024 Ingo K. All rights reserved.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -381,6 +381,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -393,9 +394,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -409,6 +413,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Info.plist;
|
INFOPLIST_FILE = Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -421,9 +426,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -223,6 +223,27 @@ class GameOverScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
retryGame()
|
||||||
|
case .keyboardEscape:
|
||||||
|
returnToMenu()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func retryGame() {
|
private func retryGame() {
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
||||||
|
|||||||
@@ -318,6 +318,29 @@ class GameScene: SKScene {
|
|||||||
isDragging = false
|
isDragging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardEscape:
|
||||||
|
togglePause()
|
||||||
|
case .keyboardSpacebar:
|
||||||
|
if gameState.currentState == .paused {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Pause Handling
|
// MARK: - Pause Handling
|
||||||
private func togglePause() {
|
private func togglePause() {
|
||||||
if gameState.currentState == .playing {
|
if gameState.currentState == .playing {
|
||||||
|
|||||||
@@ -245,6 +245,25 @@ class MenuScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
startGame()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func startGame() {
|
private func startGame() {
|
||||||
// Button press effect
|
// Button press effect
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
|
|||||||
@@ -280,6 +280,27 @@ class VictoryScene: SKScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Keyboard Handling (macOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let key = presses.first?.key else {
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.keyCode {
|
||||||
|
case .keyboardSpacebar, .keyboardReturnOrEnter:
|
||||||
|
playAgain()
|
||||||
|
case .keyboardEscape:
|
||||||
|
returnToMenu()
|
||||||
|
default:
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func playAgain() {
|
private func playAgain() {
|
||||||
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
|
||||||
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
# Visual Studio user/IDE files
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db");
|
||||||
|
await conn.OpenAsync();
|
||||||
|
string sapUsername = "", sapPassword = "";
|
||||||
|
var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "select SapUsername, SapPassword from ExportSettings limit 1";
|
||||||
|
using (var r = await cmd.ExecuteReaderAsync())
|
||||||
|
{
|
||||||
|
if (await r.ReadAsync())
|
||||||
|
{
|
||||||
|
sapUsername = r.IsDBNull(0) ? "" : r.GetString(0);
|
||||||
|
sapPassword = r.IsDBNull(1) ? "" : r.GetString(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(sapUsername) || string.IsNullOrWhiteSpace(sapPassword)) throw new Exception("Central SAP credentials missing");
|
||||||
|
var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/";
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(20);
|
||||||
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}")));
|
||||||
|
foreach (var url in new[] { serviceUrl, serviceUrl + "" })
|
||||||
|
{
|
||||||
|
Console.WriteLine($"URL|{url}");
|
||||||
|
using var response = await client.GetAsync(url);
|
||||||
|
Console.WriteLine($"STATUS|{(int)response.StatusCode}|{response.ReasonPhrase}");
|
||||||
|
foreach (var header in response.Headers)
|
||||||
|
Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
|
||||||
|
foreach (var header in response.Content.Headers)
|
||||||
|
Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine("BODY_START");
|
||||||
|
Console.WriteLine(body.Length > 5000 ? body[..5000] : body);
|
||||||
|
Console.WriteLine("BODY_END");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trafag Finanze/Sales Management Cockpit</title>
|
||||||
|
<base href="/" />
|
||||||
|
<link href="css/app.css" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||||
|
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
|
<script src="js/download.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<MudThemeProvider Theme="_theme" />
|
||||||
|
<MudPopoverProvider />
|
||||||
|
<MudDialogProvider />
|
||||||
|
<MudSnackbarProvider />
|
||||||
|
|
||||||
|
<MudLayout>
|
||||||
|
<MudAppBar Elevation="1" Color="Color.Primary">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||||
|
OnClick="ToggleDrawer" />
|
||||||
|
<MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finanze/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudSelect T="string"
|
||||||
|
Value="@UiText.CurrentLanguage"
|
||||||
|
ValueChanged="ChangeLanguage"
|
||||||
|
Dense
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Class="mr-3"
|
||||||
|
Style="min-width:100px; color:white;">
|
||||||
|
<MudSelectItem Value="@("de")">DE</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("en")">EN</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
<img src="trafag.jpg" alt="Trafag" class="app-logo" />
|
||||||
|
</MudAppBar>
|
||||||
|
|
||||||
|
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||||
|
<NavMenu />
|
||||||
|
</MudDrawer>
|
||||||
|
|
||||||
|
<MudMainContent Class="pa-4" @key="UiText.CurrentLanguage">
|
||||||
|
@Body
|
||||||
|
</MudMainContent>
|
||||||
|
</MudLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _drawerOpen = true;
|
||||||
|
|
||||||
|
private readonly MudTheme _theme = new()
|
||||||
|
{
|
||||||
|
PaletteLight = new PaletteLight
|
||||||
|
{
|
||||||
|
Primary = "#B71C1C",
|
||||||
|
Secondary = "#7F1D1D",
|
||||||
|
AppbarBackground = "#B71C1C"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
UiText.Changed += HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
|
|
||||||
|
private void ChangeLanguage(string language)
|
||||||
|
{
|
||||||
|
UiText.SetLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLanguageChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
UiText.Changed -= HandleLanguageChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<MudNavMenu>
|
||||||
|
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
|
||||||
|
@T("Dashboard", "Dashboard")
|
||||||
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
|
||||||
|
@T("Standorte", "Sites")
|
||||||
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
||||||
|
@T("Transformationen", "Transformations")
|
||||||
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
|
||||||
|
@T("Management Cockpit", "Management Cockpit")
|
||||||
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||||
|
@T("Settings", "Settings")
|
||||||
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
|
||||||
|
@T("Logs", "Logs")
|
||||||
|
</MudNavLink>
|
||||||
|
</MudNavMenu>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
@page "/"
|
||||||
|
@using System.Diagnostics
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IDashboardPageService DashboardPageActions
|
||||||
|
@inject ExportOrchestrationService Orchestrator
|
||||||
|
@inject TimerBackgroundService TimerService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageTitle>@T("Dashboard", "Dashboard")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||||
|
OnClick="ExportAll" Disabled="_anyRunning">
|
||||||
|
@T("Alle exportieren", "Export all")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
|
||||||
|
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
|
||||||
|
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
|
||||||
|
</MudButton>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
@if (TimerService.NextRun < DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
|
||||||
|
@(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
|
||||||
|
@T("Timer deaktiviert", "Timer disabled")
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Schema", "Schema")</MudTh>
|
||||||
|
<MudTh>@T("Server", "Server")</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Live-Status", "Live status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
|
||||||
|
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>@context.Schema</MudTd>
|
||||||
|
<MudTd>@context.ServerName</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsExporting(context.SiteId))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "OK")
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else if (context.LastStatus == "Error")
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.ErrorMessage">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.LiveDetails">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.LiveMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row Spacing="1">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.FileDownload"
|
||||||
|
OnClick="() => ExportSingle(context.SiteId)"
|
||||||
|
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
||||||
|
Export
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenExportFile(context)"
|
||||||
|
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
|
||||||
|
<MudTable Items="_consolidatedRows" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Datei", "File")</MudTh>
|
||||||
|
<MudTh>Pfad</MudTh>
|
||||||
|
<MudTh>Letzte Änderung</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.DisplayPath</MudTd>
|
||||||
|
<MudTd>@(context.LastModified.HasValue ? context.LastModified.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (Orchestrator.IsConsolidatedExporting())
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
|
||||||
|
<MudText Typo="Typo.caption">@Orchestrator.GetConsolidatedExportStatus()</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenFile(context.FilePath)"
|
||||||
|
Disabled="@(!context.HasOpenableFile)">
|
||||||
|
@T("Excel oeffnen", "Open Excel")
|
||||||
|
</MudButton>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<DashboardRow> _dashboardRows = new();
|
||||||
|
private List<ConsolidatedDashboardRow> _consolidatedRows = new();
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _anyRunning;
|
||||||
|
private CancellationTokenSource? _pollingCts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var state = await DashboardPageActions.LoadAsync();
|
||||||
|
_dashboardRows = state.DashboardRows;
|
||||||
|
_consolidatedRows = state.ConsolidatedRows;
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportAll()
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Orchestrator.ExportAllAsync();
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportConsolidatedOnly()
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var filePath = await Orchestrator.ExportConsolidatedOnlyAsync();
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportSingle(int siteId)
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
_ = InvokeAsync(async () => await LoadDataAsync());
|
||||||
|
StartPolling();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
|
||||||
|
}
|
||||||
|
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
|
||||||
|
{
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HandleStatusChanged()
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting() || _dashboardRows.Count == 0;
|
||||||
|
if (_anyRunning)
|
||||||
|
{
|
||||||
|
StartPolling();
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StopPolling();
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
StopPolling();
|
||||||
|
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenExportFile(DashboardRow row)
|
||||||
|
{
|
||||||
|
OpenFile(row.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFile(string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = filePath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartPolling()
|
||||||
|
{
|
||||||
|
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pollingCts = new CancellationTokenSource();
|
||||||
|
_ = PollDashboardAsync(_pollingCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopPolling()
|
||||||
|
{
|
||||||
|
_pollingCts?.Cancel();
|
||||||
|
_pollingCts?.Dispose();
|
||||||
|
_pollingCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollDashboardAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
if (!anyRunning)
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = false;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
StopPolling();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RefreshLiveDataAsync()
|
||||||
|
{
|
||||||
|
foreach (var row in _dashboardRows)
|
||||||
|
{
|
||||||
|
if (!Orchestrator.IsExporting(row.SiteId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
|
||||||
|
row.LiveDetails = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
@page "/logs"
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject ILogsPageService LogsPageActions
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Logs", "Logs")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Export Logs", "Export Logs")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
|
||||||
|
<MudSelect @bind-Value="_filterLand" Label="@T("Land", "Country")" Clearable Dense Style="max-width:200px;">
|
||||||
|
@foreach (var land in _availableLands)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@land">@land</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudSelect @bind-Value="_filterStatus" Label="@T("Status", "Status")" Clearable Dense Style="max-width:150px;">
|
||||||
|
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
|
||||||
|
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
<MudDatePicker @bind-Date="_filterDate" Label="@T("Datum", "Date")" Clearable Dense Style="max-width:200px;" />
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
|
||||||
|
StartIcon="@Icons.Material.Filled.FilterAlt">
|
||||||
|
@T("Filtern", "Filter")
|
||||||
|
</MudButton>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
|
||||||
|
StartIcon="@Icons.Material.Filled.DeleteSweep">
|
||||||
|
@T("Alte Logs loeschen", "Delete old logs")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>@T("Status", "Status")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||||
|
<MudTh>@T("Dateiname", "File name")</MudTh>
|
||||||
|
<MudTh>@T("Fehler", "Error")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.Status == "OK")
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
|
||||||
|
<MudTd>@context.FileName</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrEmpty(context.ErrorMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.ErrorMessage">
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.ErrorMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mt-6 mb-2">@T("Technische Logs", "Technical logs")</MudText>
|
||||||
|
|
||||||
|
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||||
|
<MudTh>Level</MudTh>
|
||||||
|
<MudTh>@T("Kategorie", "Category")</MudTh>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Meldung", "Message")</MudTh>
|
||||||
|
<MudTh>Details</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||||
|
<MudTd>@context.Level</MudTd>
|
||||||
|
<MudTd>@context.Category</MudTd>
|
||||||
|
<MudTd>@(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)</MudTd>
|
||||||
|
<MudTd>@context.Message</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.Details))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.Details">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.Details
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ExportLog> _logs = new();
|
||||||
|
private List<AppEventLog> _appLogs = new();
|
||||||
|
private List<string> _availableLands = new();
|
||||||
|
private string? _filterLand;
|
||||||
|
private string? _filterStatus;
|
||||||
|
private DateTime? _filterDate;
|
||||||
|
private bool _loading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadLogsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadLogsAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var state = await LogsPageActions.LoadAsync(_filterLand, _filterStatus, _filterDate);
|
||||||
|
_availableLands = state.AvailableLands;
|
||||||
|
_logs = state.Logs;
|
||||||
|
_appLogs = state.AppLogs;
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFilter()
|
||||||
|
{
|
||||||
|
await LoadLogsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteOldLogs()
|
||||||
|
{
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
T("Alte Logs loeschen", "Delete old logs"),
|
||||||
|
T("Logs aelter als 90 Tage loeschen?", "Delete logs older than 90 days?"),
|
||||||
|
yesText: T("Loeschen", "Delete"), cancelText: T("Abbrechen", "Cancel"));
|
||||||
|
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
var deletedCount = await LogsPageActions.DeleteOldLogsAsync(90);
|
||||||
|
await LoadLogsAsync();
|
||||||
|
Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), deletedCount), Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
@page "/management-cockpit"
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IManagementCockpitPageService CockpitPageService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Management Cockpit", "Management Cockpit")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Management Cockpit", "Management Cockpit")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense>
|
||||||
|
@foreach (var file in _files)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
|
||||||
|
@T("Dateien laden", "Load files")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
|
||||||
|
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
|
||||||
|
@(_analyzing ? T("Analysiere...", "Analyzing...") : T("Cockpit erzeugen", "Build cockpit"))
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik.", "This view works directly on `CentralSalesRecords` and shows only neutral raw metrics. No intercompany filter, no CHF conversion, no budget, no divisional logic.")
|
||||||
|
</MudAlert>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
|
||||||
|
@foreach (var year in _centralYears)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@year">@year</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
|
||||||
|
@foreach (var month in Enumerable.Range(1, 12))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@((int?)month)">@($"{month:D2}")</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
|
||||||
|
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
|
||||||
|
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_result is not null)
|
||||||
|
{
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Land", "Country")</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Umsatz", "Sales")</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Geschaetzte Marge", "Estimated margin")</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Management Aussagen", "Management statements")</MudText>
|
||||||
|
@foreach (var finding in _result.Findings)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
|
||||||
|
<b>@finding.Title:</b> @finding.Detail
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
|
||||||
|
@foreach (var item in _result.TopCustomers)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText>
|
||||||
|
@foreach (var item in _result.TopProductGroups)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText>
|
||||||
|
@foreach (var item in _result.TopSalesEmployees)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
|
||||||
|
@foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_centralResult is not null)
|
||||||
|
{
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Zeilen", "Rows")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.RowCount.ToString("N0")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Waehrungen", "Currencies")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CurrencyCount.ToString("N0")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Cockpit Manometer", "Cockpit gauges")</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Class="d-block mb-3">
|
||||||
|
@T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.")
|
||||||
|
</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
@foreach (var gauge in BuildCentralGauges(_centralResult))
|
||||||
|
{
|
||||||
|
<MudItem xs="12" sm="6" lg="3">
|
||||||
|
<MudPaper Class="pa-3 cockpit-gauge-card" Elevation="0">
|
||||||
|
<MudText Typo="Typo.caption" Class="d-block mb-1">@gauge.Title</MudText>
|
||||||
|
<div class="cockpit-gauge-wrap">
|
||||||
|
<svg viewBox="0 0 220 140" class="cockpit-gauge" role="img" aria-label="@gauge.Title">
|
||||||
|
<path d="@GaugeArcPath"
|
||||||
|
fill="none"
|
||||||
|
stroke="#d7e2ea"
|
||||||
|
stroke-width="16"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
<path d="@GaugeArcPath"
|
||||||
|
fill="none"
|
||||||
|
stroke="@gauge.Color"
|
||||||
|
stroke-width="16"
|
||||||
|
stroke-linecap="round"
|
||||||
|
pathLength="100"
|
||||||
|
stroke-dasharray="@BuildGaugeDashArray(gauge.Percent)" />
|
||||||
|
<line x1="110" y1="110" x2="@BuildGaugeNeedleX(gauge.Percent)" y2="@BuildGaugeNeedleY(gauge.Percent)"
|
||||||
|
stroke="#23313d"
|
||||||
|
stroke-width="5"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
<circle cx="110" cy="110" r="8" fill="#23313d" />
|
||||||
|
<text x="110" y="76" text-anchor="middle" class="cockpit-gauge-value">@gauge.DisplayValue</text>
|
||||||
|
<text x="110" y="96" text-anchor="middle" class="cockpit-gauge-subtitle">@gauge.Subtitle</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
|
||||||
|
@foreach (var notice in _centralResult.Notices)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahresumsatz 2025/2026", "Yearly sales 2025/2026")</MudText>
|
||||||
|
<MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Jahr", "Year")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Year</MudTd>
|
||||||
|
<MudTd>@context.Currency</MudTd>
|
||||||
|
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Monatsumsatz", "Monthly sales")</MudText>
|
||||||
|
<MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Monat", "Month")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Currency</MudTd>
|
||||||
|
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Tagesumsatz im ausgewaehlten Monat", "Daily sales in selected month")</MudText>
|
||||||
|
<MudTable Items="_centralResult.DailyTotals" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Tag", "Day")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Currency</MudTd>
|
||||||
|
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.caption">@T("Fuer die Tagessicht bitte zusaetzlich einen Monat waehlen.", "Please select a month as well for the daily view.")</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Quelle", "Sales by source")</MudText>
|
||||||
|
<MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||||
|
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Currency</MudTd>
|
||||||
|
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Land", "Sales by country")</MudText>
|
||||||
|
<MudTable Items="_centralResult.CountryTotals" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@T("Land", "Country")</MudTh>
|
||||||
|
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||||
|
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||||
|
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
|
||||||
|
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Label</MudTd>
|
||||||
|
<MudTd>@context.Currency</MudTd>
|
||||||
|
<MudTd>@context.SalesValue.ToString("N2")</MudTd>
|
||||||
|
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
|
||||||
|
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cockpit-gauge-card {
|
||||||
|
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fa 100%);
|
||||||
|
border: 1px solid #dce7ee;
|
||||||
|
border-radius: 18px;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-gauge-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-gauge {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-gauge-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
fill: #153047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cockpit-gauge-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
fill: #607587;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ManagementCockpitFileOption> _files = [];
|
||||||
|
private List<int> _centralYears = [];
|
||||||
|
private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110";
|
||||||
|
private string? _selectedFilePath;
|
||||||
|
private ManagementCockpitResult? _result;
|
||||||
|
private ManagementCockpitCentralResult? _centralResult;
|
||||||
|
private int _selectedCentralYear;
|
||||||
|
private int? _selectedCentralMonth;
|
||||||
|
private bool _loadingFiles;
|
||||||
|
private bool _analyzing;
|
||||||
|
private bool _analyzingCentral;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear);
|
||||||
|
_files = state.Files;
|
||||||
|
_centralYears = state.CentralYears;
|
||||||
|
_selectedFilePath = state.SelectedFilePath;
|
||||||
|
_selectedCentralYear = state.SelectedCentralYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadFiles()
|
||||||
|
{
|
||||||
|
_loadingFiles = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_files = await CockpitPageService.LoadFilesAsync();
|
||||||
|
_selectedFilePath ??= _files.FirstOrDefault()?.Path;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingFiles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadCentralYears()
|
||||||
|
{
|
||||||
|
_centralYears = await CockpitPageService.LoadCentralYearsAsync();
|
||||||
|
if (_selectedCentralYear == 0)
|
||||||
|
_selectedCentralYear = _centralYears.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Analyze()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_analyzing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(T("Cockpit konnte nicht erzeugt werden: {0}", "Could not build cockpit: {0}"), ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_analyzing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AnalyzeCentral()
|
||||||
|
{
|
||||||
|
if (_selectedCentralYear == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_analyzingCentral = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(T("Zentrale Auswertung konnte nicht erzeugt werden: {0}", "Could not build central analysis: {0}"), ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_analyzingCentral = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Severity MapSeverity(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"Warning" => Severity.Warning,
|
||||||
|
"Error" => Severity.Error,
|
||||||
|
_ => Severity.Info
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string BuildPeriodLabel(ManagementCockpitCentralResult result)
|
||||||
|
{
|
||||||
|
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
|
||||||
|
return "-";
|
||||||
|
|
||||||
|
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CentralGaugeModel> BuildCentralGauges(ManagementCockpitCentralResult result)
|
||||||
|
{
|
||||||
|
var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount;
|
||||||
|
var sourceDominance = result.SourceSystemTotals.Count == 0
|
||||||
|
? 0m
|
||||||
|
: result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
|
||||||
|
var countryDominance = result.CountryTotals.Count == 0
|
||||||
|
? 0m
|
||||||
|
: result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
|
||||||
|
var periodCoverage = BuildPeriodCoveragePercent(result);
|
||||||
|
var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals);
|
||||||
|
var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals);
|
||||||
|
var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m);
|
||||||
|
var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result);
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Rechnungsdichte", "Invoice density"),
|
||||||
|
Percent = invoiceDensity,
|
||||||
|
DisplayValue = $"{invoiceDensity:F0}%",
|
||||||
|
Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"),
|
||||||
|
Color = "#1f8a70"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Quellen-Dominanz", "Source dominance"),
|
||||||
|
Percent = sourceDominance,
|
||||||
|
DisplayValue = $"{sourceDominance:F0}%",
|
||||||
|
Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"),
|
||||||
|
Color = "#d9822b"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Land-Dominanz", "Country dominance"),
|
||||||
|
Percent = countryDominance,
|
||||||
|
DisplayValue = $"{countryDominance:F0}%",
|
||||||
|
Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"),
|
||||||
|
Color = "#c4496b"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Perioden-Abdeckung", "Period coverage"),
|
||||||
|
Percent = periodCoverage,
|
||||||
|
DisplayValue = $"{periodCoverage:F0}%",
|
||||||
|
Subtitle = BuildPeriodGaugeSubtitle(result),
|
||||||
|
Color = "#3d7ff0"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Top-Land Umsatz", "Top country sales"),
|
||||||
|
Percent = topCountrySalesShare,
|
||||||
|
DisplayValue = $"{topCountrySalesShare:F0}%",
|
||||||
|
Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"),
|
||||||
|
Color = "#7f56d9"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Top-Quelle Umsatz", "Top source sales"),
|
||||||
|
Percent = topSourceSalesShare,
|
||||||
|
DisplayValue = $"{topSourceSalesShare:F0}%",
|
||||||
|
Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"),
|
||||||
|
Color = "#0f9fb5"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Waehrungs-Komplexitaet", "Currency complexity"),
|
||||||
|
Percent = currencyComplexity,
|
||||||
|
DisplayValue = result.Summary.CurrencyCount.ToString("N0"),
|
||||||
|
Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"),
|
||||||
|
Color = "#b54708"
|
||||||
|
},
|
||||||
|
new CentralGaugeModel
|
||||||
|
{
|
||||||
|
Title = T("Monat gegen Peak", "Month vs peak"),
|
||||||
|
Percent = peakVsAverageMonth,
|
||||||
|
DisplayValue = $"{peakVsAverageMonth:F0}%",
|
||||||
|
Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"),
|
||||||
|
Color = "#d92d20"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result)
|
||||||
|
{
|
||||||
|
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
if (result.Filter.Month.HasValue)
|
||||||
|
{
|
||||||
|
var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value);
|
||||||
|
var coveredDays = result.DailyTotals
|
||||||
|
.Select(x => x.Day)
|
||||||
|
.Where(x => x.HasValue)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
var coveredMonths = result.MonthlyTotals
|
||||||
|
.Select(x => x.Month)
|
||||||
|
.Where(x => x.HasValue)
|
||||||
|
.Distinct()
|
||||||
|
.Count();
|
||||||
|
return coveredMonths * 100m / 12m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result)
|
||||||
|
=> result.Filter.Month.HasValue
|
||||||
|
? T("Tage mit Daten im Monat", "Days with data in month")
|
||||||
|
: T("Monate mit Daten im Jahr", "Months with data in year");
|
||||||
|
|
||||||
|
private static decimal BuildTopSalesSharePercent(IEnumerable<ManagementCockpitDimensionValueRow> rows)
|
||||||
|
{
|
||||||
|
var materialized = rows.ToList();
|
||||||
|
if (materialized.Count == 0)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var total = materialized.Sum(x => x.SalesValue);
|
||||||
|
if (total == 0)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
return materialized.Max(x => x.SalesValue) * 100m / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result)
|
||||||
|
{
|
||||||
|
var monthRows = result.MonthlyTotals.ToList();
|
||||||
|
if (monthRows.Count == 0)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var groupedMonths = monthRows
|
||||||
|
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.Sum(x => x.SalesValue))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (groupedMonths.Count == 0)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var peak = groupedMonths.Max();
|
||||||
|
if (peak == 0)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var average = groupedMonths.Average();
|
||||||
|
return Math.Min(100m, average * 100m / peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildGaugeDashArray(decimal percent)
|
||||||
|
=> $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100";
|
||||||
|
|
||||||
|
private static string BuildGaugeNeedleX(decimal percent)
|
||||||
|
=> GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static string BuildGaugeNeedleY(decimal percent)
|
||||||
|
=> GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d)
|
||||||
|
{
|
||||||
|
var clamped = Math.Clamp((double)percent, 0d, 100d);
|
||||||
|
var angle = Math.PI * (1d - clamped / 100d);
|
||||||
|
var x = 110d + radius * Math.Cos(angle);
|
||||||
|
var y = 110d - radius * Math.Sin(angle);
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CentralGaugeModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public decimal Percent { get; set; }
|
||||||
|
public string DisplayValue { get; set; } = string.Empty;
|
||||||
|
public string Subtitle { get; set; } = string.Empty;
|
||||||
|
public string Color { get; set; } = "#3d7ff0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
@page "/settings"
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject ISettingsPageService SettingsPageActions
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>Settings</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Konfiguration Import/Export</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="Mit Secrets exportieren" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportConfiguration"
|
||||||
|
StartIcon="@Icons.Material.Filled.Download" Disabled="_exportingConfig">
|
||||||
|
@(_exportingConfig ? "Exportiere..." : "Konfiguration exportieren")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
|
||||||
|
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
|
||||||
|
@(_importingConfig ? "Importiere..." : "Konfiguration importieren")
|
||||||
|
<InputFile OnChange="ImportConfiguration" accept=".json,application/json" style="display:none" />
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* SharePoint Config *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_spConfig.CentralExportFolder"
|
||||||
|
Label="Central Export Folder"
|
||||||
|
HelperText="Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
Speichern
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
|
||||||
|
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
|
||||||
|
@if (_testingSp)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@("Teste...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@("SharePoint Verbindung testen")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_sharePointTestPreview))
|
||||||
|
{
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
|
||||||
|
<div><b>Test Preview</b></div>
|
||||||
|
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
|
||||||
|
</MudAlert>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Quellsysteme</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
|
||||||
|
Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides überschreiben.
|
||||||
|
</MudAlert>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddSourceSystem"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add" Class="mb-3">
|
||||||
|
Quellsystem hinzufuegen
|
||||||
|
</MudButton>
|
||||||
|
<MudTable Items="_sourceSystems" Dense Hover Striped Breakpoint="Breakpoint.Md">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Code</MudTh>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Anschlussart</MudTh>
|
||||||
|
<MudTh>Zentrale URL</MudTh>
|
||||||
|
<MudTh>User</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Test</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Code</MudTd>
|
||||||
|
<MudTd>@context.DisplayName</MudTd>
|
||||||
|
<MudTd>@GetConnectionKindLabel(context.ConnectionKind)</MudTd>
|
||||||
|
<MudTd>@GetServiceUrlSummary(context)</MudTd>
|
||||||
|
<MudTd>@GetUsernameSummary(context)</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.IsActive)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!UsesManualImport(context))
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" Size="Size.Small"
|
||||||
|
OnClick='@(() => TestCentralCredentials(context.Code))'
|
||||||
|
Disabled='@_testingSystems.Contains(context.Code)'>
|
||||||
|
@(_testingSystems.Contains(context.Code) ? "Teste..." : "Testen")
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Size="Size.Small"
|
||||||
|
OnClick="() => EditSourceSystem(context)" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||||
|
OnClick="() => RemoveSourceSystem(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystems"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
Quellsysteme speichern
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_sourceSystemDialogVisible" Options="_sourceSystemDialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? "Quellsystem hinzufuegen" : "Quellsystem bearbeiten")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.Code" Label="Code" Required />
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="Name" Required />
|
||||||
|
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="Anschlussart" Required>
|
||||||
|
@foreach (var kind in SourceSystemConnectionKinds.All)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@kind">@GetConnectionKindLabel(kind)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (UsesSapGateway(_editingSourceSystem))
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralServiceUrl" Label="Zentrale SAP Service URL"
|
||||||
|
HelperText="Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben." />
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="Zentraler Username" />
|
||||||
|
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="Zentrales Passwort" InputType="InputType.Password" />
|
||||||
|
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="Aktiv" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseSourceSystemDialog">Abbrechen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">Uebernehmen</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Wechselkurse</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-3">
|
||||||
|
Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.
|
||||||
|
</MudText>
|
||||||
|
<MudStack Row Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add">
|
||||||
|
Kurs hinzufuegen
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
|
||||||
|
@(_refreshingExchangeRates ? "Aktualisiere ECB-Kurse..." : "Refresh Kurse")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
Kurse speichern
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Von</MudTh>
|
||||||
|
<MudTh>Nach</MudTh>
|
||||||
|
<MudTh>Kurs</MudTh>
|
||||||
|
<MudTh>Gueltig ab</MudTh>
|
||||||
|
<MudTh>Gueltig bis</MudTh>
|
||||||
|
<MudTh>Notiz</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh></MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.FromCurrency" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.ToCurrency" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudNumericField T="decimal" @bind-Value="context.Rate" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudDatePicker Date="context.ValidFrom"
|
||||||
|
DateChanged="@(value => context.ValidFrom = value ?? context.ValidFrom)"
|
||||||
|
Editable="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudDatePicker Date="context.ValidTo"
|
||||||
|
DateChanged="@(value => context.ValidTo = value)"
|
||||||
|
Editable="true"
|
||||||
|
Clearable="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField @bind-Value="context.Notes" Immediate="true" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudCheckBox @bind-Value="context.IsActive" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(() => RemoveExchangeRate(context))" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* Export Settings *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
|
||||||
|
HelperText="Format: yyyy-MM-dd" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2">
|
||||||
|
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="Debug Live-Logging" Color="Color.Warning" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
|
||||||
|
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
|
||||||
|
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
||||||
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
|
Speichern
|
||||||
|
</MudButton>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@* Filename Preview *@
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
|
||||||
|
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-1">
|
||||||
|
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private SharePointConfig _spConfig = new();
|
||||||
|
private ExportSettings _exportSettings = new();
|
||||||
|
private List<SourceSystemDefinition> _sourceSystems = [];
|
||||||
|
private SourceSystemDefinition _editingSourceSystem = new();
|
||||||
|
private bool _testingSp;
|
||||||
|
private bool _includeSecretsInExport;
|
||||||
|
private bool _exportingConfig;
|
||||||
|
private bool _importingConfig;
|
||||||
|
private bool _refreshingExchangeRates;
|
||||||
|
private string _sharePointTestPreview = string.Empty;
|
||||||
|
private List<CurrencyExchangeRate> _exchangeRates = [];
|
||||||
|
private readonly HashSet<string> _testingSystems = [];
|
||||||
|
private bool _sourceSystemDialogVisible;
|
||||||
|
private readonly DialogOptions _sourceSystemDialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var state = await SettingsPageActions.LoadAsync();
|
||||||
|
_spConfig = state.SharePointConfig;
|
||||||
|
_exportSettings = state.ExportSettings;
|
||||||
|
_sourceSystems = state.SourceSystems;
|
||||||
|
_exchangeRates = state.ExchangeRates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSharePoint()
|
||||||
|
{
|
||||||
|
await SettingsPageActions.SaveSharePointAsync(_spConfig);
|
||||||
|
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestSharePoint()
|
||||||
|
{
|
||||||
|
_testingSp = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
|
||||||
|
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testingSp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveExportSettings()
|
||||||
|
{
|
||||||
|
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
|
||||||
|
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSourceSystem()
|
||||||
|
{
|
||||||
|
_editingSourceSystem = new SourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = string.Empty,
|
||||||
|
DisplayName = string.Empty,
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.Hana,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
_sourceSystemDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditSourceSystem(SourceSystemDefinition definition)
|
||||||
|
{
|
||||||
|
_editingSourceSystem = new SourceSystemDefinition
|
||||||
|
{
|
||||||
|
Id = definition.Id,
|
||||||
|
Code = definition.Code,
|
||||||
|
DisplayName = definition.DisplayName,
|
||||||
|
ConnectionKind = definition.ConnectionKind,
|
||||||
|
IsActive = definition.IsActive,
|
||||||
|
CentralServiceUrl = definition.CentralServiceUrl,
|
||||||
|
CentralUsername = definition.CentralUsername,
|
||||||
|
CentralPassword = definition.CentralPassword
|
||||||
|
};
|
||||||
|
_sourceSystemDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSourceSystemEdit()
|
||||||
|
{
|
||||||
|
_editingSourceSystem.Code = NormalizeSourceSystemCode(_editingSourceSystem.Code);
|
||||||
|
_editingSourceSystem.DisplayName = NormalizeConfigValue(_editingSourceSystem.DisplayName);
|
||||||
|
_editingSourceSystem.ConnectionKind = NormalizeConnectionKind(_editingSourceSystem.ConnectionKind);
|
||||||
|
_editingSourceSystem.CentralServiceUrl = NormalizeConfigValue(_editingSourceSystem.CentralServiceUrl);
|
||||||
|
_editingSourceSystem.CentralUsername = NormalizeConfigValue(_editingSourceSystem.CentralUsername);
|
||||||
|
_editingSourceSystem.CentralPassword = _editingSourceSystem.CentralPassword ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_editingSourceSystem.Code) || string.IsNullOrWhiteSpace(_editingSourceSystem.DisplayName))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Code und Name fuer das Quellsystem sind Pflicht.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code))
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {_editingSourceSystem.Code}", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_editingSourceSystem.Id == 0)
|
||||||
|
{
|
||||||
|
_sourceSystems.Add(_editingSourceSystem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = _sourceSystems.FirstOrDefault(x => x.Id == _editingSourceSystem.Id);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.Code = _editingSourceSystem.Code;
|
||||||
|
existing.DisplayName = _editingSourceSystem.DisplayName;
|
||||||
|
existing.ConnectionKind = _editingSourceSystem.ConnectionKind;
|
||||||
|
existing.IsActive = _editingSourceSystem.IsActive;
|
||||||
|
existing.CentralServiceUrl = _editingSourceSystem.CentralServiceUrl;
|
||||||
|
existing.CentralUsername = _editingSourceSystem.CentralUsername;
|
||||||
|
existing.CentralPassword = _editingSourceSystem.CentralPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sourceSystems = _sourceSystems.OrderBy(x => x.Code).ToList();
|
||||||
|
_sourceSystemDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSourceSystemDialog()
|
||||||
|
{
|
||||||
|
_sourceSystemDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSourceSystem(SourceSystemDefinition definition)
|
||||||
|
{
|
||||||
|
_sourceSystems.Remove(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSourceSystems()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sourceSystems = await SettingsPageActions.SaveSourceSystemsAsync(_sourceSystems);
|
||||||
|
Snackbar.Add("Quellsysteme gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddExchangeRate()
|
||||||
|
{
|
||||||
|
_exchangeRates.Add(new CurrencyExchangeRate
|
||||||
|
{
|
||||||
|
FromCurrency = "USD",
|
||||||
|
ToCurrency = "EUR",
|
||||||
|
Rate = 1m,
|
||||||
|
ValidFrom = DateTime.Today,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveExchangeRate(CurrencyExchangeRate rate)
|
||||||
|
{
|
||||||
|
_exchangeRates.Remove(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveExchangeRates()
|
||||||
|
{
|
||||||
|
_exchangeRates = await SettingsPageActions.SaveExchangeRatesAsync(_exchangeRates);
|
||||||
|
Snackbar.Add("Wechselkurse gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshEcbRates()
|
||||||
|
{
|
||||||
|
if (_refreshingExchangeRates)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_refreshingExchangeRates = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SettingsPageActions.RefreshEcbRatesAsync();
|
||||||
|
_exchangeRates = result.ExchangeRates;
|
||||||
|
Snackbar.Add($"ECB-Kurse aktualisiert: {result.ImportedCount} Kurse vom {result.RateDate:yyyy-MM-dd}.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"ECB-Kursimport fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshingExchangeRates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportConfiguration()
|
||||||
|
{
|
||||||
|
if (_exportingConfig)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_exportingConfig = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await SettingsPageActions.ExportConfigurationAsync(_includeSecretsInExport);
|
||||||
|
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
|
||||||
|
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
|
||||||
|
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
|
||||||
|
Snackbar.Add("Konfiguration exportiert", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Export fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_exportingConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ImportConfiguration(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (_importingConfig)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_importingConfig = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = args.File;
|
||||||
|
await using var stream = file.OpenReadStream(5 * 1024 * 1024);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var json = await reader.ReadToEndAsync();
|
||||||
|
var state = await SettingsPageActions.ImportConfigurationAsync(json);
|
||||||
|
_spConfig = state.SharePointConfig;
|
||||||
|
_exportSettings = state.ExportSettings;
|
||||||
|
_sourceSystems = state.SourceSystems;
|
||||||
|
_exchangeRates = state.ExchangeRates;
|
||||||
|
Snackbar.Add("Konfiguration importiert", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Import fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_importingConfig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestCentralCredentials(string sourceSystem)
|
||||||
|
{
|
||||||
|
var definition = _sourceSystems.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (definition is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Quellsystem '{sourceSystem}' nicht gefunden.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_testingSystems.Add(sourceSystem))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await SettingsPageActions.TestCentralCredentialsAsync(definition);
|
||||||
|
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testingSystems.Remove(sourceSystem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSourceSystemCode(string? code) => Services.SettingsPageService.NormalizeSourceSystemCode(code);
|
||||||
|
|
||||||
|
private static string NormalizeConnectionKind(string? connectionKind) => Services.SettingsPageService.NormalizeConnectionKind(connectionKind);
|
||||||
|
|
||||||
|
private static string GetConnectionKindLabel(string connectionKind) => connectionKind switch
|
||||||
|
{
|
||||||
|
SourceSystemConnectionKinds.Hana => "HANA",
|
||||||
|
SourceSystemConnectionKinds.SapGateway => "SAP Gateway",
|
||||||
|
SourceSystemConnectionKinds.ManualExcel => "Manual Excel",
|
||||||
|
_ => connectionKind
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool UsesManualImport(SourceSystemDefinition definition)
|
||||||
|
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool UsesSapGateway(SourceSystemDefinition definition)
|
||||||
|
=> string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string GetServiceUrlSummary(SourceSystemDefinition definition)
|
||||||
|
=> string.IsNullOrWhiteSpace(definition.CentralServiceUrl) ? "-" : definition.CentralServiceUrl;
|
||||||
|
|
||||||
|
private static string GetUsernameSummary(SourceSystemDefinition definition)
|
||||||
|
=> string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername;
|
||||||
|
|
||||||
|
private static string NormalizeConfigValue(string? value) => Services.SettingsPageService.NormalizeConfigValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
@page "/source-viewer"
|
||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@inject IWebHostEnvironment Environment
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Source Viewer", "Source Viewer")</PageTitle>
|
||||||
|
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||||
|
<MudText Typo="Typo.h5">@T("Source Viewer", "Source Viewer")</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Href="/transformations">
|
||||||
|
@T("Zurueck zur Transformation", "Back to transformations")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_requestedPath))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Datei:", "File:")
|
||||||
|
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedPath</code></MudText>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_requestedType))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@T("Klasse:", "Class:")
|
||||||
|
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedType</code></MudText>
|
||||||
|
@if (_highlightLineNumber is not null)
|
||||||
|
{
|
||||||
|
<span> @T("bei Zeile", "at line") @_highlightLineNumber</span>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_error))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(_content))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4">
|
||||||
|
<div style="font-family: Consolas, monospace; font-size: 0.9rem;">
|
||||||
|
@foreach (var line in _lines)
|
||||||
|
{
|
||||||
|
<div id="@GetLineAnchor(line.Number)"
|
||||||
|
style="@GetLineStyle(line.Number)">
|
||||||
|
<span style="display:inline-block; width:4rem; color:#666;">@line.Number.ToString("0000")</span>
|
||||||
|
<span>@line.Text</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
@if (_highlightLineNumber is not null)
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
location.hash = '@GetLineAnchor(_highlightLineNumber.Value)';
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _requestedPath;
|
||||||
|
private string? _requestedType;
|
||||||
|
private string? _content;
|
||||||
|
private string? _error;
|
||||||
|
private List<SourceLine> _lines = [];
|
||||||
|
private int? _highlightLineNumber;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
_requestedPath = query.TryGetValue("path", out var value) ? value.ToString() : null;
|
||||||
|
_requestedType = query.TryGetValue("type", out var typeValue) ? typeValue.ToString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_requestedPath))
|
||||||
|
{
|
||||||
|
_error = T("Kein Dateipfad angegeben.", "No file path provided.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_requestedPath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(_requestedPath))
|
||||||
|
{
|
||||||
|
_error = T("Ungueltiger Dateipfad.", "Invalid file path.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.Combine(Environment.ContentRootPath, _requestedPath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_error = string.Format(T("Datei nicht gefunden: {0}", "File not found: {0}"), _requestedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_content = File.ReadAllText(fullPath);
|
||||||
|
_lines = _content
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Split('\n')
|
||||||
|
.Select((text, index) => new SourceLine(index + 1, text))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_requestedType))
|
||||||
|
{
|
||||||
|
_highlightLineNumber = _lines
|
||||||
|
.FirstOrDefault(x => x.Text.Contains($"class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"sealed class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"public class {_requestedType}", StringComparison.Ordinal) ||
|
||||||
|
x.Text.Contains($"public sealed class {_requestedType}", StringComparison.Ordinal))
|
||||||
|
?.Number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLineAnchor(int lineNumber) => $"line-{lineNumber}";
|
||||||
|
|
||||||
|
private string GetLineStyle(int lineNumber)
|
||||||
|
{
|
||||||
|
var highlight = _highlightLineNumber == lineNumber;
|
||||||
|
return highlight
|
||||||
|
? "background-color:#fff3cd; white-space:pre-wrap;"
|
||||||
|
: "white-space:pre-wrap;";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SourceLine(int Number, string Text);
|
||||||
|
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,994 @@
|
|||||||
|
@page "/standorte"
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Reflection
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IStandortePageService StandortePageService
|
||||||
|
@inject IStandorteSapEditorService SapEditorService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageTitle>Standorte</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Zentrale HANA-Technik</MudText>
|
||||||
|
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Hier erscheinen nur Quellsysteme mit Anschlussart HANA. SAP wird zentral unter Settings -> Quellsysteme gepflegt.
|
||||||
|
Standorte mit `BI1` oder `SAGE` verwenden diese technischen HANA-Werte automatisch. Im Standort selbst bleiben nur Schema, TSC, Land und optionale Username-/Password-Overrides.
|
||||||
|
</MudAlert>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-3">
|
||||||
|
Neue HANA-Zeilen entstehen aus den zentral gepflegten Quellsystemen. Falls hier etwas fehlt, lege das Quellsystem in Settings -> Quellsysteme mit Anschlussart `HANA` an.
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudTable Items="_servers" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Quellsystem</MudTh>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Host</MudTh>
|
||||||
|
<MudTh>Port</MudTh>
|
||||||
|
<MudTh>Verbindungsstatus</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.SourceSystem</MudTd>
|
||||||
|
<MudTd>@context.Name</MudTd>
|
||||||
|
<MudTd>@context.Host</MudTd>
|
||||||
|
<MudTd>@context.Port</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (_connectionStatus.TryGetValue(context.Id, out var status))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@BuildStatusTooltip(status)">
|
||||||
|
<MudChip T="string" Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
|
||||||
|
@(status.Success ? "OK" : "Fehler") - @status.Stage
|
||||||
|
</MudChip>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
|
||||||
|
OnClick="() => EditServer(context)" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
|
||||||
|
OnClick="() => TestServerConnection(context)" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="() => DeleteServer(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
OnClick="AddSite" Class="mb-3">
|
||||||
|
Neuen Standort hinzufügen
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
<MudTable Items="_sites" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Land</MudTh>
|
||||||
|
<MudTh>TSC</MudTh>
|
||||||
|
<MudTh>Schema</MudTh>
|
||||||
|
<MudTh>Quellsystem</MudTh>
|
||||||
|
<MudTh>Quelle</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Land</MudTd>
|
||||||
|
<MudTd>@context.TSC</MudTd>
|
||||||
|
<MudTd>@context.Schema</MudTd>
|
||||||
|
<MudTd>@context.SourceSystem</MudTd>
|
||||||
|
<MudTd>@GetConnectionTarget(context)</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (context.IsActive)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
|
||||||
|
OnClick="() => EditSite(context)" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||||
|
OnClick="() => DeleteSite(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">Zentrale HANA-Technik bearbeiten</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField Value="_editingServer.SourceSystem" Label="Quellsystem" ReadOnly />
|
||||||
|
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
|
||||||
|
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
|
||||||
|
HelperText="IP oder Hostname (ohne Protokoll)" />
|
||||||
|
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
|
||||||
|
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
|
||||||
|
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
|
||||||
|
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
|
||||||
|
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
|
||||||
|
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
|
||||||
|
Disabled="!_editingServer.UseSsl" />
|
||||||
|
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
|
||||||
|
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseServerDialog">Abbrechen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer" Disabled="_savingServer">Speichern</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
|
||||||
|
@if (UsesHanaConnection())
|
||||||
|
{
|
||||||
|
<MudStack Row Spacing="2" Class="mb-2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh"
|
||||||
|
OnClick="LoadAvailableSchemasAsync"
|
||||||
|
Disabled="_loadingSchemas">
|
||||||
|
@if (_loadingSchemas)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@("Lade Schemas...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@("Schemas laden")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
@if (_availableSchemas.Count > 0)
|
||||||
|
{
|
||||||
|
<MudSelect T="string" Value="_editingSite.Schema"
|
||||||
|
ValueChanged="OnSchemaSelected"
|
||||||
|
Label="Gefundene Schemas"
|
||||||
|
Dense
|
||||||
|
Style="min-width: 260px;">
|
||||||
|
@foreach (var schema in _availableSchemas)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@schema">@schema</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
<MudText Typo="Typo.caption" Class="mb-2">
|
||||||
|
Die Liste wird aus der zentralen HANA-Verbindung des Quellsystems gelesen und auf typische B1-Schemas eingeschraenkt.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
|
||||||
|
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
|
||||||
|
<MudSelect T="string" Value="_editingSite.SourceSystem" ValueChanged="OnSourceSystemChanged" Label="Quellsystem" Required>
|
||||||
|
@foreach (var system in GetAvailableSourceSystems())
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@system.Code">@GetSourceSystemLabel(system)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_editingSite.UsernameOverride" Label="Username Override"
|
||||||
|
HelperText="Optional. Wenn leer, wird der zentrale Username des Quellsystems verwendet." />
|
||||||
|
<MudTextField @bind-Value="_editingSite.PasswordOverride" Label="Password Override" InputType="InputType.Password"
|
||||||
|
HelperText="Optional. Wenn leer, wird das zentrale Passwort des Quellsystems verwendet." />
|
||||||
|
<MudTextField @bind-Value="_editingSite.LocalExportFolderOverride" Label="Lokaler Exportpfad Override"
|
||||||
|
HelperText="Optional. Wenn leer, wird der zentrale Standardpfad für Standort-Dateien verwendet." />
|
||||||
|
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
|
||||||
|
@if (IsSapSite())
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">SAP Gateway</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert.
|
||||||
|
</MudAlert>
|
||||||
|
<MudText Typo="Typo.body2">Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)</MudText>
|
||||||
|
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL Override"
|
||||||
|
HelperText="Optional. Wenn leer, wird die zentrale SAP Service URL des Quellsystems verwendet." />
|
||||||
|
<MudStack Row Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
|
||||||
|
@if (_refreshingSapEntitySets)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@("Lade...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@("Quellen refreshen")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-2">
|
||||||
|
Letzter Refresh: @_editingSite.SapEntitySetsRefreshedAtUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">SAP Quellen</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
<MudText Typo="Typo.caption" Class="mb-2">
|
||||||
|
Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`.
|
||||||
|
</MudText>
|
||||||
|
<MudTable Items="_sapSources" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Alias</MudTh>
|
||||||
|
<MudTh>Entity Set</MudTh>
|
||||||
|
<MudTh>Primär</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudTextField @bind-Value="context.Alias" Dense /></MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect @bind-Value="context.EntitySet" Dense>
|
||||||
|
@foreach (var entitySet in _sapEntitySetsCache)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@entitySet">@entitySet</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapSource(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">SAP Joins</MudText>
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
|
OnClick="AutoMatchSapJoins">
|
||||||
|
Auto-Match
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
<MudTable Items="_sapJoins" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Links</MudTh>
|
||||||
|
<MudTh>Left Keys</MudTh>
|
||||||
|
<MudTh>Rechts</MudTh>
|
||||||
|
<MudTh>Right Keys</MudTh>
|
||||||
|
<MudTh>Typ</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect @bind-Value="context.LeftAlias" Dense>
|
||||||
|
@foreach (var alias in GetSapAliases())
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@alias">@alias</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string"
|
||||||
|
SelectedValues="GetSelectedJoinKeys(context.LeftKeys)"
|
||||||
|
SelectedValuesChanged="@(values => context.LeftKeys = string.Join(',', values))"
|
||||||
|
MultiSelection="true"
|
||||||
|
Dense>
|
||||||
|
@foreach (var field in GetAvailableJoinFields(context.LeftAlias, context.LeftKeys))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect @bind-Value="context.RightAlias" Dense>
|
||||||
|
@foreach (var alias in GetSapAliases())
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@alias">@alias</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string"
|
||||||
|
SelectedValues="GetSelectedJoinKeys(context.RightKeys)"
|
||||||
|
SelectedValuesChanged="@(values => context.RightKeys = string.Join(',', values))"
|
||||||
|
MultiSelection="true"
|
||||||
|
Dense>
|
||||||
|
@foreach (var field in GetAvailableJoinFields(context.RightAlias, context.RightKeys))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect @bind-Value="context.JoinType" Dense>
|
||||||
|
<MudSelectItem Value="@("Left")">Left</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapJoin(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
|
<MudDivider Class="my-4" />
|
||||||
|
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
|
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
|
||||||
|
OnClick="RefreshSapSourceFields" Disabled="_refreshingSapSourceFields">
|
||||||
|
@if (_refreshingSapSourceFields)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@("Lade Felder...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@("Felder aus Quellen laden")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
<MudText Typo="Typo.caption" Class="mb-2">
|
||||||
|
Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar.
|
||||||
|
</MudText>
|
||||||
|
<MudTable Items="_sapMappings" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Zielfeld</MudTh>
|
||||||
|
<MudTh>Source Expression</MudTh>
|
||||||
|
<MudTh>Pflicht</MudTh>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect @bind-Value="context.TargetField" Dense>
|
||||||
|
@foreach (var field in _salesRecordFields)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" @bind-Value="context.SourceExpression" Dense>
|
||||||
|
@foreach (var expression in GetAvailableSourceExpressions(context.SourceExpression))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@expression">@expression</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
||||||
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapMapping(context)" /></MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
else if (IsManualExcelSite())
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Manueller Excel-Import</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen.
|
||||||
|
</MudAlert>
|
||||||
|
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
|
||||||
|
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx."
|
||||||
|
Class="mb-2" />
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
|
||||||
|
Disabled="_uploadingManualImport" Class="mb-3">
|
||||||
|
Pfad pruefen
|
||||||
|
</MudButton>
|
||||||
|
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
|
||||||
|
@if (_uploadingManualImport)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-2">Datei wird hochgeladen...</MudText>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath))
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-3 mt-3" Elevation="0">
|
||||||
|
<MudText Typo="Typo.body2">Datei: @_editingSite.ManualImportFilePath</MudText>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Letzter Upload: @(_editingSite.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-2">Noch keine Datei hinterlegt.</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
Die technische HANA-Verbindung kommt aus der zentralen HANA-Konfiguration des Quellsystems. Im Standort selbst pflegst du nur fachliche Standortdaten und optionale Username-/Password-Overrides.
|
||||||
|
</MudAlert>
|
||||||
|
<MudText Typo="Typo.body2">Aktive Zentralverbindung: @GetCentralHanaSummary(_editingSite.SourceSystem)</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-2">
|
||||||
|
Host, Port, SSL und technische Parameter bearbeitest du oben in der zentralen HANA-Konfiguration.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite || _uploadingManualImport">Abbrechen</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets || _uploadingManualImport">Speichern</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
|
||||||
|
private List<HanaServer> _servers = new();
|
||||||
|
private List<Site> _sites = new();
|
||||||
|
private List<SourceSystemDefinition> _sourceSystemDefinitions = new();
|
||||||
|
private List<string> _sapEntitySetsCache = [];
|
||||||
|
private List<string> _availableSchemas = [];
|
||||||
|
private List<string> _sapAvailableSourceExpressions = [];
|
||||||
|
private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private List<SapSourceDefinition> _sapSources = [];
|
||||||
|
private List<SapJoinDefinition> _sapJoins = [];
|
||||||
|
private List<SapFieldMapping> _sapMappings = [];
|
||||||
|
private readonly string[] _salesRecordFields = typeof(SalesRecord)
|
||||||
|
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.ToArray();
|
||||||
|
private HanaServer _editingServer = new();
|
||||||
|
private Site _editingSite = new();
|
||||||
|
private bool _serverDialogVisible;
|
||||||
|
private bool _siteDialogVisible;
|
||||||
|
private bool _refreshingSapEntitySets;
|
||||||
|
private bool _refreshingSapSourceFields;
|
||||||
|
private bool _savingServer;
|
||||||
|
private bool _savingSite;
|
||||||
|
private bool _loadingSchemas;
|
||||||
|
private bool _uploadingManualImport;
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
|
{
|
||||||
|
var state = await StandortePageService.LoadAsync();
|
||||||
|
_sourceSystemDefinitions = state.SourceSystems;
|
||||||
|
_servers = state.Servers;
|
||||||
|
_sites = state.Sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditServer(HanaServer server)
|
||||||
|
{
|
||||||
|
_editingServer = CloneServer(server);
|
||||||
|
_serverDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveServer()
|
||||||
|
{
|
||||||
|
if (_savingServer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_savingServer = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StandortePageService.SaveServerAsync(_editingServer, GetHanaSourceSystemCodes());
|
||||||
|
_serverDialogVisible = false;
|
||||||
|
await LoadDataAsync();
|
||||||
|
Snackbar.Add("Server gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_savingServer = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteServer(HanaServer server)
|
||||||
|
{
|
||||||
|
if (IsHanaSourceSystem(server.SourceSystem))
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Die zentrale HANA-Konfiguration fuer {server.SourceSystem} kann nicht geloescht werden.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"Server löschen",
|
||||||
|
$"Server '{server.Name}' wirklich löschen?",
|
||||||
|
yesText: "Löschen", cancelText: "Abbrechen");
|
||||||
|
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StandortePageService.DeleteServerAsync(server);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Server konnte nicht gelöscht werden: {ex.Message}", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadDataAsync();
|
||||||
|
Snackbar.Add("Server gelöscht", Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestServerConnection(HanaServer server)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await StandortePageService.TestServerConnectionAsync(server);
|
||||||
|
_connectionStatus[server.Id] = result;
|
||||||
|
Snackbar.Add(
|
||||||
|
result.Success
|
||||||
|
? $"Verbindung zu '{server.Name}' erfolgreich."
|
||||||
|
: $"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}",
|
||||||
|
result.Success ? Severity.Success : Severity.Error);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildStatusTooltip(ConnectionTestResult status)
|
||||||
|
{
|
||||||
|
var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
if (status.Success)
|
||||||
|
return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
|
||||||
|
|
||||||
|
return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSite()
|
||||||
|
{
|
||||||
|
_editingSite = new Site
|
||||||
|
{
|
||||||
|
IsActive = true,
|
||||||
|
SourceSystem = GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP",
|
||||||
|
HanaServerId = null,
|
||||||
|
ManualImportFilePath = string.Empty
|
||||||
|
};
|
||||||
|
_availableSchemas = [];
|
||||||
|
_sapEntitySetsCache = [];
|
||||||
|
_sapAvailableSourceExpressions = [];
|
||||||
|
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_sapSources = [];
|
||||||
|
_sapJoins = [];
|
||||||
|
_sapMappings = [];
|
||||||
|
_siteDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditSite(Site site)
|
||||||
|
{
|
||||||
|
_ = EditSiteAsync(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EditSiteAsync(Site site)
|
||||||
|
{
|
||||||
|
var editorState = await StandortePageService.LoadSiteEditorAsync(site, GetAvailableSourceSystems());
|
||||||
|
_editingSite = editorState.Site;
|
||||||
|
_availableSchemas = [];
|
||||||
|
_sapEntitySetsCache = editorState.SapEntitySets;
|
||||||
|
_sapSources = editorState.SapSources;
|
||||||
|
_sapJoins = editorState.SapJoins;
|
||||||
|
_sapMappings = editorState.SapMappings;
|
||||||
|
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
|
||||||
|
_sapSourceFieldMap = BuildSourceFieldMapFromJoins();
|
||||||
|
_siteDialogVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSite()
|
||||||
|
{
|
||||||
|
if (_savingSite)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_savingSite = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache);
|
||||||
|
_siteDialogVisible = false;
|
||||||
|
await LoadDataAsync();
|
||||||
|
Snackbar.Add("Standort gespeichert", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Speichern fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_savingSite = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSite(Site site)
|
||||||
|
{
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"Standort löschen",
|
||||||
|
$"Standort '{site.Land}' wirklich löschen?",
|
||||||
|
yesText: "Löschen", cancelText: "Abbrechen");
|
||||||
|
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
await StandortePageService.DeleteSiteAsync(site);
|
||||||
|
await LoadDataAsync();
|
||||||
|
Snackbar.Add("Standort gelöscht", Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetServerNode(HanaServer? server)
|
||||||
|
{
|
||||||
|
if (server is null || string.IsNullOrWhiteSpace(server.Host))
|
||||||
|
return "-";
|
||||||
|
|
||||||
|
return server.Host.Contains(':', StringComparison.Ordinal) ? server.Host : $"{server.Host}:{server.Port}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HanaServer CloneServer(HanaServer server)
|
||||||
|
{
|
||||||
|
return new HanaServer
|
||||||
|
{
|
||||||
|
Id = server.Id,
|
||||||
|
SourceSystem = server.SourceSystem,
|
||||||
|
Name = server.Name,
|
||||||
|
Host = server.Host,
|
||||||
|
Port = server.Port,
|
||||||
|
Username = string.Empty,
|
||||||
|
Password = string.Empty,
|
||||||
|
DatabaseName = server.DatabaseName,
|
||||||
|
UseSsl = server.UseSsl,
|
||||||
|
ValidateCertificate = server.ValidateCertificate,
|
||||||
|
AdditionalParams = server.AdditionalParams
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OnSchemaSelected(string schema)
|
||||||
|
{
|
||||||
|
_editingSite.Schema = schema;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OnSourceSystemChanged(string value)
|
||||||
|
{
|
||||||
|
_editingSite.SourceSystem = value;
|
||||||
|
_availableSchemas = [];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<SourceSystemDefinition> GetAvailableSourceSystems()
|
||||||
|
=> _sourceSystemDefinitions
|
||||||
|
.Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(x => x.DisplayName)
|
||||||
|
.ThenBy(x => x.Code);
|
||||||
|
|
||||||
|
private List<string> GetHanaSourceSystemCodes()
|
||||||
|
=> _sourceSystemDefinitions
|
||||||
|
.Where(x => string.Equals(x.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private string GetSourceSystemConnectionKind(string? sourceSystem)
|
||||||
|
=> _sourceSystemDefinitions
|
||||||
|
.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.ConnectionKind
|
||||||
|
?? SourceSystemConnectionKinds.SapGateway;
|
||||||
|
|
||||||
|
private bool IsHanaSourceSystem(string? sourceSystem)
|
||||||
|
=> string.Equals(GetSourceSystemConnectionKind(sourceSystem), SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool IsSapSite()
|
||||||
|
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool IsManualExcelSite()
|
||||||
|
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem);
|
||||||
|
|
||||||
|
private string GetSourceSystemLabel(SourceSystemDefinition definition)
|
||||||
|
=> string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})";
|
||||||
|
|
||||||
|
private string GetConnectionTarget(Site site)
|
||||||
|
{
|
||||||
|
var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem);
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return GetEffectiveSapServiceUrl(site);
|
||||||
|
if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return string.IsNullOrWhiteSpace(site.ManualImportFilePath) ? "-" : Path.GetFileName(site.ManualImportFilePath);
|
||||||
|
|
||||||
|
return GetServerNode(site.HanaServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetEffectiveSapServiceUrl(Site site)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
|
||||||
|
return site.SapServiceUrl;
|
||||||
|
|
||||||
|
var sourceDefinition = _sourceSystemDefinitions
|
||||||
|
.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(sourceDefinition?.CentralServiceUrl) ? "-" : sourceDefinition.CentralServiceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCentralSapServiceUrlSummary(string sourceSystem)
|
||||||
|
{
|
||||||
|
var sourceDefinition = _sourceSystemDefinitions
|
||||||
|
.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(sourceDefinition?.CentralServiceUrl) ? "-" : sourceDefinition.CentralServiceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCentralHanaSummary(string sourceSystem)
|
||||||
|
{
|
||||||
|
var normalizedSourceSystem = string.IsNullOrWhiteSpace(sourceSystem) ? string.Empty : sourceSystem.Trim().ToUpperInvariant();
|
||||||
|
var centralServer = _servers.FirstOrDefault(x => x.SourceSystem == normalizedSourceSystem);
|
||||||
|
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
|
||||||
|
return $"keine zentrale HANA-Konfiguration fuer {normalizedSourceSystem}";
|
||||||
|
|
||||||
|
return $"{centralServer.Name} | {GetServerNode(centralServer)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAvailableSchemasAsync()
|
||||||
|
{
|
||||||
|
if (_loadingSchemas)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_loadingSchemas = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_availableSchemas = await StandortePageService.LoadAvailableSchemasAsync(_editingSite);
|
||||||
|
|
||||||
|
if (_availableSchemas.Count == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Keine passenden Schemas gefunden.", Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_editingSite.Schema) ||
|
||||||
|
!_availableSchemas.Contains(_editingSite.Schema, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_editingSite.Schema = _availableSchemas[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add($"{_availableSchemas.Count} Schemas geladen.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Schemas laden fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingSchemas = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshSapEntitySets()
|
||||||
|
{
|
||||||
|
if (_refreshingSapEntitySets)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_refreshingSapEntitySets = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await StandortePageService.RefreshSapEntitySetsAsync(_editingSite);
|
||||||
|
_sapEntitySetsCache = result.EntitySets;
|
||||||
|
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(result.EntitySets);
|
||||||
|
_editingSite.SapEntitySetsRefreshedAtUtc = result.RefreshedAtUtc;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) &&
|
||||||
|
!_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_editingSite.SapEntitySet = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add($"{result.EntitySets.Count} SAP Entity Sets geladen.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshingSapEntitySets = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseServerDialog()
|
||||||
|
{
|
||||||
|
if (_savingServer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_serverDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSiteDialog()
|
||||||
|
{
|
||||||
|
if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_siteDialogVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UploadManualImportFileAsync(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (_uploadingManualImport)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var file = args.File;
|
||||||
|
if (file is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_uploadingManualImport = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(file.Name);
|
||||||
|
if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports");
|
||||||
|
Directory.CreateDirectory(uploadDirectory);
|
||||||
|
|
||||||
|
var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name).Select(ch =>
|
||||||
|
char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'));
|
||||||
|
if (string.IsNullOrWhiteSpace(safeBaseName))
|
||||||
|
safeBaseName = "manual_import";
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}");
|
||||||
|
|
||||||
|
await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024))
|
||||||
|
await using (var targetStream = File.Create(targetPath))
|
||||||
|
{
|
||||||
|
await sourceStream.CopyToAsync(targetStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
_editingSite.ManualImportFilePath = targetPath;
|
||||||
|
_editingSite.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
|
||||||
|
Snackbar.Add("Excel-Datei hochgeladen.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_uploadingManualImport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateManualImportPathAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_editingSite.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(_editingSite.ManualImportFilePath);
|
||||||
|
Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseSapEntitySets(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeSapEntitySets(List<string> entitySets)
|
||||||
|
=> JsonSerializer.Serialize(entitySets);
|
||||||
|
|
||||||
|
private void AddSapSource()
|
||||||
|
{
|
||||||
|
SapEditorService.AddSapSource(_sapSources, _sapEntitySetsCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSapSource(SapSourceDefinition source)
|
||||||
|
{
|
||||||
|
SapEditorService.RemoveSapSource(_sapSources, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSapJoin()
|
||||||
|
{
|
||||||
|
SapEditorService.AddSapJoin(_sapJoins);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AutoMatchSapJoins()
|
||||||
|
{
|
||||||
|
var result = SapEditorService.AutoMatchSapJoins(_sapSources, _sapJoins, _sapSourceFieldMap);
|
||||||
|
SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings);
|
||||||
|
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSapJoin(SapJoinDefinition join)
|
||||||
|
{
|
||||||
|
SapEditorService.RemoveSapJoin(_sapJoins, join);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSapMapping()
|
||||||
|
{
|
||||||
|
SapEditorService.AddSapMapping(_sapMappings, _salesRecordFields, _sapAvailableSourceExpressions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSapMapping(SapFieldMapping mapping)
|
||||||
|
{
|
||||||
|
SapEditorService.RemoveSapMapping(_sapMappings, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetSapAliases()
|
||||||
|
=> SapEditorService.GetSapAliases(_sapSources);
|
||||||
|
|
||||||
|
private void NormalizeSapConfigCollections()
|
||||||
|
=> SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings);
|
||||||
|
|
||||||
|
private async Task RefreshSapSourceFields()
|
||||||
|
{
|
||||||
|
if (_refreshingSapSourceFields)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_refreshingSapSourceFields = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activeSources = _sapSources
|
||||||
|
.Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias) && !string.IsNullOrWhiteSpace(s.EntitySet))
|
||||||
|
.OrderBy(s => s.SortOrder)
|
||||||
|
.ThenBy(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (activeSources.Count == 0)
|
||||||
|
throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set.");
|
||||||
|
|
||||||
|
var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings);
|
||||||
|
_sapAvailableSourceExpressions = result.SourceExpressions;
|
||||||
|
_sapSourceFieldMap = result.SourceFieldMap;
|
||||||
|
|
||||||
|
Snackbar.Add($"{_sapAvailableSourceExpressions.Count} Source Expressions geladen.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshingSapSourceFields = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetAvailableSourceExpressions(string? currentValue)
|
||||||
|
=> SapEditorService.GetAvailableSourceExpressions(_sapAvailableSourceExpressions, currentValue);
|
||||||
|
|
||||||
|
private List<string> BuildSourceExpressionsFromMappings()
|
||||||
|
=> SapEditorService.BuildSourceExpressionsFromMappings(_sapMappings);
|
||||||
|
|
||||||
|
private Dictionary<string, List<string>> BuildSourceFieldMapFromJoins()
|
||||||
|
=> SapEditorService.BuildSourceFieldMapFromJoins(_sapJoins);
|
||||||
|
|
||||||
|
private IEnumerable<string> GetAvailableJoinFields(string? alias, string? currentKeys)
|
||||||
|
=> SapEditorService.GetAvailableJoinFields(_sapSourceFieldMap, alias, currentKeys);
|
||||||
|
|
||||||
|
private static HashSet<string> GetSelectedJoinKeys(string? keys)
|
||||||
|
=> keys?
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||||
|
?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
@page "/transformations"
|
||||||
|
@using System.Reflection
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject ITransformationsPageService TransformationsPageActions
|
||||||
|
@inject ITransformationCatalog TransformationCatalog
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IUiTextService UiText
|
||||||
|
|
||||||
|
<PageTitle>@T("Transformationen", "Transformations")</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">@T("Transformer Ansicht", "Transformation view")</MudText>
|
||||||
|
<MudText Typo="Typo.body1" Class="mb-4">@T("Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien.", "Define simple field rules and complex record-based strategies per source system.")</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||||
|
`Value`-Regeln arbeiten feldweise. `Record`-Regeln rufen eine registrierte C#-Strategie auf und koennen mehrere Felder eines Datensatzes verwenden.
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="2" Class="mb-3">
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
|
||||||
|
@T("Regel hinzufuegen", "Add rule")
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
|
||||||
|
@T("Alle speichern", "Save all")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable Items="_rules" Dense Hover Striped>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Aktiv</MudTh>
|
||||||
|
<MudTh>System</MudTh>
|
||||||
|
<MudTh>Scope</MudTh>
|
||||||
|
<MudTh>Source</MudTh>
|
||||||
|
<MudTh>Target</MudTh>
|
||||||
|
<MudTh>Typ / Klasse</MudTh>
|
||||||
|
<MudTh>Argument</MudTh>
|
||||||
|
<MudTh>Sort</MudTh>
|
||||||
|
<MudTh>Info</MudTh>
|
||||||
|
<MudTh>Aktionen</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
|
||||||
|
@foreach (var system in _sourceSystems.Where(x => x.IsActive))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@system.Code">@system.DisplayName (@system.Code)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" Value="@context.RuleScope" ValueChanged="@(v => ChangeRuleScope(context, v))" Dense>
|
||||||
|
@foreach (var scope in _ruleScopes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@scope">@scope</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (IsRecordScope(context))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small" Text="Record-Regel" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
|
||||||
|
@foreach (var field in _recordFields)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
|
||||||
|
@foreach (var field in _recordFields)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@{
|
||||||
|
var availableTypes = GetTypesForScope(context.RuleScope);
|
||||||
|
}
|
||||||
|
<MudSelect T="string"
|
||||||
|
@key="@GetTypeSelectKey(context)"
|
||||||
|
Value="@context.TransformationType"
|
||||||
|
ValueChanged="@(v => context.TransformationType = v)"
|
||||||
|
Dense
|
||||||
|
HelperText="@GetTypeHelperText(context)">
|
||||||
|
@foreach (var type in availableTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@type.Key">@(IsRecordScope(context) ? $"Klasse: {type.Key}" : type.Key)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (IsRecordScope(context))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Class="mt-1">
|
||||||
|
Hier waehlt man die registrierte C#-Strategie.
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudTextField T="string" Value="@context.Argument" ValueChanged="@(v => context.Argument = v)"
|
||||||
|
HelperText="@GetArgumentHelperText(context)" />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@{
|
||||||
|
var catalogItem = GetCatalogItem(context);
|
||||||
|
}
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.caption">@((catalogItem?.Description ?? T("Keine Beschreibung.", "No description.")) )</MudText>
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Info" Size="Size.Small"
|
||||||
|
StartIcon="@Icons.Material.Filled.Code"
|
||||||
|
Disabled="@(catalogItem is null)"
|
||||||
|
OnClick="() => ShowCode(context)">
|
||||||
|
@T("Code anzeigen", "Show code")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||||
|
OnClick="() => RemoveRule(context)" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_codeDialogVisible" Options="_codeDialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@T("Transformationscode", "Transformation code")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
@if (_selectedCatalogItem is not null)
|
||||||
|
{
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudText Typo="Typo.subtitle2">@_selectedCatalogItem.Key (@_selectedCatalogItem.RuleScope)</MudText>
|
||||||
|
<MudText Typo="Typo.body2">@_selectedCatalogItem.Description</MudText>
|
||||||
|
<MudText Typo="Typo.caption">Klasse: @_selectedCatalogItem.TypeName</MudText>
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Datei:
|
||||||
|
<MudLink Href="@GetSourceViewerUrl(_selectedCatalogItem.SourceFile, _selectedCatalogItem.TypeName)" Target="_blank">
|
||||||
|
@_selectedCatalogItem.SourceFile
|
||||||
|
</MudLink>
|
||||||
|
</MudText>
|
||||||
|
<MudPaper Class="pa-3">
|
||||||
|
<MudText Typo="Typo.caption">Snippet</MudText>
|
||||||
|
<pre style="margin:0; white-space:pre-wrap;">@_selectedCatalogItem.CodeSnippet</pre>
|
||||||
|
</MudPaper>
|
||||||
|
@if (_selectedRule is not null)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-3">
|
||||||
|
<MudText Typo="Typo.caption">Aktuelle Regel</MudText>
|
||||||
|
<MudText Typo="Typo.body2">System: @_selectedRule.SourceSystem</MudText>
|
||||||
|
<MudText Typo="Typo.body2">Target: @_selectedRule.TargetField</MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_selectedRule.SourceField))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">Source: @_selectedRule.SourceField</MudText>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.body2">Argument: @(string.IsNullOrWhiteSpace(_selectedRule.Argument) ? "-" : _selectedRule.Argument)</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton Variant="Variant.Text" OnClick="CloseCodeDialog">@T("Schliessen", "Close")</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly string[] _ruleScopes = ["Value", "Record"];
|
||||||
|
private readonly string[] _recordFields = typeof(SalesRecord)
|
||||||
|
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private List<FieldTransformationRule> _rules = new();
|
||||||
|
private List<SourceSystemDefinition> _sourceSystems = [];
|
||||||
|
private IReadOnlyList<TransformationCatalogItem> _catalogItems = [];
|
||||||
|
private bool _codeDialogVisible;
|
||||||
|
private FieldTransformationRule? _selectedRule;
|
||||||
|
private TransformationCatalogItem? _selectedCatalogItem;
|
||||||
|
private readonly DialogOptions _codeDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_catalogItems = TransformationCatalog.GetAll();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
var state = await TransformationsPageActions.LoadAsync();
|
||||||
|
_sourceSystems = state.SourceSystems;
|
||||||
|
_rules = state.Rules;
|
||||||
|
|
||||||
|
foreach (var rule in _rules)
|
||||||
|
{
|
||||||
|
rule.RuleScope = string.IsNullOrWhiteSpace(rule.RuleScope) ? "Value" : rule.RuleScope;
|
||||||
|
if (!GetTypesForScope(rule.RuleScope).Any(x => string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
rule.TransformationType = GetTypesForScope(rule.RuleScope).FirstOrDefault()?.Key ?? "Copy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRule()
|
||||||
|
{
|
||||||
|
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
|
||||||
|
_rules.Add(new FieldTransformationRule
|
||||||
|
{
|
||||||
|
SourceSystem = _sourceSystems.FirstOrDefault(x => x.IsActive)?.Code ?? "SAP",
|
||||||
|
RuleScope = "Value",
|
||||||
|
SourceField = nameof(SalesRecord.Material),
|
||||||
|
TargetField = nameof(SalesRecord.Material),
|
||||||
|
TransformationType = "Copy",
|
||||||
|
SortOrder = nextSort,
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveRule(FieldTransformationRule rule)
|
||||||
|
{
|
||||||
|
_rules.Remove(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAllAsync()
|
||||||
|
{
|
||||||
|
_rules = await TransformationsPageActions.SaveAllAsync(_rules);
|
||||||
|
|
||||||
|
Snackbar.Add(T("Transformationsregeln gespeichert.", "Transformation rules saved."), Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<TransformationCatalogItem> GetTypesForScope(string? ruleScope)
|
||||||
|
{
|
||||||
|
var scope = string.IsNullOrWhiteSpace(ruleScope) ? "Value" : ruleScope;
|
||||||
|
return TransformationCatalog.GetByScope(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRecordScope(FieldTransformationRule rule)
|
||||||
|
=> string.Equals(rule.RuleScope, "Record", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private void ChangeRuleScope(FieldTransformationRule rule, string scope)
|
||||||
|
{
|
||||||
|
rule.RuleScope = scope;
|
||||||
|
var firstType = GetTypesForScope(scope).FirstOrDefault()?.Key;
|
||||||
|
if (!string.IsNullOrWhiteSpace(firstType))
|
||||||
|
rule.TransformationType = firstType;
|
||||||
|
|
||||||
|
if (IsRecordScope(rule))
|
||||||
|
rule.SourceField = string.Empty;
|
||||||
|
else if (string.IsNullOrWhiteSpace(rule.SourceField))
|
||||||
|
rule.SourceField = nameof(SalesRecord.Material);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetArgumentHelperText(FieldTransformationRule rule)
|
||||||
|
{
|
||||||
|
var item = _catalogItems.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return item?.Description ?? T("Optionales Argument.", "Optional argument.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransformationCatalogItem? GetCatalogItem(FieldTransformationRule rule)
|
||||||
|
=> _catalogItems.FirstOrDefault(x =>
|
||||||
|
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private void ShowCode(FieldTransformationRule rule)
|
||||||
|
{
|
||||||
|
_selectedRule = rule;
|
||||||
|
_selectedCatalogItem = GetCatalogItem(rule);
|
||||||
|
_codeDialogVisible = _selectedCatalogItem is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseCodeDialog()
|
||||||
|
{
|
||||||
|
_codeDialogVisible = false;
|
||||||
|
_selectedRule = null;
|
||||||
|
_selectedCatalogItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSourceViewerUrl(string sourceFile, string typeName)
|
||||||
|
=> $"/source-viewer?path={Uri.EscapeDataString(sourceFile)}&type={Uri.EscapeDataString(typeName)}";
|
||||||
|
|
||||||
|
private static string GetTypeSelectKey(FieldTransformationRule rule)
|
||||||
|
=> $"{rule.Id}:{rule.RuleScope}:{rule.TransformationType}";
|
||||||
|
|
||||||
|
private string GetTypeHelperText(FieldTransformationRule rule)
|
||||||
|
{
|
||||||
|
var types = GetTypesForScope(rule.RuleScope);
|
||||||
|
return types.Count == 0
|
||||||
|
? T("Keine Typen fuer diesen Scope registriert.", "No types registered for this scope.")
|
||||||
|
: IsRecordScope(rule)
|
||||||
|
? string.Format(T("Verfuegbare Klassen: {0}", "Available classes: {0}"), string.Join(", ", types.Select(x => x.Key)))
|
||||||
|
: string.Format(T("Verfuegbare Typen: {0}", "Available types: {0}"), string.Join(", ", types.Select(x => x.Key)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string T(string german, string english) => UiText.Text(german, english);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using MudBlazor
|
||||||
|
@using TrafagSalesExporter.Components
|
||||||
|
@using TrafagSalesExporter.Components.Layout
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
|
||||||
|
public DbSet<SourceSystemDefinition> SourceSystemDefinitions => Set<SourceSystemDefinition>();
|
||||||
|
public DbSet<Site> Sites => Set<Site>();
|
||||||
|
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
|
||||||
|
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
|
||||||
|
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
|
||||||
|
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
|
||||||
|
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
||||||
|
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
|
||||||
|
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||||
|
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||||
|
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
||||||
|
public DbSet<CentralSalesRecord> CentralSalesRecords => Set<CentralSalesRecord>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
# TrafagSalesExporter Handoff
|
||||||
|
|
||||||
|
Stand: 2026-04-15
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-17
|
||||||
|
|
||||||
|
Der dokumentierte Stand in diesem Handoff war bei der Waehrungslogik nicht mehr aktuell.
|
||||||
|
|
||||||
|
Inzwischen gilt:
|
||||||
|
|
||||||
|
- Kurstabellen fuer `CurrencyExchangeRates` sind im System vorhanden
|
||||||
|
- `Settings` enthaelt bereits eine Pflegeoberflaeche fuer Wechselkurse
|
||||||
|
- `ExchangeRateImportService` importiert ECB-Tageskurse nach `CurrencyExchangeRates`
|
||||||
|
- `NormalizeCurrencyCode` ist als Value-Transformation vorhanden
|
||||||
|
- `ConvertCurrency` ist als Record-Transformation vorhanden
|
||||||
|
- `Program.cs` registriert beide Strategien sowie `CurrencyExchangeRateService` und `ExchangeRateImportService`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- die Roh-Auswertung im `Management Cockpit` rechnet Stand heute weiterhin bewusst **nicht** in CHF um
|
||||||
|
- dort bleibt der Umsatz weiterhin in `Sales Currency`
|
||||||
|
- die Waehrungsumrechnung ist aktuell Teil des allgemeinen Transformations-/Mapping-Systems, nicht der Cockpit-Rohsicht
|
||||||
|
|
||||||
|
Zusatzlich wurden am 2026-04-17 fehlende Unit-Tests fuer die Waehrungslogik nachgezogen:
|
||||||
|
|
||||||
|
- `CurrencyExchangeRateServiceTests`
|
||||||
|
- `ExchangeRateImportServiceTests`
|
||||||
|
- Erweiterungen in
|
||||||
|
- `TransformationStrategiesTests`
|
||||||
|
- `RecordTransformationServiceTests`
|
||||||
|
- `TransformationCatalogTests`
|
||||||
|
|
||||||
|
Aktueller Teststatus nach diesem Nachtrag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- erfolgreich
|
||||||
|
- `31/31` Tests gruen
|
||||||
|
- bekannte Warnung bleibt:
|
||||||
|
- SAP HANA Architekturwarnung `MSB3270`
|
||||||
|
|
||||||
|
## Architekturpruefung 2026-04-17
|
||||||
|
|
||||||
|
Es wurde eine erneute Gesamtpruefung der Architektur gemacht, ausdruecklich ohne neue Implementierung.
|
||||||
|
|
||||||
|
### Gesamturteil
|
||||||
|
|
||||||
|
Die Grundrichtung ist weiterhin sinnvoll:
|
||||||
|
|
||||||
|
- klare Trennung der Quellsysteme `SAP`, `BI1`, `SAGE`, `MANUAL_EXCEL`
|
||||||
|
- zentrales fachliches Zielschema ueber `SalesRecord`
|
||||||
|
- zentrale technische Ablage ueber `CentralSalesRecords`
|
||||||
|
- separater Orchestrator fuer Standort- und Konsolidierungsexport
|
||||||
|
- Transformationssystem als eigener Layer
|
||||||
|
|
||||||
|
Aber:
|
||||||
|
|
||||||
|
- die Architektur ist **noch nicht stabil genug**, um sie als "fertig sauber" zu betrachten
|
||||||
|
- die groessten Risiken liegen aktuell nicht in SAP oder Waehrungen, sondern in
|
||||||
|
- Start-/Schema-Initialisierung
|
||||||
|
- Config-Import
|
||||||
|
- Verteilung von Logik zwischen Razor-Seiten und Services
|
||||||
|
|
||||||
|
### Wichtigste Architektur-Risiken
|
||||||
|
|
||||||
|
#### 1. Start-/Schema-Initialisierung ist fragil
|
||||||
|
|
||||||
|
`DatabaseInitializationService` mischt derzeit:
|
||||||
|
|
||||||
|
- `EnsureCreated`
|
||||||
|
- manuelle `ALTER TABLE`-Pflege
|
||||||
|
- FK-Reparaturlogik
|
||||||
|
- Seeding
|
||||||
|
- empfohlenes Regel-Seeding
|
||||||
|
|
||||||
|
Das ist funktional hilfreich, aber architektonisch gefaehrlich, weil:
|
||||||
|
|
||||||
|
- die App-Initialisierung dadurch viel implizite Datenmigration enthaelt
|
||||||
|
- Verhalten schwer vorhersehbar wird
|
||||||
|
- Fehler im Migrationspfad sofort produktive Daten treffen
|
||||||
|
|
||||||
|
Wichtiger konkreter Befund aus der Pruefung:
|
||||||
|
|
||||||
|
- beim Kopieren von `Sites_old` nach `Sites` ist die Spaltenreihenfolge im SQL inkonsistent
|
||||||
|
- dadurch koennen Werte wie `ManualImportFilePath`, `SapServiceUrl`, `SapEntitySet` verschoben gespeichert werden
|
||||||
|
- das ist eine reale Datenkorruptionsgefahr und kein reines Architekturthema
|
||||||
|
|
||||||
|
### 2. Config-Import ist destruktiv und nicht atomar
|
||||||
|
|
||||||
|
`ConfigTransferService.ImportJsonAsync` loescht aktuell zuerst grosse Teile der Konfiguration und Daten:
|
||||||
|
|
||||||
|
- Sites
|
||||||
|
- HanaServers
|
||||||
|
- Transformation Rules
|
||||||
|
- SAP-Konfiguration
|
||||||
|
- Wechselkurse
|
||||||
|
- sogar `CentralSalesRecords`
|
||||||
|
|
||||||
|
und baut danach mit mehreren `SaveChangesAsync()`-Zwischenschritten neu auf.
|
||||||
|
|
||||||
|
Risiko:
|
||||||
|
|
||||||
|
- wenn der Import in der Mitte scheitert, bleibt das System teilweise geloescht zurueck
|
||||||
|
- `CentralSalesRecords` gehoeren fachlich ohnehin nicht sauber in einen normalen Config-Import
|
||||||
|
|
||||||
|
### 3. Zu viel Fach- und Persistenzlogik in Razor-Seiten
|
||||||
|
|
||||||
|
`Settings.razor` und `Standorte.razor` machen aktuell sehr viel direkt:
|
||||||
|
|
||||||
|
- `DbContext` oeffnen
|
||||||
|
- Daten laden und speichern
|
||||||
|
- Konfigurationsimport/-export anstossen
|
||||||
|
- SAP-Refresh
|
||||||
|
- Upload-Handling
|
||||||
|
- Teile der Validierung / Persistenzlogik
|
||||||
|
|
||||||
|
Das funktioniert momentan, fuehrt aber langfristig zu:
|
||||||
|
|
||||||
|
- schwer testbarer UI-Logik
|
||||||
|
- verstreuten Regeln
|
||||||
|
- hoeherem Seiteneffekt-Risiko bei Erweiterungen
|
||||||
|
|
||||||
|
### 4. Vertrag zwischen Orchestrator und konsolidiertem Export ist unscharf
|
||||||
|
|
||||||
|
`ExportOrchestrationService` sammelt bei `ExportAllAsync` bereits `consolidatedRecords`, uebergibt sie weiter, aber `ConsolidatedExportService` ignoriert diesen Parameter und liest erneut aus `CentralSalesRecords`.
|
||||||
|
|
||||||
|
Das zeigt ein offenes Architekturthema:
|
||||||
|
|
||||||
|
- Soll die zentrale Datei aus dem Live-Exportlauf gebaut werden?
|
||||||
|
- oder immer nur aus dem persistenten Read Model `CentralSalesRecords`?
|
||||||
|
|
||||||
|
Aktuell ist beides halb vorhanden.
|
||||||
|
|
||||||
|
### 5. Reporting-/Cockpit-Logik ist noch nicht voll verallgemeinert
|
||||||
|
|
||||||
|
Bei der Pruefung wurde gesehen:
|
||||||
|
|
||||||
|
- `ManagementCockpitService` enthaelt noch hartcodierte Jahreslogik fuer `2025` und `2026`
|
||||||
|
- die Rohsicht bleibt bewusst ohne CHF-Umrechnung
|
||||||
|
|
||||||
|
Das ist fuer den aktuellen Stand akzeptabel, zeigt aber:
|
||||||
|
|
||||||
|
- Reporting ist noch kein voll abstrahierter fachlicher Layer
|
||||||
|
|
||||||
|
## Empfohlenes Sollbild
|
||||||
|
|
||||||
|
Die naechste Architektur-Stufe sollte in diese Richtung gehen:
|
||||||
|
|
||||||
|
### 1. Klare Schichten
|
||||||
|
|
||||||
|
- UI:
|
||||||
|
- Razor nur fuer Interaktion, Anzeige, Formularzustand
|
||||||
|
- Application:
|
||||||
|
- Use Cases / Commands / Queries fuer Export, Config, SAP-Refresh, Wechselkurse, Standortpflege
|
||||||
|
- Domain / Fachlogik:
|
||||||
|
- Transformationen, Mappingregeln, Waehrungsumrechnung, Cockpit-Berechnungen
|
||||||
|
- Infrastructure:
|
||||||
|
- HANA, SAP Gateway, SQLite, SharePoint, Dateisystem
|
||||||
|
|
||||||
|
### 2. Versionierte Migrationen statt manueller Start-Reparaturen
|
||||||
|
|
||||||
|
Statt immer mehr Reparaturlogik beim App-Start:
|
||||||
|
|
||||||
|
- Schema-Aenderungen versionieren
|
||||||
|
- Migrationspfade testbar machen
|
||||||
|
- Startlogik nur noch fuer minimale Bootstrap-Aufgaben behalten
|
||||||
|
|
||||||
|
### 3. Config-Import als atomarer Vorgang
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- alles in einer Transaktion oder bewusst in klar getrennten Phasen
|
||||||
|
- kein halb geloeschter Zustand bei Fehlern
|
||||||
|
- `CentralSalesRecords` aus normalem Config-Import eher herausnehmen
|
||||||
|
|
||||||
|
### 4. Zentrale Export-Semantik entscheiden
|
||||||
|
|
||||||
|
Explizit festlegen:
|
||||||
|
|
||||||
|
- zentrale Datei immer aus `CentralSalesRecords`
|
||||||
|
oder
|
||||||
|
- zentrale Datei aus dem aktuellen Export-Snapshot
|
||||||
|
|
||||||
|
Danach die doppelte Semantik entfernen.
|
||||||
|
|
||||||
|
## Priorisierung aus Architektursicht
|
||||||
|
|
||||||
|
Wenn nach Stabilitaet priorisiert wird, dann in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. `DatabaseInitializationService` / Migrationspfad absichern
|
||||||
|
2. `ConfigTransferService.ImportJsonAsync` atomar und weniger destruktiv machen
|
||||||
|
3. Logik aus `Settings.razor` und `Standorte.razor` in Anwendungsservices verschieben
|
||||||
|
4. Export-Semantik fuer Konsolidierung vereinheitlichen
|
||||||
|
5. erst danach weitere Fachfeatures wie Cockpit-CHF, Budget, Gruppenlogik
|
||||||
|
|
||||||
|
## Kurzfazit
|
||||||
|
|
||||||
|
Die Architektur ist nicht schlecht. Das Grundmodell traegt.
|
||||||
|
|
||||||
|
Aber:
|
||||||
|
|
||||||
|
- sie ist noch nicht robust genug fuer ruhigen weiteren Ausbau ohne technische Konsolidierung
|
||||||
|
- die aktuelle Hauptgefahr liegt in Infrastruktur- und Persistenzlogik, nicht in den Fachfeatures
|
||||||
|
|
||||||
|
Fuer den naechsten Einstieg nach Absturz gilt daher:
|
||||||
|
|
||||||
|
1. zuerst diesen Architektur-Nachtrag lesen
|
||||||
|
2. dann `DatabaseInitializationService` und `ConfigTransferService` als Risikobloecke ansehen
|
||||||
|
3. neue Fachfeatures erst nach dieser technischen Konsolidierung beginnen
|
||||||
|
|
||||||
|
## Nachtrag HANA-/Standort-Workflow 2026-04-17
|
||||||
|
|
||||||
|
Nach der Architekturpruefung wurde der doppelte HANA-Workflow bereinigt.
|
||||||
|
|
||||||
|
### Altes Problem
|
||||||
|
|
||||||
|
Vorher gab es zwei konkurrierende Stellen fuer HANA-Konfiguration:
|
||||||
|
|
||||||
|
- oben eine eigene `HANA Server`-Verwaltung
|
||||||
|
- unten im Standortdialog noch einmal eine fast vollstaendige HANA-Verbindung
|
||||||
|
|
||||||
|
Dadurch war unklar:
|
||||||
|
|
||||||
|
- was die zentrale Wahrheit ist
|
||||||
|
- wann ein zentraler Server geaendert wird
|
||||||
|
- wann still ein separater Server pro Standort entsteht
|
||||||
|
|
||||||
|
### Neue Logik
|
||||||
|
|
||||||
|
Oben gilt jetzt:
|
||||||
|
|
||||||
|
- `HANA Server` ist zentrale HANA-Konfiguration pro Quellsystem
|
||||||
|
- aktuell relevant fuer:
|
||||||
|
- `BI1`
|
||||||
|
- `SAGE`
|
||||||
|
|
||||||
|
Unten im Standort gilt jetzt:
|
||||||
|
|
||||||
|
- Standort pflegt nur noch standortspezifische Daten
|
||||||
|
- `Schema`
|
||||||
|
- `TSC`
|
||||||
|
- `Land`
|
||||||
|
- `SourceSystem`
|
||||||
|
- optionale Username-/Password-Overrides
|
||||||
|
- die technische HANA-Verbindung kommt aus der zentralen Konfiguration des Quellsystems
|
||||||
|
|
||||||
|
### Technische Umsetzung
|
||||||
|
|
||||||
|
- `HanaServer` hat jetzt zusaetzlich `SourceSystem`
|
||||||
|
- `DatabaseInitializationService` stellt zentrale Eintraege fuer `BI1` und `SAGE` sicher
|
||||||
|
- bestehende verknuepfte HANA-Server werden dabei moeglichst auf `BI1` / `SAGE` gemappt
|
||||||
|
- `SiteExportService` baut HANA-Verbindungen jetzt aus der zentralen HANA-Konfiguration des Quellsystems
|
||||||
|
- `Settings.razor` testet BI1/SAGE nicht mehr ueber einen Beispiel-Standort, sondern ueber die zentrale HANA-Konfiguration
|
||||||
|
- `Standorte.razor` speichert im Standort fuer HANA-basierte Systeme keine eigene Vollverbindung mehr
|
||||||
|
|
||||||
|
### Wichtige Konsequenz
|
||||||
|
|
||||||
|
Fachlich gilt jetzt:
|
||||||
|
|
||||||
|
- oben = Standardkonfiguration pro Quellsystem
|
||||||
|
- unten = Standort + optionale Credential-Overrides
|
||||||
|
|
||||||
|
Das entspricht der gewuenschten Logik:
|
||||||
|
|
||||||
|
- gleiche BI1-/SAGE-Standorte koennen zentrale Verbindungswerte teilen
|
||||||
|
- Ausnahmen koennen weiter ueber Username-/Password-Overrides reagieren
|
||||||
|
|
||||||
|
### UI-Nachtrag
|
||||||
|
|
||||||
|
Die frueher doppelte und dadurch verwirrende UI wurde danach auch sichtbar bereinigt.
|
||||||
|
|
||||||
|
Aktueller UI-Stand:
|
||||||
|
|
||||||
|
- oben heisst der Bereich jetzt klar `Zentrale HANA-Konfiguration`
|
||||||
|
- im Standortdialog gibt es fuer HANA keine zweite technische Eingabestrecke mehr
|
||||||
|
- dort wird nur noch die aktive Zentralverbindung angezeigt
|
||||||
|
- Host, Port, SSL und technische Parameter werden explizit nach oben verwiesen
|
||||||
|
- der zentrale Verbindungstest in `Settings.razor` meldet jetzt sauber die zentrale HANA-Verbindung
|
||||||
|
|
||||||
|
## Nachtrag Quellsystem-Verwaltung 2026-04-17
|
||||||
|
|
||||||
|
Die bisher noch hart codierten Quellsystem-Listen wurden entfernt und durch echte Stammdaten ersetzt.
|
||||||
|
|
||||||
|
### Neuer Stand
|
||||||
|
|
||||||
|
- neues Modell `SourceSystemDefinition`
|
||||||
|
- Quellsysteme werden jetzt zentral in der DB gehalten statt in Razor-Arrays
|
||||||
|
- pro Quellsystem werden gepflegt:
|
||||||
|
- `Code`
|
||||||
|
- `DisplayName`
|
||||||
|
- `ConnectionKind`
|
||||||
|
- `IsActive`
|
||||||
|
- `CentralUsername`
|
||||||
|
- `CentralPassword`
|
||||||
|
|
||||||
|
### Neue GUI-Logik
|
||||||
|
|
||||||
|
- `Settings.razor` enthaelt jetzt eine pflegbare Quellsystem-Tabelle
|
||||||
|
- dort koennen Quellsysteme per GUI angelegt, bearbeitet und gespeichert werden
|
||||||
|
- Anschlussart ist nicht mehr implizit im Code, sondern pro Quellsystem konfigurierbar
|
||||||
|
- zentrale Zugangsdaten haengen jetzt am Quellsystem selbst
|
||||||
|
|
||||||
|
### Anschlussarten
|
||||||
|
|
||||||
|
Aktuell technisch vorgesehen:
|
||||||
|
|
||||||
|
- `HANA`
|
||||||
|
- `SAP_GATEWAY`
|
||||||
|
- `MANUAL_EXCEL`
|
||||||
|
|
||||||
|
Damit gilt:
|
||||||
|
|
||||||
|
- HANA-Konfiguration oben in `Standorte.razor` nur noch fuer Quellsysteme mit Anschlussart `HANA`
|
||||||
|
- Standort-Dropdown zieht seine Quellsysteme jetzt aus `SourceSystemDefinitions`
|
||||||
|
- Transformationsregeln ziehen ihre Quellsystem-Auswahl ebenfalls aus `SourceSystemDefinitions`
|
||||||
|
|
||||||
|
### Technische Umsetzung
|
||||||
|
|
||||||
|
- `AppDbContext` hat jetzt `DbSet<SourceSystemDefinition>`
|
||||||
|
- `DatabaseInitializationService` erzeugt und seedet `SourceSystemDefinitions`
|
||||||
|
- `SiteExportService` loest zentrale Credentials jetzt ueber `SourceSystemDefinition`
|
||||||
|
- `ConfigTransferService` exportiert/importiert jetzt auch `SourceSystemDefinitions`
|
||||||
|
|
||||||
|
### Verifikation
|
||||||
|
|
||||||
|
Nach dieser Umstellung geprueft:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dotnet build .\TrafagSalesExporter.csproj -v minimal
|
||||||
|
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- Build erfolgreich
|
||||||
|
- Tests erfolgreich
|
||||||
|
- `31/31` Tests gruen
|
||||||
|
|
||||||
|
### Bereinigung der Legacy-Credentials
|
||||||
|
|
||||||
|
Danach wurden auch die alten zentralen Credential-Felder technisch bereinigt.
|
||||||
|
|
||||||
|
Aktueller Stand:
|
||||||
|
|
||||||
|
- `ExportSettings` enthaelt keine alten Felder mehr fuer `SapUsername`, `Bi1Username`, `SageUsername` usw.
|
||||||
|
- der Config-Export schreibt zentrale Zugangsdaten nur noch ueber `SourceSystemDefinitions`
|
||||||
|
- `ConfigTransferService` hat keinen aktiven Legacy-Credential-Pfad mehr
|
||||||
|
- die fruehere Temp-Datei `standorte_numbered.tmp` wurde entfernt
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- bestehende DB-Spalten koennen physisch noch vorhanden sein, sind aber kein aktiver Codepfad mehr
|
||||||
|
- fuehrende Wahrheit fuer zentrale Zugangsdaten ist jetzt ausschliesslich `SourceSystemDefinition`
|
||||||
|
|
||||||
|
### Schema-Bereinigung
|
||||||
|
|
||||||
|
Danach wurde auch die SQLite-Schemabereinigung nachgezogen.
|
||||||
|
|
||||||
|
Aktueller Stand:
|
||||||
|
|
||||||
|
- `DatabaseInitializationService` erkennt alte Credential-Spalten in `ExportSettings`
|
||||||
|
- wenn diese Legacy-Spalten noch existieren, wird `ExportSettings` beim Start auf das neue Schema rekonstruiert
|
||||||
|
- erhalten bleiben nur die noch gueltigen Felder:
|
||||||
|
- `DateFilter`
|
||||||
|
- `TimerHour`
|
||||||
|
- `TimerMinute`
|
||||||
|
- `TimerEnabled`
|
||||||
|
- `DebugLoggingEnabled`
|
||||||
|
- `LocalSiteExportFolder`
|
||||||
|
- `LocalConsolidatedExportFolder`
|
||||||
|
|
||||||
|
Damit gilt jetzt:
|
||||||
|
|
||||||
|
- alte zentrale SAP/BI1/SAGE-Credentials sind nicht nur logisch entfernt
|
||||||
|
- sie werden bei bestehender DB auch aktiv aus dem `ExportSettings`-Schema entfernt
|
||||||
|
|
||||||
|
### Letzte Bereinigung HANA-Credentials
|
||||||
|
|
||||||
|
Danach wurde auch die letzte doppelte Credential-Stelle in der HANA-Verwaltung entfernt.
|
||||||
|
|
||||||
|
Aktueller Stand:
|
||||||
|
|
||||||
|
- zentrale HANA-Konfiguration speichert nur noch technische Verbindungsdaten
|
||||||
|
- `Host`
|
||||||
|
- `Port`
|
||||||
|
- `DatabaseName`
|
||||||
|
- `UseSsl`
|
||||||
|
- `ValidateCertificate`
|
||||||
|
- `AdditionalParams`
|
||||||
|
- Username/Password werden nicht mehr in der zentralen HANA-UI gepflegt
|
||||||
|
- HANA-Verbindungstests in `Standorte.razor` verwenden jetzt die zentralen Credentials aus `SourceSystemDefinition`
|
||||||
|
- `SiteExportService` faellt bei HANA nicht mehr auf in `HanaServer` gespeicherte Credentials zurueck
|
||||||
|
- `ConfigTransferService` exportiert/importiert fuer `HanaServer` keine Username-/Password-Werte mehr
|
||||||
|
- `DatabaseInitializationService` bereinigt bei bestehender DB auch das `HanaServers`-Schema und entfernt die Altspalten `Username` / `Password`
|
||||||
|
|
||||||
|
Die fachliche Reihenfolge ist jetzt eindeutig:
|
||||||
|
|
||||||
|
1. zentrale Credentials aus `SourceSystemDefinition`
|
||||||
|
2. optionale Override-Credentials am `Site`
|
||||||
|
3. technische HANA-Verbindung aus der zentralen HANA-Konfiguration
|
||||||
|
|
||||||
|
### EF-/SQLite-Fix
|
||||||
|
|
||||||
|
Beim ersten Lauf nach der Schema-Bereinigung trat noch ein Mapping-Fehler auf:
|
||||||
|
|
||||||
|
- `SQLite Error 1: 'no such column: h.Password'`
|
||||||
|
|
||||||
|
Ursache:
|
||||||
|
|
||||||
|
- `HanaServers`-Schema war bereits ohne `Username` / `Password`
|
||||||
|
- das EF-Modell `HanaServer` hat diese Properties aber noch als normale Spalten behandelt
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- `HanaServer.Username` und `HanaServer.Password` sind jetzt `[NotMapped]`
|
||||||
|
- damit bleiben sie fuer Laufzeit-Verbindungsaufbau und Tests nutzbar
|
||||||
|
- EF erwartet sie aber nicht mehr als Datenbankspalten
|
||||||
|
|
||||||
|
## Nachtrag Zentrale SAP-Steuerung 2026-04-17
|
||||||
|
|
||||||
|
Der verbleibende Architekturbruch bei SAP wurde ebenfalls bereinigt.
|
||||||
|
|
||||||
|
### Neuer Stand
|
||||||
|
|
||||||
|
- `SourceSystemDefinition` enthaelt jetzt auch `CentralServiceUrl`
|
||||||
|
- zentrale SAP-Service-URL wird damit am Quellsystem gepflegt, nicht mehr primaer am Standort
|
||||||
|
- `Standorte.razor` behandelt `SapServiceUrl` jetzt als Override
|
||||||
|
- wenn kein Override gesetzt ist, zieht SAP die URL zentral aus dem Quellsystem
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- `Settings.razor` hat fuer Quellsysteme jetzt eine Dialogbearbeitung statt nur Inline-Tabellenfelder
|
||||||
|
- dadurch ist das Quellsystem sauber editierbar
|
||||||
|
- fuer `SAP_GATEWAY` wird dort die zentrale SAP-Service-URL gepflegt
|
||||||
|
- `Standorte.razor` zeigt bei SAP jetzt:
|
||||||
|
- zentrale SAP Service URL
|
||||||
|
- optionales `SAP Service URL Override`
|
||||||
|
|
||||||
|
### Laufzeitlogik
|
||||||
|
|
||||||
|
- `SiteExportService` verwendet bei SAP die effektive URL aus
|
||||||
|
- Standort-Override
|
||||||
|
- sonst `SourceSystemDefinition.CentralServiceUrl`
|
||||||
|
- SAP-Verbindungstest in `Settings.razor` testet die zentrale URL direkt aus dem Quellsystem
|
||||||
|
- Dashboard zeigt fuer SAP jetzt ebenfalls die effektive zentrale bzw. ueberschriebene URL
|
||||||
|
|
||||||
|
### Verifikation
|
||||||
|
|
||||||
|
Nach der Umstellung geprueft:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dotnet build .\TrafagSalesExporter.csproj -v minimal
|
||||||
|
dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- Build erfolgreich
|
||||||
|
- Tests erfolgreich
|
||||||
|
- `31/31` Tests gruen
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-16
|
||||||
|
|
||||||
|
Seit dem letzten Handoff wurden weitere Funktionen umgesetzt, die unten im alten Stand noch nicht voll enthalten sind.
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert:
|
||||||
|
|
||||||
|
- `BI1` und `SAGE` bleiben auf direktem HANA-Zugriff
|
||||||
|
- `SAP` laeuft separat ueber SAP Gateway / OData
|
||||||
|
- SAP-Quellen koennen gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden
|
||||||
|
- Standort-Exporte werden lokal als Excel geschrieben
|
||||||
|
- zusaetzlich werden Datensaetze in eine zentrale SQLite-Tabelle geschrieben
|
||||||
|
- ein konsolidierter Export liest aus dieser zentralen Tabelle
|
||||||
|
|
||||||
|
## Wichtigste umgesetzte Funktionen
|
||||||
|
|
||||||
|
### 1. Zentrale Credentials pro Quellsystem
|
||||||
|
|
||||||
|
Es gibt zentrale Zugangsdaten in `ExportSettings` fuer:
|
||||||
|
|
||||||
|
- `SAP`
|
||||||
|
- `BI1`
|
||||||
|
- `SAGE`
|
||||||
|
|
||||||
|
Zusaetzlich gibt es pro Standort optionale Overrides:
|
||||||
|
|
||||||
|
- `UsernameOverride`
|
||||||
|
- `PasswordOverride`
|
||||||
|
|
||||||
|
Aufloesungsreihenfolge:
|
||||||
|
|
||||||
|
1. Standort-Override
|
||||||
|
2. zentrale Credentials des Quellsystems
|
||||||
|
3. bei HANA zusaetzlich Fallback auf alten `HanaServer.Username/Password`
|
||||||
|
|
||||||
|
### 2. SAP von BI1/HANA getrennt
|
||||||
|
|
||||||
|
`SAP` nutzt nicht mehr den HANA-Pfad, sondern eine eigene Gateway/OData-Strecke.
|
||||||
|
|
||||||
|
Pro SAP-Standort gibt es:
|
||||||
|
|
||||||
|
- `SapServiceUrl`
|
||||||
|
- `SapEntitySet`
|
||||||
|
- `SapEntitySetsCache`
|
||||||
|
- `SapEntitySetsRefreshedAtUtc`
|
||||||
|
|
||||||
|
Refresh der SAP-Quellen erfolgt nur auf Knopfdruck.
|
||||||
|
|
||||||
|
Beispiel Service URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- Service URL immer nur bis zum Service
|
||||||
|
- Entity Set separat auswaehlen
|
||||||
|
|
||||||
|
### 3. SAP-Quellen, Joins und Feldmappings
|
||||||
|
|
||||||
|
Fuer SAP gibt es mehrere neue Modelle:
|
||||||
|
|
||||||
|
- `SapSourceDefinition`
|
||||||
|
- `SapJoinDefinition`
|
||||||
|
- `SapFieldMapping`
|
||||||
|
|
||||||
|
Unterstuetzt wird:
|
||||||
|
|
||||||
|
- mehrere SAP-Quellen pro Standort
|
||||||
|
- Alias pro Quelle
|
||||||
|
- Primaerquelle
|
||||||
|
- Join-Definitionen
|
||||||
|
- Mapping von `Alias.Feldname` auf zentrales Schema
|
||||||
|
|
||||||
|
UI-Erweiterungen:
|
||||||
|
|
||||||
|
- `Quellen refreshen`
|
||||||
|
- `Felder aus Quellen laden`
|
||||||
|
- Join-Key-Auswahl aus Metadaten
|
||||||
|
- `Auto-Match` fuer gleiche Feldnamen zwischen Primaerquelle und anderen Quellen
|
||||||
|
|
||||||
|
### 4. Zentrale Datenspeicherung
|
||||||
|
|
||||||
|
Neue Tabelle:
|
||||||
|
|
||||||
|
- `CentralSalesRecords`
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
|
||||||
|
- pro Standort werden alte zentrale Saetze dieses Standorts ersetzt
|
||||||
|
- konsolidierte Excel liest aus `CentralSalesRecords`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- zentrale Excel wird nicht appendet
|
||||||
|
- sie wird aus dem aktuellen Zustand der zentralen Tabelle neu erstellt
|
||||||
|
|
||||||
|
### 5. Exportpfade
|
||||||
|
|
||||||
|
Neue Konfigurationsmoeglichkeiten:
|
||||||
|
|
||||||
|
Zentral in `Settings`:
|
||||||
|
|
||||||
|
- `LocalSiteExportFolder`
|
||||||
|
- `LocalConsolidatedExportFolder`
|
||||||
|
|
||||||
|
Pro Standort:
|
||||||
|
|
||||||
|
- `LocalExportFolderOverride`
|
||||||
|
|
||||||
|
Fallback wenn leer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
./output
|
||||||
|
```
|
||||||
|
|
||||||
|
relativ zum App-Verzeichnis.
|
||||||
|
|
||||||
|
### 6. SharePoint
|
||||||
|
|
||||||
|
SharePoint-Upload ist optional.
|
||||||
|
|
||||||
|
Wenn keine vollstaendige SharePoint-Konfiguration vorhanden ist:
|
||||||
|
|
||||||
|
- Excel wird trotzdem lokal erzeugt
|
||||||
|
- kein Upload nach SharePoint
|
||||||
|
|
||||||
|
Benoetigte SharePoint-Werte:
|
||||||
|
|
||||||
|
- `Tenant ID`
|
||||||
|
- `Client ID`
|
||||||
|
- `Client Secret`
|
||||||
|
|
||||||
|
Das sind Entra App Registration Werte, nicht normale Benutzer-Credentials.
|
||||||
|
|
||||||
|
### 7. Config Import/Export
|
||||||
|
|
||||||
|
Es gibt JSON-Import/Export der Konfiguration mit Checkbox:
|
||||||
|
|
||||||
|
- mit Secrets
|
||||||
|
- ohne Secrets
|
||||||
|
|
||||||
|
Enthalten sind u. a.:
|
||||||
|
|
||||||
|
- SharePoint Config
|
||||||
|
- ExportSettings
|
||||||
|
- HanaServers
|
||||||
|
- Sites
|
||||||
|
- Transformation Rules
|
||||||
|
- SAP-Quellen
|
||||||
|
- SAP-Joins
|
||||||
|
- SAP-Mappings
|
||||||
|
|
||||||
|
### 8. Logging und Live-Status
|
||||||
|
|
||||||
|
Neue technische Logs ueber `AppEventLogs`.
|
||||||
|
|
||||||
|
Sichtbar:
|
||||||
|
|
||||||
|
- auf `/logs`
|
||||||
|
- im Dashboard als `Live-Status`
|
||||||
|
|
||||||
|
Geloggt werden u. a.:
|
||||||
|
|
||||||
|
- HANA-Query Start
|
||||||
|
- SAP Refresh
|
||||||
|
- SAP Reads
|
||||||
|
- Transformationen
|
||||||
|
- Excel-Erstellung
|
||||||
|
- zentrale Tabellenspeicherung
|
||||||
|
- Export erfolgreich / fehlgeschlagen
|
||||||
|
|
||||||
|
### 9. Excel oeffnen
|
||||||
|
|
||||||
|
Im Dashboard gibt es neben `Export` den Button:
|
||||||
|
|
||||||
|
- `Excel oeffnen`
|
||||||
|
|
||||||
|
Dieser nutzt `ExportLogs.FilePath`.
|
||||||
|
|
||||||
|
Voraussetzungen:
|
||||||
|
|
||||||
|
- letzter Export erfolgreich
|
||||||
|
- `FilePath` gespeichert
|
||||||
|
- Datei existiert lokal
|
||||||
|
|
||||||
|
### 10. Management Cockpit
|
||||||
|
|
||||||
|
Es gibt einen neuen Menuepunkt:
|
||||||
|
|
||||||
|
- `Management Cockpit`
|
||||||
|
|
||||||
|
Funktion:
|
||||||
|
|
||||||
|
- Auswahl vorhandener Excel-Dateien
|
||||||
|
- Analyse einer exportierten Standort-Datei
|
||||||
|
- Kennzahlen fuer Geschaeftsinhaber / Management
|
||||||
|
|
||||||
|
Aktuell enthalten:
|
||||||
|
|
||||||
|
- Umsatz
|
||||||
|
- geschaetzte Kosten
|
||||||
|
- geschaetzte Marge
|
||||||
|
- Rechnungsanzahl
|
||||||
|
- Kundenanzahl
|
||||||
|
- Top Kunden
|
||||||
|
- Top Produktgruppen
|
||||||
|
- Top Sales Owner
|
||||||
|
- Datenqualitaetshinweise
|
||||||
|
- automatische Management-Aussagen
|
||||||
|
|
||||||
|
### 11. Manueller Excel-Import pro Standort
|
||||||
|
|
||||||
|
Es gibt jetzt einen vierten `SourceSystem`-Typ:
|
||||||
|
|
||||||
|
- `MANUAL_EXCEL`
|
||||||
|
|
||||||
|
Gedanke:
|
||||||
|
|
||||||
|
- Standort ohne Netz-/Systemanbindung liefert nur Excel
|
||||||
|
- Datei wird im Standort hochgeladen
|
||||||
|
- Export liest diese Datei statt SAP/HANA
|
||||||
|
- Daten werden in `CentralSalesRecords` fuer diesen Standort ersetzt
|
||||||
|
- der zentrale Export liest weiter nur aus `CentralSalesRecords`
|
||||||
|
|
||||||
|
Neue Site-Felder:
|
||||||
|
|
||||||
|
- `ManualImportFilePath`
|
||||||
|
- `ManualImportLastUploadedAtUtc`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- das ist kein Excel-zu-Excel-Merge
|
||||||
|
- die App importiert ins zentrale Schema und erzeugt danach die zentrale Datei neu
|
||||||
|
|
||||||
|
### 12. Dashboard erweitert
|
||||||
|
|
||||||
|
Im Dashboard gibt es jetzt zusaetzlich:
|
||||||
|
|
||||||
|
- separaten Bereich `Zentrale Datei`
|
||||||
|
- `Excel oeffnen` fuer die neueste zentrale Datei `Sales_All_*.xlsx`
|
||||||
|
- Button `Alle exportieren`
|
||||||
|
- Button `Zentrale Datei neu erzeugen`
|
||||||
|
|
||||||
|
Bedeutung:
|
||||||
|
|
||||||
|
- `Alle exportieren` liest alle Quellen neu und erzeugt danach die zentrale Datei
|
||||||
|
- `Zentrale Datei neu erzeugen` schreibt nur aus `CentralSalesRecords` eine neue zentrale Excel
|
||||||
|
|
||||||
|
### 13. Management Cockpit Roh-Auswertung aus Zentraldaten
|
||||||
|
|
||||||
|
Zusaetzlich zur dateibasierten Cockpit-Analyse gibt es jetzt eine Roh-Auswertung direkt aus `CentralSalesRecords`.
|
||||||
|
|
||||||
|
Aktuell umgesetzt:
|
||||||
|
|
||||||
|
- Auswahl Jahr
|
||||||
|
- optional Auswahl Monat
|
||||||
|
- Jahresumsatz
|
||||||
|
- Monatsumsatz
|
||||||
|
- Tagesumsatz im gewaehlten Monat
|
||||||
|
- Umsatz nach Quelle
|
||||||
|
- Umsatz nach Land
|
||||||
|
- Periodenabdeckung / Zeilen / Rechnungen / Standorte / Laender / Waehrungen
|
||||||
|
|
||||||
|
Bewusst noch nicht enthalten:
|
||||||
|
|
||||||
|
- kein Intercompany-Filter
|
||||||
|
- keine CHF-Umrechnung
|
||||||
|
- kein Budgetvergleich
|
||||||
|
- keine Spartenlogik
|
||||||
|
- keine Gruppenlogik
|
||||||
|
- keine Margenlogik
|
||||||
|
|
||||||
|
### 14. Transformationssystem erweitert
|
||||||
|
|
||||||
|
Das Transformationssystem kann jetzt zwei Ebenen:
|
||||||
|
|
||||||
|
- `Value` fuer einfache feldweise Regeln aus der GUI
|
||||||
|
- `Record` fuer komplexere C#-Strategien per Strategy Pattern
|
||||||
|
|
||||||
|
Umgesetzt:
|
||||||
|
|
||||||
|
- neues Feld `RuleScope` auf `FieldTransformationRule`
|
||||||
|
- dynamischer Strategiekatalog
|
||||||
|
- GUI liest verfuegbare Typen aus dem Katalog
|
||||||
|
- erste `Record`-Strategie: `FirstNonEmpty`
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
- `TargetField = CustomerName`
|
||||||
|
- `TransformationType = FirstNonEmpty`
|
||||||
|
- `Argument = CustomerName|SupplierName|Name`
|
||||||
|
|
||||||
|
### 15. Schema-Lookup fuer HANA-Standorte
|
||||||
|
|
||||||
|
Im Standortdialog fuer HANA-basierte Standorte gibt es jetzt:
|
||||||
|
|
||||||
|
- Button `Schemas laden`
|
||||||
|
- Lookup mit gueltigen Schemas aus HANA
|
||||||
|
|
||||||
|
Die Liste wird nicht blind aus allen Schemas gelesen, sondern auf typische B1-Schemas eingeschraenkt, in denen z. B. Tabellen wie
|
||||||
|
|
||||||
|
- `OINV`
|
||||||
|
- `INV1`
|
||||||
|
- `ORIN`
|
||||||
|
- `RIN1`
|
||||||
|
- `OCRD`
|
||||||
|
- `OITM`
|
||||||
|
|
||||||
|
vorhanden sind.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- manuelle Eingabe bleibt moeglich
|
||||||
|
- fuer `BI1` und `SAGE` werden beim Lookup die effektiven Credentials inkl. zentraler Zugangsdaten / Overrides verwendet
|
||||||
|
- das reduziert Fehler wie `invalid schema name`
|
||||||
|
|
||||||
|
### 16. Testabdeckung ausgebaut
|
||||||
|
|
||||||
|
Es gibt jetzt ein separates Testprojekt:
|
||||||
|
|
||||||
|
- `TrafagSalesExporter.Tests`
|
||||||
|
|
||||||
|
Automatisiert getestet werden aktuell:
|
||||||
|
|
||||||
|
- Transformationsstrategien
|
||||||
|
- `RecordTransformationService`
|
||||||
|
- `TransformationCatalog`
|
||||||
|
- `ManualExcelImportService`
|
||||||
|
- `ManagementCockpitService`
|
||||||
|
- `ConfigTransferService`
|
||||||
|
|
||||||
|
Wichtiger bereits gefundener Bug:
|
||||||
|
|
||||||
|
- deutsches Dezimalformat wie `1,50` wurde im manuellen Excel-Import falsch interpretiert
|
||||||
|
- Parsing wurde korrigiert
|
||||||
|
|
||||||
|
## Wichtige Dateien
|
||||||
|
|
||||||
|
### Modelle
|
||||||
|
|
||||||
|
- `Models/Site.cs`
|
||||||
|
- `Models/ExportSettings.cs`
|
||||||
|
- `Models/ExportLog.cs`
|
||||||
|
- `Models/CentralSalesRecord.cs`
|
||||||
|
- `Models/SapSourceDefinition.cs`
|
||||||
|
- `Models/SapJoinDefinition.cs`
|
||||||
|
- `Models/SapFieldMapping.cs`
|
||||||
|
- `Models/ManagementCockpitModels.cs`
|
||||||
|
- `Models/ConfigTransferPackage.cs`
|
||||||
|
- `Models/FieldTransformationRule.cs`
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- `Services/SiteExportService.cs`
|
||||||
|
- `Services/ConsolidatedExportService.cs`
|
||||||
|
- `Services/CentralSalesRecordService.cs`
|
||||||
|
- `Services/SapGatewayService.cs`
|
||||||
|
- `Services/SapCompositionService.cs`
|
||||||
|
- `Services/ConfigTransferService.cs`
|
||||||
|
- `Services/AppEventLogService.cs`
|
||||||
|
- `Services/ManagementCockpitService.cs`
|
||||||
|
- `Services/DatabaseInitializationService.cs`
|
||||||
|
- `Services/ExportOrchestrationService.cs`
|
||||||
|
- `Services/ManualExcelImportService.cs`
|
||||||
|
- `Services/TransformationCatalog.cs`
|
||||||
|
- `Services/RecordTransformationService.cs`
|
||||||
|
- `Services/TransformationStrategies.cs`
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
- `Components/Pages/Settings.razor`
|
||||||
|
- `Components/Pages/Dashboard.razor`
|
||||||
|
- `Components/Pages/Logs.razor`
|
||||||
|
- `Components/Pages/ManagementCockpit.razor`
|
||||||
|
- `Components/Pages/Transformations.razor`
|
||||||
|
- `Components/Layout/NavMenu.razor`
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `TrafagSalesExporter.Tests/TransformationStrategiesTests.cs`
|
||||||
|
- `TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs`
|
||||||
|
- `TrafagSalesExporter.Tests/TransformationCatalogTests.cs`
|
||||||
|
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
|
||||||
|
- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
|
||||||
|
- `TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs`
|
||||||
|
|
||||||
|
## Datenbank / Migrationen
|
||||||
|
|
||||||
|
Viele Aenderungen laufen ueber `DatabaseInitializationService`.
|
||||||
|
|
||||||
|
Wichtige neue oder erweiterte Tabellen/Felder:
|
||||||
|
|
||||||
|
- `Sites`
|
||||||
|
- `UsernameOverride`
|
||||||
|
- `PasswordOverride`
|
||||||
|
- `SapServiceUrl`
|
||||||
|
- `SapEntitySet`
|
||||||
|
- `SapEntitySetsCache`
|
||||||
|
- `SapEntitySetsRefreshedAtUtc`
|
||||||
|
- `LocalExportFolderOverride`
|
||||||
|
- `ManualImportFilePath`
|
||||||
|
- `ManualImportLastUploadedAtUtc`
|
||||||
|
- `ExportSettings`
|
||||||
|
- zentrale SAP/BI1/SAGE Credentials
|
||||||
|
- `LocalSiteExportFolder`
|
||||||
|
- `LocalConsolidatedExportFolder`
|
||||||
|
- `DebugLoggingEnabled`
|
||||||
|
- `FieldTransformationRules`
|
||||||
|
- `RuleScope`
|
||||||
|
- `ExportLogs`
|
||||||
|
- `FilePath`
|
||||||
|
- neue Tabellen:
|
||||||
|
- `AppEventLogs`
|
||||||
|
- `CentralSalesRecords`
|
||||||
|
- SAP-Konfigtabellen
|
||||||
|
|
||||||
|
## Letztes Hauptproblem und Loesung
|
||||||
|
|
||||||
|
### Export hing nach zentraler Speicherung
|
||||||
|
|
||||||
|
Der Export blieb zuletzt nach
|
||||||
|
|
||||||
|
- `Zentrale Tabelle: 20106 Datensaetze gespeichert.`
|
||||||
|
|
||||||
|
haengen.
|
||||||
|
|
||||||
|
Die eigentliche Ursache war am Ende nicht mehr der Batch-Insert selbst, sondern ein kaputter SQLite-Schemazustand:
|
||||||
|
|
||||||
|
- mindestens eine Tabelle referenzierte per FK noch `main.Sites_old`
|
||||||
|
- dadurch scheiterte `SaveChangesAsync()` spaeter beim Schreiben in `AppEventLogs` oder `ExportLogs`
|
||||||
|
- die alte Tabelle `Sites_old` existierte nicht mehr
|
||||||
|
|
||||||
|
Beobachteter Fehler:
|
||||||
|
|
||||||
|
- `SQLite Error 1: 'no such table: main.Sites_old'`
|
||||||
|
|
||||||
|
## Umgesetzte Korrekturen
|
||||||
|
|
||||||
|
- `Components/Pages/Dashboard.razor`
|
||||||
|
- Live-Status pollt waehrend laufendem Export nicht mehr permanent `AppEventLogs`
|
||||||
|
- stattdessen Anzeige ueber den In-Memory-Status aus `ExportOrchestrationService`
|
||||||
|
- `Program.cs`
|
||||||
|
- SQLite `Default Timeout` von `10` auf `60` erhoeht
|
||||||
|
- `Services/CentralSalesRecordService.cs`
|
||||||
|
- nach abgeschlossenem Batch-Insert wird explizit `Zentrale Tabelle aktualisiert` gesetzt
|
||||||
|
- `Services/DatabaseInitializationService.cs`
|
||||||
|
- automatische Reparaturlogik fuer Tabellen, deren `CREATE TABLE`-SQL noch `Sites_old` referenziert
|
||||||
|
- betroffene Tabellen werden beim Start neu aufgebaut und Daten rueberkopiert
|
||||||
|
|
||||||
|
Danach wurde der Export erfolgreich getestet und geht jetzt wieder durch.
|
||||||
|
|
||||||
|
## Was bei einer naechsten Stoerung zuerst zu pruefen ist
|
||||||
|
|
||||||
|
1. Tritt beim App-Start die Schema-Reparatur sauber durch?
|
||||||
|
2. Gibt es noch weitere Tabellen mit FK-Referenz auf `Sites_old`?
|
||||||
|
3. Erst danach wieder Insert-/Commit-Batches der zentralen Speicherung untersuchen
|
||||||
|
|
||||||
|
## Build-Status
|
||||||
|
|
||||||
|
Letzter Build:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dotnet build TrafagSalesExporter.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis:
|
||||||
|
|
||||||
|
- erfolgreich
|
||||||
|
- bekannte Warnungen bleiben:
|
||||||
|
- SAP HANA Architekturwarnung `MSB3270`
|
||||||
|
- MudBlazor Analyzer `Dense`
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-17 UI-Klarstellung HANA vs. SAP
|
||||||
|
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
- Bereich oben heisst jetzt bewusst `Zentrale HANA-Technik`
|
||||||
|
- Hinweistext stellt klar: dort erscheinen nur Quellsysteme mit Anschlussart `HANA`
|
||||||
|
- `SAP` wird zentral unter `Settings -> Quellsysteme` gepflegt und gehoert nicht in diese Box
|
||||||
|
- der irrefuehrende Button `Server hinzufuegen` wurde entfernt
|
||||||
|
- neue HANA-Zeilen entstehen aus den Quellsystem-Stammdaten, nicht mehr aus einer zweiten UI-Erfassung
|
||||||
|
- Dialogtitel fuer HANA wurde auf reine Bearbeitung der zentralen Technik reduziert
|
||||||
|
|
||||||
|
Fachliche Regel jetzt:
|
||||||
|
|
||||||
|
- `Quellsysteme` verwalten die zentralen Systeme und deren Anschlussart
|
||||||
|
- `Standorte` zeigen fuer HANA nur noch die technische Zentralverbindung
|
||||||
|
- `SAP` wird nicht mehr implizit in der HANA-Box erwartet
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-17 Pruefung Config-Import/Export
|
||||||
|
|
||||||
|
Der aktuelle Config-Transfer wurde nach den Umbauten nochmals geprueft.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- Das aktuelle Import-/Exportformat passt zum neuen Modell.
|
||||||
|
- `SourceSystemDefinitions` werden mit `ConnectionKind`, `CentralServiceUrl`, `CentralUsername`, `CentralPassword` importiert/exportiert.
|
||||||
|
- `HanaServers` enthalten nur noch technische HANA-Verbindungsdaten und keine Credentials mehr.
|
||||||
|
- Standort-Overrides fuer Username/Password sowie SAP Service URL gehen weiterhin mit.
|
||||||
|
- Die vorhandenen `ConfigTransferServiceTests` laufen grün.
|
||||||
|
|
||||||
|
Weiterhin offene Architekturpunkte:
|
||||||
|
|
||||||
|
- `ConfigTransferService.ImportJsonAsync` ist weiterhin destruktiv und nicht atomar.
|
||||||
|
- Erst werden bestehende Daten geloescht, danach wird in mehreren Schritten neu aufgebaut.
|
||||||
|
- Wenn der Import in der Mitte scheitert, bleibt ein teilweiser Zustand zurueck.
|
||||||
|
- Altformat-Risiko bei `ConnectionKind`:
|
||||||
|
- Wenn ein aelteres JSON bereits `SourceSystemDefinitions` enthaelt, aber noch ohne `ConnectionKind`, faellt der DTO-Default auf `HANA`.
|
||||||
|
- Dadurch koennte ein altes `SAP` beim Import falsch als `HANA` landen.
|
||||||
|
|
||||||
|
Fazit:
|
||||||
|
|
||||||
|
- Fuer Exporte aus dem aktuellen Stand ist der Config-Transfer konsistent.
|
||||||
|
- Fuer aeltere JSON-Staende braucht der Import noch eine explizite Migrations-/Fallback-Logik.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Handoff: DataSourceAdapter-Refactoring (2026-04-17)
|
||||||
|
|
||||||
|
**Branch:** `claude/review-trafag-tool-JONMq`
|
||||||
|
**Commit:** `82ac7df` ("DataSourceAdapter-Pattern + SiteExportService schlanker + Page-Services Scoped")
|
||||||
|
**Basis:** `main` @ `2a56ba5` (umfangreiches refactoring)
|
||||||
|
|
||||||
|
## Kontext fuer den naechsten LLM
|
||||||
|
|
||||||
|
Vorheriges Review hatte drei Architektur-Punkte beanstandet:
|
||||||
|
1. `SiteExportService` war zu gross (338 Zeilen, if/else auf ConnectionKind)
|
||||||
|
2. Fehlende Adapter-Abstraktion fuer Datenquellen (HANA / SAP_GATEWAY / MANUAL_EXCEL)
|
||||||
|
3. Alle Services Singleton, auch UI-nahe Page-Services
|
||||||
|
|
||||||
|
Dieses Refactoring adressiert alle drei Punkte. **Nicht** im Scope (absichtlich offen gelassen):
|
||||||
|
- SQL-Injection-Risiko in `HanaQueryService:191,204`
|
||||||
|
- `.GetAwaiter().GetResult()` Blocking in `HanaQueryService`
|
||||||
|
- Secret-Store-Integration
|
||||||
|
- Retry/Polly
|
||||||
|
|
||||||
|
## Was konkret geaendert wurde
|
||||||
|
|
||||||
|
### Neu: `Services/DataSources/`
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `IDataSourceAdapter.cs` | Interface mit `ConnectionKind` + `FetchAsync(context)` |
|
||||||
|
| `DataSourceFetchContext.cs` | Input: Site, SourceDefinition, Settings, SharePointConfig, UpdateStatus |
|
||||||
|
| `DataSourceFetchResult.cs` | Output: Records + optionaler `ReferenceFilePath` (Manual Excel liefert Quell-Datei als Referenz) |
|
||||||
|
| `IDataSourceAdapterResolver.cs` + `DataSourceAdapterResolver.cs` | Dictionary-Lookup nach ConnectionKind |
|
||||||
|
| `HanaDataSourceAdapter.cs` | Baut `HanaServer` aus zentraler Config + Site-Overrides, ruft `IHanaQueryService.GetSalesRecords` |
|
||||||
|
| `SapGatewayDataSourceAdapter.cs` | Laedt SapSources/Joins/Mappings, ruft `ISapCompositionService.BuildSalesRecordsAsync` |
|
||||||
|
| `ManualExcelDataSourceAdapter.cs` | Lokale Datei oder SharePoint-Download, ruft `IManualExcelImportService.ReadSalesRecordsAsync` |
|
||||||
|
| `DataSourceCredentials.cs` | Interner Helper (FirstNonEmpty, Resolve, ResolveSapServiceUrl) |
|
||||||
|
|
||||||
|
### Geaendert: `Services/SiteExportService.cs`
|
||||||
|
338 -> 187 Zeilen. Jetzt reine Pipeline:
|
||||||
|
```
|
||||||
|
1. NormalizeSourceSystem
|
||||||
|
2. LoadExportConfigAsync (settings, spConfig, sourceDefinition, rules) - 1x DbContext
|
||||||
|
3. Resolve adapter per ConnectionKind
|
||||||
|
4. adapter.FetchAsync -> records (+ optional ReferenceFilePath)
|
||||||
|
5. Transform (_transformationService.Apply)
|
||||||
|
6. Excel erzeugen (falls Adapter keine Referenzdatei liefert)
|
||||||
|
7. CentralSalesRecordService.ReplaceForSiteAsync
|
||||||
|
8. UploadToSharePointIfConfiguredAsync
|
||||||
|
```
|
||||||
|
Entferntes Dead-Injection: `ISapGatewayService` (wurde konstruiert aber nie benutzt).
|
||||||
|
|
||||||
|
### Geaendert: `Program.cs`
|
||||||
|
- Adapter registriert (3x `AddSingleton<IDataSourceAdapter, ...>` + Resolver)
|
||||||
|
- **Page-Services auf Scoped** (`ISettingsPageService`, `IStandortePageService`, `IStandorteSapEditorService`, `IManagementCockpitPageService`, `IDashboardPageService`, `ILogsPageService`, `ITransformationsPageService`) — pro Blazor-Circuit
|
||||||
|
- `ExportOrchestrationService` bleibt bewusst Singleton (geteilter Export-Status ueber Circuits via `OnExportStatusChanged`)
|
||||||
|
- Stateless Connector-/Infra-Services bleiben Singleton
|
||||||
|
|
||||||
|
## Was der naechste LLM pruefen / testen soll
|
||||||
|
|
||||||
|
### 1. Build (ICH KONNTE NICHT BAUEN — kein dotnet SDK in der Sandbox)
|
||||||
|
```bash
|
||||||
|
cd TrafagSalesExporter
|
||||||
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
Falls Fehler: hohe Wahrscheinlichkeit, dass ich ein `using` vergessen oder einen Interface-Namen vertippt habe. Kandidaten fuer Tippfehler: `DataSourceCredentials.FirstNonEmpty` in `SiteExportService.cs:181`, Adapter-Constructoren in `Services/DataSources/*.cs`.
|
||||||
|
|
||||||
|
### 2. Tests laufen lassen
|
||||||
|
```bash
|
||||||
|
cd TrafagSalesExporter
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
Bestehende Tests in `TrafagSalesExporter.Tests/` referenzieren **keinen** der refactorierten Services direkt (siehe grep: `SiteExportService|IDataSource` liefert keine Treffer in Tests). Sollten also gruen bleiben.
|
||||||
|
|
||||||
|
### 3. Manueller Smoke-Test der drei Quellsysteme
|
||||||
|
In der Blazor-UI (Standorte-Seite, Export-Button):
|
||||||
|
- **HANA-Standort**: Export starten — muss wie vorher Records aus HANA ziehen, Excel erzeugen, zentrale Tabelle aktualisieren, optional nach SharePoint uploaden.
|
||||||
|
- **SAP_GATEWAY-Standort**: Export starten — muss SAP-Quellen/Joins/Mappings laden, Records ueber `SapCompositionService` bauen.
|
||||||
|
- **MANUAL_EXCEL-Standort** (lokaler Pfad): Referenz-Excel wird gelesen, **keine** neue Excel-Datei erzeugt (Referenzdatei bleibt).
|
||||||
|
- **MANUAL_EXCEL-Standort** (SharePoint-Pfad, `/Shared Documents/...`): temporaerer Download, lesen, Temp-Datei wird im `finally` wieder geloescht.
|
||||||
|
|
||||||
|
**Verhaltens-Aequivalenz** zur vorherigen Implementierung ist das Pruefkriterium — keine neue Funktionalitaet, nur Struktur.
|
||||||
|
|
||||||
|
### 4. Captive-Dependency-Check
|
||||||
|
Scoped -> Singleton wuerde DI-Fehler werfen. Ich habe per grep verifiziert, dass kein Singleton eine `I*PageService` konsumiert. Wer das nochmal manuell pruefen moechte:
|
||||||
|
```bash
|
||||||
|
grep -rn "PageService" TrafagSalesExporter/Services/ | grep -v "PageService.cs"
|
||||||
|
```
|
||||||
|
Sollte nur Registrierungen in Program.cs und UI-Komponenten zeigen.
|
||||||
|
|
||||||
|
### 5. Erweiterbarkeit testen
|
||||||
|
Um ein viertes Quellsystem hinzuzufuegen, reicht jetzt:
|
||||||
|
1. Konstante in `Models/SourceSystemDefinition.cs::SourceSystemConnectionKinds`
|
||||||
|
2. Neuer `IDataSourceAdapter` in `Services/DataSources/`
|
||||||
|
3. `builder.Services.AddSingleton<IDataSourceAdapter, NeuerAdapter>();` in `Program.cs`
|
||||||
|
|
||||||
|
Kein Eingriff in `SiteExportService` noetig.
|
||||||
|
|
||||||
|
## Offene Themen fuer Follow-up-PRs
|
||||||
|
|
||||||
|
1. **SQL-Injection (kritisch)** — `HanaQueryService.cs:191,204`: `schema`, `tsc`, `dateFilter` via String-Interpolation. Auf `HanaCommand`-Parameter umstellen (Beispiel: `GetAvailableSchemas()` nutzt das bereits korrekt).
|
||||||
|
2. **Blocking async** — `HanaQueryService` hat 8x `.GetAwaiter().GetResult()`. In Blazor Server Deadlock-Risiko — auf echtes `async/await` migrieren.
|
||||||
|
3. **Tests fuer Adapter** — Unit-Tests fuer die drei neuen Adapter mit Fakes der Connector-Services waeren sinnvoll. `DataSourceAdapterResolver`-Test (Dictionary-Lookup, Fehler bei unbekanntem Kind) einfach zu schreiben.
|
||||||
|
4. **Retry-Layer** — HTTP-Requests zu SharePoint/SAP Gateway ohne Polly. Bei Netzflackern bricht Export ab.
|
||||||
|
|
||||||
|
## Dateien-Cheatsheet
|
||||||
|
|
||||||
|
```
|
||||||
|
TrafagSalesExporter/
|
||||||
|
├── Program.cs [MOD: Lifetimes + Adapter-Registrierung]
|
||||||
|
├── Services/
|
||||||
|
│ ├── SiteExportService.cs [MOD: 338 -> 187 Zeilen, pure Pipeline]
|
||||||
|
│ └── DataSources/ [NEU]
|
||||||
|
│ ├── IDataSourceAdapter.cs
|
||||||
|
│ ├── IDataSourceAdapterResolver.cs
|
||||||
|
│ ├── DataSourceAdapterResolver.cs
|
||||||
|
│ ├── DataSourceFetchContext.cs
|
||||||
|
│ ├── DataSourceFetchResult.cs
|
||||||
|
│ ├── DataSourceCredentials.cs
|
||||||
|
│ ├── HanaDataSourceAdapter.cs
|
||||||
|
│ ├── SapGatewayDataSourceAdapter.cs
|
||||||
|
│ └── ManualExcelDataSourceAdapter.cs
|
||||||
|
```
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# TrafagSalesExporter LLM System Guide
|
||||||
|
|
||||||
|
Stand: 2026-04-17
|
||||||
|
|
||||||
|
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
|
||||||
|
|
||||||
|
## Zweck des Systems
|
||||||
|
|
||||||
|
`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt.
|
||||||
|
|
||||||
|
Quellsysteme:
|
||||||
|
|
||||||
|
- `HANA`-basierte Systeme wie `BI1` und `SAGE`
|
||||||
|
- `SAP_GATEWAY` ueber OData
|
||||||
|
- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien
|
||||||
|
|
||||||
|
Zielbild:
|
||||||
|
|
||||||
|
- jede Quelle wird in `SalesRecord` normalisiert
|
||||||
|
- Standortdaten koennen lokal als Excel exportiert werden
|
||||||
|
- alle Datensaetze werden in `CentralSalesRecords` gespeichert
|
||||||
|
- eine zentrale konsolidierte Datei wird aus dem zentralen Datenbestand erzeugt
|
||||||
|
- ein `Management Cockpit` analysiert sowohl exportierte Dateien als auch zentrale Rohdaten
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
- UI: Blazor Server + MudBlazor
|
||||||
|
- Datenbank: SQLite (`trafag_exporter.db`)
|
||||||
|
- Excel lesen/schreiben: ClosedXML
|
||||||
|
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
|
||||||
|
- SAP Gateway / OData: eigener Service ueber HTTP
|
||||||
|
- SharePoint Upload/Download: Microsoft Graph + Azure Identity
|
||||||
|
- Tests: xUnit
|
||||||
|
|
||||||
|
## Einstiegspunkte
|
||||||
|
|
||||||
|
Wichtige Dateien:
|
||||||
|
|
||||||
|
- [Program.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Program.cs)
|
||||||
|
- [Data/AppDbContext.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Data/AppDbContext.cs)
|
||||||
|
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
|
||||||
|
|
||||||
|
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
|
||||||
|
|
||||||
|
## Hauptseiten
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
|
||||||
|
- `/` Dashboard
|
||||||
|
- `/standorte`
|
||||||
|
- `/transformations`
|
||||||
|
- `/management-cockpit`
|
||||||
|
- `/settings`
|
||||||
|
- `/logs`
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- [Components/Pages/Dashboard.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Dashboard.razor)
|
||||||
|
- [Components/Pages/Standorte.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Standorte.razor)
|
||||||
|
- [Components/Pages/Transformations.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Transformations.razor)
|
||||||
|
- [Components/Pages/ManagementCockpit.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor)
|
||||||
|
- [Components/Pages/Settings.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Settings.razor)
|
||||||
|
- [Components/Pages/Logs.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Logs.razor)
|
||||||
|
|
||||||
|
Kurzrollen:
|
||||||
|
|
||||||
|
- `Dashboard`: Einzel-Export, Alle exportieren, zentrale Datei neu erzeugen, Live-Status
|
||||||
|
- `Standorte`: Standortpflege, zentrale HANA-Technik, SAP-Konfiguration pro Standort, manueller Excel-Import
|
||||||
|
- `Transformations`: feldweise und record-basierte Regeln
|
||||||
|
- `Management Cockpit`: Dateianalyse und Rohanalyse aus `CentralSalesRecords`
|
||||||
|
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
|
||||||
|
- `Logs`: technische Ereignisprotokolle
|
||||||
|
|
||||||
|
## Kernmodelle
|
||||||
|
|
||||||
|
Wichtige Entity-Klassen:
|
||||||
|
|
||||||
|
- [Models/Site.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/Site.cs)
|
||||||
|
- [Models/SourceSystemDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SourceSystemDefinition.cs)
|
||||||
|
- [Models/HanaServer.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/HanaServer.cs)
|
||||||
|
- [Models/SalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SalesRecord.cs)
|
||||||
|
- [Models/CentralSalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CentralSalesRecord.cs)
|
||||||
|
- [Models/FieldTransformationRule.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/FieldTransformationRule.cs)
|
||||||
|
- [Models/SapSourceDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapSourceDefinition.cs)
|
||||||
|
- [Models/SapJoinDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapJoinDefinition.cs)
|
||||||
|
- [Models/SapFieldMapping.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapFieldMapping.cs)
|
||||||
|
- [Models/SharePointConfig.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SharePointConfig.cs)
|
||||||
|
- [Models/ExportSettings.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportSettings.cs)
|
||||||
|
- [Models/ExportLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportLog.cs)
|
||||||
|
- [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs)
|
||||||
|
- [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs)
|
||||||
|
|
||||||
|
Wichtige Relationen:
|
||||||
|
|
||||||
|
- `Site -> HanaServer` optional
|
||||||
|
- `Site -> SapSourceDefinitions`
|
||||||
|
- `Site -> SapJoinDefinitions`
|
||||||
|
- `Site -> SapFieldMappings`
|
||||||
|
- `Site -> CentralSalesRecords`
|
||||||
|
- `SourceSystemDefinition` ist zentrale Stammdatenquelle fuer Quellsysteme
|
||||||
|
|
||||||
|
## Datenbanktabellen
|
||||||
|
|
||||||
|
`AppDbContext` enthaelt:
|
||||||
|
|
||||||
|
- `HanaServers`
|
||||||
|
- `SourceSystemDefinitions`
|
||||||
|
- `Sites`
|
||||||
|
- `SharePointConfigs`
|
||||||
|
- `ExportSettings`
|
||||||
|
- `ExportLogs`
|
||||||
|
- `AppEventLogs`
|
||||||
|
- `FieldTransformationRules`
|
||||||
|
- `CurrencyExchangeRates`
|
||||||
|
- `SapSourceDefinitions`
|
||||||
|
- `SapJoinDefinitions`
|
||||||
|
- `SapFieldMappings`
|
||||||
|
- `CentralSalesRecords`
|
||||||
|
|
||||||
|
## Architekturrollen der Services
|
||||||
|
|
||||||
|
### Export / Orchestrierung
|
||||||
|
|
||||||
|
- [Services/ExportOrchestrationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportOrchestrationService.cs)
|
||||||
|
- [Services/SiteExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SiteExportService.cs)
|
||||||
|
- [Services/ConsolidatedExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConsolidatedExportService.cs)
|
||||||
|
- [Services/CentralSalesRecordService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CentralSalesRecordService.cs)
|
||||||
|
- [Services/ExportLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportLogService.cs)
|
||||||
|
|
||||||
|
Rollen:
|
||||||
|
|
||||||
|
- `ExportOrchestrationService` steuert UI-nahe Exportlaeufe und Live-Status
|
||||||
|
- `SiteExportService` entscheidet anhand des Quellsystems, wie ein Standort gelesen wird
|
||||||
|
- `CentralSalesRecordService` ersetzt zentrale Saetze pro Standort
|
||||||
|
- `ConsolidatedExportService` erzeugt die zentrale Datei
|
||||||
|
|
||||||
|
### Datenquellen
|
||||||
|
|
||||||
|
- [Services/HanaQueryService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/HanaQueryService.cs)
|
||||||
|
- [Services/SapGatewayService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapGatewayService.cs)
|
||||||
|
- [Services/SapCompositionService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapCompositionService.cs)
|
||||||
|
- [Services/ManualExcelImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManualExcelImportService.cs)
|
||||||
|
- [Services/SharePointUploadService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SharePointUploadService.cs)
|
||||||
|
|
||||||
|
Rollen:
|
||||||
|
|
||||||
|
- `HanaQueryService`: SQL gegen SAP B1/HANA-nahe Schemata
|
||||||
|
- `SapGatewayService`: OData-Metadaten und Reads
|
||||||
|
- `SapCompositionService`: Mehrquellen-/Join-/Mapping-Aufbau fuer SAP
|
||||||
|
- `ManualExcelImportService`: Import im Exportformat aus `.xlsx`
|
||||||
|
- `SharePointUploadService`: Upload fuer Exportdateien und Download fuer manuelle Excel-Dateien
|
||||||
|
|
||||||
|
### Transformation / Mapping
|
||||||
|
|
||||||
|
- [Services/TransformationCatalog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationCatalog.cs)
|
||||||
|
- [Services/TransformationStrategies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationStrategies.cs)
|
||||||
|
- [Services/RecordTransformationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/RecordTransformationService.cs)
|
||||||
|
- [Services/CurrencyExchangeRateService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CurrencyExchangeRateService.cs)
|
||||||
|
- [Services/ExchangeRateImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExchangeRateImportService.cs)
|
||||||
|
|
||||||
|
Rollen:
|
||||||
|
|
||||||
|
- `Value`-Transformationen fuer einzelne Felder
|
||||||
|
- `Record`-Transformationen fuer zeilenweite Regeln
|
||||||
|
- Wechselkursimport und -umrechnung
|
||||||
|
|
||||||
|
### Reporting / Monitoring / Infrastruktur
|
||||||
|
|
||||||
|
- [Services/ManagementCockpitService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManagementCockpitService.cs)
|
||||||
|
- [Services/AppEventLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/AppEventLogService.cs)
|
||||||
|
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
|
||||||
|
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
|
||||||
|
- [Services/TimerBackgroundService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TimerBackgroundService.cs)
|
||||||
|
|
||||||
|
## Der wichtigste technische Ablauf
|
||||||
|
|
||||||
|
### 1. Standort-Export
|
||||||
|
|
||||||
|
Pfad:
|
||||||
|
|
||||||
|
`Dashboard/Standorte -> ExportOrchestrationService -> SiteExportService`
|
||||||
|
|
||||||
|
`SiteExportService` unterscheidet drei Modi:
|
||||||
|
|
||||||
|
1. `SAP_GATEWAY`
|
||||||
|
- SAP-Quellen lesen
|
||||||
|
- SAP-Joins anwenden
|
||||||
|
- SAP-Feldmappings auf `SalesRecord`
|
||||||
|
- Transformationen anwenden
|
||||||
|
- Standort-Excel erzeugen
|
||||||
|
- `CentralSalesRecords` ersetzen
|
||||||
|
- optional SharePoint-Upload
|
||||||
|
|
||||||
|
2. `HANA`
|
||||||
|
- effektive zentrale HANA-Konfiguration laden
|
||||||
|
- optionale Standort-Credential-Overrides anwenden
|
||||||
|
- SQL in HANA ausfuehren
|
||||||
|
- `SalesRecord` erzeugen
|
||||||
|
- Transformationen anwenden
|
||||||
|
- Standort-Excel erzeugen
|
||||||
|
- `CentralSalesRecords` ersetzen
|
||||||
|
- optional SharePoint-Upload
|
||||||
|
|
||||||
|
3. `MANUAL_EXCEL`
|
||||||
|
- `ManualImportFilePath` auswerten
|
||||||
|
- wenn lokal/UNC vorhanden: lokal lesen
|
||||||
|
- wenn SharePoint-Referenz: via Graph temp herunterladen
|
||||||
|
- Excel in `SalesRecord` lesen
|
||||||
|
- Transformationen anwenden
|
||||||
|
- keine neue Standortdatei erzeugen, bestehende Excel dient als Eingabe
|
||||||
|
- `CentralSalesRecords` ersetzen
|
||||||
|
|
||||||
|
### 2. Konsolidierter Export
|
||||||
|
|
||||||
|
Pfad:
|
||||||
|
|
||||||
|
`Dashboard -> ExportOrchestrationService -> ConsolidatedExportService`
|
||||||
|
|
||||||
|
Semantik aktuell:
|
||||||
|
|
||||||
|
- die zentrale Datei basiert fachlich auf `CentralSalesRecords`
|
||||||
|
- `ExportAllAsync()` sammelt zwar auch `consolidatedRecords`, aber die zentrale Exportsemantik ist historisch noch nicht vollkommen bereinigt
|
||||||
|
|
||||||
|
### 3. Management Cockpit
|
||||||
|
|
||||||
|
Zwei Betriebsarten:
|
||||||
|
|
||||||
|
1. Dateibasiert
|
||||||
|
- vorhandene `.xlsx` waehlen
|
||||||
|
- Datei mit ClosedXML lesen
|
||||||
|
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
|
||||||
|
|
||||||
|
2. Zentraldatenbasiert
|
||||||
|
- direkt aus `CentralSalesRecords`
|
||||||
|
- Jahr/Monat Filter
|
||||||
|
- Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik
|
||||||
|
|
||||||
|
## Quellsystemlogik
|
||||||
|
|
||||||
|
### SourceSystemDefinition
|
||||||
|
|
||||||
|
`SourceSystemDefinition` ist die fuehrende Wahrheit fuer:
|
||||||
|
|
||||||
|
- `Code`
|
||||||
|
- `DisplayName`
|
||||||
|
- `ConnectionKind`
|
||||||
|
- `IsActive`
|
||||||
|
- `CentralUsername`
|
||||||
|
- `CentralPassword`
|
||||||
|
- `CentralServiceUrl` fuer SAP
|
||||||
|
|
||||||
|
Anschlussarten:
|
||||||
|
|
||||||
|
- `HANA`
|
||||||
|
- `SAP_GATEWAY`
|
||||||
|
- `MANUAL_EXCEL`
|
||||||
|
|
||||||
|
### HANA
|
||||||
|
|
||||||
|
Fachliche Logik:
|
||||||
|
|
||||||
|
- zentrale technische HANA-Konfiguration pro Quellsystem
|
||||||
|
- keine separaten Vollverbindungen pro Standort
|
||||||
|
- Standort speichert nur Fachdaten plus optionale Username-/Password-Overrides
|
||||||
|
|
||||||
|
Schema-Lookup:
|
||||||
|
|
||||||
|
- in `Standorte` gibt es jetzt `Schemas laden`
|
||||||
|
- Lookup fragt `sys.tables` in HANA ab
|
||||||
|
- eingeschraenkt auf typische B1-Schemas mit Tabellen wie `OINV`, `INV1`, `ORIN`, `RIN1`, `OCRD`, `OITM`
|
||||||
|
|
||||||
|
### SAP
|
||||||
|
|
||||||
|
Fachliche Logik:
|
||||||
|
|
||||||
|
- zentrale SAP Service URL in `SourceSystemDefinition.CentralServiceUrl`
|
||||||
|
- Standort kann `SapServiceUrl` als Override pflegen
|
||||||
|
- pro Standort gibt es SAP-Quellen, Joins und Feldmappings
|
||||||
|
|
||||||
|
### Manual Excel
|
||||||
|
|
||||||
|
Fachliche Logik:
|
||||||
|
|
||||||
|
- `Site.ManualImportFilePath` kann sein:
|
||||||
|
- lokaler Windows-Pfad
|
||||||
|
- UNC-Pfad
|
||||||
|
- SharePoint-URL
|
||||||
|
- SharePoint-Pfad unterhalb der konfigurierten Site
|
||||||
|
- Standortdaten werden aus der Excel eingelesen und in `CentralSalesRecords` uebernommen
|
||||||
|
- SharePoint dient hier als Eingangsquelle, nicht nur als Exportziel
|
||||||
|
|
||||||
|
## Transformationen
|
||||||
|
|
||||||
|
Das System unterscheidet:
|
||||||
|
|
||||||
|
- `Value`-Transformationen
|
||||||
|
- `Record`-Transformationen
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- `Copy`
|
||||||
|
- `Uppercase`
|
||||||
|
- `Lowercase`
|
||||||
|
- `Prefix`
|
||||||
|
- `Suffix`
|
||||||
|
- `Replace`
|
||||||
|
- `Constant`
|
||||||
|
- `NormalizeCurrencyCode`
|
||||||
|
- `FirstNonEmpty`
|
||||||
|
- `ConvertCurrency`
|
||||||
|
|
||||||
|
Technischer Ablauf:
|
||||||
|
|
||||||
|
- Regeln liegen in `FieldTransformationRules`
|
||||||
|
- `TransformationCatalog` meldet verfuegbare Strategien an die UI
|
||||||
|
- `RecordTransformationService` wendet record-basierte Strategien an
|
||||||
|
|
||||||
|
## Wechselkurse
|
||||||
|
|
||||||
|
Vorhanden:
|
||||||
|
|
||||||
|
- `CurrencyExchangeRates`
|
||||||
|
- `ExchangeRateImportService` fuer ECB-Tageskurse
|
||||||
|
- `NormalizeCurrencyCode`
|
||||||
|
- `ConvertCurrency`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um
|
||||||
|
- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht
|
||||||
|
|
||||||
|
## SharePoint-Rolle im Gesamtsystem
|
||||||
|
|
||||||
|
`SharePointConfig` enthaelt:
|
||||||
|
|
||||||
|
- `SiteUrl`
|
||||||
|
- `ExportFolder`
|
||||||
|
- `CentralExportFolder`
|
||||||
|
- `TenantId`
|
||||||
|
- `ClientId`
|
||||||
|
- `ClientSecret`
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
|
||||||
|
- Upload von Standort-Exporten
|
||||||
|
- Upload der zentralen Datei
|
||||||
|
- Download von manuellen Excel-Dateien fuer `MANUAL_EXCEL`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- die App arbeitet gegen dieselbe SharePoint-Site, die in `Settings` konfiguriert ist
|
||||||
|
- fuer `MANUAL_EXCEL` muessen Referenzen auf derselben Site aufloesbar sein
|
||||||
|
|
||||||
|
## Startinitialisierung / Migrationen
|
||||||
|
|
||||||
|
Kritische Datei:
|
||||||
|
|
||||||
|
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
|
||||||
|
|
||||||
|
Aktuelle Rolle:
|
||||||
|
|
||||||
|
- `EnsureCreated`
|
||||||
|
- Schema-Ergaenzungen per `ALTER TABLE`
|
||||||
|
- Tabellen-Rebuilds bei Legacy-Schemas
|
||||||
|
- FK-Reparaturen
|
||||||
|
- Stammdaten-Seeding
|
||||||
|
- empfohlene Transformationsregeln
|
||||||
|
|
||||||
|
Bekannte Architekturrealitaet:
|
||||||
|
|
||||||
|
- das ist funktional hilfreich, aber kein sauberes Migrationssystem
|
||||||
|
- die Startlogik traegt produktive Schema-Reparaturverantwortung
|
||||||
|
- das ist einer der wichtigsten technischen Risikobloecke
|
||||||
|
|
||||||
|
Bereits gehaertete Fehlerbilder:
|
||||||
|
|
||||||
|
- kaputte FK-Referenzen auf `Sites_old`
|
||||||
|
- kaputte FK-Referenzen auf `HanaServers_repair_old`
|
||||||
|
- Legacy-Credential-Spalten in `ExportSettings`
|
||||||
|
- Legacy-Credential-Spalten in `HanaServers`
|
||||||
|
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
|
||||||
|
|
||||||
|
## Config Import / Export
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
|
||||||
|
- [Models/ConfigTransferPackage.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ConfigTransferPackage.cs)
|
||||||
|
|
||||||
|
Aktueller Stand:
|
||||||
|
|
||||||
|
- JSON Export/Import fuer Konfiguration
|
||||||
|
- Secrets optional
|
||||||
|
- `SourceSystemDefinitions` im aktuellen Modell enthalten
|
||||||
|
- HANA-Technik ohne HANA-Credentials
|
||||||
|
- Standort-Overrides bleiben erhalten
|
||||||
|
|
||||||
|
Wichtige Punkte:
|
||||||
|
|
||||||
|
- Import laeuft jetzt transaktional
|
||||||
|
- alte `ConnectionKind`-lose Formate bekommen Fallbacks
|
||||||
|
- `CentralSalesRecords` werden nicht mehr blind geloescht
|
||||||
|
- bestehende zentrale Laufzeitdaten werden fuer weiterhin vorhandene Standorte remappt
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Es gibt zwei Log-Ebenen:
|
||||||
|
|
||||||
|
- `ExportLogs` fuer fachliche Exporthistorie
|
||||||
|
- `AppEventLogs` fuer technische und UI-nahe Ereignisse
|
||||||
|
|
||||||
|
Die `Logs`-Seite liest vor allem `AppEventLogs`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Testprojekt:
|
||||||
|
|
||||||
|
- [TrafagSalesExporter.Tests](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/TrafagSalesExporter.Tests)
|
||||||
|
|
||||||
|
Aktuell vorhandene Schwerpunkte:
|
||||||
|
|
||||||
|
- Transformationen
|
||||||
|
- Record-Transformationen
|
||||||
|
- TransformationCatalog
|
||||||
|
- CurrencyExchangeRateService
|
||||||
|
- ExchangeRateImportService
|
||||||
|
- ManualExcelImportService
|
||||||
|
- ManagementCockpitService
|
||||||
|
- ConfigTransferService
|
||||||
|
- DatabaseInitializationService
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- es gibt aktuell keine echten UI-Komponententests mit `bUnit`
|
||||||
|
- es gibt keine Browser-E2E-Tests mit `Playwright`
|
||||||
|
- viele Button-Aktionen sind nur indirekt ueber Services und Persistenz getestet
|
||||||
|
|
||||||
|
## Bekannte offene Architekturfragen
|
||||||
|
|
||||||
|
Fuer andere LLMs wichtig, damit Visualisierungen nicht zu glatt oder zu idealisiert werden:
|
||||||
|
|
||||||
|
1. `DatabaseInitializationService` ist ein produktiver Reparatur-/Migrationslayer, nicht nur Bootstrap.
|
||||||
|
2. `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel Anwendungslogik.
|
||||||
|
3. Die Semantik der konsolidierten Datei ist historisch teilweise doppelt angelegt.
|
||||||
|
4. Das `Management Cockpit` ist noch kein voll generalisierter Reporting-Layer.
|
||||||
|
5. SharePoint ist sowohl Exportziel als auch bei `MANUAL_EXCEL` mittlerweile moegliche Eingangsquelle.
|
||||||
|
|
||||||
|
## Empfohlene Diagramme fuer andere LLMs
|
||||||
|
|
||||||
|
### 1. Kontextdiagramm
|
||||||
|
|
||||||
|
Zeige:
|
||||||
|
|
||||||
|
- Benutzer
|
||||||
|
- Blazor App
|
||||||
|
- SQLite
|
||||||
|
- SAP HANA
|
||||||
|
- SAP Gateway
|
||||||
|
- lokale Dateisystempfade
|
||||||
|
- SharePoint
|
||||||
|
|
||||||
|
### 2. Komponenten-/Service-Diagramm
|
||||||
|
|
||||||
|
Gruppiere:
|
||||||
|
|
||||||
|
- UI
|
||||||
|
- Orchestrierung
|
||||||
|
- Quelladapter
|
||||||
|
- Transformation
|
||||||
|
- Persistenz
|
||||||
|
- Reporting
|
||||||
|
|
||||||
|
### 3. Datenflussdiagramm pro Quelltyp
|
||||||
|
|
||||||
|
Je ein separater Flow fuer:
|
||||||
|
|
||||||
|
- HANA
|
||||||
|
- SAP Gateway
|
||||||
|
- Manual Excel lokal
|
||||||
|
- Manual Excel SharePoint
|
||||||
|
|
||||||
|
### 4. ER-Diagramm
|
||||||
|
|
||||||
|
Fokussiere auf:
|
||||||
|
|
||||||
|
- `SourceSystemDefinition`
|
||||||
|
- `HanaServer`
|
||||||
|
- `Site`
|
||||||
|
- `SapSourceDefinition`
|
||||||
|
- `SapJoinDefinition`
|
||||||
|
- `SapFieldMapping`
|
||||||
|
- `CentralSalesRecord`
|
||||||
|
- `FieldTransformationRule`
|
||||||
|
|
||||||
|
### 5. Sequenzdiagramm fuer Export
|
||||||
|
|
||||||
|
Wichtige Stationen:
|
||||||
|
|
||||||
|
- Dashboard
|
||||||
|
- ExportOrchestrationService
|
||||||
|
- SiteExportService
|
||||||
|
- spezifischer Quellservice
|
||||||
|
- Transformation
|
||||||
|
- CentralSalesRecordService
|
||||||
|
- Excel/SharePoint
|
||||||
|
- ExportLog/AppEventLog
|
||||||
|
|
||||||
|
## Prompt-Vorlage fuer ein anderes LLM
|
||||||
|
|
||||||
|
Wenn ein anderes LLM daraus Visualisierungen erzeugen soll, funktioniert diese Anweisung gut:
|
||||||
|
|
||||||
|
> Lies `LLM_SYSTEM_GUIDE.md` als primaeren Systemkontext. Erzeuge daraus ein Architekturdiagramm, ein Datenflussdiagramm fuer HANA/SAP/MANUAL_EXCEL, ein ER-Diagramm der wichtigsten Tabellen und ein Sequenzdiagramm fuer `ExportAsync`. Achte darauf, dass `DatabaseInitializationService` produktive Reparaturlogik enthaelt und dass `MANUAL_EXCEL` sowohl lokal als auch ueber SharePoint gelesen werden kann.
|
||||||
|
|
||||||
|
## Weitere Kontextdateien
|
||||||
|
|
||||||
|
Zusatzkontext fuer Verlauf und Risiken:
|
||||||
|
|
||||||
|
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
|
||||||
|
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
|
||||||
|
|
||||||
|
Diese beiden Dateien sind wichtig, wenn ein anderes LLM nicht nur Struktur, sondern auch historische Umbauten, Risiken und Prioritaeten verstehen soll.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class AppEventLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public string Level { get; set; } = "Info";
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public int? SiteId { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class CentralSalesRecord
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime StoredAtUtc { get; set; }
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SiteId))]
|
||||||
|
public Site? Site { get; set; }
|
||||||
|
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
public DateTime ExtractionDate { get; set; }
|
||||||
|
public string Tsc { get; set; } = string.Empty;
|
||||||
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
public int PositionOnInvoice { get; set; }
|
||||||
|
public string Material { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ProductGroup { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public string SupplierNumber { get; set; } = string.Empty;
|
||||||
|
public string SupplierName { get; set; } = string.Empty;
|
||||||
|
public string SupplierCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerNumber { get; set; } = string.Empty;
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string CustomerCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerIndustry { get; set; } = string.Empty;
|
||||||
|
public decimal StandardCost { get; set; }
|
||||||
|
public string StandardCostCurrency { get; set; } = string.Empty;
|
||||||
|
public string PurchaseOrderNumber { get; set; } = string.Empty;
|
||||||
|
public decimal SalesPriceValue { get; set; }
|
||||||
|
public string SalesCurrency { get; set; } = string.Empty;
|
||||||
|
public string Incoterms2020 { get; set; } = string.Empty;
|
||||||
|
public string SalesResponsibleEmployee { get; set; } = string.Empty;
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public DateTime? OrderDate { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string DocumentType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class ConfigTransferPackage
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
public DateTime ExportedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool IncludesSecrets { get; set; }
|
||||||
|
public ConfigTransferSharePoint? SharePointConfig { get; set; }
|
||||||
|
public ConfigTransferExportSettings? ExportSettings { get; set; }
|
||||||
|
public List<ConfigTransferSourceSystemDefinition> SourceSystemDefinitions { get; set; } = [];
|
||||||
|
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
|
||||||
|
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
|
||||||
|
public List<ConfigTransferSite> Sites { get; set; } = [];
|
||||||
|
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
||||||
|
public List<ConfigTransferSapSourceDefinition> SapSourceDefinitions { get; set; } = [];
|
||||||
|
public List<ConfigTransferSapJoinDefinition> SapJoinDefinitions { get; set; } = [];
|
||||||
|
public List<ConfigTransferSapFieldMapping> SapFieldMappings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string CentralServiceUrl { get; set; } = string.Empty;
|
||||||
|
public string? CentralUsername { get; set; }
|
||||||
|
public string? CentralPassword { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSharePoint
|
||||||
|
{
|
||||||
|
public string SiteUrl { get; set; } = string.Empty;
|
||||||
|
public string ExportFolder { get; set; } = string.Empty;
|
||||||
|
public string CentralExportFolder { get; set; } = string.Empty;
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferExportSettings
|
||||||
|
{
|
||||||
|
public string DateFilter { get; set; } = "2025-01-01";
|
||||||
|
public int TimerHour { get; set; } = 3;
|
||||||
|
public int TimerMinute { get; set; }
|
||||||
|
public bool TimerEnabled { get; set; } = true;
|
||||||
|
public bool DebugLoggingEnabled { get; set; }
|
||||||
|
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
||||||
|
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferCurrencyExchangeRate
|
||||||
|
{
|
||||||
|
public string FromCurrency { get; set; } = string.Empty;
|
||||||
|
public string ToCurrency { get; set; } = string.Empty;
|
||||||
|
public decimal Rate { get; set; }
|
||||||
|
public DateTime ValidFrom { get; set; }
|
||||||
|
public DateTime? ValidTo { get; set; }
|
||||||
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferHanaServer
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public int Port { get; set; } = 30015;
|
||||||
|
public string DatabaseName { get; set; } = string.Empty;
|
||||||
|
public bool UseSsl { get; set; }
|
||||||
|
public bool ValidateCertificate { get; set; }
|
||||||
|
public string AdditionalParams { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSite
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string? HanaServerKey { get; set; }
|
||||||
|
public string Schema { get; set; } = string.Empty;
|
||||||
|
public string TSC { get; set; } = string.Empty;
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
public string? UsernameOverride { get; set; }
|
||||||
|
public string? PasswordOverride { get; set; }
|
||||||
|
public string LocalExportFolderOverride { get; set; } = string.Empty;
|
||||||
|
public string ManualImportFilePath { get; set; } = string.Empty;
|
||||||
|
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
|
||||||
|
public string SapServiceUrl { get; set; } = string.Empty;
|
||||||
|
public string SapEntitySet { get; set; } = string.Empty;
|
||||||
|
public string SapEntitySetsCache { get; set; } = string.Empty;
|
||||||
|
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSapSourceDefinition
|
||||||
|
{
|
||||||
|
public string SiteKey { get; set; } = string.Empty;
|
||||||
|
public string Alias { get; set; } = string.Empty;
|
||||||
|
public string EntitySet { get; set; } = string.Empty;
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSapJoinDefinition
|
||||||
|
{
|
||||||
|
public string SiteKey { get; set; } = string.Empty;
|
||||||
|
public string LeftAlias { get; set; } = string.Empty;
|
||||||
|
public string RightAlias { get; set; } = string.Empty;
|
||||||
|
public string LeftKeys { get; set; } = string.Empty;
|
||||||
|
public string RightKeys { get; set; } = string.Empty;
|
||||||
|
public string JoinType { get; set; } = "Left";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConfigTransferSapFieldMapping
|
||||||
|
{
|
||||||
|
public string SiteKey { get; set; } = string.Empty;
|
||||||
|
public string TargetField { get; set; } = string.Empty;
|
||||||
|
public string SourceExpression { get; set; } = string.Empty;
|
||||||
|
public bool IsRequired { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class CurrencyExchangeRate
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FromCurrency { get; set; } = string.Empty;
|
||||||
|
public string ToCurrency { get; set; } = string.Empty;
|
||||||
|
public decimal Rate { get; set; }
|
||||||
|
public DateTime ValidFrom { get; set; } = DateTime.UtcNow.Date;
|
||||||
|
public DateTime? ValidTo { get; set; }
|
||||||
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class ExportLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SiteId))]
|
||||||
|
public Site? Site { get; set; }
|
||||||
|
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string TSC { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public double DurationSeconds { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class ExportSettings
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string DateFilter { get; set; } = "2025-01-01";
|
||||||
|
public int TimerHour { get; set; } = 3;
|
||||||
|
public int TimerMinute { get; set; }
|
||||||
|
public bool TimerEnabled { get; set; } = true;
|
||||||
|
public bool DebugLoggingEnabled { get; set; }
|
||||||
|
public string LocalSiteExportFolder { get; set; } = string.Empty;
|
||||||
|
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class FieldTransformationRule
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SourceField { get; set; } = nameof(SalesRecord.Material);
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string TargetField { get; set; } = nameof(SalesRecord.Material);
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string TransformationType { get; set; } = "Copy";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string RuleScope { get; set; } = "Value";
|
||||||
|
|
||||||
|
public string Argument { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Data.Common;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class HanaServer
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int Port { get; set; } = 30015;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
|
||||||
|
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
|
||||||
|
/// </summary>
|
||||||
|
public string DatabaseName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
|
||||||
|
/// </summary>
|
||||||
|
public bool UseSsl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
|
||||||
|
/// </summary>
|
||||||
|
public bool ValidateCertificate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
|
||||||
|
/// </summary>
|
||||||
|
public string AdditionalParams { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string BuildConnectionString()
|
||||||
|
{
|
||||||
|
var builder = new DbConnectionStringBuilder();
|
||||||
|
builder["ServerNode"] = BuildServerNode();
|
||||||
|
builder["UserName"] = Username.Trim();
|
||||||
|
builder["Password"] = Password;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(DatabaseName))
|
||||||
|
builder["DatabaseName"] = DatabaseName.Trim();
|
||||||
|
|
||||||
|
if (UseSsl)
|
||||||
|
{
|
||||||
|
builder["encrypt"] = true;
|
||||||
|
builder["sslValidateCertificate"] = ValidateCertificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppendAdditionalParams(builder);
|
||||||
|
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConnectionStringPreview()
|
||||||
|
{
|
||||||
|
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
|
||||||
|
var copy = new HanaServer
|
||||||
|
{
|
||||||
|
SourceSystem = SourceSystem,
|
||||||
|
Host = Host,
|
||||||
|
Port = Port,
|
||||||
|
Username = Username,
|
||||||
|
Password = pwdMasked,
|
||||||
|
DatabaseName = DatabaseName,
|
||||||
|
UseSsl = UseSsl,
|
||||||
|
ValidateCertificate = ValidateCertificate,
|
||||||
|
AdditionalParams = AdditionalParams
|
||||||
|
};
|
||||||
|
|
||||||
|
return copy.BuildConnectionString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildServerNode()
|
||||||
|
{
|
||||||
|
var normalizedHost = NormalizeHost(Host);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedHost))
|
||||||
|
throw new InvalidOperationException("HANA Host darf nicht leer sein.");
|
||||||
|
|
||||||
|
if (HasExplicitPort(normalizedHost))
|
||||||
|
return normalizedHost;
|
||||||
|
|
||||||
|
return $"{normalizedHost}:{Port}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeHost(string host)
|
||||||
|
{
|
||||||
|
var value = host.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Treat plain "host:port" values as HANA ServerNode, not as a URI scheme.
|
||||||
|
// Only parse as URI when an explicit scheme is present.
|
||||||
|
if (value.Contains("://", StringComparison.Ordinal) &&
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemeIndex = value.IndexOf("://", StringComparison.Ordinal);
|
||||||
|
if (schemeIndex >= 0)
|
||||||
|
value = value[(schemeIndex + 3)..];
|
||||||
|
|
||||||
|
var slashIndex = value.IndexOf('/');
|
||||||
|
if (slashIndex >= 0)
|
||||||
|
value = value[..slashIndex];
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExplicitPort(string host)
|
||||||
|
{
|
||||||
|
if (host.StartsWith('['))
|
||||||
|
return host.Contains("]:", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
return host.Count(c => c == ':') == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendAdditionalParams(DbConnectionStringBuilder builder)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AdditionalParams))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var rawPart in AdditionalParams.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var separatorIndex = rawPart.IndexOf('=');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex == rawPart.Length - 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var key = rawPart[..separatorIndex].Trim();
|
||||||
|
var value = rawPart[(separatorIndex + 1)..].Trim();
|
||||||
|
if (key.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
builder[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class ManagementCockpitFileOption
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitSummary
|
||||||
|
{
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string Tsc { get; set; } = string.Empty;
|
||||||
|
public DateTime? ExtractionDate { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public int InvoiceCount { get; set; }
|
||||||
|
public int CustomerCount { get; set; }
|
||||||
|
public decimal SalesValueTotal { get; set; }
|
||||||
|
public decimal EstimatedCostTotal { get; set; }
|
||||||
|
public decimal EstimatedMarginTotal { get; set; }
|
||||||
|
public decimal EstimatedMarginPercent { get; set; }
|
||||||
|
public decimal ServiceSharePercent { get; set; }
|
||||||
|
public decimal MissingOrderDatePercent { get; set; }
|
||||||
|
public decimal MissingSupplierPercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
public string Severity { get; set; } = "Info";
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Detail { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitTopItem
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public decimal SharePercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitResult
|
||||||
|
{
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public ManagementCockpitSummary Summary { get; set; } = new();
|
||||||
|
public List<ManagementCockpitFinding> Findings { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopCustomers { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopProductGroups { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopSalesEmployees { get; set; } = [];
|
||||||
|
public Dictionary<string, int> DataQualityCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitCentralFilter
|
||||||
|
{
|
||||||
|
public int Year { get; set; }
|
||||||
|
public int? Month { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitCentralSummary
|
||||||
|
{
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public int InvoiceCount { get; set; }
|
||||||
|
public int SiteCount { get; set; }
|
||||||
|
public int CountryCount { get; set; }
|
||||||
|
public int CurrencyCount { get; set; }
|
||||||
|
public DateTime? PeriodStart { get; set; }
|
||||||
|
public DateTime? PeriodEnd { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitTimeValueRow
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public int? Month { get; set; }
|
||||||
|
public int? Day { get; set; }
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public decimal SalesValue { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitDimensionValueRow
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public decimal SalesValue { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public int InvoiceCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitCentralResult
|
||||||
|
{
|
||||||
|
public ManagementCockpitCentralFilter Filter { get; set; } = new();
|
||||||
|
public ManagementCockpitCentralSummary Summary { get; set; } = new();
|
||||||
|
public List<string> Notices { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTimeValueRow> YearlyTotals { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTimeValueRow> MonthlyTotals { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTimeValueRow> DailyTotals { get; set; } = [];
|
||||||
|
public List<ManagementCockpitDimensionValueRow> SourceSystemTotals { get; set; } = [];
|
||||||
|
public List<ManagementCockpitDimensionValueRow> CountryTotals { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SalesRecord
|
||||||
|
{
|
||||||
|
public DateTime ExtractionDate { get; set; }
|
||||||
|
public string Tsc { get; set; } = string.Empty;
|
||||||
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
public int PositionOnInvoice { get; set; }
|
||||||
|
public string Material { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ProductGroup { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public string SupplierNumber { get; set; } = string.Empty;
|
||||||
|
public string SupplierName { get; set; } = string.Empty;
|
||||||
|
public string SupplierCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerNumber { get; set; } = string.Empty;
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string CustomerCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerIndustry { get; set; } = string.Empty;
|
||||||
|
public decimal StandardCost { get; set; }
|
||||||
|
public string StandardCostCurrency { get; set; } = string.Empty;
|
||||||
|
public string PurchaseOrderNumber { get; set; } = string.Empty;
|
||||||
|
public decimal SalesPriceValue { get; set; }
|
||||||
|
public string SalesCurrency { get; set; } = string.Empty;
|
||||||
|
public string Incoterms2020 { get; set; } = string.Empty;
|
||||||
|
public string SalesResponsibleEmployee { get; set; } = string.Empty;
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public DateTime? OrderDate { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string DocumentType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SapFieldMapping
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SiteId))]
|
||||||
|
public Site? Site { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string TargetField { get; set; } = nameof(SalesRecord.Material);
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SourceExpression { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsRequired { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SapJoinDefinition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SiteId))]
|
||||||
|
public Site? Site { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string LeftAlias { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string RightAlias { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string LeftKeys { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string RightKeys { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string JoinType { get; set; } = "Left";
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SapSourceDefinition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SiteId))]
|
||||||
|
public Site? Site { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Alias { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string EntitySet { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsPrimary { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SharePointConfig
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string SiteUrl { get; set; } = string.Empty;
|
||||||
|
public string ExportFolder { get; set; } = string.Empty;
|
||||||
|
public string CentralExportFolder { get; set; } = string.Empty;
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class Site
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int? HanaServerId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(HanaServerId))]
|
||||||
|
public HanaServer? HanaServer { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Schema { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string TSC { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SourceSystem { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string UsernameOverride { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PasswordOverride { get; set; } = string.Empty;
|
||||||
|
public string LocalExportFolderOverride { get; set; } = string.Empty;
|
||||||
|
public string ManualImportFilePath { get; set; } = string.Empty;
|
||||||
|
public DateTime? ManualImportLastUploadedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public string SapServiceUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SapEntitySet { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SapEntitySetsCache { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class SourceSystemDefinition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana;
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public string CentralServiceUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CentralUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CentralPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SourceSystemConnectionKinds
|
||||||
|
{
|
||||||
|
public const string Hana = "HANA";
|
||||||
|
public const string SapGateway = "SAP_GATEWAY";
|
||||||
|
public const string ManualExcel = "MANUAL_EXCEL";
|
||||||
|
|
||||||
|
public static readonly string[] All = [Hana, SapGateway, ManualExcel];
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# Next Steps
|
||||||
|
|
||||||
|
Stand: 2026-04-15
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-17
|
||||||
|
|
||||||
|
Der Punkt `CHF-Umrechnung / Wechselkurse` ist nicht mehr komplett offen.
|
||||||
|
|
||||||
|
Der aktuelle Ist-Stand ist:
|
||||||
|
|
||||||
|
- `CurrencyExchangeRateService` ist implementiert
|
||||||
|
- `ExchangeRateImportService` importiert ECB-Kurse
|
||||||
|
- `NormalizeCurrencyCode` und `ConvertCurrency` sind im Transformationssystem registriert
|
||||||
|
- fehlende Unit-Tests dafuer wurden am 2026-04-17 ergaenzt
|
||||||
|
|
||||||
|
Neuer Teststand:
|
||||||
|
|
||||||
|
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
|
||||||
|
- erfolgreich
|
||||||
|
- `31/31` Tests gruen
|
||||||
|
|
||||||
|
Was fuer Waehrungen trotzdem noch offen bleibt:
|
||||||
|
|
||||||
|
- fachlicher Einsatz der `ConvertCurrency`-Regeln in echten Standortkonfigurationen pruefen
|
||||||
|
- UI-Flow fuer Wechselkurspflege in `Settings.razor` manuell gegenpruefen
|
||||||
|
- ECB-Import einmal real ueber die UI bzw. App-Funktion pruefen
|
||||||
|
- bestaetigen, fuer welche Sichten CHF die Zielwaehrung sein soll
|
||||||
|
- Management-Cockpit-Rohsicht nur dann auf CHF umstellen, wenn fachlich gewuenscht
|
||||||
|
|
||||||
|
## Architektur-Nachtrag 2026-04-17
|
||||||
|
|
||||||
|
Nach einer separaten Architekturpruefung wurden die naechsten Schritte neu priorisiert.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- neue Fachfeatures sind aktuell **nicht** der erste Engpass
|
||||||
|
- zuerst muessen die Architektur-Risiken in Initialisierung, Config-Import und UI-Service-Schnitt bereinigt werden
|
||||||
|
|
||||||
|
### Neue Top-Prioritaeten
|
||||||
|
|
||||||
|
#### 1. `DatabaseInitializationService` absichern
|
||||||
|
|
||||||
|
Prio sehr hoch.
|
||||||
|
|
||||||
|
Gruende:
|
||||||
|
|
||||||
|
- Startlogik enthaelt manuelle Schema-Migrationen
|
||||||
|
- FK-Reparaturen laufen produktiv beim App-Start
|
||||||
|
- dort wurde ein konkretes Risiko fuer verschobene Spaltenwerte beim `Sites_old`-Kopierpfad erkannt
|
||||||
|
|
||||||
|
Vor weiterer Fachentwicklung:
|
||||||
|
|
||||||
|
- Initialisierungspfad genau pruefen
|
||||||
|
- SQL-Kopierlogik validieren
|
||||||
|
- moeglichst Richtung versionierte Migrationen bewegen
|
||||||
|
|
||||||
|
#### 2. `ConfigTransferService.ImportJsonAsync` neu denken
|
||||||
|
|
||||||
|
Prio sehr hoch.
|
||||||
|
|
||||||
|
Aktuelles Problem:
|
||||||
|
|
||||||
|
- Import loescht sehr viel und baut danach stueckweise neu auf
|
||||||
|
- nicht atomar
|
||||||
|
- potenziell teilzerstoerter Zustand bei Fehlern
|
||||||
|
- `CentralSalesRecords` werden mitimportiert/mitgeloescht, obwohl sie eher Laufzeitdaten als Konfiguration sind
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- atomarer Import
|
||||||
|
- saubere Trennung zwischen Konfiguration und Betriebsdaten
|
||||||
|
|
||||||
|
#### 3. Razor-Seiten entlasten
|
||||||
|
|
||||||
|
Prio hoch.
|
||||||
|
|
||||||
|
Betroffen vor allem:
|
||||||
|
|
||||||
|
- `Components/Pages/Settings.razor`
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- DB- und Fachlogik aus UI-Code in Services / Application-Layer verschieben
|
||||||
|
- Seiten nur noch fuer Interaktion und Formularzustand
|
||||||
|
|
||||||
|
#### 4. Konsolidierten Export semantisch klaeren
|
||||||
|
|
||||||
|
Prio mittel.
|
||||||
|
|
||||||
|
Offene Frage:
|
||||||
|
|
||||||
|
- zentrale Datei aus laufendem Snapshot
|
||||||
|
oder
|
||||||
|
- zentrale Datei immer aus `CentralSalesRecords`
|
||||||
|
|
||||||
|
Aktuell ist die Verantwortung unscharf.
|
||||||
|
|
||||||
|
#### 5. Reporting verallgemeinern
|
||||||
|
|
||||||
|
Prio mittel.
|
||||||
|
|
||||||
|
Erst nach den Infrastrukturthemen:
|
||||||
|
|
||||||
|
- hartcodierte Jahreslogik im Cockpit entfernen
|
||||||
|
- fachlich entscheiden, ob und wo CHF-Rohsicht gebraucht wird
|
||||||
|
|
||||||
|
### Praktische Reihenfolge fuer den naechsten Wiedereinstieg
|
||||||
|
|
||||||
|
Wenn nach erneutem Absturz oder Kontextverlust weitergemacht wird:
|
||||||
|
|
||||||
|
1. `HANDOFF_2026-04-15.md` lesen, speziell die Architekturpruefung vom 2026-04-17
|
||||||
|
2. `DatabaseInitializationService` als ersten Risikoblock ansehen
|
||||||
|
3. `ConfigTransferService.ImportJsonAsync` als zweiten Risikoblock ansehen
|
||||||
|
4. erst danach wieder an Cockpit / CHF / weitere Fachfeatures gehen
|
||||||
|
|
||||||
|
## Nachtrag HANA-/Standort-Workflow 2026-04-17
|
||||||
|
|
||||||
|
Der doppelte HANA-Workflow wurde inzwischen bereits bereinigt.
|
||||||
|
|
||||||
|
Neuer Stand:
|
||||||
|
|
||||||
|
- oben zentrale HANA-Konfiguration pro Quellsystem `BI1` / `SAGE`
|
||||||
|
- unten im Standort keine eigene wirksame Voll-HANA-Konfiguration mehr
|
||||||
|
- HANA-basierte Standorte ziehen ihre technische Verbindung aus der zentralen Quellsystem-Konfiguration
|
||||||
|
- Standort bleibt fuer fachliche Daten und optionale Credential-Overrides zustaendig
|
||||||
|
- die frueher doppelte HANA-UI im Standortdialog ist inzwischen auch sichtbar entfernt
|
||||||
|
- der Verbindungstest in `Settings.razor` prueft und meldet jetzt die zentrale HANA-Verbindung klar
|
||||||
|
|
||||||
|
### Was dazu noch praktisch geprueft werden sollte
|
||||||
|
|
||||||
|
- `Standorte`-Seite im UI manuell durchklicken
|
||||||
|
- pruefen, ob `BI1`- und `SAGE`-Standort beim Speichern sauber auf die zentrale HANA-Konfiguration zeigen
|
||||||
|
- pruefen, ob Aenderung oben bei zentraler HANA-Konfiguration in nachfolgenden Exporten wirklich greift
|
||||||
|
|
||||||
|
### Anschlussarbeiten
|
||||||
|
|
||||||
|
- `ConfigTransferService` spaeter auf das neue zentrale HANA-Modell fachlich nachziehen und kritisch pruefen
|
||||||
|
- `DatabaseInitializationService` weiter konsolidieren, damit die Zuordnung alter HANA-Daten langfristig robuster wird
|
||||||
|
|
||||||
|
## Nachtrag Quellsystem-Verwaltung 2026-04-17
|
||||||
|
|
||||||
|
Die bisher hart codierten Quellsystem-Listen wurden ersetzt.
|
||||||
|
|
||||||
|
Neuer Stand:
|
||||||
|
|
||||||
|
- `SourceSystemDefinition` ist jetzt die zentrale Stammdatenquelle fuer Quellsysteme
|
||||||
|
- `Settings.razor` hat jetzt eine GUI zur Pflege von Quellsystemen
|
||||||
|
- `Standorte.razor` zieht seine Quellsystem-Auswahl aus diesen Stammdaten
|
||||||
|
- `Transformations.razor` zieht die Systemauswahl ebenfalls aus diesen Stammdaten
|
||||||
|
- zentrale Credentials haengen jetzt am Quellsystem selbst
|
||||||
|
- HANA-Zentralverbindungen werden nur noch fuer Quellsysteme mit Anschlussart `HANA` gezeigt
|
||||||
|
- alte zentrale Credential-Felder in `ExportSettings` sind aus dem aktiven Codepfad entfernt
|
||||||
|
- `ExportSettings` wird beim Start auch schematisch auf das neue Feldset bereinigt
|
||||||
|
- HANA speichert zentral keine eigenen Credentials mehr; dort bleiben nur technische Verbindungsdaten
|
||||||
|
- `HanaServer.Username` / `Password` sind nur noch Laufzeitfelder und nicht mehr im EF-Schema gemappt
|
||||||
|
- SAP Service URL wird jetzt zentral im Quellsystem gepflegt; der Standort haelt nur noch ein optionales Override
|
||||||
|
- Quellsysteme werden jetzt per Dialog bearbeitet statt nur ueber Inline-Tabellenfelder
|
||||||
|
|
||||||
|
### Was dazu noch praktisch geprueft werden sollte
|
||||||
|
|
||||||
|
- in `Settings` ein neues Quellsystem per GUI anlegen
|
||||||
|
- pruefen, ob es danach in `Standorte` und `Transformations` sofort auswählbar ist
|
||||||
|
- pruefen, ob deaktivierte Quellsysteme in neuen Standort-/Regelanlagen nicht mehr normal angeboten werden
|
||||||
|
- pruefen, ob Aenderung der Anschlussart von `HANA` auf `SAP_GATEWAY` oder `MANUAL_EXCEL` fachlich sauber wirkt
|
||||||
|
- pruefen, ob bestehende BI1/SAGE/SAP-Daten nach Startmigration korrekt in `SourceSystemDefinitions` stehen
|
||||||
|
- pruefen, ob Konfiguration-Export/Import ohne die alten Credential-Felder sauber mit `SourceSystemDefinitions` arbeitet
|
||||||
|
- pruefen, ob zentrale SAP Service URL ohne Override sauber fuer Refresh, Export und Dashboard greift
|
||||||
|
- pruefen, ob SAP Service URL Override am Standort die zentrale URL erwartungsgemaess uebersteuert
|
||||||
|
|
||||||
|
## Nachtrag 2026-04-16
|
||||||
|
|
||||||
|
Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden.
|
||||||
|
|
||||||
|
## 0. Neuer Ist-Stand
|
||||||
|
|
||||||
|
Zusaetzlich zum alten Stand ist jetzt vorhanden:
|
||||||
|
|
||||||
|
- manueller Standort-Import ueber `MANUAL_EXCEL`
|
||||||
|
- Dashboard mit `Alle exportieren`, `Zentrale Datei neu erzeugen` und zentralem `Excel oeffnen`
|
||||||
|
- Roh-Auswertung im `Management Cockpit` direkt aus `CentralSalesRecords`
|
||||||
|
- erweitertes Transformationssystem mit `Value`- und `Record`-Regeln
|
||||||
|
- HANA-Schema-Lookup im Standortdialog
|
||||||
|
- Testprojekt mit aktuell 18 gruenden Tests
|
||||||
|
|
||||||
|
## 1. Status
|
||||||
|
|
||||||
|
Der Export geht jetzt wieder durch.
|
||||||
|
|
||||||
|
Die zuletzt gefundene Hauptursache war nicht mehr ein reiner SQLite-Lock beim Batch-Insert, sondern ein kaputter FK-Schemazustand in der bestehenden DB:
|
||||||
|
|
||||||
|
- SQLite referenzierte in mindestens einer Tabelle noch `main.Sites_old`
|
||||||
|
- dadurch scheiterte `SaveChangesAsync()` beim Schreiben z. B. in `AppEventLogs` oder `ExportLogs`
|
||||||
|
- sichtbarer Effekt: Export blieb nach `Zentrale Tabelle: ... Datensaetze gespeichert.` haengen
|
||||||
|
|
||||||
|
## 2. Umgesetzter Fix
|
||||||
|
|
||||||
|
Umgesetzt wurde:
|
||||||
|
|
||||||
|
- Dashboard-Live-Status liest waehrend laufendem Export nicht mehr staendig aus `AppEventLogs`, sondern nutzt den In-Memory-Status des `ExportOrchestrationService`
|
||||||
|
- SQLite `Default Timeout` in `Program.cs` auf `60` erhoeht
|
||||||
|
- `CentralSalesRecordService` setzt nach den Batches explizit `Zentrale Tabelle aktualisiert`
|
||||||
|
- `DatabaseInitializationService` repariert beim App-Start automatisch Tabellen, deren FK-SQL noch `Sites_old` referenziert
|
||||||
|
|
||||||
|
Betroffene Dateien:
|
||||||
|
|
||||||
|
- `Program.cs`
|
||||||
|
- `Components/Pages/Dashboard.razor`
|
||||||
|
- `Services/CentralSalesRecordService.cs`
|
||||||
|
- `Services/DatabaseInitializationService.cs`
|
||||||
|
|
||||||
|
## 3. Was noch getestet werden sollte
|
||||||
|
|
||||||
|
Kurz gegenpruefen:
|
||||||
|
|
||||||
|
- Export eines Standorts erneut
|
||||||
|
- `Excel oeffnen` nach erfolgreichem Export
|
||||||
|
- `Export erfolgreich` inkl. `Pfad=...`
|
||||||
|
- Dashboard-Live-Status setzt sich nach Abschluss sauber zurueck
|
||||||
|
- `Alle exportieren`
|
||||||
|
- `Zentrale Datei neu erzeugen`
|
||||||
|
- zentrale Datei im Dashboard oeffnen
|
||||||
|
|
||||||
|
## 3a. Manuellen Excel-Import pruefen
|
||||||
|
|
||||||
|
Zu testen:
|
||||||
|
|
||||||
|
- Standort auf `MANUAL_EXCEL` stellen
|
||||||
|
- Excel im Standort hochladen
|
||||||
|
- Standort exportieren
|
||||||
|
- pruefen, ob `CentralSalesRecords` fuer diesen Standort ersetzt wurden
|
||||||
|
- pruefen, ob der zentrale Export den Standort korrekt enthaelt
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
- `Services/ManualExcelImportService.cs`
|
||||||
|
- `Services/SiteExportService.cs`
|
||||||
|
|
||||||
|
## 3b. HANA-Schema-Lookup pruefen
|
||||||
|
|
||||||
|
Zu testen:
|
||||||
|
|
||||||
|
- bei `BI1`-Standort `Schemas laden`
|
||||||
|
- bei `SAGE`-Standort `Schemas laden`
|
||||||
|
- wird ein plausibles B1-Schema angeboten?
|
||||||
|
- funktioniert danach Export ohne manuelle Schema-Eingabe?
|
||||||
|
- zeigt England / Spezialstandort jetzt schneller, wenn Schema oder Rechte nicht passen?
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
- `Services/HanaQueryService.cs`
|
||||||
|
|
||||||
|
## 4. Falls wieder ein Fehler auftritt
|
||||||
|
|
||||||
|
In dieser Reihenfolge pruefen:
|
||||||
|
|
||||||
|
1. Exakte Fehlermeldung aus `AppEventLogs` bzw. Console notieren
|
||||||
|
2. Pruefen, ob die Reparaturlogik beim Start gelaufen ist
|
||||||
|
3. Pruefen, ob noch weitere Tabellen mit veralteter FK-Referenz existieren
|
||||||
|
4. Erst danach wieder am Batch-/Commit-Pfad der zentralen Speicherung arbeiten
|
||||||
|
|
||||||
|
## 5. SAP-Funktionalitaet kurz gegenpruefen
|
||||||
|
|
||||||
|
Zu testen:
|
||||||
|
|
||||||
|
- `Quellen refreshen`
|
||||||
|
- `Felder aus Quellen laden`
|
||||||
|
- `Auto-Match`
|
||||||
|
- SAP-Export eines Standorts
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- `Components/Pages/Standorte.razor`
|
||||||
|
- `Services/SapGatewayService.cs`
|
||||||
|
- `Services/SapCompositionService.cs`
|
||||||
|
|
||||||
|
## 6. Management Cockpit pruefen
|
||||||
|
|
||||||
|
Zu testen:
|
||||||
|
|
||||||
|
- vorhandene Excel-Datei auswaehlbar
|
||||||
|
- Analyse laeuft
|
||||||
|
- Kennzahlen plausibel
|
||||||
|
- Roh-Auswertung aus `CentralSalesRecords` laeuft
|
||||||
|
- Jahr/Monat-Filter funktionieren
|
||||||
|
- Summen nach Quelle / Land plausibel
|
||||||
|
|
||||||
|
Dateien:
|
||||||
|
|
||||||
|
- `Components/Pages/ManagementCockpit.razor`
|
||||||
|
- `Services/ManagementCockpitService.cs`
|
||||||
|
|
||||||
|
## 6a. Fachlich bewusst noch offen
|
||||||
|
|
||||||
|
Noch nicht final umsetzen ohne Rueckmeldung Fachseite:
|
||||||
|
|
||||||
|
- Intercompany-Filter
|
||||||
|
- fachliche Nutzung der CHF-Umrechnung in Cockpit / Reports
|
||||||
|
- Budgetvergleich
|
||||||
|
- Gruppenlogik
|
||||||
|
- Spartenlogik
|
||||||
|
- Margenlogik
|
||||||
|
|
||||||
|
Diese Punkte sollen spaeter moeglichst dynamisch auf dem neuen Transformations-/Mapping-Ansatz aufsetzen, aber aktuell nicht hart geraten werden.
|
||||||
|
|
||||||
|
## 6b. Naechste sinnvolle Testkandidaten
|
||||||
|
|
||||||
|
Wenn weiter in Tests investiert wird, sind die naechsten Kandidaten:
|
||||||
|
|
||||||
|
- `ExportOrchestrationService`
|
||||||
|
- spaeter End-to-End-Tests fuer den Wechselkurs-/Transformationspfad
|
||||||
|
- spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService`
|
||||||
|
|
||||||
|
Aktueller Teststatus:
|
||||||
|
|
||||||
|
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
|
||||||
|
- erfolgreich
|
||||||
|
- `31/31` Tests gruen
|
||||||
|
|
||||||
|
## 7. Referenzdatei
|
||||||
|
|
||||||
|
Fuer den vollstaendigen Kontext zuerst lesen:
|
||||||
|
|
||||||
|
- `HANDOFF_2026-04-15.md`
|
||||||
|
|
||||||
|
## 8. Letzte bereinigte UI-Irritation
|
||||||
|
|
||||||
|
Stand 2026-04-17:
|
||||||
|
|
||||||
|
- In `Standorte` wurde die obere Box auf `Zentrale HANA-Technik` geklaert.
|
||||||
|
- Dort gibt es keinen `Server hinzufuegen`-Pfad mehr.
|
||||||
|
- Grund: zentrale HANA-Eintraege werden aus `Quellsystemen` mit Anschlussart `HANA` abgeleitet.
|
||||||
|
- `SAP` gehoert fachlich nicht in diese Box, sondern in `Settings -> Quellsysteme`.
|
||||||
|
|
||||||
|
Wichtig fuer den naechsten Wiedereinstieg:
|
||||||
|
|
||||||
|
- Wenn ein Benutzer fragt `wo ist SAP?`, ist die richtige Antwort: nicht in der HANA-Box, sondern in der zentralen Quellsystem-Verwaltung.
|
||||||
|
- Wenn ein HANA-System oben fehlt, zuerst `Settings -> Quellsysteme` pruefen und dort Anschlussart `HANA` setzen.
|
||||||
|
|
||||||
|
## 9. Config-Transfer erneut geprueft
|
||||||
|
|
||||||
|
Stand 2026-04-17:
|
||||||
|
|
||||||
|
- Der aktuelle Config-Import/-Export passt zum neuen Datenmodell.
|
||||||
|
- Zentral verwaltete Quellsysteme, SAP-Zentral-URL, HANA-Technik ohne HANA-Credentials und Standort-Overrides werden korrekt im Transferformat abgebildet.
|
||||||
|
- Die vorhandenen `ConfigTransferServiceTests` bestaetigen den aktuellen Rundlauf.
|
||||||
|
|
||||||
|
Fuer den naechsten Wiedereinstieg wichtig:
|
||||||
|
|
||||||
|
- Das aktuelle Format ist fuer heutige Exporte konsistent.
|
||||||
|
- `ImportJsonAsync` ist aber weiterhin nicht atomar und loescht zuerst produktive Konfiguration.
|
||||||
|
- Zusaetzlich gibt es ein Altformat-Risiko:
|
||||||
|
- aeltere JSONs mit `SourceSystemDefinitions`, aber ohne `ConnectionKind`, koennen wegen DTO-Default falsch als `HANA` interpretiert werden.
|
||||||
|
|
||||||
|
Naechste saubere Haertung fuer dieses Thema:
|
||||||
|
|
||||||
|
- Config-Import transaktional machen
|
||||||
|
- Legacy-Fallback fuer fehlendes `ConnectionKind` einbauen
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MudBlazor.Services;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Services;
|
||||||
|
using TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.AddMudServices();
|
||||||
|
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
|
||||||
|
|
||||||
|
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
|
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
|
||||||
|
|
||||||
|
// Stateless Infrastruktur- und Connector-Services: Singleton.
|
||||||
|
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
|
||||||
|
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
||||||
|
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
|
||||||
|
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
|
||||||
|
builder.Services.AddSingleton<ISapCompositionService, SapCompositionService>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, PrefixTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationStrategy, NormalizeCurrencyCodeTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ICurrencyExchangeRateService, CurrencyExchangeRateService>();
|
||||||
|
builder.Services.AddSingleton<IExchangeRateImportService, ExchangeRateImportService>();
|
||||||
|
builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<IRecordTransformationStrategy, ConvertCurrencyRecordTransformationStrategy>();
|
||||||
|
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
|
||||||
|
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||||
|
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||||
|
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||||
|
builder.Services.AddSingleton<IManualExcelImportService, ManualExcelImportService>();
|
||||||
|
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||||
|
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||||
|
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
||||||
|
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
||||||
|
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
|
||||||
|
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
|
||||||
|
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
||||||
|
builder.Services.AddSingleton<IUiTextService, UiTextService>();
|
||||||
|
|
||||||
|
// Datenquellen-Adapter (Strategy per ConnectionKind).
|
||||||
|
builder.Services.AddSingleton<IDataSourceAdapter, HanaDataSourceAdapter>();
|
||||||
|
builder.Services.AddSingleton<IDataSourceAdapter, SapGatewayDataSourceAdapter>();
|
||||||
|
builder.Services.AddSingleton<IDataSourceAdapter, ManualExcelDataSourceAdapter>();
|
||||||
|
builder.Services.AddSingleton<IDataSourceAdapterResolver, DataSourceAdapterResolver>();
|
||||||
|
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
|
||||||
|
|
||||||
|
// Orchestrator mit gemeinsamem Status ueber alle Circuits.
|
||||||
|
builder.Services.AddSingleton<ExportOrchestrationService>();
|
||||||
|
builder.Services.AddSingleton<TimerBackgroundService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
|
||||||
|
|
||||||
|
// UI-/Page-Services: Scoped = pro Blazor-Circuit.
|
||||||
|
builder.Services.AddScoped<ISettingsPageService, SettingsPageService>();
|
||||||
|
builder.Services.AddScoped<IStandortePageService, StandortePageService>();
|
||||||
|
builder.Services.AddScoped<IStandorteSapEditorService, StandorteSapEditorService>();
|
||||||
|
builder.Services.AddScoped<IManagementCockpitPageService, ManagementCockpitPageService>();
|
||||||
|
builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
|
||||||
|
builder.Services.AddScoped<ILogsPageService, LogsPageService>();
|
||||||
|
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var databaseInitialization = scope.ServiceProvider.GetRequiredService<IDatabaseInitializationService>();
|
||||||
|
await databaseInitialization.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
|
||||||
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"TrafagSalesExporter": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:55415;http://localhost:55416"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class AppEventLogService : IAppEventLogService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.AppEventLogs.Add(new AppEventLog
|
||||||
|
{
|
||||||
|
Timestamp = DateTime.Now,
|
||||||
|
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
|
||||||
|
Category = category?.Trim() ?? string.Empty,
|
||||||
|
SiteId = siteId,
|
||||||
|
Land = land?.Trim() ?? string.Empty,
|
||||||
|
Message = message?.Trim() ?? string.Empty,
|
||||||
|
Details = details?.Trim() ?? string.Empty
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||||
|
if (settings is null || !settings.DebugLoggingEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
db.AppEventLogs.Add(new AppEventLog
|
||||||
|
{
|
||||||
|
Timestamp = DateTime.Now,
|
||||||
|
Level = "Debug",
|
||||||
|
Category = category?.Trim() ?? string.Empty,
|
||||||
|
SiteId = siteId,
|
||||||
|
Land = land?.Trim() ?? string.Empty,
|
||||||
|
Message = message?.Trim() ?? string.Empty,
|
||||||
|
Details = details?.Trim() ?? string.Empty
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class CentralSalesRecordService : ICentralSalesRecordService
|
||||||
|
{
|
||||||
|
private const int BatchSize = 25;
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var recordList = records.ToList();
|
||||||
|
|
||||||
|
await db.Database.OpenConnectionAsync();
|
||||||
|
var connection = (SqliteConnection)db.Database.GetDbConnection();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle: bestehende Saetze zaehlen...");
|
||||||
|
var existingCount = await CountExistingAsync(connection, site.Id);
|
||||||
|
|
||||||
|
if (existingCount > 0)
|
||||||
|
{
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle: alte Saetze loeschen...");
|
||||||
|
await DeleteExistingAsync(connection, site.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
|
||||||
|
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle aktualisiert");
|
||||||
|
|
||||||
|
await _appEventLogService.WriteAsync(
|
||||||
|
"Export",
|
||||||
|
"Zentrale Tabelle aktualisiert",
|
||||||
|
siteId: site.Id,
|
||||||
|
land: site.Land,
|
||||||
|
details: $"Geloescht={existingCount} | Neu={recordList.Count}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<SalesRecord>> GetAllAsync()
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
return await db.CentralSalesRecords
|
||||||
|
.OrderBy(r => r.Land)
|
||||||
|
.ThenBy(r => r.Tsc)
|
||||||
|
.Select(r => new SalesRecord
|
||||||
|
{
|
||||||
|
ExtractionDate = r.ExtractionDate,
|
||||||
|
Tsc = r.Tsc,
|
||||||
|
InvoiceNumber = r.InvoiceNumber,
|
||||||
|
PositionOnInvoice = r.PositionOnInvoice,
|
||||||
|
Material = r.Material,
|
||||||
|
Name = r.Name,
|
||||||
|
ProductGroup = r.ProductGroup,
|
||||||
|
Quantity = r.Quantity,
|
||||||
|
SupplierNumber = r.SupplierNumber,
|
||||||
|
SupplierName = r.SupplierName,
|
||||||
|
SupplierCountry = r.SupplierCountry,
|
||||||
|
CustomerNumber = r.CustomerNumber,
|
||||||
|
CustomerName = r.CustomerName,
|
||||||
|
CustomerCountry = r.CustomerCountry,
|
||||||
|
CustomerIndustry = r.CustomerIndustry,
|
||||||
|
StandardCost = r.StandardCost,
|
||||||
|
StandardCostCurrency = r.StandardCostCurrency,
|
||||||
|
PurchaseOrderNumber = r.PurchaseOrderNumber,
|
||||||
|
SalesPriceValue = r.SalesPriceValue,
|
||||||
|
SalesCurrency = r.SalesCurrency,
|
||||||
|
Incoterms2020 = r.Incoterms2020,
|
||||||
|
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
|
||||||
|
InvoiceDate = r.InvoiceDate,
|
||||||
|
OrderDate = r.OrderDate,
|
||||||
|
Land = r.Land,
|
||||||
|
DocumentType = r.DocumentType
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> CountExistingAsync(SqliteConnection connection, int siteId)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT COUNT(1) FROM CentralSalesRecords WHERE SiteId = $siteId;";
|
||||||
|
command.Parameters.AddWithValue("$siteId", siteId);
|
||||||
|
var scalar = await command.ExecuteScalarAsync();
|
||||||
|
return scalar is null or DBNull ? 0 : Convert.ToInt32(scalar);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteExistingAsync(SqliteConnection connection, int siteId)
|
||||||
|
{
|
||||||
|
await using var transaction = connection.BeginTransaction();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = "DELETE FROM CentralSalesRecords WHERE SiteId = $siteId;";
|
||||||
|
command.Parameters.AddWithValue("$siteId", siteId);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InsertRecordsInCommittedBatchesAsync(
|
||||||
|
SqliteConnection connection,
|
||||||
|
Site site,
|
||||||
|
IReadOnlyList<SalesRecord> records,
|
||||||
|
Action<string>? updateStatus)
|
||||||
|
{
|
||||||
|
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
|
||||||
|
var total = records.Count;
|
||||||
|
var totalBatches = Math.Max(1, (int)Math.Ceiling(total / (double)BatchSize));
|
||||||
|
var processed = 0;
|
||||||
|
|
||||||
|
for (var batchIndex = 0; batchIndex < totalBatches; batchIndex++)
|
||||||
|
{
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} speichern...");
|
||||||
|
|
||||||
|
await using var transaction = connection.BeginTransaction();
|
||||||
|
await using var command = CreateInsertCommand(connection, transaction);
|
||||||
|
|
||||||
|
var batchRecords = records
|
||||||
|
.Skip(batchIndex * BatchSize)
|
||||||
|
.Take(BatchSize);
|
||||||
|
|
||||||
|
foreach (var record in batchRecords)
|
||||||
|
{
|
||||||
|
SetInsertParameters(command, site, sourceSystem, record);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} abschliessen...");
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: {processed} Datensaetze gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SqliteCommand CreateInsertCommand(SqliteConnection connection, SqliteTransaction transaction)
|
||||||
|
{
|
||||||
|
var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO CentralSalesRecords (
|
||||||
|
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice,
|
||||||
|
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
|
||||||
|
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
|
||||||
|
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
|
||||||
|
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
|
||||||
|
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
|
||||||
|
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
|
||||||
|
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
|
||||||
|
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
command.Parameters.Add("$storedAtUtc", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$siteId", SqliteType.Integer);
|
||||||
|
command.Parameters.Add("$sourceSystem", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$extractionDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$tsc", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
|
||||||
|
command.Parameters.Add("$material", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$name", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$productGroup", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$quantity", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$supplierNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$supplierName", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$supplierCountry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerName", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerCountry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerIndustry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$standardCost", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$standardCostCurrency", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$salesPriceValue", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$salesCurrency", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$incoterms2020", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$invoiceDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$orderDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$land", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$documentType", SqliteType.Text);
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetInsertParameters(SqliteCommand command, Site site, string sourceSystem, SalesRecord record)
|
||||||
|
{
|
||||||
|
command.Parameters["$storedAtUtc"].Value = DateTime.UtcNow.ToString("O");
|
||||||
|
command.Parameters["$siteId"].Value = site.Id;
|
||||||
|
command.Parameters["$sourceSystem"].Value = sourceSystem;
|
||||||
|
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
|
||||||
|
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
|
||||||
|
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
|
||||||
|
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
|
||||||
|
command.Parameters["$material"].Value = record.Material ?? string.Empty;
|
||||||
|
command.Parameters["$name"].Value = record.Name ?? string.Empty;
|
||||||
|
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
|
||||||
|
command.Parameters["$quantity"].Value = record.Quantity;
|
||||||
|
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
|
||||||
|
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
|
||||||
|
command.Parameters["$supplierCountry"].Value = record.SupplierCountry ?? string.Empty;
|
||||||
|
command.Parameters["$customerNumber"].Value = record.CustomerNumber ?? string.Empty;
|
||||||
|
command.Parameters["$customerName"].Value = record.CustomerName ?? string.Empty;
|
||||||
|
command.Parameters["$customerCountry"].Value = record.CustomerCountry ?? string.Empty;
|
||||||
|
command.Parameters["$customerIndustry"].Value = record.CustomerIndustry ?? string.Empty;
|
||||||
|
command.Parameters["$standardCost"].Value = record.StandardCost;
|
||||||
|
command.Parameters["$standardCostCurrency"].Value = record.StandardCostCurrency ?? string.Empty;
|
||||||
|
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
|
||||||
|
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
|
||||||
|
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
|
||||||
|
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
|
||||||
|
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
|
||||||
|
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
|
||||||
|
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
|
||||||
|
command.Parameters["$land"].Value = record.Land ?? string.Empty;
|
||||||
|
command.Parameters["$documentType"].Value = record.DocumentType ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ConfigTransferService : IConfigTransferService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
|
public ConfigTransferService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ExportJsonAsync(bool includeSecrets)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||||
|
var exportSettings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||||
|
var sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
|
||||||
|
var exchangeRates = await db.CurrencyExchangeRates
|
||||||
|
.OrderBy(x => x.FromCurrency)
|
||||||
|
.ThenBy(x => x.ToCurrency)
|
||||||
|
.ThenByDescending(x => x.ValidFrom)
|
||||||
|
.ToListAsync();
|
||||||
|
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
|
||||||
|
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
|
||||||
|
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
|
||||||
|
var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
|
||||||
|
var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
|
||||||
|
var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
|
||||||
|
|
||||||
|
var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
|
||||||
|
var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
var package = new ConfigTransferPackage
|
||||||
|
{
|
||||||
|
IncludesSecrets = includeSecrets,
|
||||||
|
SharePointConfig = sharePoint is null ? null : new ConfigTransferSharePoint
|
||||||
|
{
|
||||||
|
SiteUrl = sharePoint.SiteUrl,
|
||||||
|
ExportFolder = sharePoint.ExportFolder,
|
||||||
|
CentralExportFolder = sharePoint.CentralExportFolder,
|
||||||
|
TenantId = sharePoint.TenantId,
|
||||||
|
ClientId = sharePoint.ClientId,
|
||||||
|
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
|
||||||
|
},
|
||||||
|
ExportSettings = exportSettings is null ? null : new ConfigTransferExportSettings
|
||||||
|
{
|
||||||
|
DateFilter = exportSettings.DateFilter,
|
||||||
|
TimerHour = exportSettings.TimerHour,
|
||||||
|
TimerMinute = exportSettings.TimerMinute,
|
||||||
|
TimerEnabled = exportSettings.TimerEnabled,
|
||||||
|
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
|
||||||
|
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
|
||||||
|
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder
|
||||||
|
},
|
||||||
|
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = system.Code,
|
||||||
|
DisplayName = system.DisplayName,
|
||||||
|
ConnectionKind = system.ConnectionKind,
|
||||||
|
IsActive = system.IsActive,
|
||||||
|
CentralServiceUrl = system.CentralServiceUrl,
|
||||||
|
CentralUsername = includeSecrets ? system.CentralUsername : null,
|
||||||
|
CentralPassword = includeSecrets ? system.CentralPassword : null
|
||||||
|
}).ToList(),
|
||||||
|
CurrencyExchangeRates = exchangeRates.Select(rate => new ConfigTransferCurrencyExchangeRate
|
||||||
|
{
|
||||||
|
FromCurrency = rate.FromCurrency,
|
||||||
|
ToCurrency = rate.ToCurrency,
|
||||||
|
Rate = rate.Rate,
|
||||||
|
ValidFrom = rate.ValidFrom,
|
||||||
|
ValidTo = rate.ValidTo,
|
||||||
|
Notes = rate.Notes,
|
||||||
|
IsActive = rate.IsActive
|
||||||
|
}).ToList(),
|
||||||
|
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
|
||||||
|
{
|
||||||
|
Key = serverKeyMap[server.Id],
|
||||||
|
SourceSystem = server.SourceSystem,
|
||||||
|
Name = server.Name,
|
||||||
|
Host = server.Host,
|
||||||
|
Port = server.Port,
|
||||||
|
DatabaseName = server.DatabaseName,
|
||||||
|
UseSsl = server.UseSsl,
|
||||||
|
ValidateCertificate = server.ValidateCertificate,
|
||||||
|
AdditionalParams = server.AdditionalParams
|
||||||
|
}).ToList(),
|
||||||
|
Sites = sites.Select(site => new ConfigTransferSite
|
||||||
|
{
|
||||||
|
Key = siteKeyMap[site.Id],
|
||||||
|
HanaServerKey = site.HanaServerId.HasValue && serverKeyMap.TryGetValue(site.HanaServerId.Value, out var serverKey) ? serverKey : null,
|
||||||
|
Schema = site.Schema,
|
||||||
|
TSC = site.TSC,
|
||||||
|
Land = site.Land,
|
||||||
|
SourceSystem = site.SourceSystem,
|
||||||
|
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
|
||||||
|
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
|
ManualImportFilePath = site.ManualImportFilePath,
|
||||||
|
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
|
||||||
|
SapServiceUrl = site.SapServiceUrl,
|
||||||
|
SapEntitySet = site.SapEntitySet,
|
||||||
|
SapEntitySetsCache = site.SapEntitySetsCache,
|
||||||
|
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
|
||||||
|
IsActive = site.IsActive
|
||||||
|
}).ToList(),
|
||||||
|
FieldTransformationRules = rules.Select(r => new FieldTransformationRule
|
||||||
|
{
|
||||||
|
SourceSystem = r.SourceSystem,
|
||||||
|
SourceField = r.SourceField,
|
||||||
|
TargetField = r.TargetField,
|
||||||
|
TransformationType = r.TransformationType,
|
||||||
|
RuleScope = r.RuleScope,
|
||||||
|
Argument = r.Argument,
|
||||||
|
SortOrder = r.SortOrder,
|
||||||
|
IsActive = r.IsActive
|
||||||
|
}).ToList(),
|
||||||
|
SapSourceDefinitions = sapSources.Select(s => new ConfigTransferSapSourceDefinition
|
||||||
|
{
|
||||||
|
SiteKey = siteKeyMap[s.SiteId],
|
||||||
|
Alias = s.Alias,
|
||||||
|
EntitySet = s.EntitySet,
|
||||||
|
IsPrimary = s.IsPrimary,
|
||||||
|
IsActive = s.IsActive,
|
||||||
|
SortOrder = s.SortOrder
|
||||||
|
}).ToList(),
|
||||||
|
SapJoinDefinitions = sapJoins.Select(j => new ConfigTransferSapJoinDefinition
|
||||||
|
{
|
||||||
|
SiteKey = siteKeyMap[j.SiteId],
|
||||||
|
LeftAlias = j.LeftAlias,
|
||||||
|
RightAlias = j.RightAlias,
|
||||||
|
LeftKeys = j.LeftKeys,
|
||||||
|
RightKeys = j.RightKeys,
|
||||||
|
JoinType = j.JoinType,
|
||||||
|
IsActive = j.IsActive,
|
||||||
|
SortOrder = j.SortOrder
|
||||||
|
}).ToList(),
|
||||||
|
SapFieldMappings = sapMappings.Select(m => new ConfigTransferSapFieldMapping
|
||||||
|
{
|
||||||
|
SiteKey = siteKeyMap[m.SiteId],
|
||||||
|
TargetField = m.TargetField,
|
||||||
|
SourceExpression = m.SourceExpression,
|
||||||
|
IsRequired = m.IsRequired,
|
||||||
|
IsActive = m.IsActive,
|
||||||
|
SortOrder = m.SortOrder
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(package, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ImportJsonAsync(string json)
|
||||||
|
{
|
||||||
|
var package = JsonSerializer.Deserialize<ConfigTransferPackage>(json, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden.");
|
||||||
|
var importedSourceSystems = ResolveImportedSourceSystems(json, package);
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||||
|
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||||
|
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
|
||||||
|
var existingServers = await db.HanaServers.ToListAsync();
|
||||||
|
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
|
||||||
|
var existingSites = await db.Sites.ToListAsync();
|
||||||
|
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
|
||||||
|
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
||||||
|
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
|
||||||
|
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
|
||||||
|
var existingSapMappings = await db.SapFieldMappings.ToListAsync();
|
||||||
|
|
||||||
|
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
|
||||||
|
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
|
||||||
|
x => x.Code,
|
||||||
|
x => (CentralUsername: x.CentralUsername, CentralPassword: x.CentralPassword),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var preservedSiteSecrets = existingSites.ToDictionary(
|
||||||
|
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem),
|
||||||
|
x => (x.UsernameOverride, x.PasswordOverride));
|
||||||
|
var existingSiteSignaturesById = existingSites.ToDictionary(
|
||||||
|
x => x.Id,
|
||||||
|
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
|
||||||
|
|
||||||
|
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
|
||||||
|
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
|
||||||
|
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
||||||
|
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
|
||||||
|
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
|
||||||
|
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
||||||
|
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
|
||||||
|
if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems);
|
||||||
|
if (existingSharePoint is not null) db.SharePointConfigs.Remove(existingSharePoint);
|
||||||
|
if (existingSettings is not null) db.ExportSettings.Remove(existingSettings);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var newSharePoint = package.SharePointConfig is null ? new SharePointConfig() : new SharePointConfig
|
||||||
|
{
|
||||||
|
SiteUrl = package.SharePointConfig.SiteUrl,
|
||||||
|
ExportFolder = package.SharePointConfig.ExportFolder,
|
||||||
|
CentralExportFolder = package.SharePointConfig.CentralExportFolder,
|
||||||
|
TenantId = package.SharePointConfig.TenantId,
|
||||||
|
ClientId = package.SharePointConfig.ClientId,
|
||||||
|
ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret
|
||||||
|
};
|
||||||
|
db.SharePointConfigs.Add(newSharePoint);
|
||||||
|
|
||||||
|
var importedSettings = package.ExportSettings ?? new ConfigTransferExportSettings();
|
||||||
|
db.ExportSettings.Add(new ExportSettings
|
||||||
|
{
|
||||||
|
DateFilter = importedSettings.DateFilter,
|
||||||
|
TimerHour = importedSettings.TimerHour,
|
||||||
|
TimerMinute = importedSettings.TimerMinute,
|
||||||
|
TimerEnabled = importedSettings.TimerEnabled,
|
||||||
|
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
|
||||||
|
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
|
||||||
|
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var sourceSystem in importedSourceSystems)
|
||||||
|
{
|
||||||
|
preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved);
|
||||||
|
db.SourceSystemDefinitions.Add(new SourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = sourceSystem.Code,
|
||||||
|
DisplayName = sourceSystem.DisplayName,
|
||||||
|
ConnectionKind = sourceSystem.ConnectionKind,
|
||||||
|
IsActive = sourceSystem.IsActive,
|
||||||
|
CentralServiceUrl = sourceSystem.CentralServiceUrl,
|
||||||
|
CentralUsername = package.IncludesSecrets ? sourceSystem.CentralUsername ?? string.Empty : preserved.CentralUsername ?? string.Empty,
|
||||||
|
CentralPassword = package.IncludesSecrets ? sourceSystem.CentralPassword ?? string.Empty : preserved.CentralPassword ?? string.Empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package.CurrencyExchangeRates.Count > 0)
|
||||||
|
{
|
||||||
|
db.CurrencyExchangeRates.AddRange(package.CurrencyExchangeRates.Select(rate => new CurrencyExchangeRate
|
||||||
|
{
|
||||||
|
FromCurrency = rate.FromCurrency,
|
||||||
|
ToCurrency = rate.ToCurrency,
|
||||||
|
Rate = rate.Rate,
|
||||||
|
ValidFrom = rate.ValidFrom,
|
||||||
|
ValidTo = rate.ValidTo,
|
||||||
|
Notes = rate.Notes,
|
||||||
|
IsActive = rate.IsActive
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var server in package.HanaServers)
|
||||||
|
{
|
||||||
|
var entity = new HanaServer
|
||||||
|
{
|
||||||
|
SourceSystem = server.SourceSystem,
|
||||||
|
Name = server.Name,
|
||||||
|
Host = server.Host,
|
||||||
|
Port = server.Port,
|
||||||
|
Username = string.Empty,
|
||||||
|
Password = string.Empty,
|
||||||
|
DatabaseName = server.DatabaseName,
|
||||||
|
UseSsl = server.UseSsl,
|
||||||
|
ValidateCertificate = server.ValidateCertificate,
|
||||||
|
AdditionalParams = server.AdditionalParams
|
||||||
|
};
|
||||||
|
db.HanaServers.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
serverIdMap[server.Key] = entity.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var importedSiteIdBySignature = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var site in package.Sites)
|
||||||
|
{
|
||||||
|
preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved);
|
||||||
|
var entity = new Site
|
||||||
|
{
|
||||||
|
HanaServerId = !string.IsNullOrWhiteSpace(site.HanaServerKey) && serverIdMap.TryGetValue(site.HanaServerKey, out var mappedServerId)
|
||||||
|
? mappedServerId
|
||||||
|
: null,
|
||||||
|
Schema = site.Schema,
|
||||||
|
TSC = site.TSC,
|
||||||
|
Land = site.Land,
|
||||||
|
SourceSystem = site.SourceSystem,
|
||||||
|
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
|
||||||
|
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
|
ManualImportFilePath = site.ManualImportFilePath,
|
||||||
|
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
|
||||||
|
SapServiceUrl = site.SapServiceUrl,
|
||||||
|
SapEntitySet = site.SapEntitySet,
|
||||||
|
SapEntitySetsCache = site.SapEntitySetsCache,
|
||||||
|
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
|
||||||
|
IsActive = site.IsActive
|
||||||
|
};
|
||||||
|
db.Sites.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
siteIdMap[site.Key] = entity.Id;
|
||||||
|
importedSiteIdBySignature[BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem)] = entity.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var centralRecordsToPreserve = existingCentralRecords
|
||||||
|
.Where(record => existingSiteSignaturesById.TryGetValue(record.SiteId, out var signature) && importedSiteIdBySignature.ContainsKey(signature))
|
||||||
|
.Select(record =>
|
||||||
|
{
|
||||||
|
var signature = existingSiteSignaturesById[record.SiteId];
|
||||||
|
return new CentralSalesRecord
|
||||||
|
{
|
||||||
|
StoredAtUtc = record.StoredAtUtc,
|
||||||
|
SiteId = importedSiteIdBySignature[signature],
|
||||||
|
SourceSystem = record.SourceSystem,
|
||||||
|
ExtractionDate = record.ExtractionDate,
|
||||||
|
Tsc = record.Tsc,
|
||||||
|
InvoiceNumber = record.InvoiceNumber,
|
||||||
|
PositionOnInvoice = record.PositionOnInvoice,
|
||||||
|
Material = record.Material,
|
||||||
|
Name = record.Name,
|
||||||
|
ProductGroup = record.ProductGroup,
|
||||||
|
Quantity = record.Quantity,
|
||||||
|
SupplierNumber = record.SupplierNumber,
|
||||||
|
SupplierName = record.SupplierName,
|
||||||
|
SupplierCountry = record.SupplierCountry,
|
||||||
|
CustomerNumber = record.CustomerNumber,
|
||||||
|
CustomerName = record.CustomerName,
|
||||||
|
CustomerCountry = record.CustomerCountry,
|
||||||
|
CustomerIndustry = record.CustomerIndustry,
|
||||||
|
StandardCost = record.StandardCost,
|
||||||
|
StandardCostCurrency = record.StandardCostCurrency,
|
||||||
|
PurchaseOrderNumber = record.PurchaseOrderNumber,
|
||||||
|
SalesPriceValue = record.SalesPriceValue,
|
||||||
|
SalesCurrency = record.SalesCurrency,
|
||||||
|
Incoterms2020 = record.Incoterms2020,
|
||||||
|
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
|
||||||
|
InvoiceDate = record.InvoiceDate,
|
||||||
|
OrderDate = record.OrderDate,
|
||||||
|
Land = record.Land,
|
||||||
|
DocumentType = record.DocumentType
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (centralRecordsToPreserve.Count > 0)
|
||||||
|
db.CentralSalesRecords.AddRange(centralRecordsToPreserve);
|
||||||
|
|
||||||
|
if (package.FieldTransformationRules.Count > 0)
|
||||||
|
{
|
||||||
|
db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule
|
||||||
|
{
|
||||||
|
SourceSystem = r.SourceSystem,
|
||||||
|
SourceField = r.SourceField,
|
||||||
|
TargetField = r.TargetField,
|
||||||
|
TransformationType = r.TransformationType,
|
||||||
|
RuleScope = r.RuleScope,
|
||||||
|
Argument = r.Argument,
|
||||||
|
SortOrder = r.SortOrder,
|
||||||
|
IsActive = r.IsActive
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package.SapSourceDefinitions.Count > 0)
|
||||||
|
{
|
||||||
|
db.SapSourceDefinitions.AddRange(package.SapSourceDefinitions
|
||||||
|
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
|
||||||
|
.Select(x => new SapSourceDefinition
|
||||||
|
{
|
||||||
|
SiteId = siteIdMap[x.SiteKey],
|
||||||
|
Alias = x.Alias,
|
||||||
|
EntitySet = x.EntitySet,
|
||||||
|
IsPrimary = x.IsPrimary,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
SortOrder = x.SortOrder
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package.SapJoinDefinitions.Count > 0)
|
||||||
|
{
|
||||||
|
db.SapJoinDefinitions.AddRange(package.SapJoinDefinitions
|
||||||
|
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
|
||||||
|
.Select(x => new SapJoinDefinition
|
||||||
|
{
|
||||||
|
SiteId = siteIdMap[x.SiteKey],
|
||||||
|
LeftAlias = x.LeftAlias,
|
||||||
|
RightAlias = x.RightAlias,
|
||||||
|
LeftKeys = x.LeftKeys,
|
||||||
|
RightKeys = x.RightKeys,
|
||||||
|
JoinType = x.JoinType,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
SortOrder = x.SortOrder
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package.SapFieldMappings.Count > 0)
|
||||||
|
{
|
||||||
|
db.SapFieldMappings.AddRange(package.SapFieldMappings
|
||||||
|
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
|
||||||
|
.Select(x => new SapFieldMapping
|
||||||
|
{
|
||||||
|
SiteId = siteIdMap[x.SiteKey],
|
||||||
|
TargetField = x.TargetField,
|
||||||
|
SourceExpression = x.SourceExpression,
|
||||||
|
IsRequired = x.IsRequired,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
SortOrder = x.SortOrder
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem)
|
||||||
|
=> $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant();
|
||||||
|
|
||||||
|
private static List<ConfigTransferSourceSystemDefinition> ResolveImportedSourceSystems(string json, ConfigTransferPackage package)
|
||||||
|
{
|
||||||
|
if (package.SourceSystemDefinitions.Count == 0)
|
||||||
|
return BuildDefaultSourceSystems();
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
if (!document.RootElement.TryGetProperty(nameof(ConfigTransferPackage.SourceSystemDefinitions), out var sourceSystemsElement) ||
|
||||||
|
sourceSystemsElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return package.SourceSystemDefinitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imported = package.SourceSystemDefinitions
|
||||||
|
.Select((sourceSystem, index) =>
|
||||||
|
{
|
||||||
|
var hasExplicitConnectionKind =
|
||||||
|
index < sourceSystemsElement.GetArrayLength() &&
|
||||||
|
sourceSystemsElement[index].TryGetProperty(nameof(ConfigTransferSourceSystemDefinition.ConnectionKind), out _);
|
||||||
|
|
||||||
|
if (hasExplicitConnectionKind)
|
||||||
|
return sourceSystem;
|
||||||
|
|
||||||
|
sourceSystem.ConnectionKind = InferLegacyConnectionKind(sourceSystem.Code);
|
||||||
|
return sourceSystem;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string InferLegacyConnectionKind(string code)
|
||||||
|
{
|
||||||
|
if (string.Equals(code, "SAP", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SourceSystemConnectionKinds.SapGateway;
|
||||||
|
|
||||||
|
if (string.Equals(code, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SourceSystemConnectionKinds.ManualExcel;
|
||||||
|
|
||||||
|
return SourceSystemConnectionKinds.Hana;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ConfigTransferSourceSystemDefinition> BuildDefaultSourceSystems()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = "SAP",
|
||||||
|
DisplayName = "SAP",
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.SapGateway,
|
||||||
|
IsActive = true,
|
||||||
|
CentralServiceUrl = string.Empty
|
||||||
|
},
|
||||||
|
new ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = "BI1",
|
||||||
|
DisplayName = "BI1",
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.Hana,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = "SAGE",
|
||||||
|
DisplayName = "SAGE",
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.Hana,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new ConfigTransferSourceSystemDefinition
|
||||||
|
{
|
||||||
|
Code = "MANUAL_EXCEL",
|
||||||
|
DisplayName = "Manual Excel",
|
||||||
|
ConnectionKind = SourceSystemConnectionKinds.ManualExcel,
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ConsolidatedExportService : IConsolidatedExportService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||||
|
private readonly IExcelExportService _excelService;
|
||||||
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
|
|
||||||
|
public ConsolidatedExportService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
ICentralSalesRecordService centralSalesRecordService,
|
||||||
|
IExcelExportService excelService,
|
||||||
|
ISharePointUploadService sharePointService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_centralSalesRecordService = centralSalesRecordService;
|
||||||
|
_excelService = excelService;
|
||||||
|
_sharePointService = sharePointService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> ExportAsync(List<SalesRecord> records)
|
||||||
|
{
|
||||||
|
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
|
||||||
|
if (consolidatedRecords.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||||
|
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
||||||
|
var outputDir = ResolveConsolidatedOutputDirectory(settings);
|
||||||
|
var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
|
||||||
|
outputDir,
|
||||||
|
DateTime.UtcNow.Date,
|
||||||
|
consolidatedRecords
|
||||||
|
.OrderBy(r => r.Land)
|
||||||
|
.ThenBy(r => r.Tsc)
|
||||||
|
.ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue)
|
||||||
|
.ThenBy(r => r.InvoiceNumber)
|
||||||
|
.ThenBy(r => r.PositionOnInvoice)
|
||||||
|
.ToList());
|
||||||
|
|
||||||
|
if (spConfig is not null &&
|
||||||
|
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
|
||||||
|
{
|
||||||
|
var centralFolderConfigured = !string.IsNullOrWhiteSpace(spConfig.CentralExportFolder);
|
||||||
|
var sharePointFolder = centralFolderConfigured
|
||||||
|
? spConfig.CentralExportFolder
|
||||||
|
: spConfig.ExportFolder;
|
||||||
|
var landSubfolder = centralFolderConfigured ? string.Empty : "Alle";
|
||||||
|
|
||||||
|
await _sharePointService.UploadAsync(
|
||||||
|
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
||||||
|
spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return consolidatedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
|
||||||
|
return settings.LocalConsolidatedExportFolder.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||||
|
return settings.LocalSiteExportFolder.Trim();
|
||||||
|
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "output");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class CurrencyExchangeRateService : ICurrencyExchangeRateService
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> BuiltInCurrencyAliases = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["$"] = "USD",
|
||||||
|
["US$"] = "USD",
|
||||||
|
["USD"] = "USD",
|
||||||
|
["€"] = "EUR",
|
||||||
|
["EUR"] = "EUR",
|
||||||
|
["CHF"] = "CHF",
|
||||||
|
["SFR"] = "CHF",
|
||||||
|
["INR"] = "INR",
|
||||||
|
["RS"] = "INR",
|
||||||
|
["GBP"] = "GBP",
|
||||||
|
["CAD"] = "CAD"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public CurrencyExchangeRateService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate)
|
||||||
|
{
|
||||||
|
var normalizedFrom = NormalizeCurrencyCode(fromCurrency);
|
||||||
|
var normalizedTo = NormalizeCurrencyCode(toCurrency);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedFrom) || string.IsNullOrWhiteSpace(normalizedTo))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (string.Equals(normalizedFrom, normalizedTo, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 1m;
|
||||||
|
|
||||||
|
var date = (effectiveDate ?? DateTime.UtcNow).Date;
|
||||||
|
|
||||||
|
using var db = _dbFactory.CreateDbContext();
|
||||||
|
var directRate = db.CurrencyExchangeRates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.IsActive
|
||||||
|
&& x.FromCurrency.ToUpper() == normalizedFrom
|
||||||
|
&& x.ToCurrency.ToUpper() == normalizedTo
|
||||||
|
&& x.ValidFrom.Date <= date
|
||||||
|
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
|
||||||
|
.OrderByDescending(x => x.ValidFrom)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (directRate is not null)
|
||||||
|
return directRate.Rate;
|
||||||
|
|
||||||
|
var inverseRate = db.CurrencyExchangeRates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.IsActive
|
||||||
|
&& x.FromCurrency.ToUpper() == normalizedTo
|
||||||
|
&& x.ToCurrency.ToUpper() == normalizedFrom
|
||||||
|
&& x.ValidFrom.Date <= date
|
||||||
|
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
|
||||||
|
.OrderByDescending(x => x.ValidFrom)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (inverseRate is not null && inverseRate.Rate != 0m)
|
||||||
|
return 1m / inverseRate.Rate;
|
||||||
|
|
||||||
|
var fromToEur = ResolveDirectOrInverseRate(db, normalizedFrom, "EUR", date);
|
||||||
|
var eurToTarget = ResolveDirectOrInverseRate(db, "EUR", normalizedTo, date);
|
||||||
|
if (fromToEur.HasValue && eurToTarget.HasValue)
|
||||||
|
return fromToEur.Value * eurToTarget.Value;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string NormalizeCurrencyCode(string? currencyCode)
|
||||||
|
{
|
||||||
|
var normalized = currencyCode?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return BuiltInCurrencyAliases.TryGetValue(normalized, out var mapped)
|
||||||
|
? mapped
|
||||||
|
: normalized.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ResolveDirectOrInverseRate(AppDbContext db, string fromCurrency, string toCurrency, DateTime date)
|
||||||
|
{
|
||||||
|
if (string.Equals(fromCurrency, toCurrency, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 1m;
|
||||||
|
|
||||||
|
var directRate = db.CurrencyExchangeRates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.IsActive
|
||||||
|
&& x.FromCurrency.ToUpper() == fromCurrency
|
||||||
|
&& x.ToCurrency.ToUpper() == toCurrency
|
||||||
|
&& x.ValidFrom.Date <= date
|
||||||
|
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
|
||||||
|
.OrderByDescending(x => x.ValidFrom)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (directRate is not null)
|
||||||
|
return directRate.Rate;
|
||||||
|
|
||||||
|
var inverseRate = db.CurrencyExchangeRates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.IsActive
|
||||||
|
&& x.FromCurrency.ToUpper() == toCurrency
|
||||||
|
&& x.ToCurrency.ToUpper() == fromCurrency
|
||||||
|
&& x.ValidFrom.Date <= date
|
||||||
|
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
|
||||||
|
.OrderByDescending(x => x.ValidFrom)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (inverseRate is not null && inverseRate.Rate != 0m)
|
||||||
|
return 1m / inverseRate.Rate;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IDashboardPageService
|
||||||
|
{
|
||||||
|
Task<DashboardPageState> LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardPageService : IDashboardPageService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardPageState> LoadAsync()
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
||||||
|
var sourceSystems = await db.SourceSystemDefinitions.AsNoTracking().ToListAsync();
|
||||||
|
var logs = await db.ExportLogs
|
||||||
|
.GroupBy(l => l.SiteId)
|
||||||
|
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
|
||||||
|
.ToListAsync();
|
||||||
|
var appLogs = await db.AppEventLogs
|
||||||
|
.Where(l => l.SiteId != null)
|
||||||
|
.OrderByDescending(l => l.Timestamp)
|
||||||
|
.Take(1000)
|
||||||
|
.ToListAsync();
|
||||||
|
var latestAppLogsBySite = appLogs
|
||||||
|
.GroupBy(l => l.SiteId!.Value)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
|
||||||
|
|
||||||
|
var rows = sites.Select(s =>
|
||||||
|
{
|
||||||
|
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
|
||||||
|
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
|
||||||
|
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, s.SourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return new DashboardRow
|
||||||
|
{
|
||||||
|
SiteId = s.Id,
|
||||||
|
Land = s.Land,
|
||||||
|
TSC = s.TSC,
|
||||||
|
Schema = s.Schema,
|
||||||
|
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ResolveDashboardSapServiceUrl(s, sourceSystems)
|
||||||
|
: s.HanaServer?.Name ?? string.Empty,
|
||||||
|
LastStatus = log?.Status ?? string.Empty,
|
||||||
|
RowCount = log?.RowCount ?? 0,
|
||||||
|
LastRun = log?.Timestamp,
|
||||||
|
DurationSeconds = log?.DurationSeconds ?? 0,
|
||||||
|
ErrorMessage = log?.ErrorMessage ?? string.Empty,
|
||||||
|
FilePath = log?.FilePath ?? string.Empty,
|
||||||
|
LiveMessage = appLog is null ? string.Empty : $"{appLog.Category}: {appLog.Message}",
|
||||||
|
LiveDetails = appLog?.Details ?? string.Empty
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new DashboardPageState
|
||||||
|
{
|
||||||
|
DashboardRows = rows,
|
||||||
|
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
|
||||||
|
return site.SapServiceUrl;
|
||||||
|
|
||||||
|
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
|
||||||
|
{
|
||||||
|
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
|
||||||
|
if (!Directory.Exists(outputDirectory))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return Directory.GetFiles(outputDirectory, "Sales_All_*.xlsx")
|
||||||
|
.Select(path => new FileInfo(path))
|
||||||
|
.OrderByDescending(file => file.LastWriteTime)
|
||||||
|
.Take(1)
|
||||||
|
.Select(file => new ConsolidatedDashboardRow
|
||||||
|
{
|
||||||
|
Label = "Konsolidierter Export",
|
||||||
|
FilePath = file.FullName,
|
||||||
|
DisplayPath = file.FullName,
|
||||||
|
LastModified = file.LastWriteTime
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
|
||||||
|
return settings.LocalConsolidatedExportFolder.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||||
|
return settings.LocalSiteExportFolder.Trim();
|
||||||
|
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "output");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardPageState
|
||||||
|
{
|
||||||
|
public List<DashboardRow> DashboardRows { get; set; } = [];
|
||||||
|
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardRow
|
||||||
|
{
|
||||||
|
public int SiteId { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string TSC { get; set; } = string.Empty;
|
||||||
|
public string Schema { get; set; } = string.Empty;
|
||||||
|
public string ServerName { get; set; } = string.Empty;
|
||||||
|
public string LastStatus { get; set; } = string.Empty;
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public DateTime? LastRun { get; set; }
|
||||||
|
public double DurationSeconds { get; set; }
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string LiveMessage { get; set; } = string.Empty;
|
||||||
|
public string LiveDetails { get; set; } = string.Empty;
|
||||||
|
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConsolidatedDashboardRow
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string DisplayPath { get; set; } = string.Empty;
|
||||||
|
public DateTime? LastModified { get; set; }
|
||||||
|
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class DataSourceAdapterResolver : IDataSourceAdapterResolver
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IDataSourceAdapter> _adapters;
|
||||||
|
|
||||||
|
public DataSourceAdapterResolver(IEnumerable<IDataSourceAdapter> adapters)
|
||||||
|
{
|
||||||
|
_adapters = adapters.ToDictionary(
|
||||||
|
a => a.ConnectionKind,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDataSourceAdapter Resolve(string connectionKind)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionKind))
|
||||||
|
connectionKind = SourceSystemConnectionKinds.Hana;
|
||||||
|
|
||||||
|
if (_adapters.TryGetValue(connectionKind, out var adapter))
|
||||||
|
return adapter;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Kein DataSourceAdapter fuer ConnectionKind '{connectionKind}' registriert.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
internal static class DataSourceCredentials
|
||||||
|
{
|
||||||
|
public static (string Username, string Password) Resolve(Site site, SourceSystemDefinition sourceDefinition)
|
||||||
|
=> (FirstNonEmpty(site.UsernameOverride, sourceDefinition.CentralUsername),
|
||||||
|
FirstNonEmpty(site.PasswordOverride, sourceDefinition.CentralPassword));
|
||||||
|
|
||||||
|
public static string ResolveSapServiceUrl(Site site, SourceSystemDefinition sourceDefinition)
|
||||||
|
=> FirstNonEmpty(site.SapServiceUrl, sourceDefinition.CentralServiceUrl);
|
||||||
|
|
||||||
|
public static string FirstNonEmpty(params string[] values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class DataSourceFetchContext
|
||||||
|
{
|
||||||
|
public required Site Site { get; init; }
|
||||||
|
public required SourceSystemDefinition SourceDefinition { get; init; }
|
||||||
|
public required ExportSettings Settings { get; init; }
|
||||||
|
public SharePointConfig? SharePointConfig { get; init; }
|
||||||
|
public Action<string>? UpdateStatus { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class DataSourceFetchResult
|
||||||
|
{
|
||||||
|
public required List<SalesRecord> Records { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wenn gesetzt, liefert der Adapter bereits eine Referenz-Datei (z. B. manueller Excel-Import).
|
||||||
|
/// SiteExportService erzeugt dann keine neue Excel-Datei.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReferenceFilePath { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class HanaDataSourceAdapter : IDataSourceAdapter
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IHanaQueryService _hanaService;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public HanaDataSourceAdapter(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
IHanaQueryService hanaService,
|
||||||
|
IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_hanaService = hanaService;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ConnectionKind => SourceSystemConnectionKinds.Hana;
|
||||||
|
|
||||||
|
public async Task<DataSourceFetchResult> FetchAsync(DataSourceFetchContext context)
|
||||||
|
{
|
||||||
|
var site = context.Site;
|
||||||
|
var sourceDefinition = context.SourceDefinition;
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition);
|
||||||
|
|
||||||
|
context.UpdateStatus?.Invoke("HANA Abfrage...");
|
||||||
|
await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet",
|
||||||
|
siteId: site.Id, land: site.Land,
|
||||||
|
details: exportServer.GetConnectionStringPreview());
|
||||||
|
|
||||||
|
var records = await Task.Run(() => _hanaService.GetSalesRecords(
|
||||||
|
exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter));
|
||||||
|
|
||||||
|
return new DataSourceFetchResult { Records = records };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HanaServer> BuildEffectiveServerAsync(
|
||||||
|
AppDbContext db, Site site, SourceSystemDefinition sourceDefinition)
|
||||||
|
{
|
||||||
|
var centralServer = await db.HanaServers
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.FirstOrDefaultAsync(x => x.SourceSystem == sourceDefinition.Code)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Fuer Quellsystem '{sourceDefinition.Code}' ist keine zentrale HANA-Konfiguration vorhanden.");
|
||||||
|
|
||||||
|
var credentials = DataSourceCredentials.Resolve(site, sourceDefinition);
|
||||||
|
|
||||||
|
return new HanaServer
|
||||||
|
{
|
||||||
|
Id = centralServer.Id,
|
||||||
|
SourceSystem = centralServer.SourceSystem,
|
||||||
|
Name = centralServer.Name,
|
||||||
|
Host = centralServer.Host,
|
||||||
|
Port = centralServer.Port,
|
||||||
|
Username = credentials.Username,
|
||||||
|
Password = credentials.Password,
|
||||||
|
DatabaseName = centralServer.DatabaseName,
|
||||||
|
UseSsl = centralServer.UseSsl,
|
||||||
|
ValidateCertificate = centralServer.ValidateCertificate,
|
||||||
|
AdditionalParams = centralServer.AdditionalParams
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public interface IDataSourceAdapter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Der Wert aus <see cref="Models.SourceSystemConnectionKinds"/>, den dieser Adapter behandelt.
|
||||||
|
/// </summary>
|
||||||
|
string ConnectionKind { get; }
|
||||||
|
|
||||||
|
Task<DataSourceFetchResult> FetchAsync(DataSourceFetchContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public interface IDataSourceAdapterResolver
|
||||||
|
{
|
||||||
|
IDataSourceAdapter Resolve(string connectionKind);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class ManualExcelDataSourceAdapter : IDataSourceAdapter
|
||||||
|
{
|
||||||
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
|
private readonly IManualExcelImportService _manualExcelImportService;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public ManualExcelDataSourceAdapter(
|
||||||
|
ISharePointUploadService sharePointService,
|
||||||
|
IManualExcelImportService manualExcelImportService,
|
||||||
|
IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_sharePointService = sharePointService;
|
||||||
|
_manualExcelImportService = manualExcelImportService;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ConnectionKind => SourceSystemConnectionKinds.ManualExcel;
|
||||||
|
|
||||||
|
public async Task<DataSourceFetchResult> FetchAsync(DataSourceFetchContext context)
|
||||||
|
{
|
||||||
|
var site = context.Site;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(site.ManualImportFilePath))
|
||||||
|
throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei.");
|
||||||
|
|
||||||
|
var manualImportPath = site.ManualImportFilePath.Trim();
|
||||||
|
string filePath;
|
||||||
|
string? tempManualImportPath = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(manualImportPath))
|
||||||
|
{
|
||||||
|
filePath = manualImportPath;
|
||||||
|
}
|
||||||
|
else if (LooksLikeSharePointReference(manualImportPath))
|
||||||
|
{
|
||||||
|
var spConfig = context.SharePointConfig
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(spConfig.TenantId) ||
|
||||||
|
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
|
||||||
|
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
|
||||||
|
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.UpdateStatus?.Invoke("Manuelle Excel von SharePoint laden...");
|
||||||
|
await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden",
|
||||||
|
siteId: site.Id, land: site.Land, details: manualImportPath);
|
||||||
|
|
||||||
|
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync(
|
||||||
|
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
||||||
|
spConfig.SiteUrl, manualImportPath);
|
||||||
|
filePath = manualImportPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readPath = tempManualImportPath ?? filePath;
|
||||||
|
context.UpdateStatus?.Invoke("Manuelle Excel lesen...");
|
||||||
|
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen",
|
||||||
|
siteId: site.Id, land: site.Land, details: filePath);
|
||||||
|
|
||||||
|
var records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site);
|
||||||
|
return new DataSourceFetchResult
|
||||||
|
{
|
||||||
|
Records = records,
|
||||||
|
ReferenceFilePath = filePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath))
|
||||||
|
File.Delete(tempManualImportPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeSharePointReference(string path)
|
||||||
|
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services.DataSources;
|
||||||
|
|
||||||
|
public sealed class SapGatewayDataSourceAdapter : IDataSourceAdapter
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly ISapCompositionService _sapCompositionService;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public SapGatewayDataSourceAdapter(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
ISapCompositionService sapCompositionService,
|
||||||
|
IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_sapCompositionService = sapCompositionService;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ConnectionKind => SourceSystemConnectionKinds.SapGateway;
|
||||||
|
|
||||||
|
public async Task<DataSourceFetchResult> FetchAsync(DataSourceFetchContext context)
|
||||||
|
{
|
||||||
|
var site = context.Site;
|
||||||
|
var sourceDefinition = context.SourceDefinition;
|
||||||
|
|
||||||
|
var credentials = DataSourceCredentials.Resolve(site, sourceDefinition);
|
||||||
|
var sapServiceUrl = DataSourceCredentials.ResolveSapServiceUrl(site, sourceDefinition);
|
||||||
|
if (string.IsNullOrWhiteSpace(sapServiceUrl))
|
||||||
|
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL.");
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
|
||||||
|
var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
|
||||||
|
var sapMappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
|
||||||
|
|
||||||
|
if (sapSources.Count == 0)
|
||||||
|
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Quellen konfiguriert.");
|
||||||
|
if (sapMappings.Count == 0)
|
||||||
|
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
|
||||||
|
|
||||||
|
context.UpdateStatus?.Invoke("SAP Quellen laden...");
|
||||||
|
await _appEventLogService.WriteAsync("Export", "SAP Quellen laden",
|
||||||
|
siteId: site.Id, land: site.Land,
|
||||||
|
details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}");
|
||||||
|
|
||||||
|
var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl);
|
||||||
|
var records = await _sapCompositionService.BuildSalesRecordsAsync(
|
||||||
|
effectiveSite, sapSources, sapJoins, sapMappings,
|
||||||
|
credentials.Username, credentials.Password);
|
||||||
|
|
||||||
|
return new DataSourceFetchResult { Records = records };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl)
|
||||||
|
{
|
||||||
|
return new Site
|
||||||
|
{
|
||||||
|
Id = site.Id,
|
||||||
|
HanaServerId = site.HanaServerId,
|
||||||
|
HanaServer = site.HanaServer,
|
||||||
|
Schema = site.Schema,
|
||||||
|
TSC = site.TSC,
|
||||||
|
Land = site.Land,
|
||||||
|
SourceSystem = site.SourceSystem,
|
||||||
|
UsernameOverride = site.UsernameOverride,
|
||||||
|
PasswordOverride = site.PasswordOverride,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
|
ManualImportFilePath = site.ManualImportFilePath,
|
||||||
|
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
|
||||||
|
SapServiceUrl = sapServiceUrl,
|
||||||
|
SapEntitySet = site.SapEntitySet,
|
||||||
|
SapEntitySetsCache = site.SapEntitySetsCache,
|
||||||
|
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
|
||||||
|
IsActive = site.IsActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
internal static class DatabaseSchemaSql
|
||||||
|
{
|
||||||
|
internal static string GetExportLogsCreateSql() => @"
|
||||||
|
CREATE TABLE ExportLogs (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Timestamp TEXT NOT NULL,
|
||||||
|
SiteId INTEGER NOT NULL,
|
||||||
|
Land TEXT NOT NULL,
|
||||||
|
TSC TEXT NOT NULL,
|
||||||
|
Status TEXT NOT NULL,
|
||||||
|
RowCount INTEGER NOT NULL,
|
||||||
|
ErrorMessage TEXT NULL,
|
||||||
|
FileName TEXT NOT NULL DEFAULT '',
|
||||||
|
FilePath TEXT NOT NULL DEFAULT '',
|
||||||
|
DurationSeconds REAL NOT NULL,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetExportSettingsCreateSql() => @"
|
||||||
|
CREATE TABLE ExportSettings (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
DateFilter TEXT NOT NULL,
|
||||||
|
TimerHour INTEGER NOT NULL,
|
||||||
|
TimerMinute INTEGER NOT NULL,
|
||||||
|
TimerEnabled INTEGER NOT NULL,
|
||||||
|
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
|
||||||
|
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT ''
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetHanaServersCreateSql() => @"
|
||||||
|
CREATE TABLE HanaServers (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
SourceSystem TEXT NOT NULL,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
Host TEXT NOT NULL,
|
||||||
|
Port INTEGER NOT NULL,
|
||||||
|
DatabaseName TEXT NOT NULL DEFAULT '',
|
||||||
|
UseSsl INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ValidateCertificate INTEGER NOT NULL DEFAULT 0,
|
||||||
|
AdditionalParams TEXT NOT NULL DEFAULT ''
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetSitesCreateSql() => @"
|
||||||
|
CREATE TABLE Sites (
|
||||||
|
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
|
||||||
|
HanaServerId INTEGER NULL,
|
||||||
|
Schema TEXT NOT NULL,
|
||||||
|
TSC TEXT NOT NULL,
|
||||||
|
Land TEXT NOT NULL,
|
||||||
|
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
|
||||||
|
UsernameOverride TEXT NOT NULL DEFAULT '',
|
||||||
|
PasswordOverride TEXT NOT NULL DEFAULT '',
|
||||||
|
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
|
||||||
|
ManualImportFilePath TEXT NOT NULL DEFAULT '',
|
||||||
|
ManualImportLastUploadedAtUtc TEXT NULL,
|
||||||
|
SapServiceUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
SapEntitySet TEXT NOT NULL DEFAULT '',
|
||||||
|
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
|
||||||
|
SapEntitySetsRefreshedAtUtc TEXT NULL,
|
||||||
|
IsActive INTEGER NOT NULL,
|
||||||
|
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetAppEventLogsCreateSql() => @"
|
||||||
|
CREATE TABLE AppEventLogs (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Timestamp TEXT NOT NULL,
|
||||||
|
Level TEXT NOT NULL,
|
||||||
|
Category TEXT NOT NULL,
|
||||||
|
SiteId INTEGER NULL,
|
||||||
|
Land TEXT NOT NULL,
|
||||||
|
Message TEXT NOT NULL,
|
||||||
|
Details TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetCentralSalesRecordsCreateSql() => @"
|
||||||
|
CREATE TABLE CentralSalesRecords (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
StoredAtUtc TEXT NOT NULL,
|
||||||
|
SiteId INTEGER NOT NULL,
|
||||||
|
SourceSystem TEXT NOT NULL,
|
||||||
|
ExtractionDate TEXT NOT NULL,
|
||||||
|
Tsc TEXT NOT NULL,
|
||||||
|
InvoiceNumber TEXT NOT NULL,
|
||||||
|
PositionOnInvoice INTEGER NOT NULL,
|
||||||
|
Material TEXT NOT NULL,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
ProductGroup TEXT NOT NULL,
|
||||||
|
Quantity TEXT NOT NULL,
|
||||||
|
SupplierNumber TEXT NOT NULL,
|
||||||
|
SupplierName TEXT NOT NULL,
|
||||||
|
SupplierCountry TEXT NOT NULL,
|
||||||
|
CustomerNumber TEXT NOT NULL,
|
||||||
|
CustomerName TEXT NOT NULL,
|
||||||
|
CustomerCountry TEXT NOT NULL,
|
||||||
|
CustomerIndustry TEXT NOT NULL,
|
||||||
|
StandardCost TEXT NOT NULL,
|
||||||
|
StandardCostCurrency TEXT NOT NULL,
|
||||||
|
PurchaseOrderNumber TEXT NOT NULL,
|
||||||
|
SalesPriceValue TEXT NOT NULL,
|
||||||
|
SalesCurrency TEXT NOT NULL,
|
||||||
|
Incoterms2020 TEXT NOT NULL,
|
||||||
|
SalesResponsibleEmployee TEXT NOT NULL,
|
||||||
|
InvoiceDate TEXT NULL,
|
||||||
|
OrderDate TEXT NULL,
|
||||||
|
Land TEXT NOT NULL,
|
||||||
|
DocumentType TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetSapSourceDefinitionsCreateSql() => @"
|
||||||
|
CREATE TABLE SapSourceDefinitions (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
SiteId INTEGER NOT NULL,
|
||||||
|
Alias TEXT NOT NULL,
|
||||||
|
EntitySet TEXT NOT NULL,
|
||||||
|
IsPrimary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetSapJoinDefinitionsCreateSql() => @"
|
||||||
|
CREATE TABLE SapJoinDefinitions (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
SiteId INTEGER NOT NULL,
|
||||||
|
LeftAlias TEXT NOT NULL,
|
||||||
|
RightAlias TEXT NOT NULL,
|
||||||
|
LeftKeys TEXT NOT NULL,
|
||||||
|
RightKeys TEXT NOT NULL,
|
||||||
|
JoinType TEXT NOT NULL DEFAULT 'Left',
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
|
||||||
|
internal static string GetSapFieldMappingsCreateSql() => @"
|
||||||
|
CREATE TABLE SapFieldMappings (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
SiteId INTEGER NOT NULL,
|
||||||
|
TargetField TEXT NOT NULL,
|
||||||
|
SourceExpression TEXT NOT NULL,
|
||||||
|
IsRequired INTEGER NOT NULL DEFAULT 0,
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
|
||||||
|
);";
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public partial class DatabaseInitializationService : IDatabaseInitializationService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IDatabaseSchemaMaintenanceService _schemaMaintenanceService;
|
||||||
|
private readonly IDatabaseSeedService _seedService;
|
||||||
|
|
||||||
|
public DatabaseInitializationService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
IDatabaseSchemaMaintenanceService schemaMaintenanceService,
|
||||||
|
IDatabaseSeedService seedService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_schemaMaintenanceService = schemaMaintenanceService;
|
||||||
|
_seedService = seedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
ConfigureSqlite(db);
|
||||||
|
_schemaMaintenanceService.EnsureSchema(db);
|
||||||
|
_seedService.SeedDefaults(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureSqlite(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using (var wal = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
wal.CommandText = "PRAGMA journal_mode=WAL;";
|
||||||
|
wal.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var timeout = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
timeout.CommandText = "PRAGMA busy_timeout=10000;";
|
||||||
|
timeout.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceService
|
||||||
|
{
|
||||||
|
public void EnsureSchema(AppDbContext db)
|
||||||
|
{
|
||||||
|
EnsureSitesTableSupportsOptionalHanaServer(db);
|
||||||
|
EnsureExportSettingsTableSupportsCurrentSchema(db);
|
||||||
|
EnsureHanaServersTableSupportsCurrentSchema(db);
|
||||||
|
RepairBrokenForeignKeys(db);
|
||||||
|
AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
|
||||||
|
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "ManualImportFilePath", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "ManualImportLastUploadedAtUtc", "TEXT NULL");
|
||||||
|
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
|
||||||
|
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
EnsureTransformationTable(db);
|
||||||
|
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
|
||||||
|
EnsureCurrencyExchangeRateTable(db);
|
||||||
|
EnsureSourceSystemDefinitionTable(db);
|
||||||
|
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||||
|
EnsureSapSourceTable(db);
|
||||||
|
EnsureSapJoinTable(db);
|
||||||
|
EnsureSapFieldMappingTable(db);
|
||||||
|
EnsureCentralSalesRecordTable(db);
|
||||||
|
EnsureAppEventLogTable(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExportSettingsTableSupportsCurrentSchema(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, "ExportSettings");
|
||||||
|
if (columns.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var legacyColumns = new[]
|
||||||
|
{
|
||||||
|
"SapUsername",
|
||||||
|
"SapPassword",
|
||||||
|
"Bi1Username",
|
||||||
|
"Bi1Password",
|
||||||
|
"SageUsername",
|
||||||
|
"SagePassword"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!legacyColumns.Any(columns.Contains))
|
||||||
|
return;
|
||||||
|
|
||||||
|
DatabaseSchemaTools.RebuildTable(conn, "ExportSettings", DatabaseSchemaSql.GetExportSettingsCreateSql());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureHanaServersTableSupportsCurrentSchema(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, "HanaServers");
|
||||||
|
if (columns.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!columns.Contains("Username") && !columns.Contains("Password"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
DatabaseSchemaTools.RebuildTable(conn, "HanaServers", DatabaseSchemaSql.GetHanaServersCreateSql());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var hanaServerIdIsRequired = false;
|
||||||
|
{
|
||||||
|
using var pragma = conn.CreateCommand();
|
||||||
|
pragma.CommandText = "PRAGMA table_info(Sites)";
|
||||||
|
using var reader = pragma.ExecuteReader();
|
||||||
|
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hanaServerIdIsRequired)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var disableFk = conn.CreateCommand();
|
||||||
|
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
|
||||||
|
disableFk.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var transaction = conn.BeginTransaction();
|
||||||
|
|
||||||
|
using (var rename = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
rename.Transaction = transaction;
|
||||||
|
rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
|
||||||
|
rename.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var create = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
create.Transaction = transaction;
|
||||||
|
create.CommandText = DatabaseSchemaSql.GetSitesCreateSql();
|
||||||
|
create.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var copy = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
copy.Transaction = transaction;
|
||||||
|
copy.CommandText = @"
|
||||||
|
INSERT INTO Sites (
|
||||||
|
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
|
||||||
|
UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
|
||||||
|
ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
|
||||||
|
SapEntitySetsRefreshedAtUtc, IsActive
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
Id, HanaServerId, Schema, TSC, Land,
|
||||||
|
COALESCE(SourceSystem, 'SAP'),
|
||||||
|
COALESCE(UsernameOverride, ''),
|
||||||
|
COALESCE(PasswordOverride, ''),
|
||||||
|
COALESCE(LocalExportFolderOverride, ''),
|
||||||
|
COALESCE(ManualImportFilePath, ''),
|
||||||
|
ManualImportLastUploadedAtUtc,
|
||||||
|
COALESCE(SapServiceUrl, ''),
|
||||||
|
COALESCE(SapEntitySet, ''),
|
||||||
|
COALESCE(SapEntitySetsCache, ''),
|
||||||
|
SapEntitySetsRefreshedAtUtc,
|
||||||
|
IsActive
|
||||||
|
FROM Sites_old;";
|
||||||
|
copy.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var drop = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
drop.Transaction = transaction;
|
||||||
|
drop.CommandText = "DROP TABLE Sites_old;";
|
||||||
|
drop.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
|
||||||
|
using var enableFk = conn.CreateCommand();
|
||||||
|
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
|
||||||
|
enableFk.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RepairBrokenForeignKeys(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var siteDependentTables = new[]
|
||||||
|
{
|
||||||
|
("ExportLogs", DatabaseSchemaSql.GetExportLogsCreateSql()),
|
||||||
|
("AppEventLogs", DatabaseSchemaSql.GetAppEventLogsCreateSql()),
|
||||||
|
("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()),
|
||||||
|
("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()),
|
||||||
|
("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()),
|
||||||
|
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql())
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (tableName, createSql) in siteDependentTables)
|
||||||
|
{
|
||||||
|
if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old"))
|
||||||
|
DatabaseSchemaTools.RebuildTable(conn, tableName, createSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old"))
|
||||||
|
DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var exists = false;
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = $"PRAGMA table_info({table})";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
using var alter = conn.CreateCommand();
|
||||||
|
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureTransformationTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
|
||||||
|
SourceField TEXT NOT NULL,
|
||||||
|
TargetField TEXT NOT NULL,
|
||||||
|
TransformationType TEXT NOT NULL,
|
||||||
|
RuleScope TEXT NOT NULL DEFAULT 'Value',
|
||||||
|
Argument TEXT NOT NULL DEFAULT '',
|
||||||
|
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSapSourceTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureCurrencyExchangeRateTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
FromCurrency TEXT NOT NULL,
|
||||||
|
ToCurrency TEXT NOT NULL,
|
||||||
|
Rate REAL NOT NULL,
|
||||||
|
ValidFrom TEXT NOT NULL,
|
||||||
|
ValidTo TEXT NULL,
|
||||||
|
Notes TEXT NOT NULL DEFAULT '',
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSapJoinTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSapFieldMappingTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = DatabaseSchemaSql.GetSapFieldMappingsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureCentralSalesRecordTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = DatabaseSchemaSql.GetCentralSalesRecordsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureAppEventLogTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = DatabaseSchemaSql.GetAppEventLogsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSourceSystemDefinitionTable(AppDbContext db)
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS SourceSystemDefinitions (
|
||||||
|
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Code TEXT NOT NULL,
|
||||||
|
DisplayName TEXT NOT NULL,
|
||||||
|
ConnectionKind TEXT NOT NULL,
|
||||||
|
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
CentralServiceUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
CentralUsername TEXT NOT NULL DEFAULT '',
|
||||||
|
CentralPassword TEXT NOT NULL DEFAULT ''
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DatabaseSchemaTools
|
||||||
|
{
|
||||||
|
internal static bool TableReferences(System.Data.Common.DbConnection connection, string tableName, string referencedTableName)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
|
||||||
|
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = "$tableName";
|
||||||
|
parameter.Value = tableName;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
|
||||||
|
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
|
||||||
|
return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
|
||||||
|
{
|
||||||
|
using var disableFk = connection.CreateCommand();
|
||||||
|
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
|
||||||
|
disableFk.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
var tempTableName = $"{tableName}_repair_old";
|
||||||
|
|
||||||
|
using (var rename = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
rename.Transaction = transaction;
|
||||||
|
rename.CommandText = $"ALTER TABLE {tableName} RENAME TO {tempTableName};";
|
||||||
|
rename.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var create = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
create.Transaction = transaction;
|
||||||
|
create.CommandText = createSql;
|
||||||
|
create.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns = GetSharedColumns(connection, transaction, tableName, tempTableName);
|
||||||
|
if (columns.Count > 0)
|
||||||
|
{
|
||||||
|
var columnList = string.Join(", ", columns);
|
||||||
|
|
||||||
|
using var copy = connection.CreateCommand();
|
||||||
|
copy.Transaction = transaction;
|
||||||
|
copy.CommandText = $"INSERT INTO {tableName} ({columnList}) SELECT {columnList} FROM {tempTableName};";
|
||||||
|
copy.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var drop = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
drop.Transaction = transaction;
|
||||||
|
drop.CommandText = $"DROP TABLE {tempTableName};";
|
||||||
|
drop.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
|
||||||
|
using var enableFk = connection.CreateCommand();
|
||||||
|
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
|
||||||
|
enableFk.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<string> GetSharedColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string newTableName, string oldTableName)
|
||||||
|
{
|
||||||
|
var newColumns = GetTableColumns(connection, transaction, newTableName);
|
||||||
|
var oldColumns = GetTableColumns(connection, transaction, oldTableName);
|
||||||
|
|
||||||
|
return newColumns.Where(oldColumns.Contains).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HashSet<string> GetTableColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string tableName)
|
||||||
|
{
|
||||||
|
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = $"PRAGMA table_info({tableName})";
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var name = reader["name"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
columns.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class DatabaseSeedService : IDatabaseSeedService
|
||||||
|
{
|
||||||
|
public void SeedDefaults(AppDbContext db)
|
||||||
|
{
|
||||||
|
SeedIfEmpty(db);
|
||||||
|
EnsureRecommendedTransformationRules(db);
|
||||||
|
EnsureSourceSystemDefinitions(db);
|
||||||
|
EnsureCentralHanaServerRecords(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedIfEmpty(AppDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Sites.Any() || db.HanaServers.Any() || db.SharePointConfigs.Any() || db.ExportSettings.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var serverBi1 = new HanaServer { SourceSystem = "BI1", Name = "BI1", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
|
||||||
|
var serverSage = new HanaServer { SourceSystem = "SAGE", Name = "SAGE", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
|
||||||
|
db.HanaServers.AddRange(serverBi1, serverSage);
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
db.Sites.AddRange(
|
||||||
|
new Site { HanaServerId = serverBi1.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", SourceSystem = "BI1", IsActive = true },
|
||||||
|
new Site { HanaServerId = serverBi1.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", SourceSystem = "BI1", IsActive = true },
|
||||||
|
new Site { HanaServerId = serverBi1.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", SourceSystem = "BI1", IsActive = true },
|
||||||
|
new Site { HanaServerId = serverSage.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", SourceSystem = "SAGE", IsActive = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
db.SharePointConfigs.Add(new SharePointConfig
|
||||||
|
{
|
||||||
|
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
|
||||||
|
ExportFolder = "/Shared Documents/Exports/",
|
||||||
|
CentralExportFolder = "",
|
||||||
|
TenantId = "",
|
||||||
|
ClientId = "",
|
||||||
|
ClientSecret = ""
|
||||||
|
});
|
||||||
|
|
||||||
|
db.ExportSettings.Add(new ExportSettings
|
||||||
|
{
|
||||||
|
DateFilter = "2025-01-01",
|
||||||
|
TimerHour = 3,
|
||||||
|
TimerMinute = 0,
|
||||||
|
TimerEnabled = true,
|
||||||
|
DebugLoggingEnabled = false,
|
||||||
|
LocalSiteExportFolder = "",
|
||||||
|
LocalConsolidatedExportFolder = ""
|
||||||
|
});
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureRecommendedTransformationRules(AppDbContext db)
|
||||||
|
{
|
||||||
|
var recommendedRules = new[]
|
||||||
|
{
|
||||||
|
new FieldTransformationRule
|
||||||
|
{
|
||||||
|
SourceSystem = "MANUAL_EXCEL",
|
||||||
|
SourceField = nameof(SalesRecord.SalesCurrency),
|
||||||
|
TargetField = nameof(SalesRecord.SalesCurrency),
|
||||||
|
TransformationType = "Replace",
|
||||||
|
RuleScope = "Value",
|
||||||
|
Argument = "$=>USD",
|
||||||
|
SortOrder = 100,
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new FieldTransformationRule
|
||||||
|
{
|
||||||
|
SourceSystem = "MANUAL_EXCEL",
|
||||||
|
SourceField = nameof(SalesRecord.StandardCostCurrency),
|
||||||
|
TargetField = nameof(SalesRecord.StandardCostCurrency),
|
||||||
|
TransformationType = "Replace",
|
||||||
|
RuleScope = "Value",
|
||||||
|
Argument = "$=>USD",
|
||||||
|
SortOrder = 110,
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var hasChanges = false;
|
||||||
|
|
||||||
|
foreach (var rule in recommendedRules)
|
||||||
|
{
|
||||||
|
var exists = db.FieldTransformationRules.Any(existing =>
|
||||||
|
existing.SourceSystem == rule.SourceSystem &&
|
||||||
|
existing.RuleScope == rule.RuleScope &&
|
||||||
|
existing.SourceField == rule.SourceField &&
|
||||||
|
existing.TargetField == rule.TargetField &&
|
||||||
|
existing.TransformationType == rule.TransformationType &&
|
||||||
|
existing.Argument == rule.Argument);
|
||||||
|
|
||||||
|
if (exists)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
db.FieldTransformationRules.Add(rule);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges)
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureCentralHanaServerRecords(AppDbContext db)
|
||||||
|
{
|
||||||
|
var centralSystems = db.SourceSystemDefinitions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.Hana)
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToList();
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var sourceSystem in centralSystems)
|
||||||
|
{
|
||||||
|
var existingCentral = db.HanaServers
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.FirstOrDefault(x => x.SourceSystem == sourceSystem);
|
||||||
|
|
||||||
|
if (existingCentral is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(existingCentral.Name))
|
||||||
|
{
|
||||||
|
existingCentral.Name = sourceSystem;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkedServer = db.Sites
|
||||||
|
.Include(x => x.HanaServer)
|
||||||
|
.Where(x => x.SourceSystem == sourceSystem && x.HanaServerId != null && x.HanaServer != null)
|
||||||
|
.Select(x => x.HanaServer!)
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (linkedServer is not null)
|
||||||
|
{
|
||||||
|
linkedServer.SourceSystem = sourceSystem;
|
||||||
|
if (string.IsNullOrWhiteSpace(linkedServer.Name))
|
||||||
|
linkedServer.Name = sourceSystem;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.HanaServers.Add(new HanaServer
|
||||||
|
{
|
||||||
|
SourceSystem = sourceSystem,
|
||||||
|
Name = sourceSystem,
|
||||||
|
Host = string.Empty,
|
||||||
|
Port = 30015,
|
||||||
|
Username = string.Empty,
|
||||||
|
Password = string.Empty,
|
||||||
|
DatabaseName = string.Empty,
|
||||||
|
AdditionalParams = string.Empty
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSourceSystemDefinitions(AppDbContext db)
|
||||||
|
{
|
||||||
|
var defaults = new[]
|
||||||
|
{
|
||||||
|
new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true },
|
||||||
|
new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
|
||||||
|
new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
|
||||||
|
new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
var existing = db.SourceSystemDefinitions.ToList();
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var item in defaults)
|
||||||
|
{
|
||||||
|
var current = existing.FirstOrDefault(x => x.Code == item.Code);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
db.SourceSystemDefinitions.Add(item);
|
||||||
|
existing.Add(item);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(current.DisplayName))
|
||||||
|
{
|
||||||
|
current.DisplayName = item.DisplayName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(current.ConnectionKind))
|
||||||
|
{
|
||||||
|
current.ConnectionKind = item.ConnectionKind;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(current.CentralServiceUrl) &&
|
||||||
|
string.Equals(current.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var sapSite = db.Sites
|
||||||
|
.Where(x => x.SourceSystem == current.Code && !string.IsNullOrWhiteSpace(x.SapServiceUrl))
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (sapSite is not null)
|
||||||
|
{
|
||||||
|
current.CentralServiceUrl = sapSite.SapServiceUrl;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ExcelExportService : IExcelExportService
|
||||||
|
{
|
||||||
|
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
|
||||||
|
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||||
|
WriteWorkbook(fullPath, records);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateConsolidatedExcelFile(string outputDirectory, DateTime fileDate, List<SalesRecord> records)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
var fileName = $"Sales_All_{fileDate:yyyy-MM-dd}.xlsx";
|
||||||
|
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||||
|
WriteWorkbook(fullPath, records);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "Export" : filePrefix.Trim();
|
||||||
|
var fileName = $"{safePrefix}_{fileDate:yyyy-MM-dd}.xlsx";
|
||||||
|
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||||
|
WriteGenericWorkbook(fullPath, worksheetName, rows);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteWorkbook(string fullPath, List<SalesRecord> records)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var ws = workbook.Worksheets.Add("Sales");
|
||||||
|
|
||||||
|
var headers = new[]
|
||||||
|
{
|
||||||
|
"extraction date",
|
||||||
|
"TSC",
|
||||||
|
"Invoice Number",
|
||||||
|
"Position on invoice",
|
||||||
|
"Material",
|
||||||
|
"Name",
|
||||||
|
"Product Group",
|
||||||
|
"Quantity",
|
||||||
|
"Supplier number",
|
||||||
|
"Supplier name",
|
||||||
|
"Supplier country",
|
||||||
|
"Customer number",
|
||||||
|
"Customer name",
|
||||||
|
"Customer country",
|
||||||
|
"Customer Industry",
|
||||||
|
"Standard cost",
|
||||||
|
"Standard Cost Currency",
|
||||||
|
"Purchase Order number",
|
||||||
|
"Sales Price/Value",
|
||||||
|
"Sales Currency",
|
||||||
|
"Incoterms 2020",
|
||||||
|
"Sales responsible employee",
|
||||||
|
"invoice date",
|
||||||
|
"order date",
|
||||||
|
"Land",
|
||||||
|
"Document Type"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 0; i < headers.Length; i++)
|
||||||
|
{
|
||||||
|
ws.Cell(1, i + 1).Value = headers[i];
|
||||||
|
ws.Cell(1, i + 1).Style.Font.Bold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = 2;
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
|
||||||
|
ws.Cell(row, 2).Value = record.Tsc;
|
||||||
|
ws.Cell(row, 3).Value = record.InvoiceNumber;
|
||||||
|
ws.Cell(row, 4).Value = record.PositionOnInvoice;
|
||||||
|
ws.Cell(row, 5).Value = record.Material;
|
||||||
|
ws.Cell(row, 6).Value = record.Name;
|
||||||
|
ws.Cell(row, 7).Value = record.ProductGroup;
|
||||||
|
ws.Cell(row, 8).Value = record.Quantity;
|
||||||
|
ws.Cell(row, 9).Value = record.SupplierNumber;
|
||||||
|
ws.Cell(row, 10).Value = record.SupplierName;
|
||||||
|
ws.Cell(row, 11).Value = record.SupplierCountry;
|
||||||
|
ws.Cell(row, 12).Value = record.CustomerNumber;
|
||||||
|
ws.Cell(row, 13).Value = record.CustomerName;
|
||||||
|
ws.Cell(row, 14).Value = record.CustomerCountry;
|
||||||
|
ws.Cell(row, 15).Value = record.CustomerIndustry;
|
||||||
|
ws.Cell(row, 16).Value = record.StandardCost;
|
||||||
|
ws.Cell(row, 17).Value = record.StandardCostCurrency;
|
||||||
|
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
|
||||||
|
ws.Cell(row, 19).Value = record.SalesPriceValue;
|
||||||
|
ws.Cell(row, 20).Value = record.SalesCurrency;
|
||||||
|
ws.Cell(row, 21).Value = record.Incoterms2020;
|
||||||
|
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
|
||||||
|
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||||
|
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||||
|
ws.Cell(row, 25).Value = record.Land;
|
||||||
|
ws.Cell(row, 26).Value = record.DocumentType;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
workbook.SaveAs(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var sheetName = string.IsNullOrWhiteSpace(worksheetName) ? "Export" : worksheetName.Trim();
|
||||||
|
var ws = workbook.Worksheets.Add(sheetName.Length > 31 ? sheetName[..31] : sheetName);
|
||||||
|
|
||||||
|
var headers = rows
|
||||||
|
.SelectMany(r => r.Keys)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < headers.Count; i++)
|
||||||
|
{
|
||||||
|
ws.Cell(1, i + 1).Value = headers[i];
|
||||||
|
ws.Cell(1, i + 1).Style.Font.Bold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
var row = rows[rowIndex];
|
||||||
|
for (var colIndex = 0; colIndex < headers.Count; colIndex++)
|
||||||
|
{
|
||||||
|
row.TryGetValue(headers[colIndex], out var value);
|
||||||
|
ws.Cell(rowIndex + 2, colIndex + 1).Value = value?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.Columns().AdjustToContents();
|
||||||
|
workbook.SaveAs(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ExchangeRateImportService : IExchangeRateImportService
|
||||||
|
{
|
||||||
|
private const string EcbXmlUrl = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
|
||||||
|
private const string EcbSourceNote = "ECB daily reference rate";
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public ExchangeRateImportService(IHttpClientFactory httpClientFactory, IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExchangeRateImportResult> RefreshEcbRatesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient(nameof(ExchangeRateImportService));
|
||||||
|
using var response = await client.GetAsync(EcbXmlUrl, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var document = XDocument.Parse(xml);
|
||||||
|
|
||||||
|
var rateEntries = ParseRates(document);
|
||||||
|
if (rateEntries.Count == 0)
|
||||||
|
throw new InvalidOperationException("ECB response did not contain any exchange rates.");
|
||||||
|
|
||||||
|
var rateDate = rateEntries[0].RateDate;
|
||||||
|
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var existingRates = await db.CurrencyExchangeRates
|
||||||
|
.Where(x => x.Notes == EcbSourceNote && x.ValidFrom == rateDate)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (existingRates.Count > 0)
|
||||||
|
db.CurrencyExchangeRates.RemoveRange(existingRates);
|
||||||
|
|
||||||
|
db.CurrencyExchangeRates.AddRange(rateEntries.Select(entry => new CurrencyExchangeRate
|
||||||
|
{
|
||||||
|
FromCurrency = "EUR",
|
||||||
|
ToCurrency = entry.Currency,
|
||||||
|
Rate = entry.Rate,
|
||||||
|
ValidFrom = entry.RateDate,
|
||||||
|
ValidTo = null,
|
||||||
|
Notes = EcbSourceNote,
|
||||||
|
IsActive = true
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new ExchangeRateImportResult
|
||||||
|
{
|
||||||
|
ImportedCount = rateEntries.Count,
|
||||||
|
RateDate = rateDate,
|
||||||
|
SourceName = "ECB"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<EcbRateEntry> ParseRates(XDocument document)
|
||||||
|
{
|
||||||
|
var cubes = document
|
||||||
|
.Descendants()
|
||||||
|
.Where(x => x.Name.LocalName == "Cube")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var datedCube = cubes.FirstOrDefault(x => x.Attribute("time") is not null)
|
||||||
|
?? throw new InvalidOperationException("ECB response did not contain a dated rate section.");
|
||||||
|
|
||||||
|
var dateText = datedCube.Attribute("time")?.Value
|
||||||
|
?? throw new InvalidOperationException("ECB rate date is missing.");
|
||||||
|
|
||||||
|
var rateDate = DateTime.ParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return datedCube.Elements()
|
||||||
|
.Where(x => x.Name.LocalName == "Cube")
|
||||||
|
.Select(x => new EcbRateEntry(
|
||||||
|
Currency: (x.Attribute("currency")?.Value ?? string.Empty).Trim().ToUpperInvariant(),
|
||||||
|
Rate: decimal.Parse(x.Attribute("rate")?.Value ?? "0", CultureInfo.InvariantCulture),
|
||||||
|
RateDate: rateDate))
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.Currency) && x.Rate > 0m)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EcbRateEntry(string Currency, decimal Rate, DateTime RateDate);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ExportLogService : IExportLogService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public ExportLogService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(ExportLog log)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.ExportLogs.Add(log);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ExportOrchestrationService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly ISiteExportService _siteExportService;
|
||||||
|
private readonly IConsolidatedExportService _consolidatedExportService;
|
||||||
|
private readonly IExportLogService _exportLogService;
|
||||||
|
|
||||||
|
public event Action? OnExportStatusChanged;
|
||||||
|
|
||||||
|
private readonly Dictionary<int, string> _runningExports = new();
|
||||||
|
private bool _consolidatedExportRunning;
|
||||||
|
private string _consolidatedExportStatus = string.Empty;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public ExportOrchestrationService(
|
||||||
|
IDbContextFactory<AppDbContext> dbFactory,
|
||||||
|
ISiteExportService siteExportService,
|
||||||
|
IConsolidatedExportService consolidatedExportService,
|
||||||
|
IExportLogService exportLogService)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_siteExportService = siteExportService;
|
||||||
|
_consolidatedExportService = consolidatedExportService;
|
||||||
|
_exportLogService = exportLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsExporting(int siteId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _runningExports.ContainsKey(siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetExportStatus(int siteId)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConsolidatedExporting()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _consolidatedExportRunning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConsolidatedExportStatus()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _consolidatedExportStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExportAllAsync()
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
||||||
|
var consolidatedRecords = new List<SalesRecord>();
|
||||||
|
|
||||||
|
foreach (var site in sites)
|
||||||
|
{
|
||||||
|
var result = await ExportSiteAsync(site);
|
||||||
|
if (result?.Records is { Count: > 0 })
|
||||||
|
consolidatedRecords.AddRange(result.Records);
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunConsolidatedExportAsync(consolidatedRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> ExportConsolidatedOnlyAsync()
|
||||||
|
{
|
||||||
|
return await RunConsolidatedExportAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
|
||||||
|
if (site is null) return null;
|
||||||
|
return await ExportSiteAsync(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
|
||||||
|
{
|
||||||
|
SiteExportResult? result = null;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_runningExports.ContainsKey(site.Id)) return null;
|
||||||
|
_runningExports[site.Id] = "HANA Abfrage...";
|
||||||
|
}
|
||||||
|
NotifyChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _siteExportService.ExportAsync(site, status => UpdateStatus(site.Id, status));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
await _exportLogService.WriteAsync(result.Log);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_runningExports.Remove(site.Id);
|
||||||
|
}
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatus(int siteId, string status)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_runningExports[siteId] = status;
|
||||||
|
}
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyChanged()
|
||||||
|
{
|
||||||
|
OnExportStatusChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> RunConsolidatedExportAsync(List<SalesRecord>? records)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_consolidatedExportRunning)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
_consolidatedExportRunning = true;
|
||||||
|
_consolidatedExportStatus = "Zentrale Datei erzeugen...";
|
||||||
|
}
|
||||||
|
NotifyChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _consolidatedExportService.ExportAsync(records ?? []);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_consolidatedExportRunning = false;
|
||||||
|
_consolidatedExportStatus = string.Empty;
|
||||||
|
}
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
using Sap.Data.Hana;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class HanaQueryService : IHanaQueryService
|
||||||
|
{
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public HanaQueryService(IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SalesRecord> GetSalesRecords(HanaServer server,
|
||||||
|
string schema, string tsc, string land, string dateFilter)
|
||||||
|
{
|
||||||
|
var connectionString = server.BuildConnectionString();
|
||||||
|
var result = new List<SalesRecord>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land,
|
||||||
|
details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
using var connection = new HanaConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land,
|
||||||
|
details: $"Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
|
||||||
|
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
|
||||||
|
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land, details: invoiceQuery).GetAwaiter().GetResult();
|
||||||
|
var invoiceRecords = ReadRecords(connection, invoiceQuery, land, "Invoice");
|
||||||
|
result.AddRange(invoiceRecords);
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}").GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land, details: creditNoteQuery).GetAwaiter().GetResult();
|
||||||
|
var creditRecords = ReadRecords(connection, creditNoteQuery, land, "Credit");
|
||||||
|
result.AddRange(creditRecords);
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}").GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString()).GetAwaiter().GetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var record in result)
|
||||||
|
{
|
||||||
|
if (record.Material.Contains('/'))
|
||||||
|
{
|
||||||
|
var parts = record.Material.Split('/');
|
||||||
|
record.Material = parts[^1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConnectionTestResult TestConnectionDetailed(HanaServer server)
|
||||||
|
{
|
||||||
|
var testResult = new ConnectionTestResult
|
||||||
|
{
|
||||||
|
TestedAtUtc = DateTime.UtcNow,
|
||||||
|
ConnectionStringPreview = server.GetConnectionStringPreview(),
|
||||||
|
Stage = "Verbindungsaufbau"
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet",
|
||||||
|
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
|
||||||
|
var connectionString = server.BuildConnectionString();
|
||||||
|
using var connection = new HanaConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
testResult.Stage = "Ping-Query";
|
||||||
|
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
|
||||||
|
command.ExecuteScalar();
|
||||||
|
|
||||||
|
testResult.Success = true;
|
||||||
|
testResult.Stage = "OK";
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich",
|
||||||
|
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
|
||||||
|
return testResult;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
testResult.Success = false;
|
||||||
|
testResult.ErrorMessage = ex.Message;
|
||||||
|
testResult.ExceptionType = ex.GetType().Name;
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error",
|
||||||
|
details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}").GetAwaiter().GetResult();
|
||||||
|
return testResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TestConnection(HanaServer server)
|
||||||
|
{
|
||||||
|
var connectionString = server.BuildConnectionString();
|
||||||
|
using var connection = new HanaConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetAvailableSchemas(HanaServer server)
|
||||||
|
{
|
||||||
|
var connectionString = server.BuildConnectionString();
|
||||||
|
using var connection = new HanaConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
const string query = """
|
||||||
|
SELECT schema_name
|
||||||
|
FROM (
|
||||||
|
SELECT schema_name, COUNT(DISTINCT table_name) AS required_table_count
|
||||||
|
FROM sys.tables
|
||||||
|
WHERE table_name IN ('OINV', 'INV1', 'ORIN', 'RIN1', 'OCRD', 'OITM')
|
||||||
|
GROUP BY schema_name
|
||||||
|
) t
|
||||||
|
WHERE required_table_count >= 4
|
||||||
|
ORDER BY schema_name;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var command = new HanaCommand(query, connection);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
|
||||||
|
var schemas = new List<string>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var schema = reader["schema_name"]?.ToString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(schema))
|
||||||
|
schemas.Add(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
|
||||||
|
{
|
||||||
|
var records = new List<SalesRecord>();
|
||||||
|
|
||||||
|
using var command = new HanaCommand(query, connection);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var counter = 0;
|
||||||
|
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
records.Add(new SalesRecord
|
||||||
|
{
|
||||||
|
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
|
||||||
|
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
|
||||||
|
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
|
||||||
|
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
|
||||||
|
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
|
||||||
|
Material = reader["material"]?.ToString() ?? string.Empty,
|
||||||
|
Name = reader["material_name"]?.ToString() ?? string.Empty,
|
||||||
|
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
|
||||||
|
Quantity = Convert.ToDecimal(reader["quantity"]),
|
||||||
|
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
|
||||||
|
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
|
||||||
|
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
|
||||||
|
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
|
||||||
|
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
|
||||||
|
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
|
||||||
|
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
|
||||||
|
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
|
||||||
|
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
|
||||||
|
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
|
||||||
|
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
|
||||||
|
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
|
||||||
|
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
|
||||||
|
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
|
||||||
|
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
|
||||||
|
Land = land,
|
||||||
|
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
|
counter++;
|
||||||
|
if (counter % 250 == 0)
|
||||||
|
{
|
||||||
|
_appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land,
|
||||||
|
details: $"Bisher gelesene Zeilen={counter}").GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
|
||||||
|
SELECT
|
||||||
|
CURRENT_TIMESTAMP AS extraction_date,
|
||||||
|
'{tsc}' AS tsc,
|
||||||
|
h.""DocNum"" AS invoice_number,
|
||||||
|
p.""LineNum"" AS invoice_position,
|
||||||
|
h.""DocDate"" AS invoice_date,
|
||||||
|
p.""ItemCode"" AS material,
|
||||||
|
p.""Dscription"" AS material_name,
|
||||||
|
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
|
||||||
|
p.""Quantity"" AS quantity,
|
||||||
|
COALESCE(itm.""CardCode"", '') AS supplier_number,
|
||||||
|
COALESCE(sup.""CardName"", '') AS supplier_name,
|
||||||
|
COALESCE(sup_adr.""Country"", '') AS supplier_country,
|
||||||
|
h.""CardCode"" AS customer_number,
|
||||||
|
h.""CardName"" AS customer_name,
|
||||||
|
COALESCE(cust_adr.""Country"", '') AS customer_country,
|
||||||
|
COALESCE(ind.""IndName"", '') AS customer_industry,
|
||||||
|
p.""StockPrice"" AS standard_cost,
|
||||||
|
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
|
||||||
|
CASE WHEN p.""BaseType"" = 22
|
||||||
|
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
|
||||||
|
ELSE '' END AS purchase_order_number,
|
||||||
|
p.""LineTotal"" AS sales_value,
|
||||||
|
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
|
||||||
|
'' AS incoterms_2020,
|
||||||
|
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||||
|
CASE WHEN p.""BaseType"" = 17
|
||||||
|
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
|
||||||
|
WHERE o.""DocEntry"" = p.""BaseEntry"")
|
||||||
|
ELSE NULL END AS order_date,
|
||||||
|
'INV' AS doc_type
|
||||||
|
FROM {schema}.""OINV"" h
|
||||||
|
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||||
|
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||||
|
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||||
|
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||||
|
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||||
|
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||||
|
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||||
|
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||||
|
AND sup.""CardType"" = 'S'
|
||||||
|
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
|
||||||
|
AND sup_adr.""AdresType"" = 'B'
|
||||||
|
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
|
||||||
|
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
|
||||||
|
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||||
|
|
||||||
|
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
|
||||||
|
SELECT
|
||||||
|
CURRENT_TIMESTAMP AS extraction_date,
|
||||||
|
'{tsc}' AS tsc,
|
||||||
|
h.""DocNum"" AS invoice_number,
|
||||||
|
p.""LineNum"" AS invoice_position,
|
||||||
|
h.""DocDate"" AS invoice_date,
|
||||||
|
p.""ItemCode"" AS material,
|
||||||
|
p.""Dscription"" AS material_name,
|
||||||
|
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
|
||||||
|
p.""Quantity"" * -1 AS quantity,
|
||||||
|
COALESCE(itm.""CardCode"", '') AS supplier_number,
|
||||||
|
COALESCE(sup.""CardName"", '') AS supplier_name,
|
||||||
|
COALESCE(sup_adr.""Country"", '') AS supplier_country,
|
||||||
|
h.""CardCode"" AS customer_number,
|
||||||
|
h.""CardName"" AS customer_name,
|
||||||
|
COALESCE(cust_adr.""Country"", '') AS customer_country,
|
||||||
|
COALESCE(ind.""IndName"", '') AS customer_industry,
|
||||||
|
p.""StockPrice"" AS standard_cost,
|
||||||
|
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
|
||||||
|
'' AS purchase_order_number,
|
||||||
|
p.""LineTotal"" * -1 AS sales_value,
|
||||||
|
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
|
||||||
|
'' AS incoterms_2020,
|
||||||
|
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||||
|
NULL AS order_date,
|
||||||
|
'CRN' AS doc_type
|
||||||
|
FROM {schema}.""ORIN"" h
|
||||||
|
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||||
|
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||||
|
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||||
|
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||||
|
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||||
|
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||||
|
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||||
|
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||||
|
AND sup.""CardType"" = 'S'
|
||||||
|
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
|
||||||
|
AND sup_adr.""AdresType"" = 'B'
|
||||||
|
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
|
||||||
|
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
|
||||||
|
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConnectionTestResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public DateTime TestedAtUtc { get; set; }
|
||||||
|
public string Stage { get; set; } = string.Empty;
|
||||||
|
public string ErrorMessage { get; set; } = string.Empty;
|
||||||
|
public string ExceptionType { get; set; } = string.Empty;
|
||||||
|
public string ConnectionStringPreview { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IAppEventLogService
|
||||||
|
{
|
||||||
|
Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null);
|
||||||
|
Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user