diff --git a/src/ApiServer/Services/SmsApiService.cs b/src/ApiServer/Services/SmsApiService.cs index bf9d877..11d3ee9 100644 --- a/src/ApiServer/Services/SmsApiService.cs +++ b/src/ApiServer/Services/SmsApiService.cs @@ -39,7 +39,7 @@ internal sealed class SmsApiService(IMenuStore store) : ISmsApiService return FailSendOrder("CommandParameters не указаны или имеют неверный формат."); } - var items = (parameters.MenuItems ?? []) + var items = parameters.MenuItems .Select(item => (item.Id, item.Quantity)) .ToList(); diff --git a/src/Console/Console.csproj b/src/Console/Console.csproj index 49ef502..c28f9e5 100644 --- a/src/Console/Console.csproj +++ b/src/Console/Console.csproj @@ -15,15 +15,14 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - diff --git a/src/Console/Data/AppDbContextFactory.cs b/src/Console/Data/AppDbContextFactory.cs index 87413f9..beaa372 100644 --- a/src/Console/Data/AppDbContextFactory.cs +++ b/src/Console/Data/AppDbContextFactory.cs @@ -13,9 +13,14 @@ internal sealed class AppDbContextFactory : IDesignTimeDbContextFactory(); - optionsBuilder.UseNpgsql(configuration.GetConnectionString("Default")) - .UseSnakeCaseNamingConvention(); - return new AppDbContext(optionsBuilder.Options); + var connectionString = configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Connection string 'Default' не задана."); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention() + .Options; + + return new AppDbContext(options); } } diff --git a/src/Console/Data/DatabaseInitializer.cs b/src/Console/Data/DatabaseInitializer.cs deleted file mode 100644 index 120e34e..0000000 --- a/src/Console/Data/DatabaseInitializer.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Npgsql; - -namespace ConsoleApp.Data; - -internal sealed class DatabaseInitializer(AppDbContext db, IConfiguration configuration) -{ - public async Task InitializeAsync(CancellationToken cancellationToken) - { - var connectionString = configuration.GetConnectionString("Default") - ?? throw new InvalidOperationException("Connection string 'Default' не задана."); - - await EnsureDatabaseExistsAsync(connectionString, cancellationToken); - await db.Database.MigrateAsync(cancellationToken); - } - - private static async Task EnsureDatabaseExistsAsync(string connectionString, CancellationToken cancellationToken) - { - var builder = new NpgsqlConnectionStringBuilder(connectionString); - var databaseName = builder.Database - ?? throw new InvalidOperationException("Имя базы данных не указано в строке подключения."); - - builder.Database = "postgres"; - - await using var connection = new NpgsqlConnection(builder.ConnectionString); - await connection.OpenAsync(cancellationToken); - - await using var checkCommand = connection.CreateCommand(); - checkCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name"; - checkCommand.Parameters.AddWithValue("name", databaseName); - - var exists = await checkCommand.ExecuteScalarAsync(cancellationToken) is not null; - if (exists) - { - return; - } - - await using var createCommand = connection.CreateCommand(); - createCommand.CommandText = $"CREATE DATABASE \"{databaseName.Replace("\"", "\"\"")}\""; - await createCommand.ExecuteNonQueryAsync(cancellationToken); - } -} diff --git a/src/Console/Data/DishRepository.cs b/src/Console/Data/DishRepository.cs deleted file mode 100644 index 56262f2..0000000 --- a/src/Console/Data/DishRepository.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace ConsoleApp.Data; - -internal sealed class DishRepository(AppDbContext db) -{ - public async Task SaveAsync(IReadOnlyList dishes, CancellationToken cancellationToken) - { - await db.Dishes.ExecuteDeleteAsync(cancellationToken); - db.Dishes.AddRange(dishes.Select(ToEntity)); - await db.SaveChangesAsync(cancellationToken); - } - - private static MenuDish ToEntity(Dish dish) => new() - { - Id = dish.Id, - Article = dish.Article, - Name = dish.Name, - Price = dish.Price, - IsWeighted = dish.IsWeighted, - FullPath = dish.FullPath, - Barcodes = dish.Barcodes.ToList(), - }; -} diff --git a/src/Console/DependencyInjection.cs b/src/Console/DependencyInjection.cs deleted file mode 100644 index f16b6bc..0000000 --- a/src/Console/DependencyInjection.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ApiClient; -using ApiClient.Grpc; -using ApiClient.Http; -using ConsoleApp.Data; -using ConsoleApp.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace ConsoleApp; - -public static class DependencyInjection -{ - public static IServiceCollection AddConsoleApp(this IServiceCollection services, IConfiguration configuration) - { - services.AddDbContext(options => - options.UseNpgsql(configuration.GetConnectionString("Default")) - .UseSnakeCaseNamingConvention()); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddSingleton(sp => - { - var backend = configuration["ApiClient:Backend"] ?? "Http"; - return backend.Equals("Grpc", StringComparison.OrdinalIgnoreCase) - ? SmsClientFactory.CreateGrpc(configuration.GetSection("Grpc").Get() ?? new()) - : SmsClientFactory.CreateHttp(configuration.GetSection("Http").Get() ?? new()); - }); - - return services; - } -} diff --git a/src/Console/Logging/ConsoleLog.cs b/src/Console/Logging/ConsoleLog.cs index 6f12faf..fc76c15 100644 --- a/src/Console/Logging/ConsoleLog.cs +++ b/src/Console/Logging/ConsoleLog.cs @@ -1,52 +1,57 @@ namespace ConsoleApp.Logging; -internal sealed class TeeTextWriter(TextWriter consoleWriter, TextWriter logWriter) : TextWriter +public sealed class ConsoleLog : IDisposable { - public override System.Text.Encoding Encoding => consoleWriter.Encoding; + private readonly StreamWriter _file; - public override void Write(char value) + private ConsoleLog(string filePath, DateTime startedAt) { - consoleWriter.Write(value); - logWriter.Write(value); + FilePath = filePath; + StartedAt = startedAt; + _file = new StreamWriter(filePath, append: true) { AutoFlush = true }; + _file.WriteLine($"Запуск: {startedAt:yyyy-MM-dd HH:mm:ss}"); } - public override void Write(string? value) + public string FilePath { get; } + + public DateTime StartedAt { get; } + + public static ConsoleLog Open(string? fileName = null) { - consoleWriter.Write(value); - logWriter.Write(value); + var startedAt = TruncateToSeconds(DateTime.Now); + fileName ??= $"test-sms-console-app-{startedAt:yyyyMMdd_HHmmss}.log"; + return new ConsoleLog(Path.GetFullPath(fileName), startedAt); } - public override void WriteLine(string? value) + private static DateTime TruncateToSeconds(DateTime value) => + new(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind); + + public void Write(string? text) { - consoleWriter.WriteLine(value); - logWriter.WriteLine(value); + Console.Write(text); + _file.Write(text); } - public override void Flush() + public void WriteLine(string? text = null) { - consoleWriter.Flush(); - logWriter.Flush(); - } - - protected override void Dispose(bool disposing) - { - if (disposing) + if (text is null) { - logWriter.Dispose(); + Console.WriteLine(); + _file.WriteLine(); + return; } - base.Dispose(disposing); + Console.WriteLine(text); + _file.WriteLine(text); } -} -internal static class ConsoleLog -{ - public static IDisposable Configure() + public string? ReadLine(string prompt) { - var logPath = $"test-sms-console-app-{DateTime.Now:yyyyMMdd}.log"; - var logWriter = new StreamWriter(logPath, append: true) { AutoFlush = true }; - var tee = new TeeTextWriter(global::System.Console.Out, logWriter); - global::System.Console.SetOut(tee); - return logWriter; + Write(prompt); + var line = Console.ReadLine(); + _file.WriteLine(line ?? ""); + return line; } + + public void Dispose() => _file.Dispose(); } diff --git a/src/Console/Program.cs b/src/Console/Program.cs index dbbf939..31c6eb3 100644 --- a/src/Console/Program.cs +++ b/src/Console/Program.cs @@ -1,8 +1,13 @@ -using ConsoleApp; +using System.Globalization; +using ApiClient; +using ApiClient.Grpc; +using ApiClient.Http; +using ConsoleApp.Data; using ConsoleApp.Logging; -using ConsoleApp.Services; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Npgsql; var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) @@ -10,12 +15,222 @@ var configuration = new ConfigurationBuilder() .AddEnvironmentVariables() .Build(); -using var _ = ConsoleLog.Configure(); +var connectionString = configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Connection string 'Default' не задана."); -var services = new ServiceCollection(); -services.AddSingleton(configuration); -services.AddConsoleApp(configuration); +var httpOptions = configuration.GetSection("Http").Get() ?? new(); +var grpcOptions = configuration.GetSection("Grpc").Get() ?? new(); -await using var provider = services.BuildServiceProvider(); -await using var scope = provider.CreateAsyncScope(); -await scope.ServiceProvider.GetRequiredService().RunAsync(CancellationToken.None); +using var log = ConsoleLog.Open(); + +var smsClient = AskSmsClient(log, httpOptions, grpcOptions); +try +{ + log.WriteLine("Инициализация базы данных..."); + await using var db = CreateDbContext(connectionString); + await EnsureDatabaseAsync(connectionString); + await db.Database.MigrateAsync(); + log.WriteLine("База данных готова."); + + log.WriteLine("Запрос меню с сервера..."); + var menuResponse = await smsClient.GetMenuAsync(withPrice: true); + + if (!menuResponse.Success) + { + log.WriteLine(menuResponse.ToString()); + return; + } + + var dishes = menuResponse.Data?.MenuItems ?? []; + await SaveDishesAsync(db, dishes); + + log.WriteLine("Меню:"); + foreach (var dish in dishes) + { + log.WriteLine(dish.ToString()); + } + + var order = ReadOrder(log, dishes); + if (order is null) + { + return; + } + + log.WriteLine(order.ToString()); + log.WriteLine("Отправка заказа на сервер..."); + var sendResponse = await smsClient.SendOrderAsync(order); + + if (sendResponse.Success) + { + log.WriteLine("УСПЕХ"); + return; + } + + log.WriteLine(sendResponse.ToString()); +} +finally +{ + (smsClient as IDisposable)?.Dispose(); + log.WriteLine($"Файл лога (запуск {log.StartedAt:yyyy-MM-dd HH:mm:ss}): {log.FilePath}"); +} + +static ISmsClient AskSmsClient(ConsoleLog log, HttpSmsClientOptions http, GrpcSmsClientOptions grpc) +{ + log.WriteLine("Выберите протокол: 1 — HTTP, 2 — gRPC"); + + while (true) + { + var input = log.ReadLine("> ")?.Trim(); + + if (input is "1" or "http" or "Http" or "HTTP") + { + return SmsClientFactory.CreateHttp(http); + } + + if (input is "2" or "grpc" or "Grpc" or "gRPC") + { + return SmsClientFactory.CreateGrpc(grpc); + } + + log.WriteLine("Введите 1 (HTTP) или 2 (gRPC)."); + } +} + +static AppDbContext CreateDbContext(string connectionString) +{ + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention() + .Options; + return new AppDbContext(options); +} + +static async Task EnsureDatabaseAsync(string connectionString) +{ + var builder = new NpgsqlConnectionStringBuilder(connectionString); + var databaseName = builder.Database + ?? throw new InvalidOperationException("Имя базы данных не указано в строке подключения."); + + builder.Database = "postgres"; + + await using var connection = new NpgsqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + await using var checkCommand = connection.CreateCommand(); + checkCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name"; + checkCommand.Parameters.AddWithValue("name", databaseName); + + if (await checkCommand.ExecuteScalarAsync() is not null) + { + return; + } + + await using var createCommand = connection.CreateCommand(); + createCommand.CommandText = $"CREATE DATABASE \"{databaseName.Replace("\"", "\"\"")}\""; + await createCommand.ExecuteNonQueryAsync(); +} + +static async Task SaveDishesAsync(AppDbContext db, IReadOnlyList dishes) +{ + await db.Dishes.ExecuteDeleteAsync(); + db.Dishes.AddRange(dishes.Select(d => new MenuDish + { + Id = d.Id, + Article = d.Article, + Name = d.Name, + Price = d.Price, + IsWeighted = d.IsWeighted, + FullPath = d.FullPath, + Barcodes = d.Barcodes.ToList(), + })); + await db.SaveChangesAsync(); +} + +static Order? ReadOrder(ConsoleLog log, IReadOnlyList dishes) +{ + var dishesByArticle = dishes.ToDictionary(d => d.Article, StringComparer.OrdinalIgnoreCase); + + log.WriteLine(); + log.WriteLine("Введите позиции заказа в формате Код:Количество;Код:Количество;..."); + + while (true) + { + var input = log.ReadLine("> "); + + if (!TryParseOrderInput(input, out var lines, out var parseError)) + { + log.WriteLine(parseError); + continue; + } + + var unknownArticles = lines + .Select(line => line.Article) + .Where(article => !dishesByArticle.ContainsKey(article)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (unknownArticles.Count > 0) + { + log.WriteLine($"Неизвестные коды: {string.Join(", ", unknownArticles)}"); + continue; + } + + Order order = new(); + foreach (var (article, quantity) in lines) + { + order = order.AddItem(dishesByArticle[article].Id, quantity); + } + + return order; + } +} + +static bool TryParseOrderInput( + string? input, + out List<(string Article, decimal Quantity)> items, + out string errorMessage) +{ + items = []; + + if (string.IsNullOrWhiteSpace(input)) + { + errorMessage = "Строка заказа не может быть пустой."; + return false; + } + + var parts = input.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + var pair = part.Split(':', 2); + if (pair.Length != 2) + { + errorMessage = $"Некорректный формат позиции: '{part}'. Ожидается Код:Количество."; + return false; + } + + var article = pair[0].Trim(); + if (string.IsNullOrWhiteSpace(article)) + { + errorMessage = "Артикул не может быть пустым."; + return false; + } + + if (!decimal.TryParse(pair[1].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var quantity) + && !decimal.TryParse(pair[1].Trim(), NumberStyles.Number, CultureInfo.CurrentCulture, out quantity)) + { + errorMessage = $"Некорректное количество для артикула '{article}'."; + return false; + } + + if (quantity <= 0) + { + errorMessage = $"Количество для артикула '{article}' должно быть больше нуля."; + return false; + } + + items.Add((article, quantity)); + } + + errorMessage = ""; + return true; +} diff --git a/src/Console/Services/ConsoleAppRunner.cs b/src/Console/Services/ConsoleAppRunner.cs deleted file mode 100644 index 41a30fe..0000000 --- a/src/Console/Services/ConsoleAppRunner.cs +++ /dev/null @@ -1,96 +0,0 @@ -using ApiClient; -using ConsoleApp.Data; -using Domain.Entities; - -namespace ConsoleApp.Services; - -internal sealed class ConsoleAppRunner( - DatabaseInitializer databaseInitializer, - DishRepository dishRepository, - ISmsClient smsClient) -{ - public async Task RunAsync(CancellationToken cancellationToken) - { - Console.WriteLine("Инициализация базы данных..."); - await databaseInitializer.InitializeAsync(cancellationToken); - Console.WriteLine("База данных готова."); - - Console.WriteLine("Запрос меню с сервера..."); - var menuResponse = await smsClient.GetMenuAsync(withPrice: true, cancellationToken); - - if (!menuResponse.Success) - { - Console.WriteLine(menuResponse); - return; - } - - var dishes = menuResponse.Data?.MenuItems ?? []; - await dishRepository.SaveAsync(dishes, cancellationToken); - - Console.WriteLine("Меню:"); - foreach (var dish in dishes) - { - Console.WriteLine(dish); - } - - var order = await ReadOrderAsync(dishes, cancellationToken); - if (order is null) - { - return; - } - - Console.WriteLine(order); - Console.WriteLine("Отправка заказа на сервер..."); - var sendResponse = await smsClient.SendOrderAsync(order, cancellationToken); - - if (sendResponse.Success) - { - Console.WriteLine("УСПЕХ"); - return; - } - - Console.WriteLine(sendResponse); - } - - private static Task ReadOrderAsync(IReadOnlyList dishes, CancellationToken cancellationToken) - { - var dishesByArticle = dishes.ToDictionary(d => d.Article, StringComparer.OrdinalIgnoreCase); - - Console.WriteLine(); - Console.WriteLine("Введите позиции заказа в формате Код:Количество;Код:Количество;..."); - - while (!cancellationToken.IsCancellationRequested) - { - Console.Write("> "); - var input = Console.ReadLine(); - - if (!OrderInputParser.TryParse(input, out var lines, out var parseError)) - { - Console.WriteLine(parseError); - continue; - } - - var unknownArticles = lines - .Select(line => line.Article) - .Where(article => !dishesByArticle.ContainsKey(article)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (unknownArticles.Count > 0) - { - Console.WriteLine($"Неизвестные коды: {string.Join(", ", unknownArticles)}"); - continue; - } - - Order order = new(); - foreach (var (article, quantity) in lines) - { - order = order.AddItem(dishesByArticle[article].Id, quantity); - } - - return Task.FromResult(order); - } - - return Task.FromResult(null); - } -} diff --git a/src/Console/Services/OrderInputParser.cs b/src/Console/Services/OrderInputParser.cs deleted file mode 100644 index 3e28158..0000000 --- a/src/Console/Services/OrderInputParser.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Globalization; - -namespace ConsoleApp.Services; - -internal static class OrderInputParser -{ - public static bool TryParse( - string? input, - out List<(string Article, decimal Quantity)> items, - out string errorMessage) - { - items = []; - - if (string.IsNullOrWhiteSpace(input)) - { - errorMessage = "Строка заказа не может быть пустой."; - return false; - } - - var parts = input.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var part in parts) - { - var pair = part.Split(':', 2); - if (pair.Length != 2) - { - errorMessage = $"Некорректный формат позиции: '{part}'. Ожидается Код:Количество."; - return false; - } - - var article = pair[0].Trim(); - if (string.IsNullOrWhiteSpace(article)) - { - errorMessage = "Артикул не может быть пустым."; - return false; - } - - if (!decimal.TryParse(pair[1].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var quantity) - && !decimal.TryParse(pair[1].Trim(), NumberStyles.Number, CultureInfo.CurrentCulture, out quantity)) - { - errorMessage = $"Некорректное количество для артикула '{article}'."; - return false; - } - - if (quantity <= 0) - { - errorMessage = $"Количество для артикула '{article}' должно быть больше нуля."; - return false; - } - - items.Add((article, quantity)); - } - - errorMessage = ""; - return true; - } -} diff --git a/src/Console/appsettings.json b/src/Console/appsettings.json index 019ddd3..08dab0b 100644 --- a/src/Console/appsettings.json +++ b/src/Console/appsettings.json @@ -2,9 +2,6 @@ "ConnectionStrings": { "Default": "Host=localhost;Port=5432;Database=sms_task;Username=sms;Password=sms" }, - "ApiClient": { - "Backend": "Http" - }, "Http": { "BaseUrl": "http://localhost:5053", "Username": "user",