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
РЕЕСТР ТОВАРОВ (глобальный системный шаблон карточек):
- Домен 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>
203 lines
13 KiB
C#
203 lines
13 KiB
C#
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.");
|
||
});
|
||
}
|
||
}
|