Compare commits

...

7 Commits

Author SHA1 Message Date
4a1ad01a13 Readme 2026-06-04 18:09:06 +03:00
e353e9e7f9 Убрано лишнее создание 2026-06-04 18:06:52 +03:00
6e6831c232 Упрощён клиент 2026-06-04 13:28:22 +03:00
1b85ec5ce8 Улучшения, теперь работает http и grpc
Но ещё требуется ревью и чистка
2026-06-01 18:10:42 +03:00
50626c6ac6 Базово работает
gRPC на отдельном порту
2026-06-01 18:02:48 +03:00
3af9cb1912 форматирование моделей 2026-06-01 17:57:09 +03:00
9c5763ba38 ApiClient 2026-06-01 00:41:06 +03:00
42 changed files with 1085 additions and 192 deletions

53
.vscode/launch.json vendored
View File

@@ -2,50 +2,33 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
// Use IntelliSense to find out which attributes exist for C# debugging "name": "ApiServer",
// 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", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "preLaunchTask": "build ApiServer",
// 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", "program": "${workspaceFolder}/src/ApiServer/bin/Debug/net10.0/ApiServer.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/src/ApiServer", "cwd": "${workspaceFolder}/src/ApiServer",
"stopAtEntry": false, "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": { "env": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
} }
},
{
"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
} }
] ]
} }

38
.vscode/tasks.json vendored
View File

@@ -2,39 +2,17 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "build", "label": "build ApiServer",
"command": "dotnet", "type": "shell",
"type": "process", "command": "dotnet build ${workspaceFolder}/src/ApiServer/ApiServer.csproj",
"args": [ "group": "build",
"build",
"${workspaceFolder}/src/ApiServer/ApiServer.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
}, },
{ {
"label": "publish", "label": "build Console",
"command": "dotnet", "type": "shell",
"type": "process", "command": "dotnet build ${workspaceFolder}/src/Console/Console.csproj",
"args": [ "group": "build",
"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" "problemMatcher": "$msCompile"
} }
] ]

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# SMS Task One
Тестовое задание SMS (Middle C#): REST/gRPC API сервера и консольное приложение с PostgreSQL.
## Требования
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
- Docker (для PostgreSQL) или свой экземпляр Postgres 16+
## Структура
| Проект | Назначение |
|--------|------------|
| `Domain` | Доменные модели |
| `Contracts` | DTO, JSON, gRPC proto |
| `ApiServer` | ASP.NET Core: HTTP API + gRPC |
| `ApiClient` | HTTP и gRPC клиенты |
| `Console` | Консоль (часть 3 ТЗ): меню, заказ, БД |
## Сборка
```bash
dotnet build
```
## База данных
Запуск Postgres в Docker (сброс данных):
```bash
./scripts/restart-db.sh
```
Параметры: `localhost:5432`, БД `sms_task`, пользователь/поль `sms` / `sms`.
Подключение через `psql`:
```bash
psql -h localhost -p 5432 -U sms -d sms_task
```
Пароль: `sms`. Вариант с URI:
```bash
psql "postgresql://sms:sms@localhost:5432/sms_task"
```
## ApiServer
```bash
dotnet run --project src/ApiServer/ApiServer.csproj
```
| Сервис | URL |
|--------|-----|
| HTTP API | http://localhost:5053 |
| gRPC | http://localhost:5054 |
Basic Auth (HTTP): `user` / `password` (см. `src/ApiServer/appsettings.json`).
Примеры запросов: `src/ApiServer/ApiServer.http`.
## Console
Сначала запустите ApiServer и БД.
```bash
dotnet run --project src/Console/Console.csproj
```
При старте выбирается протокол: `1` — HTTP, `2` — gRPC. Настройки — в `src/Console/appsettings.json`. Лог пишется в файл `test-sms-console-app-*.log` в каталоге запуска; путь выводится в конце работы.
Формат ввода заказа: `Артикул:Количество;Артикул:Количество` (например `A1004292:1`).

40
scripts/restart-db.sh Executable file
View File

@@ -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}"

View File

@@ -1,6 +0,0 @@
namespace ApiClient;
public class Class1
{
}

View File

@@ -0,0 +1,81 @@
using Contracts;
using Contracts.Menu;
using Contracts.Responses;
using Domain.Entities;
using Google.Protobuf.WellKnownTypes;
using Grpc.Net.Client;
using Sms.Test;
namespace ApiClient.Grpc;
public sealed class GrpcSmsClient : ISmsClient, IDisposable
{
private readonly GrpcChannel _channel;
private readonly SmsTestService.SmsTestServiceClient _client;
public GrpcSmsClient(GrpcSmsClientOptions options)
{
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);
}
public async Task<GetMenuApiResponse> GetMenuAsync(bool withPrice = true, CancellationToken cancellationToken = default)
{
var response = await _client.GetMenuAsync(new BoolValue { Value = withPrice }, cancellationToken: cancellationToken);
return new GetMenuApiResponse
{
Command = Commands.GetMenu,
Success = response.Success,
ErrorMessage = response.ErrorMessage,
Data = response.Success
? new GetMenuData { MenuItems = response.MenuItems.Select(ToDish).ToList() }
: null,
};
}
public async Task<SendOrderApiResponse> SendOrderAsync(Domain.Entities.Order order, CancellationToken cancellationToken = default)
{
var grpcOrder = new Sms.Test.Order { Id = order.Id.ToString() };
grpcOrder.OrderItems.AddRange(order.Items.Select(item => new Sms.Test.OrderItem
{
Id = item.Id,
Quantity = (double)item.Quantity,
}));
var response = await _client.SendOrderAsync(grpcOrder, cancellationToken: cancellationToken);
return new SendOrderApiResponse
{
Command = Commands.SendOrder,
Success = response.Success,
ErrorMessage = response.ErrorMessage,
};
}
public void Dispose()
{
_channel.Dispose();
}
private static Dish ToDish(MenuItem item) => new(
item.Id,
item.Article,
item.Name,
(decimal)item.Price,
item.IsWeighted,
item.FullPath,
item.Barcodes.ToList());
}

View File

@@ -0,0 +1,6 @@
namespace ApiClient.Grpc;
public sealed class GrpcSmsClientOptions
{
public string Address { get; set; } = "http://localhost:5054";
}

View File

@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Contracts;
using Contracts.Menu;
using Contracts.Orders;
using Contracts.Requests;
using Contracts.Responses;
using Domain.Entities;
namespace ApiClient.Http;
public sealed class HttpSmsClient : ISmsClient, IDisposable
{
private readonly HttpClient _httpClient;
public HttpSmsClient(HttpSmsClientOptions options)
{
_httpClient = new HttpClient { BaseAddress = new Uri(options.BaseUrl) };
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.Username}:{options.Password}"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
public async Task<GetMenuApiResponse> GetMenuAsync(bool withPrice = true, CancellationToken cancellationToken = default)
{
var request = new GetMenuApiRequest
{
CommandParameters = new GetMenuParameters(withPrice),
};
var response = await SendAsync(request, cancellationToken);
return ToGetMenuResponse(response);
}
public async Task<SendOrderApiResponse> SendOrderAsync(Order order, CancellationToken cancellationToken = default)
{
var request = new SendOrderApiRequest
{
CommandParameters = new SendOrderParameters
{
OrderId = order.Id.ToString(),
MenuItems = order.Items.ToList(),
},
};
var response = await SendAsync(request, cancellationToken);
return ToSendOrderResponse(response);
}
public void Dispose() => _httpClient.Dispose();
private async Task<ApiResponse> SendAsync(ApiRequest request, CancellationToken cancellationToken)
{
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);
if (httpResponse.StatusCode == HttpStatusCode.Unauthorized)
{
return new ApiResponse
{
Command = request.Command,
Success = false,
ErrorMessage = "Требуется аутентификация.",
};
}
var responseJson = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(responseJson))
{
return new ApiResponse
{
Command = request.Command,
Success = false,
ErrorMessage = $"Пустой ответ сервера (HTTP {(int)httpResponse.StatusCode}).",
};
}
try
{
return ApiResponseDeserializer.Deserialize(responseJson);
}
catch (JsonException)
{
return new ApiResponse
{
Command = request.Command,
Success = false,
ErrorMessage = $"Некорректный JSON в ответе (HTTP {(int)httpResponse.StatusCode}): {responseJson}",
};
}
}
private static GetMenuApiResponse ToGetMenuResponse(ApiResponse response) =>
response as GetMenuApiResponse ?? new GetMenuApiResponse
{
Command = Commands.GetMenu,
Success = false,
ErrorMessage = response.ErrorMessage,
};
private static SendOrderApiResponse ToSendOrderResponse(ApiResponse response) =>
response as SendOrderApiResponse ?? new SendOrderApiResponse
{
Command = Commands.SendOrder,
Success = false,
ErrorMessage = response.ErrorMessage,
};
}

View File

@@ -0,0 +1,10 @@
namespace ApiClient.Http;
public sealed class HttpSmsClientOptions
{
public string BaseUrl { get; set; } = "http://localhost:5053";
public string Username { get; set; } = "user";
public string Password { get; set; } = "password";
}

View File

@@ -0,0 +1,11 @@
using Contracts.Responses;
using Domain.Entities;
namespace ApiClient;
public interface ISmsClient
{
Task<GetMenuApiResponse> GetMenuAsync(bool withPrice = true, CancellationToken cancellationToken = default);
Task<SendOrderApiResponse> SendOrderAsync(Order order, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
using ApiClient.Grpc;
using ApiClient.Http;
namespace ApiClient;
public static class SmsClientFactory
{
public static HttpSmsClient CreateHttp(HttpSmsClientOptions options) => new(options);
public static GrpcSmsClient CreateGrpc(GrpcSmsClientOptions options) => new(options);
}

View File

@@ -31,6 +31,15 @@ public sealed class ApiController(ISmsApiService apiService) : ControllerBase
ErrorMessage = $"Некорректный JSON: {ex.Message}", ErrorMessage = $"Некорректный JSON: {ex.Message}",
}); });
} }
catch (Exception ex)
{
return Ok(new ApiResponse
{
Command = "",
Success = false,
ErrorMessage = ex.Message,
});
}
return Ok(apiService.Handle(request)); return Ok(apiService.Handle(request));
} }

View File

@@ -19,9 +19,18 @@ public static class DependencyInjection
services.AddSingleton<IMenuStore, InMemoryMenuStore>(); services.AddSingleton<IMenuStore, InMemoryMenuStore>();
services.AddSingleton<ISmsApiService, SmsApiService>(); services.AddSingleton<ISmsApiService, SmsApiService>();
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy;
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.Converters.Add(new DecimalFromStringJsonConverter());
});
services.ConfigureHttpJsonOptions(options => services.ConfigureHttpJsonOptions(options =>
{ {
options.SerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy; options.SerializerOptions.PropertyNamingPolicy = Contracts.ApiJsonOptions.Instance.PropertyNamingPolicy;
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(new DecimalFromStringJsonConverter()); options.SerializerOptions.Converters.Add(new DecimalFromStringJsonConverter());
}); });

View File

@@ -5,16 +5,8 @@ namespace ApiServer.Mapping;
internal static class MenuMapper internal static class MenuMapper
{ {
public static Dish ApplyPriceVisibility(Dish dish, bool withPrice) => new() public static Dish ApplyPriceVisibility(Dish dish, bool withPrice) =>
{ dish with { Price = withPrice ? dish.Price : 0 };
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 MenuItem ToGrpc(Dish dish, bool withPrice) => new() public static MenuItem ToGrpc(Dish dish, bool withPrice) => new()
{ {

View File

@@ -1,10 +1,17 @@
using ApiServer; using ApiServer;
using ApiServer.Grpc; using ApiServer.Grpc;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args); 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.AddApiServer(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddGrpc(); builder.Services.AddGrpc();
var app = builder.Build(); var app = builder.Build();

View File

@@ -5,7 +5,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "http://localhost:5053", "applicationUrl": "http://localhost:5053;http://localhost:5054",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -14,7 +14,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://localhost:7193;http://localhost:5053", "applicationUrl": "https://localhost:7193;http://localhost:5053;http://localhost:5054",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@@ -6,26 +6,22 @@ internal sealed class InMemoryMenuStore : IMenuStore
{ {
private readonly List<Dish> _menu = private readonly List<Dish> _menu =
[ [
new() new(
{ "5979224",
Id = "5979224", "A1004292",
Article = "A1004292", "Каша гречневая",
Name = "Каша гречневая", 50,
Price = 50, false,
IsWeighted = false, @"ПРОИЗВОДСТВО\Гарниры",
FullPath = @"ПРОИЗВОДСТВО\Гарниры", ["57890975627974236429"]),
Barcodes = ["57890975627974236429"], new(
}, "9084246",
new() "A1004293",
{ "Конфеты Коровка",
Id = "9084246", 300,
Article = "A1004293", true,
Name = "Конфеты Коровка", @"ДЕСЕРТЫ\Развес",
Price = 300, []),
IsWeighted = true,
FullPath = @"ДЕСЕРТЫ\Развес",
Barcodes = [],
},
]; ];
private readonly HashSet<string> _orderIds = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _orderIds = new(StringComparer.OrdinalIgnoreCase);

View File

@@ -1,10 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace>ConsoleApp</RootNamespace>
<AssemblyName>Sms.Console</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ApiClient\ApiClient.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="EFCore.NamingConventions" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
namespace ConsoleApp.Data;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<MenuDish> Dishes => Set<MenuDish>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MenuDish>(entity =>
{
entity.ToTable("dishes");
entity.Property(d => d.Barcodes)
.HasColumnType("jsonb")
.HasConversion(BarcodesJsonConverter.Instance);
});
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace ConsoleApp.Data;
internal sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' не задана.");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention()
.Options;
return new AppDbContext(options);
}
}

View File

@@ -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<List<string>, string> Instance { get; } = new(
v => Serialize(v),
v => Deserialize(v));
private static string Serialize(List<string> value) =>
JsonSerializer.Serialize(value, JsonOptions);
private static List<string> Deserialize(string value) =>
JsonSerializer.Deserialize<List<string>>(value, JsonOptions) ?? [];
}

View File

@@ -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<string> Barcodes { get; set; } = [];
}

View File

@@ -0,0 +1,57 @@
namespace ConsoleApp.Logging;
public sealed class ConsoleLog : IDisposable
{
private readonly StreamWriter _file;
private ConsoleLog(string filePath, DateTime startedAt)
{
FilePath = filePath;
StartedAt = startedAt;
_file = new StreamWriter(filePath, append: true) { AutoFlush = true };
_file.WriteLine($"Запуск: {startedAt:yyyy-MM-dd HH:mm:ss}");
}
public string FilePath { get; }
public DateTime StartedAt { get; }
public static ConsoleLog Open(string? fileName = null)
{
var startedAt = TruncateToSeconds(DateTime.Now);
fileName ??= $"test-sms-console-app-{startedAt:yyyyMMdd_HHmmss}.log";
return new ConsoleLog(Path.GetFullPath(fileName), startedAt);
}
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)
{
Console.Write(text);
_file.Write(text);
}
public void WriteLine(string? text = null)
{
if (text is null)
{
Console.WriteLine();
_file.WriteLine();
return;
}
Console.WriteLine(text);
_file.WriteLine(text);
}
public string? ReadLine(string prompt)
{
Write(prompt);
var line = Console.ReadLine();
_file.WriteLine(line ?? "");
return line;
}
public void Dispose() => _file.Dispose();
}

View File

@@ -0,0 +1,69 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Article")
.IsRequired()
.HasColumnType("text")
.HasColumnName("article");
b.Property<string>("Barcodes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("barcodes");
b.Property<string>("FullPath")
.IsRequired()
.HasColumnType("text")
.HasColumnName("full_path");
b.Property<bool>("IsWeighted")
.HasColumnType("boolean")
.HasColumnName("is_weighted");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<decimal>("Price")
.HasColumnType("numeric")
.HasColumnName("price");
b.HasKey("Id")
.HasName("pk_dishes");
b.ToTable("dishes", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ConsoleApp.Migrations
{
/// <inheritdoc />
public partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "dishes",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
article = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
price = table.Column<decimal>(type: "numeric", nullable: false),
is_weighted = table.Column<bool>(type: "boolean", nullable: false),
full_path = table.Column<string>(type: "text", nullable: false),
barcodes = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_dishes", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "dishes");
}
}
}

View File

@@ -0,0 +1,66 @@
// <auto-generated />
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<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Article")
.IsRequired()
.HasColumnType("text")
.HasColumnName("article");
b.Property<string>("Barcodes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("barcodes");
b.Property<string>("FullPath")
.IsRequired()
.HasColumnType("text")
.HasColumnName("full_path");
b.Property<bool>("IsWeighted")
.HasColumnType("boolean")
.HasColumnName("is_weighted");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<decimal>("Price")
.HasColumnType("numeric")
.HasColumnName("price");
b.HasKey("Id")
.HasName("pk_dishes");
b.ToTable("dishes", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,2 +1,205 @@
// See https://aka.ms/new-console-template for more information using System.Globalization;
Console.WriteLine("Hello, World!"); using ApiClient;
using ApiClient.Grpc;
using ApiClient.Http;
using ConsoleApp.Data;
using ConsoleApp.Logging;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' не задана.");
var httpOptions = configuration.GetSection("Http").Get<HttpSmsClientOptions>() ?? new();
var grpcOptions = configuration.GetSection("Grpc").Get<GrpcSmsClientOptions>() ?? new();
using var log = ConsoleLog.Open();
var smsClient = AskSmsClient(log, httpOptions, grpcOptions);
try
{
log.WriteLine("Инициализация базы данных...");
await using var db = CreateDbContext(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);
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<AppDbContext>()
.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention()
.Options;
return new AppDbContext(options);
}
static async Task SaveDishesAsync(AppDbContext db, IReadOnlyList<Dish> 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<Dish> 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;
}

View File

@@ -0,0 +1,13 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=sms_task;Username=sms;Password=sms"
},
"Http": {
"BaseUrl": "http://localhost:5053",
"Username": "user",
"Password": "password"
},
"Grpc": {
"Address": "http://localhost:5054"
}
}

View File

@@ -12,6 +12,7 @@ public static class ApiJsonOptions
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
{ {
PropertyNamingPolicy = null, PropertyNamingPolicy = null,
PropertyNameCaseInsensitive = true,
}; };
options.Converters.Add(new DecimalFromStringJsonConverter()); options.Converters.Add(new DecimalFromStringJsonConverter());

View File

@@ -2,7 +2,10 @@ using Domain.Entities;
namespace Contracts.Menu; namespace Contracts.Menu;
public sealed class GetMenuData public sealed record GetMenuData
{ {
public IReadOnlyList<Dish> MenuItems { get; init; } = []; public IReadOnlyList<Dish> MenuItems { get; init; } = [];
public override string ToString() =>
string.Join(Environment.NewLine, MenuItems);
} }

View File

@@ -1,6 +1,6 @@
namespace Contracts.Menu; 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}";
} }

View File

@@ -2,9 +2,12 @@ using Domain.Entities;
namespace Contracts.Orders; namespace Contracts.Orders;
public sealed class SendOrderParameters public sealed record SendOrderParameters
{ {
public required string OrderId { get; init; } public required string OrderId { get; init; }
public IReadOnlyList<OrderItem> MenuItems { get; init; } = []; public IReadOnlyList<OrderItem> MenuItems { get; init; } = [];
public override string ToString() =>
$"OrderId={OrderId}, Items=[{string.Join("; ", MenuItems)}]";
} }

View File

@@ -1,11 +1,16 @@
namespace Contracts.Requests; 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<TParameters> : ApiRequest public record ApiRequest<TParameters> : ApiRequest
{ {
public TParameters? CommandParameters { get; set; } public TParameters? CommandParameters { get; init; }
public override string ToString() =>
CommandParameters is null ? Command : $"{Command} ({CommandParameters})";
} }

View File

@@ -2,7 +2,7 @@ using Contracts.Menu;
namespace Contracts.Requests; namespace Contracts.Requests;
public sealed class GetMenuApiRequest : ApiRequest<GetMenuParameters> public sealed record GetMenuApiRequest : ApiRequest<GetMenuParameters>
{ {
public GetMenuApiRequest() public GetMenuApiRequest()
{ {

View File

@@ -2,7 +2,7 @@ using Contracts.Orders;
namespace Contracts.Requests; namespace Contracts.Requests;
public sealed class SendOrderApiRequest : ApiRequest<SendOrderParameters> public sealed record SendOrderApiRequest : ApiRequest<SendOrderParameters>
{ {
public SendOrderApiRequest() public SendOrderApiRequest()
{ {

View File

@@ -1,10 +1,13 @@
namespace Contracts.Responses; namespace Contracts.Responses;
public class ApiResponse public record ApiResponse
{ {
public required string Command { get; init; } public required string Command { get; init; }
public bool Success { get; init; } public bool Success { get; init; }
public string ErrorMessage { get; init; } = ""; public string ErrorMessage { get; init; } = "";
public override string ToString() =>
Success ? $"{Command}: OK" : $"{Command}: {ErrorMessage}";
} }

View File

@@ -18,16 +18,33 @@ public static class ApiResponseDeserializer
public static ApiResponse Deserialize(JsonElement json) public static ApiResponse Deserialize(JsonElement json)
{ {
if (!json.TryGetProperty("Command", out var commandElement)) if (!TryGetCommand(json, out var command))
{ {
return json.Deserialize<ApiResponse>(ApiJsonOptions.Instance)!; return json.Deserialize<ApiResponse>(ApiJsonOptions.Instance)!;
} }
return commandElement.GetString() switch return command switch
{ {
Commands.GetMenu => json.Deserialize<GetMenuApiResponse>(ApiJsonOptions.Instance)!, Commands.GetMenu => json.Deserialize<GetMenuApiResponse>(ApiJsonOptions.Instance)!,
Commands.SendOrder => json.Deserialize<SendOrderApiResponse>(ApiJsonOptions.Instance)!, Commands.SendOrder => json.Deserialize<SendOrderApiResponse>(ApiJsonOptions.Instance)!,
_ => json.Deserialize<ApiResponse>(ApiJsonOptions.Instance)!, _ => json.Deserialize<ApiResponse>(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;
}
} }

View File

@@ -2,7 +2,10 @@ using Contracts.Menu;
namespace Contracts.Responses; namespace Contracts.Responses;
public sealed class GetMenuApiResponse : ApiResponse public sealed record GetMenuApiResponse : ApiResponse
{ {
public GetMenuData? Data { get; init; } public GetMenuData? Data { get; init; }
public override string ToString() =>
Data is null ? base.ToString() : $"{base.ToString()}{Environment.NewLine}{Data}";
} }

View File

@@ -1,4 +1,3 @@
namespace Contracts.Responses; namespace Contracts.Responses;
public sealed class SendOrderApiResponse : ApiResponse; public sealed record SendOrderApiResponse : ApiResponse;

View File

@@ -1,24 +1,28 @@
using System.Text;
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Блюдо из меню (GetMenu / MenuItem). /// Блюдо из меню (GetMenu / MenuItem).
/// </summary> /// </summary>
public sealed class Dish public sealed record Dish(
string Id,
string Article,
string Name,
decimal Price,
bool IsWeighted,
string FullPath,
IReadOnlyList<string> Barcodes)
{ {
public required string Id { get; init; } private bool PrintMembers(StringBuilder builder)
{
/// <summary> builder.Append($"Id = {Id}, ");
/// Артикул блюда. Используется при вводе заказа с клавиатуры. builder.Append($"Article = {Article}, ");
/// </summary> builder.Append($"Name = {Name}, ");
public required string Article { get; init; } builder.Append($"Price = {Price}, ");
builder.Append($"IsWeighted = {IsWeighted}, ");
public required string Name { get; init; } builder.Append($"FullPath = {FullPath}, ");
builder.Append($"Barcodes = [{string.Join(", ", Barcodes)}]");
public decimal Price { get; init; } return true;
}
public bool IsWeighted { get; init; }
public required string FullPath { get; init; }
public IReadOnlyList<string> Barcodes { get; init; } = [];
} }

View File

@@ -1,22 +1,18 @@
using System.Text;
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Заказ (SendOrder). /// Заказ (SendOrder).
/// </summary> /// </summary>
public sealed class Order public sealed record Order(Guid Id, IReadOnlyList<OrderItem> Items)
{ {
private readonly List<OrderItem> _items = []; public Order()
: this(Guid.NewGuid(), [])
public Guid Id { get; }
public IReadOnlyCollection<OrderItem> Items => _items;
public Order(Guid? id = null)
{ {
Id = id ?? Guid.NewGuid();
} }
public void AddItem(string id, decimal quantity) public Order AddItem(string id, decimal quantity)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(id); ArgumentException.ThrowIfNullOrWhiteSpace(id);
@@ -28,10 +24,15 @@ public sealed class Order
"Количество должно быть больше нуля."); "Количество должно быть больше нуля.");
} }
_items.Add(new OrderItem var items = Items.ToList();
items.Add(new OrderItem(id, quantity));
return this with { Items = items };
}
private bool PrintMembers(StringBuilder builder)
{ {
Id = id, builder.Append($"Id = {Id}, ");
Quantity = quantity, builder.Append($"Items = [{string.Join(", ", Items)}]");
}); return true;
} }
} }

View File

@@ -3,15 +3,4 @@ namespace Domain.Entities;
/// <summary> /// <summary>
/// Позиция заказа (SendOrder / MenuItems). /// Позиция заказа (SendOrder / MenuItems).
/// </summary> /// </summary>
public sealed class OrderItem public sealed record OrderItem(string Id, decimal Quantity);
{
/// <summary>
/// Идентификатор блюда на сервере (<see cref="Dish.Id"/>).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Количество. Для весовых блюд допускаются дробные значения.
/// </summary>
public decimal Quantity { get; init; }
}