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."); }); } }