Compare commits
6 Commits
51a047602e
...
e353e9e7f9
| Author | SHA1 | Date | |
|---|---|---|---|
| e353e9e7f9 | |||
| 6e6831c232 | |||
| 1b85ec5ce8 | |||
| 50626c6ac6 | |||
| 3af9cb1912 | |||
| 9c5763ba38 |
53
.vscode/launch.json
vendored
53
.vscode/launch.json
vendored
@@ -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
38
.vscode/tasks.json
vendored
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
40
scripts/restart-db.sh
Executable file
40
scripts/restart-db.sh
Executable 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}"
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace ApiClient;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
81
src/ApiClient/Grpc/GrpcSmsClient.cs
Normal file
81
src/ApiClient/Grpc/GrpcSmsClient.cs
Normal 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());
|
||||||
|
}
|
||||||
6
src/ApiClient/Grpc/GrpcSmsClientOptions.cs
Normal file
6
src/ApiClient/Grpc/GrpcSmsClientOptions.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ApiClient.Grpc;
|
||||||
|
|
||||||
|
public sealed class GrpcSmsClientOptions
|
||||||
|
{
|
||||||
|
public string Address { get; set; } = "http://localhost:5054";
|
||||||
|
}
|
||||||
112
src/ApiClient/Http/HttpSmsClient.cs
Normal file
112
src/ApiClient/Http/HttpSmsClient.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/ApiClient/Http/HttpSmsClientOptions.cs
Normal file
10
src/ApiClient/Http/HttpSmsClientOptions.cs
Normal 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";
|
||||||
|
}
|
||||||
11
src/ApiClient/ISmsClient.cs
Normal file
11
src/ApiClient/ISmsClient.cs
Normal 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);
|
||||||
|
}
|
||||||
11
src/ApiClient/SmsClientFactory.cs
Normal file
11
src/ApiClient/SmsClientFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
19
src/Console/Data/AppDbContext.cs
Normal file
19
src/Console/Data/AppDbContext.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Console/Data/AppDbContextFactory.cs
Normal file
26
src/Console/Data/AppDbContextFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Console/Data/BarcodesJsonConverter.cs
Normal file
19
src/Console/Data/BarcodesJsonConverter.cs
Normal 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) ?? [];
|
||||||
|
}
|
||||||
18
src/Console/Data/MenuDish.cs
Normal file
18
src/Console/Data/MenuDish.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
57
src/Console/Logging/ConsoleLog.cs
Normal file
57
src/Console/Logging/ConsoleLog.cs
Normal 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();
|
||||||
|
}
|
||||||
69
src/Console/Migrations/20260601135443_Init.Designer.cs
generated
Normal file
69
src/Console/Migrations/20260601135443_Init.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Console/Migrations/20260601135443_Init.cs
Normal file
38
src/Console/Migrations/20260601135443_Init.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Console/Migrations/AppDbContextModelSnapshot.cs
Normal file
66
src/Console/Migrations/AppDbContextModelSnapshot.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
13
src/Console/appsettings.json
Normal file
13
src/Console/appsettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
namespace Contracts.Responses;
|
namespace Contracts.Responses;
|
||||||
|
|
||||||
public sealed class SendOrderApiResponse : ApiResponse;
|
public sealed record SendOrderApiResponse : ApiResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -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; } = [];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user