sammelxport

This commit is contained in:
2026-04-15 11:37:47 +02:00
parent 90133cd0e2
commit 7891dfb3dd
6 changed files with 672 additions and 28 deletions
@@ -310,41 +310,19 @@
} }
} }
private async Task RefreshLiveDataAsync() private Task RefreshLiveDataAsync()
{ {
var runningSiteIds = _dashboardRows
.Where(r => Orchestrator.IsExporting(r.SiteId))
.Select(r => r.SiteId)
.Distinct()
.ToList();
if (runningSiteIds.Count == 0)
{
_anyRunning = false;
return;
}
using var db = await DbFactory.CreateDbContextAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null && runningSiteIds.Contains(l.SiteId.Value))
.OrderByDescending(l => l.Timestamp)
.Take(200)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
foreach (var row in _dashboardRows) foreach (var row in _dashboardRows)
{ {
if (!latestAppLogsBySite.TryGetValue(row.SiteId, out var appLog)) if (!Orchestrator.IsExporting(row.SiteId))
continue; continue;
row.LiveMessage = $"{appLog.Category}: {appLog.Message}"; row.LiveMessage = Orchestrator.GetExportStatus(row.SiteId);
row.LiveDetails = appLog.Details ?? string.Empty; row.LiveDetails = string.Empty;
} }
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
return Task.CompletedTask;
} }
private class DashboardRow private class DashboardRow
+347
View File
@@ -0,0 +1,347 @@
# TrafagSalesExporter Handoff
Stand: 2026-04-15
## Zielbild
Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert:
- `BI1` und `SAGE` bleiben auf direktem HANA-Zugriff
- `SAP` läuft separat über SAP Gateway / OData
- SAP-Quellen können gelesen, gejoint und auf das zentrale `SalesRecord`-Schema gemappt werden
- Standort-Exporte werden lokal als Excel geschrieben
- Zusätzlich werden Datensätze 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` für:
- `SAP`
- `BI1`
- `SAGE`
Zusätzlich gibt es pro Standort optionale Overrides:
- `UsernameOverride`
- `PasswordOverride`
Auflösungsreihenfolge:
1. Standort-Override
2. zentrale Credentials des Quellsystems
3. bei HANA zusätzlich 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 auswählen
## 3. SAP-Quellen, Joins und Feldmappings
Für SAP gibt es mehrere neue Modelle:
- `SapSourceDefinition`
- `SapJoinDefinition`
- `SapFieldMapping`
Unterstützt wird:
- mehrere SAP-Quellen pro Standort
- Alias pro Quelle
- Primärquelle
- Join-Definitionen
- Mapping von `Alias.Feldname` auf zentrales Schema
UI-Erweiterungen:
- `Quellen refreshen`
- `Felder aus Quellen laden`
- Join-Key-Auswahl aus Metadaten
- `Auto-Match` für gleiche Feldnamen zwischen Primärquelle und anderen Quellen
## 4. Zentrale Datenspeicherung
Neue Tabelle:
- `CentralSalesRecords`
Verwendung:
- pro Standort werden alte zentrale Sätze 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 Konfigurationsmöglichkeiten:
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 vollständige SharePoint-Konfiguration vorhanden ist:
- Excel wird trotzdem lokal erzeugt
- kein Upload nach SharePoint
Benötigte 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 über `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 öffnen
Im Dashboard gibt es neben `Export` den Button:
- `Excel öffnen`
Dieser nutzt `ExportLogs.FilePath`.
Voraussetzungen:
- letzter Export erfolgreich
- `FilePath` gespeichert
- Datei existiert lokal
## 10. Management Cockpit
Es gibt einen neuen Menüpunkt:
- `Management Cockpit`
Funktion:
- Auswahl vorhandener Excel-Dateien
- Analyse einer exportierten Standort-Datei
- Kennzahlen für Geschäftsinhaber / Management
Aktuell enthalten:
- Umsatz
- geschätzte Kosten
- geschätzte Marge
- Rechnungsanzahl
- Kundenanzahl
- Top Kunden
- Top Produktgruppen
- Top Sales Owner
- Datenqualitätshinweise
- automatische Management-Aussagen
## 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`
### 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`
### UI
- `Components/Pages/Standorte.razor`
- `Components/Pages/Settings.razor`
- `Components/Pages/Dashboard.razor`
- `Components/Pages/Logs.razor`
- `Components/Pages/ManagementCockpit.razor`
- `Components/Layout/NavMenu.razor`
## Datenbank / Migrationen
Viele Änderungen laufen über `DatabaseInitializationService`.
Wichtige neue oder erweiterte Tabellen/Felder:
- `Sites`
- `UsernameOverride`
- `PasswordOverride`
- `SapServiceUrl`
- `SapEntitySet`
- `SapEntitySetsCache`
- `SapEntitySetsRefreshedAtUtc`
- `LocalExportFolderOverride`
- `ExportSettings`
- zentrale SAP/BI1/SAGE Credentials
- `LocalSiteExportFolder`
- `LocalConsolidatedExportFolder`
- `DebugLoggingEnabled`
- `ExportLogs`
- `FilePath`
- neue Tabellen:
- `AppEventLogs`
- `CentralSalesRecords`
- SAP-Konfigtabellen
## Aktuell offenes Hauptproblem
### Zentrale Speicherung hängt noch
Die große Problemstelle war die zentrale SQLite-Speicherung.
Bereits probiert:
- EF `RemoveRange + SaveChanges`
- EF Batch-Speichern
- Dashboard-Polling reduziert
- SQLite WAL + busy timeout
- direkte SQLite-Inserts in einer großen Transaktion
- jetzt: kleine abgeschlossene Transaktionen pro Batch
Aktueller Stand:
- zentrale Excel ist jetzt sehr schnell
- das Hängen wurde stark eingegrenzt
- zuletzt wurde der Schreibpfad so umgebaut, dass:
- Löschen in eigener kurzer Transaktion läuft
- Inserts batchweise mit Commit pro Batch laufen
Datei:
- `Services/CentralSalesRecordService.cs`
Die nächste Session sollte genau dort weiter debuggen, falls es noch hängt.
Wichtig:
- Das Problem ist nicht SAP
- nicht SharePoint
- nicht mehr der große EF-Insert
- sondern sehr wahrscheinlich SQLite-Commit/Lock-Verhalten rund um die zentrale Tabelle
## Letzte bekannte Beobachtung
Der User meldete zuletzt:
- vorher Hänger bei `Zentrale Tabelle: Abschluss speichern...`
- danach wurde auf Commit pro Batch umgestellt
- neue Session soll testen, ob es jetzt bei
- `Batch x/y speichern...`
- `Batch x/y abschliessen...`
- oder gar nicht mehr hängt
## Build-Status
Letzter Build:
```text
dotnet build TrafagSalesExporter.sln
```
Ergebnis:
- erfolgreich
- bekannte Warnungen bleiben:
- SAP HANA Architekturwarnung `MSB3270`
- MudBlazor Analyzer `Dense`
## Hinweise für nächste Session
1. Zuerst aktuellen Export testen
2. Genaue letzte Live-Status-Meldung notieren
3. `Services/CentralSalesRecordService.cs` prüfen
4. Falls nötig:
- SQLite pragmas weiter anpassen
- zentrale Tabelle temporär ganz abschaltbar machen
- oder Schreiben über separate DB / Queue entkoppeln
@@ -0,0 +1,102 @@
# Next Steps
Stand: 2026-04-15
## 1. Erstes Ziel
Prüfen, ob die aktuelle Version beim Standort-Export noch in der zentralen SQLite-Speicherung hängen bleibt.
Wichtig:
- App neu starten
- denselben Standort erneut exportieren
- letzte sichtbare `Live-Status`-Meldung exakt notieren
Interessant sind vor allem diese Fälle:
- `Zentrale Tabelle: Batch x/y speichern...`
- `Zentrale Tabelle: Batch x/y abschliessen...`
- `Zentrale Tabelle aktualisiert`
- `Export erfolgreich`
## 2. Hauptverdächtiger
Datei:
- `Services/CentralSalesRecordService.cs`
Aktueller Stand:
- alte Sätze werden in eigener Transaktion gelöscht
- Inserts laufen in Batches von 25
- jeder Batch wird separat committed
Wenn es noch hängt, dort zuerst ansetzen.
## 3. Falls es weiter hängt
In dieser Reihenfolge prüfen:
1. Batchgröße weiter reduzieren
- z. B. `10` statt `25`
2. Direkt vor und direkt nach `transaction.CommitAsync()` zusätzlich technische Logs setzen
3. Prüfen, ob parallel noch andere SQLite-Zugriffe laufen
4. Optional zentrale Speicherung vorübergehend per Setting deaktivierbar machen
5. Falls nötig zentrale Speicherung in separate DB-Datei auslagern
## 4. Dashboard / UI prüfen
Zu testen:
- `Excel öffnen` wird nach neuem erfolgreichen Export aktiv
- `Export erfolgreich` zeigt `Pfad=...`
- Dashboard-Live-Status setzt sich nach Abschluss sauber zurück
Dateien:
- `Components/Pages/Dashboard.razor`
- `Services/SiteExportService.cs`
- `Models/ExportLog.cs`
## 5. SAP-Funktionalität kurz gegenprüfen
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 prüfen
Zu testen:
- vorhandene Excel-Datei auswählbar
- Analyse läuft
- Kennzahlen plausibel
Dateien:
- `Components/Pages/ManagementCockpit.razor`
- `Services/ManagementCockpitService.cs`
## 7. Wenn Stabilität vor Funktion geht
Sinnvolle pragmatische Zwischenlösung:
- zentrale SQLite-Speicherung per Setting abschaltbar machen
- Export lokal und zentral Excel weiter erlauben
- zentrale DB erst wieder aktivieren, wenn der Commit-Pfad stabil ist
## 8. Referenzdatei
Für den vollständigen Kontext zuerst lesen:
- `HANDOFF_2026-04-15.md`
+1 -1
View File
@@ -11,7 +11,7 @@ builder.Services.AddRazorComponents()
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=10")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>(); builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>(); builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
@@ -39,6 +39,7 @@ public class CentralSalesRecordService : ICentralSalesRecordService
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten..."); updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus); await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
updateStatus?.Invoke("Zentrale Tabelle aktualisiert");
await _appEventLogService.WriteAsync( await _appEventLogService.WriteAsync(
"Export", "Export",
@@ -45,6 +45,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
private static void EnsureSchema(AppDbContext db) private static void EnsureSchema(AppDbContext db)
{ {
EnsureSitesTableSupportsOptionalHanaServer(db); EnsureSitesTableSupportsOptionalHanaServer(db);
RepairBrokenSiteForeignKeys(db);
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
@@ -175,6 +176,221 @@ FROM Sites_old;";
enableFk.ExecuteNonQuery(); enableFk.ExecuteNonQuery();
} }
private static void RepairBrokenSiteForeignKeys(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var tablesToRepair = new[]
{
("ExportLogs", GetExportLogsCreateSql()),
("AppEventLogs", GetAppEventLogsCreateSql()),
("CentralSalesRecords", GetCentralSalesRecordsCreateSql()),
("SapSourceDefinitions", GetSapSourceDefinitionsCreateSql()),
("SapJoinDefinitions", GetSapJoinDefinitionsCreateSql()),
("SapFieldMappings", GetSapFieldMappingsCreateSql())
};
foreach (var (tableName, createSql) in tablesToRepair)
{
if (TableReferencesSitesOld(conn, tableName))
RebuildTable(conn, tableName, createSql);
}
}
private static bool TableReferencesSitesOld(System.Data.Common.DbConnection connection, string tableName)
{
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("Sites_old", StringComparison.OrdinalIgnoreCase);
}
private 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();
}
private 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();
}
private 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;
}
private 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)
);";
private 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)
);";
private 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)
);";
private 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)
);";
private 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)
);";
private 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)
);";
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type) private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();