food-market/src/food-market.api/Infrastructure/Validation/Validators.cs
nns e39e4fe1d5
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(registry+pricing): «Реестр товаров» + схема цен (закупочная/себестоимость/продажная)
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен ProductRegistry{Group,Card,Barcode} (Entity, без OrganizationId),
  миграция Phase47; штрихкод уникален в реестре (ключ новизны).
- Наполнение «из МойСклад заново» (команда суперадмина, повторяемая): читает
  entity/product (архивные/модификации/комплекты/услуги — считает и пропускает),
  группы из productFolder (путь), штрихкоды всех типов; buyPrice→закупочная+
  себестоимость, salePrices→продажная; пересоздаёт только MoySklad-карточки,
  Shop-карточки не трогает. МойСклад только чтение (пагинация 1000, 429-бэкофф).
- Фоновое пополнение из каталогов магазинов-участников (recurring, флаг в
  PlatformSettings, по умолчанию ВЫКЛ): новые штрихкоды → карточки; фильтр
  мусора (неверная контр. цифра EAN-13/8, пустое имя); существующие не трогает.
- Копия реестра в каталог нового магазина при регистрации — фоновым заданием
  (Hangfire) сразу после signup; баннер «Каталог загружается…», повторно не
  копирует. Остатков нет (0).
- Флаг организации ContributesToRegistry (демо/автотест — ВЫКЛ миграцией).
- SuperAdmin раздел «Реестр товаров»: поиск/правка/удаление карточек, тоггл
  фонового пополнения (причина+аудит), кнопка наполнения из МойСклад, флаги
  участия орг. Только SuperAdmin.

СХЕМА ЦЕН (решение владельца 12.10): три цены в карточке.
- Закупочная: ReferencePrice→PurchasePrice (миграция Phase48 RENAME COLUMN,
  данные сохранены). Каждая проведённая приёмка пишет закупочную = цена строки;
  unpost возвращает из предыдущей проведённой приёмки. API отдаёт purchasePrice
  и временно referencePrice (алиас для iOS).
- Себестоимость (Cost): только чтение, скользящее среднее (как было).
- Продажная (бывш. «розничная»): меняется приёмкой только при ручной правке в
  строке. Системный тип цены «Розничная цена»→«Продажная цена» (миграция+сидер),
  подписи в UI переименованы.
- Убраны: автозаполнение эталонной при первой приёмке, ReferencePriceRefreshJob
  (30-дневная перезапись), понятие «эталонная» в UI.

Тесты: SupplyPricingSchemeTests (приёмка без правок → продажная не меняется,
закупочная = цена строки, себестоимость средняя; правка продажной в строке;
unpost → закупочная вернулась) — 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 19:19:47 +05:00

203 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FluentValidation;
namespace foodmarket.Api.Infrastructure.Validation;
/// <summary>Sprint 23 / bug-001: проверка что строка не содержит NUL-байт
/// и иных control-chars (кроме \t\r\n). Postgres TEXT/VARCHAR отказывается
/// принять \x00, что роняло SaveChanges с 500 без понятного ответа.
/// Применяется на пользовательские input-поля строкового типа.</summary>
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<Program>().
//
// Зачем выносим из контроллеров: единый формат ошибок (ValidationProblemDetails),
// тестируемые юнит-тестами без HTTP, переиспользуемые правила (например, BIN
// длиной 12 цифр — одинаково для Counterparty.Create и Counterparty.Update),
// читаемые правила (RuleFor + цепочка).
//
// Стиль: лаконично, на одно поле — одна цепочка. Сообщения по-русски (UI ждёт RU).
// ──────────────────────────────────────────────────────────────────────────────
public sealed class SupplyInputValidator : AbstractValidator<foodmarket.Api.Controllers.Purchases.SuppliesController.SupplyInput>
{
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<foodmarket.Api.Controllers.Sales.RetailSalesController.RetailSaleInput>
{
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<foodmarket.Application.Catalog.ProductInput>
{
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<foodmarket.Application.Catalog.CounterpartyInput>
{
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<foodmarket.Api.Controllers.Purchases.InvoicesController.SaveLinesDto>
{
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<foodmarket.Api.Controllers.Purchases.InvoicesController.ApplyDto>
{
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<foodmarket.Api.Controllers.Organizations.EmployeesController.EmployeeInput>
{
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.");
});
}
}