Files
GerbilManager/GerbilManagerWebAPI/Export/ExportService.cs
Gulum 1a5f3218fb STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)
GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED.
GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1),
ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4).
All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply().
Startup sweep flips >7y gerbils to Deceased at next app restart.
Migration StatusModel: Active→Breeding rename + backfill derived statuses.
10 new tests; 200/200 green; has-pending=No.

Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale.
FE contract: status field values updated (see Done-Report).
2026-06-07 03:27:11 +02:00

215 lines
9.9 KiB
C#

using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Export
{
/// <summary>
/// EXPORT-1: user-facing Datenexport — builds a zip containing
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
///
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
/// and escaping tests run without a database.
/// </summary>
public static class ExportService
{
public sealed record ExportData(
List<Gerbil> Gerbils,
List<Litter> Litters,
List<Contact> Contacts,
List<Enclosure> Enclosures,
List<ColorVariety> ColorVarieties,
List<HealthRecord> HealthRecords,
List<WeightRecord> WeightRecords,
List<GerbilPhoto> Photos);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
// German labels for enum values (CSV is the wife-facing backup format;
// the JSON keeps the enum member names for lossless re-import).
private static readonly Dictionary<Gender, string> GenderDe = new()
{
[Gender.unknown] = "Unbekannt",
[Gender.male] = "Männlich",
[Gender.female] = "Weiblich",
};
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
{
[HealthRecordType.Examination] = "Untersuchung",
[HealthRecordType.Treatment] = "Behandlung",
[HealthRecordType.Injury] = "Verletzung",
[HealthRecordType.Vaccination] = "Impfung",
[HealthRecordType.Other] = "Sonstiges",
};
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
{
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
key is null ? null : map.GetValueOrDefault(key.Value);
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
AddText(zip, "export.json", JsonSerializer.Serialize(new
{
exportedAt = exportDate.ToString("yyyy-MM-dd"),
data.Gerbils,
data.Litters,
data.Contacts,
data.Enclosures,
data.ColorVarieties,
data.HealthRecords,
data.WeightRecords,
data.Photos,
}, JsonOptions), bom: false);
AddCsv(zip, "tiere.csv",
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
"Genotyp", "Notizen"],
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
{
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
g.Genotype, g.Notes,
}));
AddCsv(zip, "wuerfe.csv",
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
"Voraussichtliches Abgabedatum", "Notizen"],
data.Litters.OrderBy(l => l.Date).Select(l => new[]
{
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
De(l.ExpectedGoHomeDate), l.Notes,
}));
AddCsv(zip, "kontakte.csv",
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
{
c.Name, c.Email, c.Phone, c.Address, c.Notes,
}));
AddCsv(zip, "gesundheit.csv",
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
{
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
}));
AddCsv(zip, "gewichte.csv",
["Tier", "Datum", "Gewicht (g)", "Notizen"],
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
{
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
}));
AddText(zip, "LIESMICH.txt", $"""
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
Enthalten:
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
- wuerfe.csv : alle Würfe.
- kontakte.csv : alle Kontakte.
- gesundheit.csv: alle Gesundheitseinträge.
- gewichte.csv : alle Gewichtseinträge.
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
steht in export.json (Abschnitt "photos").
""", bom: false);
}
return stream.ToArray();
}
// ── CSV building blocks ────────────────────────────────────────────
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
private static string? De(DateOnly? date) =>
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
private static string De(DateOnly date) =>
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
/// <summary>
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
/// they contain separator/quote/newline; quotes doubled.
/// </summary>
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
{
var sb = new StringBuilder();
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
foreach (var row in rows)
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
return sb.ToString();
}
internal static string Escape(string? field)
{
if (string.IsNullOrEmpty(field)) return "";
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
? $"\"{field.Replace("\"", "\"\"")}\""
: field;
}
private static void AddCsv(ZipArchive zip, string name, string[] header,
IEnumerable<string?[]> rows) =>
AddText(zip, name, BuildCsv(header, rows), bom: true);
private static void AddText(ZipArchive zip, string name, string content, bool bom)
{
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
using var entryStream = entry.Open();
if (bom)
{
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
entryStream.Write(Encoding.UTF8.GetPreamble());
}
var bytes = Encoding.UTF8.GetBytes(content);
entryStream.Write(bytes);
}
}
}