From 50626c6ac63a9748096984adfbf3e4ab774a0d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Mon, 1 Jun 2026 18:02:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gRPC на отдельном порту --- .vscode/launch.json | 83 +++++++--------- .vscode/tasks.json | 58 ++++------- scripts/restart-db.sh | 40 ++++++++ src/ApiClient/Grpc/GrpcSmsClient.cs | 32 ++++--- src/ApiClient/Grpc/GrpcSmsClientOptions.cs | 2 +- src/ApiClient/Http/HttpSmsClient.cs | 12 +-- src/ApiServer/DependencyInjection.cs | 9 ++ src/ApiServer/Mapping/MenuMapper.cs | 12 +-- src/ApiServer/Program.cs | 9 +- src/ApiServer/Properties/launchSettings.json | 4 +- src/ApiServer/Services/InMemoryMenuStore.cs | 36 ++++--- src/Console/Console.csproj | 29 +++++- src/Console/Data/AppDbContext.cs | 19 ++++ src/Console/Data/AppDbContextFactory.cs | 21 ++++ src/Console/Data/BarcodesJsonConverter.cs | 19 ++++ src/Console/Data/DatabaseInitializer.cs | 43 +++++++++ src/Console/Data/DishRepository.cs | 25 +++++ src/Console/Data/MenuDish.cs | 18 ++++ src/Console/DependencyInjection.cs | 34 +++++++ src/Console/Logging/ConsoleLog.cs | 52 ++++++++++ .../20260601135443_Init.Designer.cs | 69 +++++++++++++ src/Console/Migrations/20260601135443_Init.cs | 38 ++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 66 +++++++++++++ src/Console/Program.cs | 23 ++++- src/Console/Services/ConsoleAppRunner.cs | 96 +++++++++++++++++++ src/Console/Services/OrderInputParser.cs | 56 +++++++++++ src/Console/appsettings.json | 16 ++++ src/Contracts/ApiJsonOptions.cs | 1 + src/Contracts/Menu/GetMenuData.cs | 10 +- src/Contracts/Menu/GetMenuParameters.cs | 4 +- src/Contracts/Orders/SendOrderParameters.cs | 10 +- src/Contracts/Requests/ApiRequest.cs | 13 ++- src/Contracts/Requests/GetMenuApiRequest.cs | 2 +- src/Contracts/Requests/SendOrderApiRequest.cs | 2 +- src/Contracts/Responses/ApiResponse.cs | 5 +- .../Responses/ApiResponseDeserializer.cs | 21 +++- src/Contracts/Responses/GetMenuApiResponse.cs | 5 +- .../Responses/SendOrderApiResponse.cs | 3 +- 38 files changed, 833 insertions(+), 164 deletions(-) create mode 100755 scripts/restart-db.sh create mode 100644 src/Console/Data/AppDbContext.cs create mode 100644 src/Console/Data/AppDbContextFactory.cs create mode 100644 src/Console/Data/BarcodesJsonConverter.cs create mode 100644 src/Console/Data/DatabaseInitializer.cs create mode 100644 src/Console/Data/DishRepository.cs create mode 100644 src/Console/Data/MenuDish.cs create mode 100644 src/Console/DependencyInjection.cs create mode 100644 src/Console/Logging/ConsoleLog.cs create mode 100644 src/Console/Migrations/20260601135443_Init.Designer.cs create mode 100644 src/Console/Migrations/20260601135443_Init.cs create mode 100644 src/Console/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/Console/Services/ConsoleAppRunner.cs create mode 100644 src/Console/Services/OrderInputParser.cs create mode 100644 src/Console/appsettings.json diff --git a/.vscode/launch.json b/.vscode/launch.json index e26b9f5..7cc83e6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,51 +1,34 @@ { - "version": "0.2.0", - "configurations": [ - { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md - "name": ".NET Core Launch (console)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/src/Console/bin/Debug/net10.0/Console.dll", - "args": [], - "cwd": "${workspaceFolder}/src/Console", - // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console - "console": "internalConsole", - "stopAtEntry": false - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach" - }, - { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. - "name": ".NET Core Launch (web)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/src/ApiServer/bin/Debug/net10.0/ApiServer.dll", - "args": [], - "cwd": "${workspaceFolder}/src/ApiServer", - "stopAtEntry": false, - // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser - "serverReadyAction": { - "action": "openExternally", - "pattern": "\\bNow listening on:\\s+(https?://\\S+)" - }, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sourceFileMap": { - "/Views": "${workspaceFolder}/Views" - } - } - ] -} \ No newline at end of file + "version": "0.2.0", + "configurations": [ + { + "name": "ApiServer", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build ApiServer", + "program": "${workspaceFolder}/src/ApiServer/bin/Debug/net10.0/ApiServer.dll", + "args": [], + "cwd": "${workspaceFolder}/src/ApiServer", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + { + "name": "Console", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build Console", + "program": "dotnet", + "args": [ + "run", + "--no-build", + "--project", + "${workspaceFolder}/src/Console/Console.csproj" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 169c575..7888f92 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,41 +1,19 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/ApiServer/ApiServer.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "${workspaceFolder}/src/ApiServer/ApiServer.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "watch", - "command": "dotnet", - "type": "process", - "args": [ - "watch", - "run", - "--project", - "${workspaceFolder}/src/ApiServer/ApiServer.csproj" - ], - "problemMatcher": "$msCompile" - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "build ApiServer", + "type": "shell", + "command": "dotnet build ${workspaceFolder}/src/ApiServer/ApiServer.csproj", + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "build Console", + "type": "shell", + "command": "dotnet build ${workspaceFolder}/src/Console/Console.csproj", + "group": "build", + "problemMatcher": "$msCompile" + } + ] +} diff --git a/scripts/restart-db.sh b/scripts/restart-db.sh new file mode 100755 index 0000000..ca656a8 --- /dev/null +++ b/scripts/restart-db.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER_NAME="sms-postgres" +VOLUME_NAME="sms-postgres-data" +IMAGE="postgres:16" +PORT="5432" +POSTGRES_USER="sms" +POSTGRES_PASSWORD="sms" +POSTGRES_DB="sms_task" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker не найден. Установите Docker и повторите." >&2 + exit 1 +fi + +echo "Останавливаю и удаляю контейнер ${CONTAINER_NAME}..." +docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + +echo "Удаляю том ${VOLUME_NAME}..." +docker volume rm "${VOLUME_NAME}" >/dev/null 2>&1 || true + +echo "Запускаю PostgreSQL (${IMAGE})..." +docker run -d \ + --name "${CONTAINER_NAME}" \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e POSTGRES_DB="${POSTGRES_DB}" \ + -p "${PORT}:5432" \ + -v "${VOLUME_NAME}:/var/lib/postgresql/data" \ + "${IMAGE}" >/dev/null + +echo "Готово." +echo " Host: localhost:${PORT}" +echo " Database: ${POSTGRES_DB}" +echo " User: ${POSTGRES_USER}" +echo " Password: ${POSTGRES_PASSWORD}" +echo "" +echo "Connection string:" +echo " Host=localhost;Port=${PORT};Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" diff --git a/src/ApiClient/Grpc/GrpcSmsClient.cs b/src/ApiClient/Grpc/GrpcSmsClient.cs index 4f50393..4beee90 100644 --- a/src/ApiClient/Grpc/GrpcSmsClient.cs +++ b/src/ApiClient/Grpc/GrpcSmsClient.cs @@ -15,7 +15,19 @@ public sealed class GrpcSmsClient : ISmsClient, IDisposable public GrpcSmsClient(GrpcSmsClientOptions options) { - _channel = GrpcChannel.ForAddress(options.Address); + var address = options.Address; + var channelOptions = new GrpcChannelOptions(); + + if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + channelOptions.HttpHandler = new SocketsHttpHandler + { + EnableMultipleHttp2Connections = true, + }; + } + + _channel = GrpcChannel.ForAddress(address, channelOptions); _client = new SmsTestService.SmsTestServiceClient(_channel); } @@ -58,14 +70,12 @@ public sealed class GrpcSmsClient : ISmsClient, IDisposable _channel.Dispose(); } - private static Dish ToDish(MenuItem item) => new() - { - Id = item.Id, - Article = item.Article, - Name = item.Name, - Price = (decimal)item.Price, - IsWeighted = item.IsWeighted, - FullPath = item.FullPath, - Barcodes = item.Barcodes.ToList(), - }; + private static Dish ToDish(MenuItem item) => new( + item.Id, + item.Article, + item.Name, + (decimal)item.Price, + item.IsWeighted, + item.FullPath, + item.Barcodes.ToList()); } diff --git a/src/ApiClient/Grpc/GrpcSmsClientOptions.cs b/src/ApiClient/Grpc/GrpcSmsClientOptions.cs index 0d4243a..b643590 100644 --- a/src/ApiClient/Grpc/GrpcSmsClientOptions.cs +++ b/src/ApiClient/Grpc/GrpcSmsClientOptions.cs @@ -2,5 +2,5 @@ namespace ApiClient.Grpc; public sealed class GrpcSmsClientOptions { - public string Address { get; set; } = "http://localhost:5053"; + public string Address { get; set; } = "http://localhost:5054"; } diff --git a/src/ApiClient/Http/HttpSmsClient.cs b/src/ApiClient/Http/HttpSmsClient.cs index 26c2193..6ed5bfb 100644 --- a/src/ApiClient/Http/HttpSmsClient.cs +++ b/src/ApiClient/Http/HttpSmsClient.cs @@ -27,7 +27,7 @@ public sealed class HttpSmsClient : ISmsClient, IDisposable { var request = new GetMenuApiRequest { - CommandParameters = new GetMenuParameters { WithPrice = withPrice }, + CommandParameters = new GetMenuParameters(withPrice), }; var response = await SendAsync(request, cancellationToken); @@ -38,11 +38,9 @@ public sealed class HttpSmsClient : ISmsClient, IDisposable { var request = new SendOrderApiRequest { - CommandParameters = new SendOrderParameters - { - OrderId = order.Id.ToString(), - MenuItems = order.Items.ToList(), - }, + CommandParameters = new SendOrderParameters( + order.Id.ToString(), + order.Items.ToList()), }; var response = await SendAsync(request, cancellationToken); @@ -53,7 +51,7 @@ public sealed class HttpSmsClient : ISmsClient, IDisposable private async Task SendAsync(ApiRequest request, CancellationToken cancellationToken) { - var json = JsonSerializer.Serialize(request, ApiJsonOptions.Instance); + var json = JsonSerializer.Serialize(request, request.GetType(), ApiJsonOptions.Instance); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpResponse = await _httpClient.PostAsync("/", content, cancellationToken); diff --git a/src/ApiServer/DependencyInjection.cs b/src/ApiServer/DependencyInjection.cs index 0c64012..fcb1925 100644 --- a/src/ApiServer/DependencyInjection.cs +++ b/src/ApiServer/DependencyInjection.cs @@ -19,9 +19,18 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy; + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.Converters.Add(new DecimalFromStringJsonConverter()); + }); + services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy; + options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.Converters.Add(new DecimalFromStringJsonConverter()); }); diff --git a/src/ApiServer/Mapping/MenuMapper.cs b/src/ApiServer/Mapping/MenuMapper.cs index 6f67837..faa475b 100644 --- a/src/ApiServer/Mapping/MenuMapper.cs +++ b/src/ApiServer/Mapping/MenuMapper.cs @@ -5,16 +5,8 @@ namespace ApiServer.Mapping; internal static class MenuMapper { - public static Dish ApplyPriceVisibility(Dish dish, bool withPrice) => new() - { - Id = dish.Id, - Article = dish.Article, - Name = dish.Name, - Price = withPrice ? dish.Price : 0, - IsWeighted = dish.IsWeighted, - FullPath = dish.FullPath, - Barcodes = dish.Barcodes, - }; + public static Dish ApplyPriceVisibility(Dish dish, bool withPrice) => + dish with { Price = withPrice ? dish.Price : 0 }; public static MenuItem ToGrpc(Dish dish, bool withPrice) => new() { diff --git a/src/ApiServer/Program.cs b/src/ApiServer/Program.cs index 765c129..6f832f9 100644 --- a/src/ApiServer/Program.cs +++ b/src/ApiServer/Program.cs @@ -1,10 +1,17 @@ using ApiServer; using ApiServer.Grpc; +using Microsoft.AspNetCore.Server.Kestrel.Core; var builder = WebApplication.CreateBuilder(args); +// REST — HTTP/1.1; gRPC (h2c) — отдельный порт с HTTP/2 (без TLS нельзя смешать на одном порту). +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenLocalhost(5053, listen => listen.Protocols = HttpProtocols.Http1); + options.ListenLocalhost(5054, listen => listen.Protocols = HttpProtocols.Http2); +}); + builder.Services.AddApiServer(builder.Configuration); -builder.Services.AddControllers(); builder.Services.AddGrpc(); var app = builder.Build(); diff --git a/src/ApiServer/Properties/launchSettings.json b/src/ApiServer/Properties/launchSettings.json index 323d8b2..2377551 100644 --- a/src/ApiServer/Properties/launchSettings.json +++ b/src/ApiServer/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5053", + "applicationUrl": "http://localhost:5053;http://localhost:5054", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7193;http://localhost:5053", + "applicationUrl": "https://localhost:7193;http://localhost:5053;http://localhost:5054", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/ApiServer/Services/InMemoryMenuStore.cs b/src/ApiServer/Services/InMemoryMenuStore.cs index 512f88f..054414f 100644 --- a/src/ApiServer/Services/InMemoryMenuStore.cs +++ b/src/ApiServer/Services/InMemoryMenuStore.cs @@ -6,26 +6,22 @@ internal sealed class InMemoryMenuStore : IMenuStore { private readonly List _menu = [ - new() - { - Id = "5979224", - Article = "A1004292", - Name = "Каша гречневая", - Price = 50, - IsWeighted = false, - FullPath = @"ПРОИЗВОДСТВО\Гарниры", - Barcodes = ["57890975627974236429"], - }, - new() - { - Id = "9084246", - Article = "A1004293", - Name = "Конфеты Коровка", - Price = 300, - IsWeighted = true, - FullPath = @"ДЕСЕРТЫ\Развес", - Barcodes = [], - }, + new( + "5979224", + "A1004292", + "Каша гречневая", + 50, + false, + @"ПРОИЗВОДСТВО\Гарниры", + ["57890975627974236429"]), + new( + "9084246", + "A1004293", + "Конфеты Коровка", + 300, + true, + @"ДЕСЕРТЫ\Развес", + []), ]; private readonly HashSet _orderIds = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Console/Console.csproj b/src/Console/Console.csproj index ed9781c..49ef502 100644 --- a/src/Console/Console.csproj +++ b/src/Console/Console.csproj @@ -1,10 +1,37 @@ - + Exe net10.0 + ConsoleApp + Sms.Console enable enable + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + PreserveNewest + + + diff --git a/src/Console/Data/AppDbContext.cs b/src/Console/Data/AppDbContext.cs new file mode 100644 index 0000000..3da635e --- /dev/null +++ b/src/Console/Data/AppDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace ConsoleApp.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Dishes => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("dishes"); + entity.Property(d => d.Barcodes) + .HasColumnType("jsonb") + .HasConversion(BarcodesJsonConverter.Instance); + }); + } +} diff --git a/src/Console/Data/AppDbContextFactory.cs b/src/Console/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..87413f9 --- /dev/null +++ b/src/Console/Data/AppDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace ConsoleApp.Data; + +internal sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(configuration.GetConnectionString("Default")) + .UseSnakeCaseNamingConvention(); + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/src/Console/Data/BarcodesJsonConverter.cs b/src/Console/Data/BarcodesJsonConverter.cs new file mode 100644 index 0000000..e7d46a5 --- /dev/null +++ b/src/Console/Data/BarcodesJsonConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ConsoleApp.Data; + +internal static class BarcodesJsonConverter +{ + private static readonly JsonSerializerOptions JsonOptions = new(); + + public static ValueConverter, string> Instance { get; } = new( + v => Serialize(v), + v => Deserialize(v)); + + private static string Serialize(List value) => + JsonSerializer.Serialize(value, JsonOptions); + + private static List Deserialize(string value) => + JsonSerializer.Deserialize>(value, JsonOptions) ?? []; +} diff --git a/src/Console/Data/DatabaseInitializer.cs b/src/Console/Data/DatabaseInitializer.cs new file mode 100644 index 0000000..120e34e --- /dev/null +++ b/src/Console/Data/DatabaseInitializer.cs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..56262f2 --- /dev/null +++ b/src/Console/Data/DishRepository.cs @@ -0,0 +1,25 @@ +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/Data/MenuDish.cs b/src/Console/Data/MenuDish.cs new file mode 100644 index 0000000..78d721f --- /dev/null +++ b/src/Console/Data/MenuDish.cs @@ -0,0 +1,18 @@ +namespace ConsoleApp.Data; + +public sealed class MenuDish +{ + public string Id { get; set; } = ""; + + public string Article { get; set; } = ""; + + public string Name { get; set; } = ""; + + public decimal Price { get; set; } + + public bool IsWeighted { get; set; } + + public string FullPath { get; set; } = ""; + + public List Barcodes { get; set; } = []; +} diff --git a/src/Console/DependencyInjection.cs b/src/Console/DependencyInjection.cs new file mode 100644 index 0000000..f16b6bc --- /dev/null +++ b/src/Console/DependencyInjection.cs @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..6f12faf --- /dev/null +++ b/src/Console/Logging/ConsoleLog.cs @@ -0,0 +1,52 @@ +namespace ConsoleApp.Logging; + +internal sealed class TeeTextWriter(TextWriter consoleWriter, TextWriter logWriter) : TextWriter +{ + public override System.Text.Encoding Encoding => consoleWriter.Encoding; + + public override void Write(char value) + { + consoleWriter.Write(value); + logWriter.Write(value); + } + + public override void Write(string? value) + { + consoleWriter.Write(value); + logWriter.Write(value); + } + + public override void WriteLine(string? value) + { + consoleWriter.WriteLine(value); + logWriter.WriteLine(value); + } + + public override void Flush() + { + consoleWriter.Flush(); + logWriter.Flush(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + logWriter.Dispose(); + } + + base.Dispose(disposing); + } +} + +internal static class ConsoleLog +{ + public static IDisposable Configure() + { + 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; + } +} diff --git a/src/Console/Migrations/20260601135443_Init.Designer.cs b/src/Console/Migrations/20260601135443_Init.Designer.cs new file mode 100644 index 0000000..311341a --- /dev/null +++ b/src/Console/Migrations/20260601135443_Init.Designer.cs @@ -0,0 +1,69 @@ +// +using ConsoleApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ConsoleApp.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260601135443_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ConsoleApp.Data.MenuDish", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Article") + .IsRequired() + .HasColumnType("text") + .HasColumnName("article"); + + b.Property("Barcodes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("barcodes"); + + b.Property("FullPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("full_path"); + + b.Property("IsWeighted") + .HasColumnType("boolean") + .HasColumnName("is_weighted"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("numeric") + .HasColumnName("price"); + + b.HasKey("Id") + .HasName("pk_dishes"); + + b.ToTable("dishes", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Console/Migrations/20260601135443_Init.cs b/src/Console/Migrations/20260601135443_Init.cs new file mode 100644 index 0000000..1fb4548 --- /dev/null +++ b/src/Console/Migrations/20260601135443_Init.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConsoleApp.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "dishes", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + article = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + price = table.Column(type: "numeric", nullable: false), + is_weighted = table.Column(type: "boolean", nullable: false), + full_path = table.Column(type: "text", nullable: false), + barcodes = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_dishes", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "dishes"); + } + } +} diff --git a/src/Console/Migrations/AppDbContextModelSnapshot.cs b/src/Console/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..4ec78b0 --- /dev/null +++ b/src/Console/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using ConsoleApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ConsoleApp.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ConsoleApp.Data.MenuDish", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Article") + .IsRequired() + .HasColumnType("text") + .HasColumnName("article"); + + b.Property("Barcodes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("barcodes"); + + b.Property("FullPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("full_path"); + + b.Property("IsWeighted") + .HasColumnType("boolean") + .HasColumnName("is_weighted"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Price") + .HasColumnType("numeric") + .HasColumnName("price"); + + b.HasKey("Id") + .HasName("pk_dishes"); + + b.ToTable("dishes", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Console/Program.cs b/src/Console/Program.cs index 3751555..dbbf939 100644 --- a/src/Console/Program.cs +++ b/src/Console/Program.cs @@ -1,2 +1,21 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using ConsoleApp; +using ConsoleApp.Logging; +using ConsoleApp.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + +using var _ = ConsoleLog.Configure(); + +var services = new ServiceCollection(); +services.AddSingleton(configuration); +services.AddConsoleApp(configuration); + +await using var provider = services.BuildServiceProvider(); +await using var scope = provider.CreateAsyncScope(); +await scope.ServiceProvider.GetRequiredService().RunAsync(CancellationToken.None); diff --git a/src/Console/Services/ConsoleAppRunner.cs b/src/Console/Services/ConsoleAppRunner.cs new file mode 100644 index 0000000..41a30fe --- /dev/null +++ b/src/Console/Services/ConsoleAppRunner.cs @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..3e28158 --- /dev/null +++ b/src/Console/Services/OrderInputParser.cs @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..9c5a074 --- /dev/null +++ b/src/Console/appsettings.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=sms_task;Username=sms;Password=sms" + }, + "ApiClient": { + "Backend": "Grpc" + }, + "Http": { + "BaseUrl": "http://localhost:5053", + "Username": "user", + "Password": "password" + }, + "Grpc": { + "Address": "http://localhost:5054" + } +} diff --git a/src/Contracts/ApiJsonOptions.cs b/src/Contracts/ApiJsonOptions.cs index 2e345e1..e442ef6 100644 --- a/src/Contracts/ApiJsonOptions.cs +++ b/src/Contracts/ApiJsonOptions.cs @@ -12,6 +12,7 @@ public static class ApiJsonOptions var options = new JsonSerializerOptions { PropertyNamingPolicy = null, + PropertyNameCaseInsensitive = true, }; options.Converters.Add(new DecimalFromStringJsonConverter()); diff --git a/src/Contracts/Menu/GetMenuData.cs b/src/Contracts/Menu/GetMenuData.cs index e50d25c..5da530d 100644 --- a/src/Contracts/Menu/GetMenuData.cs +++ b/src/Contracts/Menu/GetMenuData.cs @@ -2,7 +2,13 @@ using Domain.Entities; namespace Contracts.Menu; -public sealed class GetMenuData +public sealed record GetMenuData(IReadOnlyList MenuItems) { - public IReadOnlyList MenuItems { get; init; } = []; + public GetMenuData() + : this([]) + { + } + + public override string ToString() => + string.Join(Environment.NewLine, MenuItems); } diff --git a/src/Contracts/Menu/GetMenuParameters.cs b/src/Contracts/Menu/GetMenuParameters.cs index bc0106b..2ee387b 100644 --- a/src/Contracts/Menu/GetMenuParameters.cs +++ b/src/Contracts/Menu/GetMenuParameters.cs @@ -1,6 +1,6 @@ namespace Contracts.Menu; -public sealed class GetMenuParameters +public sealed record GetMenuParameters(bool WithPrice = true) { - public bool WithPrice { get; set; } = true; + public override string ToString() => $"WithPrice={WithPrice}"; } diff --git a/src/Contracts/Orders/SendOrderParameters.cs b/src/Contracts/Orders/SendOrderParameters.cs index 9f3baa3..880ae0d 100644 --- a/src/Contracts/Orders/SendOrderParameters.cs +++ b/src/Contracts/Orders/SendOrderParameters.cs @@ -2,9 +2,13 @@ using Domain.Entities; namespace Contracts.Orders; -public sealed class SendOrderParameters +public sealed record SendOrderParameters(string OrderId, IReadOnlyList MenuItems) { - public required string OrderId { get; init; } + public SendOrderParameters(string OrderId) + : this(OrderId, []) + { + } - public IReadOnlyList MenuItems { get; init; } = []; + public override string ToString() => + $"OrderId={OrderId}, Items=[{string.Join("; ", MenuItems)}]"; } diff --git a/src/Contracts/Requests/ApiRequest.cs b/src/Contracts/Requests/ApiRequest.cs index e58b920..3cba2f8 100644 --- a/src/Contracts/Requests/ApiRequest.cs +++ b/src/Contracts/Requests/ApiRequest.cs @@ -1,11 +1,16 @@ namespace Contracts.Requests; -public class ApiRequest +public record ApiRequest { - public string Command { get; set; } = ""; + public string Command { get; init; } = ""; + + public override string ToString() => Command; } -public class ApiRequest : ApiRequest +public record ApiRequest : ApiRequest { - public TParameters? CommandParameters { get; set; } + public TParameters? CommandParameters { get; init; } + + public override string ToString() => + CommandParameters is null ? Command : $"{Command} ({CommandParameters})"; } diff --git a/src/Contracts/Requests/GetMenuApiRequest.cs b/src/Contracts/Requests/GetMenuApiRequest.cs index a5340f6..a0d4bbc 100644 --- a/src/Contracts/Requests/GetMenuApiRequest.cs +++ b/src/Contracts/Requests/GetMenuApiRequest.cs @@ -2,7 +2,7 @@ using Contracts.Menu; namespace Contracts.Requests; -public sealed class GetMenuApiRequest : ApiRequest +public sealed record GetMenuApiRequest : ApiRequest { public GetMenuApiRequest() { diff --git a/src/Contracts/Requests/SendOrderApiRequest.cs b/src/Contracts/Requests/SendOrderApiRequest.cs index b09d4d4..1110535 100644 --- a/src/Contracts/Requests/SendOrderApiRequest.cs +++ b/src/Contracts/Requests/SendOrderApiRequest.cs @@ -2,7 +2,7 @@ using Contracts.Orders; namespace Contracts.Requests; -public sealed class SendOrderApiRequest : ApiRequest +public sealed record SendOrderApiRequest : ApiRequest { public SendOrderApiRequest() { diff --git a/src/Contracts/Responses/ApiResponse.cs b/src/Contracts/Responses/ApiResponse.cs index 4bbe37a..936877c 100644 --- a/src/Contracts/Responses/ApiResponse.cs +++ b/src/Contracts/Responses/ApiResponse.cs @@ -1,10 +1,13 @@ namespace Contracts.Responses; -public class ApiResponse +public record ApiResponse { public required string Command { get; init; } public bool Success { get; init; } public string ErrorMessage { get; init; } = ""; + + public override string ToString() => + Success ? $"{Command}: OK" : $"{Command}: {ErrorMessage}"; } diff --git a/src/Contracts/Responses/ApiResponseDeserializer.cs b/src/Contracts/Responses/ApiResponseDeserializer.cs index eadde04..b15e6d2 100644 --- a/src/Contracts/Responses/ApiResponseDeserializer.cs +++ b/src/Contracts/Responses/ApiResponseDeserializer.cs @@ -18,16 +18,33 @@ public static class ApiResponseDeserializer public static ApiResponse Deserialize(JsonElement json) { - if (!json.TryGetProperty("Command", out var commandElement)) + if (!TryGetCommand(json, out var command)) { return json.Deserialize(ApiJsonOptions.Instance)!; } - return commandElement.GetString() switch + return command switch { Commands.GetMenu => json.Deserialize(ApiJsonOptions.Instance)!, Commands.SendOrder => json.Deserialize(ApiJsonOptions.Instance)!, _ => json.Deserialize(ApiJsonOptions.Instance)!, }; } + + private static bool TryGetCommand(JsonElement json, out string? command) + { + foreach (var property in json.EnumerateObject()) + { + if (!property.Name.Equals("Command", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + command = property.Value.GetString(); + return true; + } + + command = null; + return false; + } } diff --git a/src/Contracts/Responses/GetMenuApiResponse.cs b/src/Contracts/Responses/GetMenuApiResponse.cs index 5a94c79..748f663 100644 --- a/src/Contracts/Responses/GetMenuApiResponse.cs +++ b/src/Contracts/Responses/GetMenuApiResponse.cs @@ -2,7 +2,10 @@ using Contracts.Menu; namespace Contracts.Responses; -public sealed class GetMenuApiResponse : ApiResponse +public sealed record GetMenuApiResponse : ApiResponse { public GetMenuData? Data { get; init; } + + public override string ToString() => + Data is null ? base.ToString() : $"{base.ToString()}{Environment.NewLine}{Data}"; } diff --git a/src/Contracts/Responses/SendOrderApiResponse.cs b/src/Contracts/Responses/SendOrderApiResponse.cs index 240874e..606c8c3 100644 --- a/src/Contracts/Responses/SendOrderApiResponse.cs +++ b/src/Contracts/Responses/SendOrderApiResponse.cs @@ -1,4 +1,3 @@ namespace Contracts.Responses; -public sealed class SendOrderApiResponse : ApiResponse; - +public sealed record SendOrderApiResponse : ApiResponse;