using FluentValidation;
namespace foodmarket.Api.Infrastructure.Validation;
/// Sprint 23 / bug-001: проверка что строка не содержит NUL-байт
/// и иных control-chars (кроме \t\r\n). Postgres TEXT/VARCHAR отказывается
/// принять \x00, что роняло SaveChanges с 500 без понятного ответа.
/// Применяется на пользовательские input-поля строкового типа.
internal static class ValidationExt
{
public static bool NoControlChars(string? s)
{
if (s is null) return true;
foreach (var ch in s)
if (char.IsControl(ch) && ch != '\t' && ch != '\r' && ch != '\n') return false;
return true;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// FluentValidation-валидаторы для основных input-DTO. Зарегистрированы
// автоматически через AddValidatorsFromAssemblyContaining().
//
// Зачем выносим из контроллеров: единый формат ошибок (ValidationProblemDetails),
// тестируемые юнит-тестами без HTTP, переиспользуемые правила (например, BIN
// длиной 12 цифр — одинаково для Counterparty.Create и Counterparty.Update),
// читаемые правила (RuleFor + цепочка).
//
// Стиль: лаконично, на одно поле — одна цепочка. Сообщения по-русски (UI ждёт RU).
// ──────────────────────────────────────────────────────────────────────────────
public sealed class SupplyInputValidator : AbstractValidator
{
public SupplyInputValidator()
{
RuleFor(x => x.SupplierId).NotEqual(Guid.Empty).WithMessage("Не указан поставщик.");
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.Lines).NotEmpty().WithMessage("Приёмка должна содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty).WithMessage("Не указан товар в строке.");
line.RuleFor(l => l.Quantity).GreaterThan(0).WithMessage("Количество должно быть больше 0.");
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0).WithMessage("Цена не может быть отрицательной.");
});
}
}
public sealed class RetailSaleInputValidator : AbstractValidator
{
public RetailSaleInputValidator()
{
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
RuleFor(x => x.Date).LessThan(DateTime.UtcNow.AddDays(1)).WithMessage("Дата не может быть в будущем.");
RuleFor(x => x.Notes).MaximumLength(1000);
RuleFor(x => x.PaidCash).GreaterThanOrEqualTo(0);
RuleFor(x => x.PaidCard).GreaterThanOrEqualTo(0);
// Возврат с reference: ReferenceSaleId должен быть валидный GUID
// (саму ссылку валидирует контроллер обращаясь в БД).
When(x => x.IsReturn, () =>
{
RuleFor(x => x.ReferenceSaleId).Must(id => id is null || id != Guid.Empty)
.WithMessage("Невалидный ReferenceSaleId.");
});
RuleFor(x => x.Lines).NotEmpty().WithMessage("Чек должен содержать хотя бы одну позицию.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEqual(Guid.Empty);
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.Discount).GreaterThanOrEqualTo(0);
line.RuleFor(l => l.VatPercent).InclusiveBetween(0, 100);
});
}
}
public sealed class ProductInputValidator : AbstractValidator
{
public ProductInputValidator()
{
// Sprint 23 / bug-002: было MaximumLength(200), а schema разрешает 500.
// Sprint 23 / bug-001: NoControlChars() — Postgres TEXT отказывается
// принять NUL-byte (DbUpdateException → 500). Проверяем ВСЕ строковые
// поля input'а на control-chars (кроме \t\r\n).
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(500)
.Must(ValidationExt.NoControlChars)
.WithMessage("Поле Name не должно содержать управляющих символов (NUL и т.п.).");
RuleFor(x => x.Article).MaximumLength(500)
.Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов.");
RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s))
.WithMessage("Поле Description не должно содержать управляющих символов.");
// Единица/группа НЕ обязательны в запросе — контроллер подставляет
// дефолты («шт» / корневая группа). Обязательно только Наименование
// (решение владельца 08.10). Штрихкод тоже не обязателен в запросе —
// контроллер генерирует внутренний EAN-13.
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
.WithMessage("Ставка НДС должна быть от 0 до 100%.");
RuleFor(x => x.PurchasePrice).GreaterThanOrEqualTo(0).When(x => x.PurchasePrice.HasValue);
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);
RuleFor(x => x.MinStock).GreaterThanOrEqualTo(0).When(x => x.MinStock.HasValue);
RuleFor(x => x.MaxStock).GreaterThanOrEqualTo(0).When(x => x.MaxStock.HasValue);
// MinStock <= MaxStock когда оба заданы.
RuleFor(x => x).Must(x => !x.MinStock.HasValue || !x.MaxStock.HasValue || x.MinStock.Value <= x.MaxStock.Value)
.WithMessage("MinStock не может быть больше MaxStock.");
}
}
public sealed class CounterpartyInputValidator : AbstractValidator
{
public CounterpartyInputValidator()
{
// Sprint 23 / bug-002: Counterparty.Name column = 255, validator был 200.
// Sprint 23 / bug-001: NoControlChars на всех строковых полях.
RuleFor(x => x.Name).NotEmpty().WithMessage("Название обязательно.")
.MaximumLength(255)
.Must(ValidationExt.NoControlChars).WithMessage("Поле Name не должно содержать управляющих символов.");
RuleFor(x => x.LegalName).MaximumLength(500)
.Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле LegalName не должно содержать управляющих символов.");
RuleFor(x => x.Address).Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Address не должно содержать управляющих символов.");
RuleFor(x => x.Notes).Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Notes не должно содержать управляющих символов.");
// БИН (юрлицо) — 12 цифр, ИИН (физлицо) — 12 цифр. Один из двух
// обязателен только если тип LegalEntity (Type=1). Для Individual (2)
// достаточно ИИН или имени. На уровне БД эти ограничения не enforced —
// валидируем именно тут.
When(x => !string.IsNullOrEmpty(x.Bin), () =>
{
RuleFor(x => x.Bin!).Matches(@"^\d{12}$")
.WithMessage("БИН должен быть 12-значный.");
});
When(x => !string.IsNullOrEmpty(x.Iin), () =>
{
RuleFor(x => x.Iin!).Matches(@"^\d{12}$")
.WithMessage("ИИН должен быть 12-значный.");
});
// Простая validation формата e-mail (без RFC-зубодробильника):
// contains @ and a dot, with no spaces.
When(x => !string.IsNullOrEmpty(x.Email), () =>
{
RuleFor(x => x.Email!).EmailAddress().WithMessage("Невалидный e-mail.");
});
}
}
// Port sklad 06.10 (handoff §1): после отключения implicit [Required] для
// non-nullable string валидаторы ниже закрывают критичные обязательные поля
// в DTO приёмки накладной. Без них PUT /lines с null-Lines падал бы NRE внутри
// контроллера, а POST /apply с Guid.Empty поставщиком — на FK в БД (500).
public sealed class InvoiceSaveLinesValidator
: AbstractValidator
{
public InvoiceSaveLinesValidator()
{
RuleFor(x => x.Lines)
.NotNull().WithMessage("Строки обязательны (можно пустой массив).");
RuleFor(x => x.SupplierName)
.Must(ValidationExt.NoControlChars).WithMessage("Поле SupplierName не должно содержать управляющих символов.");
RuleFor(x => x.InvoiceNumber)
.Must(ValidationExt.NoControlChars).WithMessage("Поле InvoiceNumber не должно содержать управляющих символов.");
}
}
public sealed class InvoiceApplyValidator
: AbstractValidator
{
public InvoiceApplyValidator()
{
RuleFor(x => x.StoreId).NotEqual(Guid.Empty).WithMessage("Не указан склад.");
RuleFor(x => x.CurrencyId).NotEqual(Guid.Empty).WithMessage("Не указана валюта.");
// SupplierId опционален: если Guid.Empty — контроллер подставит
// служебного «Поставщик не указан» (sklad 06.10 §11, адаптировано).
RuleFor(x => x.Notes).MaximumLength(1000)
.Must(s => ValidationExt.NoControlChars(s))
.WithMessage("Поле Notes не должно содержать управляющих символов.");
}
}
public sealed class EmployeeInputValidator : AbstractValidator
{
public EmployeeInputValidator()
{
RuleFor(x => x.LastName).NotEmpty().WithMessage("Фамилия обязательна.").MaximumLength(100);
RuleFor(x => x.FirstName).NotEmpty().WithMessage("Имя обязательно.").MaximumLength(100);
RuleFor(x => x.RoleId).NotEqual(Guid.Empty).WithMessage("Не выбрана роль.");
// SendInvite только при CreateAccount=true и наличии Email.
When(x => x.SendInvite, () =>
{
RuleFor(x => x.CreateAccount).Equal(true)
.WithMessage("Для отправки приглашения нужно создавать учётную запись.");
RuleFor(x => x.Email).NotEmpty()
.WithMessage("Для приглашения нужен email сотрудника.");
});
When(x => x.CreateAccount, () =>
{
RuleFor(x => x.Email).NotEmpty().EmailAddress()
.WithMessage("Для создания учётки нужен валидный email.");
});
}
}