From 4ef9c687db0e6a703e03015580e47141b7ea7329 Mon Sep 17 00:00:00 2001 From: JLdgu Date: Mon, 1 Jun 2026 11:22:08 +0100 Subject: [PATCH 1/6] Import EE long phone summary into Sims table --- DbUtil/SqlScripts.cs | 27 ++- PhoneAssistant.Cli/Base.cs | 17 +- PhoneAssistant.Cli/EE.cs | 182 ++++++++++++++++++ PhoneAssistant.Cli/ModelContext.cs | 21 ++ PhoneAssistant.Cli/Program.cs | 4 + .../Properties/launchSettings.json | 4 + PhoneAssistant.Model/Entities/Sim.cs | 3 +- .../PhoneAssistantDbContext.cs | 5 +- 8 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 PhoneAssistant.Cli/EE.cs create mode 100644 PhoneAssistant.Cli/ModelContext.cs diff --git a/DbUtil/SqlScripts.cs b/DbUtil/SqlScripts.cs index 8c6e3e08..f86be3f0 100644 --- a/DbUtil/SqlScripts.cs +++ b/DbUtil/SqlScripts.cs @@ -12,7 +12,8 @@ public SqlScripts() Scripts = [ new Script("Drop table SchemaVersions", "DROP TABLE SchemaVersions;"), new Script("Restructure Locations",RestructureLocations), - new Script("Add 3 columns to Phones", Add3ColumnsToPhones) + new Script("Add 3 columns to Phones", Add3ColumnsToPhones), + new Script("Create SIMs Table", CreateSIMsTable1) ]; } @@ -31,7 +32,29 @@ private static string Add3ColumnsToPhones return sb.ToString(); } - } + } + + private const string CreateSIMsTable1 = """ + CREATE TABLE Sims ( + PhoneNumber TEXT NOT NULL, + BillingPeriod TEXT NOT NULL, + PRIMARY KEY (PhoneNumber, BillingPeriod) + ); + """; + + private static string CreateSIMsTable + { + get + { + StringBuilder sb = new(); + sb.AppendLine("CREATE TABLE Sims ("); + sb.AppendLine(" Id INTEGER NOT NULL CONSTRAINT PK_Sims PRIMARY KEY AUTOINCREMENT,"); + sb.AppendLine(" PhoneNumber TEXT NOT NULL"); + sb.AppendLine(");"); + + return sb.ToString(); + } + } private static string RestructureLocations { diff --git a/PhoneAssistant.Cli/Base.cs b/PhoneAssistant.Cli/Base.cs index 3ec52d1a..cad7f08e 100644 --- a/PhoneAssistant.Cli/Base.cs +++ b/PhoneAssistant.Cli/Base.cs @@ -48,8 +48,6 @@ internal static void Command(RootCommand rootCommand) internal static async Task ExecuteAsync(FileInfo baseFile) { - ApplicationSettingsRepository settingsRepository = new(); - Log.Information("Applying update to {0}", settingsRepository.ApplicationSettings.Database); Log.Information("Importing EE Base report from {0}", baseFile.FullName); using FileStream? stream = new(baseFile.FullName, FileMode.Open, FileAccess.Read); @@ -85,14 +83,10 @@ internal static async Task ExecuteAsync(FileInfo baseFile) return; } - string connectionString = $@"DataSource={settingsRepository.ApplicationSettings.Database};"; - var options = new DbContextOptionsBuilder() - .UseSqlite(connectionString) - .Options; - PhoneAssistantDbContext dbContext = new(options); - BaseReportRepository _repository = new(dbContext); + PhoneAssistantDbContext dbContext = ModelContext.Create(); + BaseReportRepository repository = new(dbContext); - await _repository.TruncateAsync(); + await repository.TruncateAsync(); int added = 0; Progress progress = new(); @@ -123,7 +117,7 @@ internal static async Task ExecuteAsync(FileInfo baseFile) LastUsedIMEI = lastUsedIMEI }; - await _repository.CreateAsync(item); + await repository.CreateAsync(item); progress.Draw(i, sheet.Rows.Count - 1); added++; @@ -134,6 +128,5 @@ internal static async Task ExecuteAsync(FileInfo baseFile) Log.Information("Added {0} SIMs", added); Log.Information("Base Report imported successfully."); - } - + } } diff --git a/PhoneAssistant.Cli/EE.cs b/PhoneAssistant.Cli/EE.cs new file mode 100644 index 00000000..125ad790 --- /dev/null +++ b/PhoneAssistant.Cli/EE.cs @@ -0,0 +1,182 @@ +using FluentResults; +using Microsoft.VisualBasic.FileIO; +using PhoneAssistant.Model; +using Serilog; +using System.CommandLine; +using System.Globalization; +using System.IO.Compression; +using System.Text; + +namespace PhoneAssistant.Cli; + +internal static class EE +{ + internal static void Command(RootCommand rootCommand) + { + StringBuilder description = new(); + description.AppendLine("Import EE long phone summary"); + description.AppendLine(); + description.AppendLine("Inputs"); + description.AppendLine("CMP-G01916_LONG_PHONE_SUMMARY_ccyymm.ZIP"); + Command eeCommand = new("ee", description.ToString()); + + Option folderOption = new("--folder", "-f") + { + Description = "Path to the folder where import files exist", + Required = true, + Validators = + { + result => + { + var dir = result.GetValueOrDefault(); + if (dir == null || !dir.Exists) + { + result.AddError( "The specified folder does not exist."); + return; + } + } + } + }; + eeCommand.Add(folderOption); + + eeCommand.SetAction(parseResult => + { + try + { + DirectoryInfo? folder = parseResult.GetValue(folderOption); + Execute(directory: folder); + } + catch (Exception ex) + { + Log.Fatal(exception: ex, "Unhandled exception:"); + } + }); + + rootCommand.Add(eeCommand); + } + + private static void Execute(DirectoryInfo? directory) + { + Log.Information("EE Long Phone Summary import starting"); + Log.Information("Looking for import files in folder: {Folder}", directory?.FullName); + + FileInfo? summary = directory?.GetFiles("CMP-G01916_LONG_PHONE_SUMMARY_*.zip") + .OrderByDescending(f => f.Name) + .FirstOrDefault(); + if (summary is null) + { + Log.Error("No 'CMP-G01916_LONG_PHONE_SUMMARY_*.zip' was found in the specified folder."); + return; + } + + string billingPeriod = summary.Name.Replace(".zip", "", StringComparison.OrdinalIgnoreCase).Split('_').Last(); + + Log.Information("Billing period identified as: {BillingPeriod}", billingPeriod); + try + { + using FileStream zipToOpen = new(summary.FullName, FileMode.Open, FileAccess.Read); + using ZipArchive archive = new(zipToOpen, ZipArchiveMode.Read); + + if (archive.Entries.Count == 0) + { + Log.Error("The ZIP file is empty."); + return; + } + var csvName = archive.Entries[0].Name; + + ZipArchiveEntry? entry = archive.GetEntry(csvName); + if (entry is null) + { + Log.Error("File {0} not found in ZIP.", csvName); + return; + } + Log.Information("Reading file {FileName} from ZIP", csvName); + + using StreamReader reader = new(entry.Open(), Encoding.Latin1); + int lineCount = 0; + + string? csvHeader = reader.ReadLine(); + if (csvHeader is null || !csvHeader.StartsWith("Cost centre name")) //, StringComparison.OrdinalIgnoreCase)) + throw new Exception("Unexpected CSV format. Header line is missing or does not start with 'Cost centre name'."); + + PhoneAssistantDbContext dbContext = ModelContext.Create(); + HashSet<(string, string)> sims = []; + + while (!reader.EndOfStream) + { + string? csvLine = reader.ReadLine(); + lineCount++; + + if (string.IsNullOrWhiteSpace(csvLine)) + continue; + + Result csvSim = CsvSim.Parse(csvLine); + if (csvSim.IsFailed) + throw new Exception($"Line {lineCount + 1}: {csvSim.Errors[0].Message}"); + + if (!sims.Add((csvSim.Value.PhoneNumber, billingPeriod))) + { + Log.Warning("Duplicate record for phone number {PhoneNumber} and billing period {BillingPeriod} found at line {LineNumber}. Skipping.", csvSim.Value.PhoneNumber, billingPeriod, lineCount + 1); + continue; + } + var sim = new Sim + { + PhoneNumber = csvSim.Value.PhoneNumber, + BillingPeriod = billingPeriod + }; + dbContext.Sims.Add(sim); + } + dbContext.SaveChanges(); + Log.Information("{LineCount} records processed", lineCount); + + } + catch (FileNotFoundException) + { + Log.Error("ZIP file not found."); + } + catch (InvalidDataException) + { + Log.Error("Invalid ZIP file format."); + } + catch (Exception ex) + { + Log.Error($"Error: {ex.Message}"); + } + Log.Information("EE Long Phone Summary import finished"); + } +} + +internal class CsvSim(string phoneNumber, decimal recurringCharge, long dataVolume) +{ + internal string PhoneNumber { get; init; } = phoneNumber ?? throw new ArgumentNullException(nameof(phoneNumber)); + internal decimal RecurringCharge { get; init; } = recurringCharge; + internal long DataVolume { get; init; } = dataVolume; + + internal static Result Parse(string csvLine) + { + using var parser = new TextFieldParser(new StringReader(csvLine)); + parser.HasFieldsEnclosedInQuotes = true; + parser.SetDelimiters(","); + + string[]? fields = parser.ReadFields(); + + if (fields is null || fields.Length < 5) + throw new FormatException("CSV line does not contain enough columns."); + + string phoneNumber = fields[2]; + + string recurringChargeText = fields[4].Trim('"'); + if (!decimal.TryParse(recurringChargeText, NumberStyles.Currency, CultureInfo.GetCultureInfo("en-GB"), out var recurringChargeValue)) + { + return Result.Fail("Unable to parse recurring charge value"); + } + + string dataVolumeText = fields[21].Trim('"'); + if (!long.TryParse(dataVolumeText, NumberStyles.AllowThousands, CultureInfo.GetCultureInfo("en-GB"), out var dataVolumeValue)) + { + return Result.Fail("Unable to parse data volume value"); + } + + return new CsvSim(phoneNumber, recurringChargeValue, dataVolumeValue); + } +} diff --git a/PhoneAssistant.Cli/ModelContext.cs b/PhoneAssistant.Cli/ModelContext.cs new file mode 100644 index 00000000..c11d6fd2 --- /dev/null +++ b/PhoneAssistant.Cli/ModelContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using PhoneAssistant.Model; +using Serilog; + +namespace PhoneAssistant.Cli; + +internal static class ModelContext +{ + internal static PhoneAssistantDbContext Create() + { + ApplicationSettingsRepository settingsRepository = new(); + Log.Information("Applying update to {0}", settingsRepository.ApplicationSettings.Database); + + string connectionString = $@"DataSource={settingsRepository.ApplicationSettings.Database};"; + var options = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + PhoneAssistantDbContext dbContext = new(options); + return dbContext; + } +} diff --git a/PhoneAssistant.Cli/Program.cs b/PhoneAssistant.Cli/Program.cs index 5735a658..a38d27f7 100644 --- a/PhoneAssistant.Cli/Program.cs +++ b/PhoneAssistant.Cli/Program.cs @@ -16,7 +16,9 @@ private static Task Main(string[] args) .Enrich.FromLogContext() .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen) .MinimumLevel.Debug() +#if !DEBUG .WriteTo.File("pac.log") +#endif .CreateLogger(); try { @@ -24,6 +26,8 @@ private static Task Main(string[] args) Base.Command(rootCommand); + EE.Command(rootCommand); + Disposal.Command(rootCommand); return rootCommand.Parse(args).InvokeAsync(); diff --git a/PhoneAssistant.Cli/Properties/launchSettings.json b/PhoneAssistant.Cli/Properties/launchSettings.json index 29ef5e16..f1307b0d 100644 --- a/PhoneAssistant.Cli/Properties/launchSettings.json +++ b/PhoneAssistant.Cli/Properties/launchSettings.json @@ -1,5 +1,9 @@ { "profiles": { + "ee": { + "commandName": "Project", + "commandLineArgs": "ee -f c:\\dev\\testdata " + }, "knox": { "commandName": "Project", "commandLineArgs": "knox" diff --git a/PhoneAssistant.Model/Entities/Sim.cs b/PhoneAssistant.Model/Entities/Sim.cs index 19b22d26..3c0ae47e 100644 --- a/PhoneAssistant.Model/Entities/Sim.cs +++ b/PhoneAssistant.Model/Entities/Sim.cs @@ -2,6 +2,7 @@ public sealed class Sim { - public int Id { get; set; } public required string PhoneNumber { get; set; } + + public required string BillingPeriod { get; set; } } diff --git a/PhoneAssistant.Model/PhoneAssistantDbContext.cs b/PhoneAssistant.Model/PhoneAssistantDbContext.cs index b2adf0a5..38ca1771 100644 --- a/PhoneAssistant.Model/PhoneAssistantDbContext.cs +++ b/PhoneAssistant.Model/PhoneAssistantDbContext.cs @@ -89,10 +89,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); modelBuilder.Entity() - .HasIndex(s => s.PhoneNumber).IsUnique(); - - modelBuilder.Entity() - .HasIndex(h => new { h.SimId, h.Period }).IsUnique(); + .HasKey(s => new { s.PhoneNumber, s.BillingPeriod}); OnModelCreatingPartial(modelBuilder); } From 43ac94506d1ad2aa0a08e27abdac0b0f25b4fcbc Mon Sep 17 00:00:00 2001 From: JLdgu Date: Mon, 1 Jun 2026 11:23:22 +0100 Subject: [PATCH 2/6] Create SIM table and start import --- DbUtil.Tests/ProgramTests.cs | 19 ++ DbUtil/Program.cs | 14 +- DbUtil/SqlScripts.cs | 28 ++- PhoneAssistant.Cli.Tests/CsvSimTests.cs | 164 ++++++++++++++++++ PhoneAssistant.Cli/EE.cs | 92 +++++++++- PhoneAssistant.Cli/PhoneAssistant.Cli.csproj | 6 +- PhoneAssistant.Model/Entities/Sim.cs | 11 +- .../Features/Sims/SimsMainView.xaml | 89 ++++++---- 8 files changed, 355 insertions(+), 68 deletions(-) create mode 100644 PhoneAssistant.Cli.Tests/CsvSimTests.cs diff --git a/DbUtil.Tests/ProgramTests.cs b/DbUtil.Tests/ProgramTests.cs index 1b143a66..0afea30c 100644 --- a/DbUtil.Tests/ProgramTests.cs +++ b/DbUtil.Tests/ProgramTests.cs @@ -45,6 +45,25 @@ public async Task ApplyScript_ShouldReturnError_WhenScriptInvalid() await Assert.That(scriptApplied.Reasons.Select(reason => reason.Message).First()).StartsWith("SQLite Error 1:"); } + [Test] + public async Task ApplyScript_ShouldRollbackSchemaVersion_WhenScriptFails() + { + using SqliteConnection connection = CreateAndOpenSqliteConnection(); + CreateSchemaVersionsTable(connection); + var script = new Script("script1", "CREATE TABLE (Name TEXT);"); + + Result scriptApplied = Program.ApplyScript(connection, script); + + await Assert.That(scriptApplied.IsFailed).IsTrue(); + + var command = connection.CreateCommand(); + command.CommandText = $"SELECT count(*) FROM SchemaVersion WHERE ScriptName = '{script.Name}';"; + using var reader = command.ExecuteReader(); + reader.Read(); + var count = reader.GetInt32(0); + await Assert.That(count).IsEqualTo(0); + } + [Test] public async Task CheckSchemaVersionsExists_ShouldCreateSchemaVersionTable_WhenTableDoesNotExist() { diff --git a/DbUtil/Program.cs b/DbUtil/Program.cs index a74b5ad2..5a786613 100644 --- a/DbUtil/Program.cs +++ b/DbUtil/Program.cs @@ -124,17 +124,21 @@ public static Result ApplyScript(SqliteConnection connection, Script scrip reader.Close(); if (count > 0) return Result.Ok(false); - command.CommandText = $"INSERT INTO SchemaVersion (ScriptName) VALUES ('{script.Name}');"; - _ = command.ExecuteNonQuery(); - - command.CommandText = script.Sql; + using var transaction = connection.BeginTransaction(); try { + command.Transaction = transaction; + command.CommandText = $"INSERT INTO SchemaVersion (ScriptName) VALUES ('{script.Name}');"; + _ = command.ExecuteNonQuery(); + + command.CommandText = script.Sql; _ = command.ExecuteNonQuery(); + + transaction.Commit(); } catch (Exception ex) { - + transaction.Rollback(); return Result.Fail(ex.Message); } diff --git a/DbUtil/SqlScripts.cs b/DbUtil/SqlScripts.cs index f86be3f0..57480165 100644 --- a/DbUtil/SqlScripts.cs +++ b/DbUtil/SqlScripts.cs @@ -13,7 +13,7 @@ public SqlScripts() new Script("Drop table SchemaVersions", "DROP TABLE SchemaVersions;"), new Script("Restructure Locations",RestructureLocations), new Script("Add 3 columns to Phones", Add3ColumnsToPhones), - new Script("Create SIMs Table", CreateSIMsTable1) + new Script("Create SIMs Table", CreateSIMsTable) ]; } @@ -34,28 +34,20 @@ private static string Add3ColumnsToPhones } } - private const string CreateSIMsTable1 = """ + private const string CreateSIMsTable = """ CREATE TABLE Sims ( - PhoneNumber TEXT NOT NULL, - BillingPeriod TEXT NOT NULL, + PhoneNumber TEXT NOT NULL, + BillingPeriod TEXT NOT NULL, + UserName TEXT NOT NULL, + MonthlyRecurringCharge TEXT NOT NULL, + OtherCosts TEXT NOT NULL, + VoiceCalls INTEGER NOT NULL, + TextMessages INTEGER NOT NULL, + Data4G5G INTEGER NOT NULL, PRIMARY KEY (PhoneNumber, BillingPeriod) ); """; - private static string CreateSIMsTable - { - get - { - StringBuilder sb = new(); - sb.AppendLine("CREATE TABLE Sims ("); - sb.AppendLine(" Id INTEGER NOT NULL CONSTRAINT PK_Sims PRIMARY KEY AUTOINCREMENT,"); - sb.AppendLine(" PhoneNumber TEXT NOT NULL"); - sb.AppendLine(");"); - - return sb.ToString(); - } - } - private static string RestructureLocations { get diff --git a/PhoneAssistant.Cli.Tests/CsvSimTests.cs b/PhoneAssistant.Cli.Tests/CsvSimTests.cs new file mode 100644 index 00000000..d2f1bdcb --- /dev/null +++ b/PhoneAssistant.Cli.Tests/CsvSimTests.cs @@ -0,0 +1,164 @@ +using FluentResults; + +namespace PhoneAssistant.Cli.Tests; + +public sealed class CsvSimTests +{ + [Test] + public async Task Parse_ShouldSucceed_WithValidCsvLineAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\",\"£10.00\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"5000\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.PhoneNumber).IsEqualTo("07700900000"); + await Assert.That(result.Value.UserName).IsEqualTo("John Doe"); + await Assert.That(result.Value.RecurringCharge).IsEqualTo(10.00m); + await Assert.That(result.Value.DataVolume).IsEqualTo(5000L); + } + + [Test] + public async Task Parse_ShouldThrow_WhenCsvLineHasFewerThanRequiredColumnsAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\""; + + await Assert.That(() => CsvSim.Parse(csvLine)) + .Throws() + .WithMessageContaining("CSV line does not contain enough columns."); + } + + [Test] + public async Task Parse_ShouldFail_WhenRecurringChargeCannotBeParsedAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\",\"InvalidAmount\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"5000\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsFailed).IsTrue(); + await Assert.That(result.Errors[0].Message).IsEqualTo("Unable to parse recurring charge value"); + } + + [Test] + public async Task Parse_ShouldFail_WhenDataVolumeCannotBeParsedAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\",\"£10.00\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"InvalidDataVolume\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsFailed).IsTrue(); + await Assert.That(result.Errors[0].Message).IsEqualTo("Unable to parse data volume value"); + } + + [Test] + public async Task Parse_ShouldSucceed_WithThousandsSeparatorInDataVolumeAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\",\"£20.50\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"1,000,000\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.DataVolume).IsEqualTo(1000000L); + } + + [Test] + public async Task Parse_ShouldSucceed_WithZeroValuesAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"User\",\"£0.00\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"0\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.RecurringCharge).IsEqualTo(0m); + await Assert.That(result.Value.DataVolume).IsEqualTo(0L); + } + + [Test] + public async Task Constructor_ShouldThrowArgumentNullException_WhenPhoneNumberIsNullAsync() + { + await Assert.That(() => new CsvSim(null!, "John Doe", 10m, 5000L)) + .Throws() + .WithMessageContaining("phoneNumber"); + } + + [Test] + public async Task Properties_ShouldBeInitOnlyAsync() + { + var csvSim = new CsvSim("07700900000", "John Doe", 10m, 5000L); + + await Assert.That(csvSim.PhoneNumber).IsEqualTo("07700900000"); + await Assert.That(csvSim.UserName).IsEqualTo("John Doe"); + await Assert.That(csvSim.RecurringCharge).IsEqualTo(10m); + await Assert.That(csvSim.DataVolume).IsEqualTo(5000L); + } + + [Test] + public async Task Parse_ShouldHandleDecimalWithCurrencySymbolAsync() + { + string csvLine = "\"Field0\",\"Field1\",\"07700900000\",\"John Doe\",\"£99.99\",\"Field5\",\"Field6\",\"Field7\",\"Field8\",\"Field9\",\"Field10\",\"Field11\",\"Field12\",\"Field13\",\"Field14\",\"Field15\",\"Field16\",\"Field17\",\"Field18\",\"Field19\",\"Field20\",\"2500\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.RecurringCharge).IsEqualTo(99.99m); + } + + [Test] + public async Task Parse_ShouldExtractCorrectFieldIndicesAsync() + { + string csvLine = "\"Col0\",\"Col1\",\"07123456789\",\"Alice Smith\",\"£15.75\",\"Col5\",\"Col6\",\"Col7\",\"Col8\",\"Col9\",\"Col10\",\"Col11\",\"Col12\",\"Col13\",\"Col14\",\"Col15\",\"Col16\",\"Col17\",\"Col18\",\"Col19\",\"Col20\",\"7500\""; + + Result result = CsvSim.Parse(csvLine); + + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value.PhoneNumber).IsEqualTo("07123456789"); + await Assert.That(result.Value.UserName).IsEqualTo("Alice Smith"); + await Assert.That(result.Value.RecurringCharge).IsEqualTo(15.75m); + await Assert.That(result.Value.DataVolume).IsEqualTo(7500L); + } + + [Test] + public async Task ValidateHeader_ShouldSucceed_WithCorrectHeaderAsync() + { + string headerLine = "\"Cost centre name\",\"Cost centre code\",\"Phone number\",\"User name\",\"Monthly recurring charges\",\"Other costs\",\"Call costs (airtime)\",\"Credits\",\"Total costs (excluding VAT)\",\"Cost of Voice\",\"Cost of SMS\",\"Cost of MMS\",\"Cost of Data\",\"Cost of GPRS\",\"Cost of Fax\",\"Cost of Email\",\"Number of voice calls\",\"Duration of voice calls\",\"Number of SMS\",\"Number of MMS (photo and video)\",\"Duration of data calls (CSD/HSCSD)\",\"Quantity of data (bytes)\",\"Quantity of GPRS data (bytes)\",\"Number of faxes\",\"Number of emails\",\"Duration of landline\",\"Duration of answerphone\",\"Number of text messages\",\"Duration of calls to EE mobiles (EE to EE)\",\"Duration of calls to other mobiles (other mobile network)\",\"Duration of calls to other\",\"Duration of calls to roaming\",\"Duration of calls to international\",\"Duration of calls to premium rate numbers\",\"Duration of calls to mobile voice VPN\",\"Invoice number\",\"Account/Group\",\"VAT exempt call costs (airtime)\""; + + Result result = CsvSim.ValidateHeader(headerLine); + + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task ValidateHeader_ShouldFail_WhenHeaderHasTooFewColumnsAsync() + { + string headerLine = "\"Cost centre name\",\"Cost centre code\",\"Phone number\""; + + Result result = CsvSim.ValidateHeader(headerLine); + + await Assert.That(result.IsFailed).IsTrue(); + await Assert.That(result.Errors[0].Message).Contains("expected 38"); + } + + [Test] + public async Task ValidateHeader_ShouldFail_WhenHeaderHasTooManyColumnsAsync() + { + string headerLine = "\"Cost centre name\",\"Cost centre code\",\"Phone number\",\"User name\",\"Monthly recurring charges\",\"Other costs\",\"Call costs (airtime)\",\"Credits\",\"Total costs (excluding VAT)\",\"Cost of Voice\",\"Cost of SMS\",\"Cost of MMS\",\"Cost of Data\",\"Cost of GPRS\",\"Cost of Fax\",\"Cost of Email\",\"Number of voice calls\",\"Duration of voice calls\",\"Number of SMS\",\"Number of MMS (photo and video)\",\"Duration of data calls (CSD/HSCSD)\",\"Quantity of data (bytes)\",\"Quantity of GPRS data (bytes)\",\"Number of faxes\",\"Number of emails\",\"Duration of landline\",\"Duration of answerphone\",\"Number of text messages\",\"Duration of calls to EE mobiles (EE to EE)\",\"Duration of calls to other mobiles (other mobile network)\",\"Duration of calls to other\",\"Duration of calls to roaming\",\"Duration of calls to international\",\"Duration of calls to premium rate numbers\",\"Duration of calls to mobile voice VPN\",\"Invoice number\",\"Account/Group\",\"VAT exempt call costs (airtime)\",\"Extra column\""; + + Result result = CsvSim.ValidateHeader(headerLine); + + await Assert.That(result.IsFailed).IsTrue(); + await Assert.That(result.Errors[0].Message).Contains("expected 38"); + } + + [Test] + public async Task ValidateHeader_ShouldFail_WhenColumnHeaderMismatchesAsync() + { + string headerLine = "\"Cost centre name\",\"Cost centre code\",\"Phone number\",\"User name\",\"WRONG_HEADER\",\"Other costs\",\"Call costs (airtime)\",\"Credits\",\"Total costs (excluding VAT)\",\"Cost of Voice\",\"Cost of SMS\",\"Cost of MMS\",\"Cost of Data\",\"Cost of GPRS\",\"Cost of Fax\",\"Cost of Email\",\"Number of voice calls\",\"Duration of voice calls\",\"Number of SMS\",\"Number of MMS (photo and video)\",\"Duration of data calls (CSD/HSCSD)\",\"Quantity of data (bytes)\",\"Quantity of GPRS data (bytes)\",\"Number of faxes\",\"Number of emails\",\"Duration of landline\",\"Duration of answerphone\",\"Number of text messages\",\"Duration of calls to EE mobiles (EE to EE)\",\"Duration of calls to other mobiles (other mobile network)\",\"Duration of calls to other\",\"Duration of calls to roaming\",\"Duration of calls to international\",\"Duration of calls to premium rate numbers\",\"Duration of calls to mobile voice VPN\",\"Invoice number\",\"Account/Group\",\"VAT exempt call costs (airtime)\""; + + Result result = CsvSim.ValidateHeader(headerLine); + + await Assert.That(result.IsFailed).IsTrue(); + await Assert.That(result.Errors[0].Message).Contains("Column 4 header mismatch"); + await Assert.That(result.Errors[0].Message).Contains("Monthly recurring charges"); + await Assert.That(result.Errors[0].Message).Contains("WRONG_HEADER"); + } +} diff --git a/PhoneAssistant.Cli/EE.cs b/PhoneAssistant.Cli/EE.cs index 125ad790..d7725928 100644 --- a/PhoneAssistant.Cli/EE.cs +++ b/PhoneAssistant.Cli/EE.cs @@ -121,9 +121,25 @@ private static void Execute(DirectoryInfo? directory) } var sim = new Sim { + /* + * TODO: Monthly recurring charges + * Other costs + * Number of voice calls + * Number of text messages + * Quantity of GPRS data (bytes) + */ + PhoneNumber = csvSim.Value.PhoneNumber, - BillingPeriod = billingPeriod + BillingPeriod = billingPeriod, + + MonthlyRecurringCharge = "£0.00", + OtherCosts = "£0.00", + VoiceCalls = 0, + TextMessages = 0, + BroadbandData = 0, + UserName = csvSim.Value.UserName }; + dbContext.Sims.Add(sim); } dbContext.SaveChanges(); @@ -146,11 +162,78 @@ private static void Execute(DirectoryInfo? directory) } } -internal class CsvSim(string phoneNumber, decimal recurringCharge, long dataVolume) +internal class CsvSim(string phoneNumber, string userName, decimal recurringCharge, long dataVolume) { internal string PhoneNumber { get; init; } = phoneNumber ?? throw new ArgumentNullException(nameof(phoneNumber)); - internal decimal RecurringCharge { get; init; } = recurringCharge; internal long DataVolume { get; init; } = dataVolume; + internal decimal RecurringCharge { get; init; } = recurringCharge; + internal string UserName { get; init; } = userName; + + private static readonly string[] ExpectedHeader = + [ + "Cost centre name", + "Cost centre code", + "Phone number", + "User name", + "Monthly recurring charges", + "Other costs", + "Call costs (airtime)", + "Credits", + "Total costs (excluding VAT)", + "Cost of Voice", + "Cost of SMS", + "Cost of MMS", + "Cost of Data", + "Cost of GPRS", + "Cost of Fax", + "Cost of Email", + "Number of voice calls", + "Duration of voice calls", + "Number of SMS", + "Number of MMS (photo and video)", + "Duration of data calls (CSD/HSCSD)", + "Quantity of data (bytes)", + "Quantity of GPRS data (bytes)", + "Number of faxes", + "Number of emails", + "Duration of landline", + "Duration of answerphone", + "Number of text messages", + "Duration of calls to EE mobiles (EE to EE)", + "Duration of calls to other mobiles (other mobile network)", + "Duration of calls to other", + "Duration of calls to roaming", + "Duration of calls to international", + "Duration of calls to premium rate numbers", + "Duration of calls to mobile voice VPN", + "Invoice number", + "Account/Group", + "VAT exempt call costs (airtime)" + ]; + + internal static Result ValidateHeader(string headerLine) + { + using var parser = new TextFieldParser(new StringReader(headerLine)); + parser.HasFieldsEnclosedInQuotes = true; + parser.SetDelimiters(","); + + string[]? fields = parser.ReadFields(); + + if (fields is null || fields.Length != ExpectedHeader.Length) + { + return Result.Fail($"Header line has {fields?.Length ?? 0} columns, expected {ExpectedHeader.Length}"); + } + + for (int i = 0; i < ExpectedHeader.Length; i++) + { + if (fields[i].Trim('"') != ExpectedHeader[i]) + { + return Result.Fail($"Column {i} header mismatch. Expected '{ExpectedHeader[i]}', but got '{fields[i].Trim('"')}'"); + } + } + + return Result.Ok(headerLine); + } internal static Result Parse(string csvLine) { @@ -164,6 +247,7 @@ internal static Result Parse(string csvLine) throw new FormatException("CSV line does not contain enough columns."); string phoneNumber = fields[2]; + string userName = fields[3]; string recurringChargeText = fields[4].Trim('"'); if (!decimal.TryParse(recurringChargeText, NumberStyles.Currency, CultureInfo.GetCultureInfo("en-GB"), out var recurringChargeValue)) @@ -177,6 +261,6 @@ internal static Result Parse(string csvLine) return Result.Fail("Unable to parse data volume value"); } - return new CsvSim(phoneNumber, recurringChargeValue, dataVolumeValue); + return new CsvSim(phoneNumber, userName, recurringChargeValue, dataVolumeValue); } } diff --git a/PhoneAssistant.Cli/PhoneAssistant.Cli.csproj b/PhoneAssistant.Cli/PhoneAssistant.Cli.csproj index c6fc36f5..86df504f 100644 --- a/PhoneAssistant.Cli/PhoneAssistant.Cli.csproj +++ b/PhoneAssistant.Cli/PhoneAssistant.Cli.csproj @@ -10,7 +10,11 @@ Database Utility net10.0 1.0.0 - + + + + + diff --git a/PhoneAssistant.Model/Entities/Sim.cs b/PhoneAssistant.Model/Entities/Sim.cs index 3c0ae47e..1c9a6f4a 100644 --- a/PhoneAssistant.Model/Entities/Sim.cs +++ b/PhoneAssistant.Model/Entities/Sim.cs @@ -1,8 +1,15 @@ -namespace PhoneAssistant.Model; +using static System.Net.Mime.MediaTypeNames; + +namespace PhoneAssistant.Model; public sealed class Sim { public required string PhoneNumber { get; set; } - public required string BillingPeriod { get; set; } + public required string UserName { get; set; } + public required string MonthlyRecurringCharge { get; set; } + public required string OtherCosts { get; set; } + public required int VoiceCalls { get; set; } + public required int TextMessages { get; set; } + public required int BroadbandData { get; set; } } diff --git a/PhoneAssistant.WPF/Features/Sims/SimsMainView.xaml b/PhoneAssistant.WPF/Features/Sims/SimsMainView.xaml index c58d0af4..8c9507d7 100644 --- a/PhoneAssistant.WPF/Features/Sims/SimsMainView.xaml +++ b/PhoneAssistant.WPF/Features/Sims/SimsMainView.xaml @@ -19,49 +19,62 @@ - - - + + + + - + - + - + -