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",