diff --git a/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs b/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs
new file mode 100644
index 0000000..d2e4eb7
--- /dev/null
+++ b/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs
@@ -0,0 +1,123 @@
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace foodmarket.Api.Infrastructure.Validation;
+
+/// Переводит технические ошибки разбора тела запроса (model binding /
+/// JSON-десериализация) в дружелюбный русский. ASP.NET для неверного типа/enum
+/// отдаёт тексты вида «The JSON value could not be converted to
+/// foodmarket.Api.Controllers.Inventory.LossesController+LossInput. Path: $.reason …»
+/// — показывать их пользователю нельзя (имена классов, пути, англ.). Заменяем на
+/// «Неверное значение поля «Причина»». Сообщения FluentValidation (уже осмысленные,
+/// русские) оставляем как есть.
+public static class ModelErrorTranslator
+{
+ // Маркеры технических сообщений от System.Text.Json / model binder.
+ private static readonly string[] TechnicalMarkers =
+ {
+ "could not be converted",
+ "JSON value",
+ "is not a valid",
+ "could not be parsed",
+ "The input was not valid",
+ "The supplied value is invalid",
+ "is not valid for",
+ "Cannot deserialize",
+ "A non-empty request body is required",
+ "The dto field is required",
+ "could not convert",
+ "Could not convert",
+ };
+
+ // Человеческие названия частых полей (ключ — последний сегмент пути, lower).
+ private static readonly IReadOnlyDictionary FieldLabels =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["reason"] = "Причина",
+ ["quantity"] = "Количество",
+ ["qty"] = "Количество",
+ ["unitcost"] = "Себестоимость",
+ ["unitprice"] = "Цена",
+ ["price"] = "Цена",
+ ["amount"] = "Сумма",
+ ["sum"] = "Сумма",
+ ["date"] = "Дата",
+ ["storeid"] = "Склад",
+ ["productid"] = "Товар",
+ ["counterpartyid"] = "Контрагент",
+ ["customerid"] = "Покупатель",
+ ["supplierid"] = "Поставщик",
+ ["currencyid"] = "Валюта",
+ ["discount"] = "Скидка",
+ ["vatpercent"] = "НДС",
+ ["vat"] = "НДС",
+ ["payment"] = "Оплата",
+ ["paidcash"] = "Оплата наличными",
+ ["paidcard"] = "Оплата картой",
+ ["type"] = "Тип",
+ ["status"] = "Статус",
+ ["packaging"] = "Фасовка",
+ ["kind"] = "Вид",
+ ["email"] = "Email",
+ ["phone"] = "Телефон",
+ ["name"] = "Название",
+ ["article"] = "Артикул",
+ ["barcode"] = "Штрихкод",
+ ["code"] = "Код",
+ ["plan"] = "Тариф",
+ ["role"] = "Роль",
+ ["lines"] = "Позиции",
+ ["items"] = "Позиции",
+ };
+
+ private static bool IsTechnical(string msg)
+ => !string.IsNullOrEmpty(msg) && TechnicalMarkers.Any(m => msg.Contains(m, StringComparison.OrdinalIgnoreCase));
+
+ /// Человеческое имя поля из ключа ModelState («$.reason», «lines[0].reason»,
+ /// «dto.Amount» → «Причина» / «Сумма»; неизвестное — очищенный ключ в кавычках).
+ public static string FieldLabel(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key) || key == "$") return "данные запроса";
+ var seg = key.TrimStart('$', '.');
+ // последний сегмент пути без индексов массива
+ var lastDot = seg.LastIndexOf('.');
+ if (lastDot >= 0) seg = seg[(lastDot + 1)..];
+ var bracket = seg.IndexOf('[');
+ if (bracket >= 0) seg = seg[..bracket];
+ seg = seg.Trim();
+ if (seg.Length == 0) return "данные запроса";
+ return FieldLabels.TryGetValue(seg, out var label) ? label : seg;
+ }
+
+ /// Переводит одно сообщение. Техническое → «Неверное значение поля «…»»;
+ /// осмысленное (FluentValidation) — без изменений.
+ public static string Translate(string key, string message)
+ => IsTechnical(message)
+ ? $"Неверное значение поля «{FieldLabel(key)}»"
+ : message;
+
+ /// Строит словарь дружелюбных ошибок по полям (ключ — очищенное имя поля)
+ /// из ModelState. Технические сообщения переведены, имена классов/пути убраны.
+ public static Dictionary Humanize(ModelStateDictionary modelState)
+ {
+ var result = new Dictionary();
+ foreach (var kv in modelState)
+ {
+ if (kv.Value is null || kv.Value.Errors.Count == 0) continue;
+ var field = CleanFieldKey(kv.Key);
+ var msgs = kv.Value.Errors
+ .Select(e => Translate(kv.Key, string.IsNullOrWhiteSpace(e.ErrorMessage)
+ ? $"Неверное значение поля «{FieldLabel(kv.Key)}»" : e.ErrorMessage))
+ .Distinct()
+ .ToArray();
+ result[field] = msgs;
+ }
+ return result;
+ }
+
+ private static string CleanFieldKey(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key) || key == "$") return "";
+ var seg = key.TrimStart('$', '.');
+ return seg.Length == 0 ? "" : seg;
+ }
+}
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 378dc7c..5663d6b 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -337,20 +337,25 @@ static string ApplyDefaultPoolConfig(string? raw)
// «за какое поле отвалилось» без детализации в теле ответа.
o.InvalidModelStateResponseFactory = ctx =>
{
- var problems = ctx.ModelState
+ // Технический лог — с сырыми путями/классами (видит только бэкенд).
+ var raw = ctx.ModelState
.Where(kv => kv.Value?.Errors.Count > 0)
.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")
.ToList();
ctx.HttpContext.RequestServices.GetRequiredService()
.CreateLogger("ModelValidation")
.LogWarning("[Проверка] {Path}: {Problems}",
- ctx.HttpContext.Request.Path, string.Join("; ", problems));
- var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState)
+ ctx.HttpContext.Request.Path, string.Join("; ", raw));
+
+ // Пользователю — дружелюбный русский без имён классов/путей/англ.
+ var friendly = foodmarket.Api.Infrastructure.Validation.ModelErrorTranslator.Humanize(ctx.ModelState);
+ var detailMsgs = friendly.Values.SelectMany(v => v).Distinct().ToList();
+ var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(friendly)
{
Status = StatusCodes.Status400BadRequest,
- Title = "Сервер не принял данные",
- Detail = string.Join("; ", problems.Take(3))
- + (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""),
+ Title = "Проверьте правильность заполнения",
+ Detail = string.Join("; ", detailMsgs.Take(3))
+ + (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""),
};
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd)
{
diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs
index 270dba1..a5dcde6 100644
--- a/tests/food-market.IntegrationTests/MobileApiTests.cs
+++ b/tests/food-market.IntegrationTests/MobileApiTests.cs
@@ -139,6 +139,36 @@ public async Task Stock_group_filter_with_includeZero_shows_group_products_witho
}
}
+ // ── 1d. Ошибки ввода — дружелюбный русский без имён классов/путей ──────
+ [Fact]
+ public async Task Invalid_enum_value_returns_friendly_russian_without_class_names()
+ {
+ var api = new ApiActor(_factory.CreateClient());
+ await api.SignupAndLoginAsync($"mob-err-{Guid.NewGuid():N}");
+ var refs = await api.LoadRefsAsync();
+ var pid = await api.CreateProductAsync(refs, $"Списываемый-{Guid.NewGuid():N}", 100m, RandomBarcode());
+
+ // Списание с несуществующим значением enum «reason» → ранее приходил
+ // технический англ. текст с именем класса LossInput. Теперь — русский.
+ var json = System.Text.Json.JsonSerializer.Serialize(new
+ {
+ date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
+ reason = "НЕвернаяПричина", notes = "x",
+ lines = new[] { new { productId = pid, quantity = 1m, unitCost = 10m } },
+ });
+ using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+ using var resp = await api.Http.PostAsync("/api/inventory/losses", content);
+ resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var body = await resp.Content.ReadAsStringAsync();
+
+ body.Should().NotContain("foodmarket.", "в ответе не должно быть имён классов");
+ body.Should().NotContain("JSON value", "технический англ. текст недопустим");
+ body.Should().NotContain("could not be converted");
+ body.Should().NotContain("$.", "пути JSON не показываем пользователю");
+ body.Should().Contain("Причина", "поле названо по-русски");
+ body.Should().MatchRegex("[Нн]еверное значение", "дружелюбная формулировка");
+ }
+
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
[Fact]
public async Task Add_barcode_creates_and_rejects_duplicate()