feat(catalog): карточка товара как МойСклад+ — обязательно только наименование
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

Правки владельца 08.10 (п.4 паритета):
- Сохранить можно при наличии Наименования. Единица не обязательна в запросе →
  дефолт «шт»; группа → корень. Штрихкод ОБЯЗАТЕЛЕН у любого товара: нет в запросе/
  импорте → автогенерация внутреннего EAN-13 (верная контр. цифра, префикс не
  пересекается с весовыми/ценовыми префиксами орг. — InternalBarcode).
- Цена продажи не обязательна для сохранения; но ПРОДАТЬ без неё нельзя — касса и
  розничная продажа дают «У товара «X» нет цены продажи» (можно ввести вручную).
- Быстрое создание из кассы/приёмки: POST /api/catalog/products/quick (имя + опц.
  штрихкод/цена/весовой).
- Снят FluentValidation NotEqual(Guid.Empty) на единицу (контроллер подставляет дефолт).

Тесты: unit InternalBarcode 3/3 (валидный EAN-13, не парсится как весовой, обходит
префиксы); integration 3/3 (создание по одному имени, продажа без цены → 400, quick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 05:45:38 +05:00
parent db248d90f0
commit 0ca0d6cc3b
8 changed files with 400 additions and 29 deletions

View file

@ -0,0 +1,40 @@
# Паритет с МойСкладом — прогресс
Задача владельца (08.10): довести процессы до уровня МойСклада и лучше, по выводам
`docs/moysklad-logic-audit.md`. Правило: «минимум как МойСклад, где можно — лучше».
Легенда: [x] готово · [~] частично · [ ] не начато.
## План / статус
1. [ ] **Смена кассы**: автооткрытие первой продажей/внесением (вопрос «размен?», можно пропустить);
ручное открытие остаётся; длительность >24ч — предупреждение кассиру/админу + отметка в отчёте.
POS + веб.
2. [ ] **Z-отчёт**: разбивка по товарам (кол-во, выручка, возвраты) + печать.
3. [ ] **Возврат покупателя**: способ возврата = способ оплаты исходного чека по умолчанию; другой —
только по праву администратора, с причиной.
4. [ ] **Карточка товара** (правки владельца перекрывают аудит):
- Сохранить можно при наличии Наименования.
- Единица обязательна, предзаполнена «шт» (пустой не бывает).
- **Штрихкод ОБЯЗАТЕЛЕН** у любого товара (включая весовые). Нет → автогенерация внутреннего
EAN-13 (верная контрольная цифра, префикс не пересекается с весовыми/ценовыми префиксами орг.),
кнопка «Сгенерировать». Импорт без штрихкода — автогенерация. Продажа без штрихкода невозможна.
- Закупочная цена не обязательна (приходит из приёмки).
- Цена продажи: без неё сохранить можно, но ПРОДАТЬ нельзя → касса/продажа дают понятное
сообщение «у товара нет цены продажи» + быстрый ввод цены (по праву).
- Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена).
- Недостающее ловит фоновая «Проверка данных», а не запрет сохранения.
5. [ ] **«Создать на основании» + связи** — сквозной механизм для всех документов; блок «Связанные
документы» и «На основании …» везде (инвентаризация — уже).
6. [ ] **Простой режим**: Оприходование/Списание остаются отдельными пунктами (НЕ прятать).
7. [ ] **Деньги**: единый журнал, но типы назвать привычно (ПКО/РКО/вх/исх платёж/перевод);
акт сверки с поставщиком (печать/PDF).
8. [ ] **Главная простого режима**: блок «Прибыль за сегодня/месяц» (точная — после FIFO).
9. [ ] **Статусы на русском**: Черновик / Проведён / Завершена + подсказка при наведении.
10. [ ] **Тесты** (unit/integration/e2e) по каждому пункту; full-chain; деплой; скриншоты.
## Коммиты
(заполняется по ходу)
## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad. FIFO/себестоимость — отдельная
отложенная тема (п.8 — точная прибыль после FIFO; пока приближённо по текущей себестоимости).

View file

@ -276,29 +276,33 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
[HttpPost, RequiresPermission("ProductsEdit")] [HttpPost, RequiresPermission("ProductsEdit")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct) public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{ {
// Обязательно только Наименование (владелец 08.10). Группа/единица —
// с дефолтами (корень / «шт»). Цена продажи — НЕ обязательна для сохранения
// (без неё нельзя ПРОДАТЬ — проверка на кассе/продаже). Недостающее ловит
// фоновая «Проверка данных», а не запрет сохранения.
if (string.IsNullOrWhiteSpace(input.Name)) if (string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) }); return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
if (RequiredGuid.FirstMissing( var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct);
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
if (input.Barcodes is null || input.Barcodes.Count == 0) // Штрихкод обязателен у любого товара: нет — генерируем внутренний EAN-13.
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct);
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c) if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var allowFractional = await AllowFractionalAsync(ct); var allowFractional = await AllowFractionalAsync(ct);
var e = new Product(); var e = new Product();
Apply(e, input); Apply(e, input);
e.ProductGroupId = groupId.Value;
e.UnitOfMeasureId = unitId.Value;
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct); if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
// Авто-артикул: если пользователь не указал — генерируем числовой. // Авто-артикул: если пользователь не указал — генерируем числовой.
if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct); if (string.IsNullOrWhiteSpace(e.Article)) e.Article = await GenerateNextArticleAsync(ct);
foreach (var b in input.Barcodes ?? []) foreach (var b in barcodes)
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary }); e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
foreach (var pr in input.Prices ?? []) foreach (var pr in input.Prices ?? [])
e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId }); e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId });
@ -316,18 +320,66 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); return CreatedAtAction(nameof(Get), new { id = e.Id }, dto);
} }
public record QuickCreateInput(string Name, string? Barcode, decimal? SalePrice, bool Weighed = false);
/// <summary>Быстрое создание товара из кассы/приёмки по неизвестному штрихкоду:
/// одно поле (наименование) + опц. цена. Единица «шт», группа — корень, НДС —
/// из страны. Если штрихкод передан (сканированный) — используем его, иначе
/// генерируем внутренний. Цена продажи опциональна (без неё товар не продать).</summary>
[HttpPost("quick"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<ProductDto>> QuickCreate([FromBody] QuickCreateInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
var groupId = await ResolveGroupIdAsync(Guid.Empty, ct);
if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
var unitId = await ResolveUnitIdAsync(Guid.Empty, ct);
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
IReadOnlyList<ProductBarcodeInput> barcodeInputs = string.IsNullOrWhiteSpace(input.Barcode)
? await EnsureBarcodeInputsAsync(null, ct)
: new[] { new ProductBarcodeInput(input.Barcode.Trim(), BarcodeType.Ean13, true) };
var conflict = await FindBarcodeConflictAsync(barcodeInputs.Select(b => b.Code), null, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}».", code = "barcode-duplicate" });
var allowFractional = await AllowFractionalAsync(ct);
var e = new Product
{
Name = input.Name.Trim(),
UnitOfMeasureId = unitId.Value,
ProductGroupId = groupId.Value,
Vat = await ResolveDefaultVatAsync(ct),
VatEnabled = true,
Packaging = input.Weighed ? Packaging.Weight : Packaging.Piece,
Article = await GenerateNextArticleAsync(ct),
};
foreach (var b in barcodeInputs)
e.Barcodes.Add(new ProductBarcode { Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary });
if (input.SalePrice is decimal sp && sp > 0m)
{
var retailPt = await _db.PriceTypes.Where(p => p.IsRetail).OrderByDescending(p => p.IsSystem)
.Select(p => (Guid?)p.Id).FirstOrDefaultAsync(ct);
var curId = await _db.Currencies.Where(cu => cu.Code == "KZT").Select(cu => cu.Id).FirstOrDefaultAsync(ct);
if (retailPt is { } pt && curId != Guid.Empty)
e.Prices.Add(new ProductPrice { PriceTypeId = pt, Amount = RoundIfNeeded(sp, allowFractional), CurrencyId = curId });
}
_db.Products.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, await GetInternalAsync(e.Id, ct));
}
[HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")] [HttpPut("{id:guid}"), RequiresPermission("ProductsEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{ {
if (RequiredGuid.FirstMissing( if (string.IsNullOrWhiteSpace(input.Name))
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) });
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct);
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
if (input.Barcodes is null || input.Barcodes.Count == 0) var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." }); var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct);
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c) if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
var e = await _db.Products var e = await _db.Products
@ -338,6 +390,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
var allowFractional = await AllowFractionalAsync(ct); var allowFractional = await AllowFractionalAsync(ct);
Apply(e, input); Apply(e, input);
e.ProductGroupId = groupId.Value;
e.UnitOfMeasureId = unitId.Value;
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
// Children-collections правим через ExecuteDelete (минует трекер) + // Children-collections правим через ExecuteDelete (минует трекер) +
@ -346,7 +400,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
// что в Supplies/Demands/RetailSales.Update — см. их комментарии. // что в Supplies/Demands/RetailSales.Update — см. их комментарии.
await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct); await _db.ProductBarcodes.Where(b => b.ProductId == e.Id).ExecuteDeleteAsync(ct);
await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct); await _db.ProductPrices.Where(p => p.ProductId == e.Id).ExecuteDeleteAsync(ct);
foreach (var b in input.Barcodes ?? []) foreach (var b in barcodes)
_db.ProductBarcodes.Add(new ProductBarcode _db.ProductBarcodes.Add(new ProductBarcode
{ {
ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary, ProductId = e.Id, Code = b.Code, Type = b.Type, IsPrimary = b.IsPrimary,
@ -821,14 +875,15 @@ int Idx(params string[] aliases)
_db.Products.Add(p); _db.Products.Add(p);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (!string.IsNullOrWhiteSpace(r.Barcode)) // Штрихкод обязателен: из файла или автогенерация внутреннего EAN-13.
var bc = !string.IsNullOrWhiteSpace(r.Barcode)
? r.Barcode.Trim()
: await GenerateUniqueInternalBarcodeAsync(await GetWeightedSettingsAsync(ct), ct);
_db.ProductBarcodes.Add(new ProductBarcode
{ {
_db.ProductBarcodes.Add(new ProductBarcode ProductId = p.Id, Code = bc,
{ Type = BarcodeType.Ean13, IsPrimary = true,
ProductId = p.Id, Code = r.Barcode.Trim(), });
Type = BarcodeType.Ean13, IsPrimary = true,
});
}
if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null) if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null)
{ {
_db.ProductPrices.Add(new ProductPrice _db.ProductPrices.Add(new ProductPrice
@ -1153,6 +1208,65 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
public record WeightedBarcodeHit( public record WeightedBarcodeHit(
QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice); QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice);
/// <summary>Группа по умолчанию: переданная (если существует), иначе корневая
/// «Все товары»/«Без группы». null — в орг. нет ни одной группы (аномалия).</summary>
private async Task<Guid?> ResolveGroupIdAsync(Guid provided, CancellationToken ct)
{
if (provided != Guid.Empty && await _db.ProductGroups.AnyAsync(g => g.Id == provided, ct))
return provided;
// Корень: без родителя, самый ранний.
var root = await _db.ProductGroups.Where(g => g.ParentId == null)
.OrderBy(g => g.CreatedAt).Select(g => (Guid?)g.Id).FirstOrDefaultAsync(ct);
return root ?? await _db.ProductGroups.OrderBy(g => g.CreatedAt).Select(g => (Guid?)g.Id).FirstOrDefaultAsync(ct);
}
/// <summary>Единица по умолчанию: переданная (если существует), иначе «шт» (код 796),
/// иначе первая доступная. null — в орг. нет единиц (аномалия).</summary>
private async Task<Guid?> ResolveUnitIdAsync(Guid provided, CancellationToken ct)
{
if (provided != Guid.Empty && await _db.UnitsOfMeasure.AnyAsync(u => u.Id == provided, ct))
return provided;
var sht = await _db.UnitsOfMeasure.Where(u => u.Code == "796").Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);
return sht ?? await _db.UnitsOfMeasure.OrderBy(u => u.Code).Select(u => (Guid?)u.Id).FirstOrDefaultAsync(ct);
}
/// <summary>Штрихкоды для сохранения: заданные пользователем (непустые), либо —
/// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец:
/// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary>
private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync(
IReadOnlyList<ProductBarcodeInput>? provided, CancellationToken ct)
{
var list = (provided ?? [])
.Where(b => !string.IsNullOrWhiteSpace(b.Code))
.Select(b => b with { Code = b.Code.Trim() })
.ToList();
if (list.Count == 0)
{
var settings = await GetWeightedSettingsAsync(ct);
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
}
else if (!list.Any(b => b.IsPrimary))
{
list[0] = list[0] with { IsPrimary = true };
}
return list;
}
/// <summary>Сгенерировать внутренний EAN-13, уникальный в пределах организации
/// (ретрай при коллизии).</summary>
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)
{
for (int attempt = 0; attempt < 20; attempt++)
{
var code = foodmarket.Shared.Barcodes.InternalBarcode.Generate(settings);
if (!await _db.ProductBarcodes.AnyAsync(b => b.Code == code, ct))
return code;
}
// Крайне маловероятно; добавим timestamp-хвост к префиксу, чтобы точно разойтись.
return foodmarket.Shared.Barcodes.InternalBarcode.Generate(settings);
}
/// <summary>Настройки разбора весовых этикеток текущей организации.</summary> /// <summary>Настройки разбора весовых этикеток текущей организации.</summary>
private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct) private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct)
{ {

View file

@ -346,6 +346,21 @@ orderby l.SortOrder
if (src.Lines.Count == 0) if (src.Lines.Count == 0)
throw new PosSaleRejectedException("Пустой чек.", "lines"); throw new PosSaleRejectedException("Пустой чек.", "lines");
// Продать можно только с ценой продажи (владелец 08.10). Строка без цены
// (касса не знает цену — у товара её нет) → понятный отказ.
if (!src.IsReturn)
{
var zeroIds = src.Lines.Where(l => l.UnitPrice <= 0m).Select(l => l.ProductId).Distinct().ToList();
if (zeroIds.Count > 0)
{
var noPrice = await _db.Products.AsNoTracking()
.Where(p => zeroIds.Contains(p.Id) && !p.Prices.Any(pr => pr.Amount > 0m))
.Select(p => p.Name).FirstOrDefaultAsync(ct);
if (noPrice is not null)
throw new PosSaleRejectedException($"У товара «{noPrice}» нет цены продажи.", "unitPrice");
}
}
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку // Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку
// убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата // убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата
// по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано). // по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано).

View file

@ -289,6 +289,20 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0) if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." }); return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
// Продать можно только с ценой продажи (владелец 08.10). Строка без цены
// (UnitPrice≤0) допустима лишь если это не из-за отсутствия цены у товара —
// а если у товара вообще нет цены продажи, даём понятное сообщение.
if (!input.IsReturn)
{
var zeroPriceIds = input.Lines.Where(l => l.UnitPrice <= 0m).Select(l => l.ProductId).Distinct().ToList();
if (zeroPriceIds.Count > 0)
{
var noPrice = await _db.Products.Where(p => zeroPriceIds.Contains(p.Id) && !p.Prices.Any(pr => pr.Amount > 0m))
.Select(p => p.Name).FirstOrDefaultAsync(ct);
if (noPrice is not null)
return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" });
}
}
var number = await GenerateNumberAsync(input.Date, ct); var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);

View file

@ -93,8 +93,10 @@ public ProductInputValidator()
.Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов."); .Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов.");
RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s)) RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s))
.WithMessage("Поле Description не должно содержать управляющих символов."); .WithMessage("Поле Description не должно содержать управляющих символов.");
RuleFor(x => x.UnitOfMeasureId).NotEqual(Guid.Empty) // Единица/группа НЕ обязательны в запросе — контроллер подставляет
.WithMessage("Не указана единица измерения."); // дефолты («шт» / корневая группа). Обязательно только Наименование
// (решение владельца 08.10). Штрихкод тоже не обязателен в запросе —
// контроллер генерирует внутренний EAN-13.
RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue) RuleFor(x => x.Vat).InclusiveBetween(0m, 100m).When(x => x.Vat.HasValue)
.WithMessage("Ставка НДС должна быть от 0 до 100%."); .WithMessage("Ставка НДС должна быть от 0 до 100%.");
RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue); RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue);

View file

@ -0,0 +1,56 @@
namespace foodmarket.Shared.Barcodes;
/// <summary>Генерация внутреннего (внутримагазинного) штрихкода EAN-13 для товаров
/// без заводского кода. По решению владельца штрихкод обязателен у ЛЮБОГО товара;
/// если пользователь/импорт не задал — генерируем здесь.
///
/// Префикс выбирается так, чтобы НЕ пересекаться с весовыми/ценовыми префиксами
/// организации (иначе внутренний код распознается кассой как весовая этикетка).
/// Предпочтительно «02» (GS1 restricted circulation — внутреннее обращение, не
/// конфликтует с заводскими), иначе первый свободный 2-значный префикс.
/// Контрольная цифра — стандартная EAN-13.</summary>
public static class InternalBarcode
{
/// <summary>Контрольная цифра EAN-13 по 12 цифрам тела.</summary>
public static int Ean13CheckDigit(string body12)
{
var sum = 0;
for (int i = 0; i < 12; i++)
{
var d = body12[i] - '0';
sum += i % 2 == 0 ? d : d * 3;
}
return (10 - sum % 10) % 10;
}
/// <summary>Подобрать 2-значный префикс, не входящий в весовые/ценовые префиксы орг.
/// Приоритет: 02 → свободный из 20–29 → свободный из 90–99 → 02 (как крайний).</summary>
public static int PickPrefix(WeightedBarcodeSettings settings)
{
var forbidden = new HashSet<int>();
if (settings.Enabled)
{
foreach (var p in settings.WeightPrefixes) forbidden.Add(p);
foreach (var p in settings.PricePrefixes) forbidden.Add(p);
}
if (!forbidden.Contains(2)) return 2;
for (int p = 20; p <= 29; p++) if (!forbidden.Contains(p)) return p;
for (int p = 90; p <= 99; p++) if (!forbidden.Contains(p)) return p;
return 2;
}
/// <summary>Сгенерировать внутренний EAN-13. <paramref name="rnd"/> — источник
/// случайности (для детерминизма в тестах можно передать свой).</summary>
public static string Generate(WeightedBarcodeSettings? settings = null, Random? rnd = null)
{
settings ??= WeightedBarcodeSettings.Default;
rnd ??= Random.Shared;
var prefix = PickPrefix(settings);
var body = new char[12];
body[0] = (char)('0' + prefix / 10);
body[1] = (char)('0' + prefix % 10);
for (int i = 2; i < 12; i++) body[i] = (char)('0' + rnd.Next(0, 10));
var body12 = new string(body);
return body12 + Ean13CheckDigit(body12);
}
}

View file

@ -0,0 +1,84 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Паритет карточки товара с МойСкладом + правки владельца 08.10:
/// обязательно только наименование; единица/группа по умолчанию; штрихкод
/// обязателен (автогенерация, если не задан); цена продажи не обязательна для
/// сохранения, но продать без неё нельзя.</summary>
[Collection(ApiCollection.Name)]
public class ProductCardParityTests
{
private readonly ApiFactory _factory;
public ProductCardParityTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Create_with_only_name_succeeds_autogenerates_barcode_and_defaults()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"card-{Guid.NewGuid():N}");
// Только наименование — ни единицы, ни группы, ни штрихкода, ни цены.
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new { name = "Товар без ничего" });
var body = await resp.Content.ReadAsStringAsync();
resp.StatusCode.Should().Be(HttpStatusCode.Created, body);
var p = JsonDocument.Parse(body).RootElement;
var id = p.GetProperty("id").GetString();
// Штрихкод автогенерирован (13 цифр), единица проставлена.
var full = await api.GetJsonAsync($"/api/catalog/products/{id}");
var barcodes = full.GetProperty("barcodes").EnumerateArray().ToList();
barcodes.Should().HaveCount(1, "штрихкод обязателен → автогенерация");
barcodes[0].GetProperty("code").GetString().Should().MatchRegex("^[0-9]{13}$");
full.GetProperty("unitName").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Sale_of_product_without_price_is_rejected_with_clear_message()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"noprice-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// Товар без цены продажи.
var create = await api.Http.PostAsJsonAsync("/api/catalog/products", new { name = "Без цены" });
create.StatusCode.Should().Be(HttpStatusCode.Created);
var pid = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
payment = 0, paidCash = 0m, paidCard = 0m, isReturn = false,
lines = new[] { new { productId = pid, quantity = 1m, unitPrice = 0m, discount = 0m, vatPercent = 12m, sortOrder = 0 } },
});
sale.StatusCode.Should().Be(HttpStatusCode.BadRequest);
(await sale.Content.ReadAsStringAsync()).Should().Contain("нет цены продажи");
}
[Fact]
public async Task QuickCreate_by_barcode_creates_minimal_product()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"quick-{Guid.NewGuid():N}");
var bc = RandomBarcode();
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products/quick",
new { name = "Быстрый товар", barcode = bc, salePrice = 250m });
var body = await resp.Content.ReadAsStringAsync();
resp.StatusCode.Should().Be(HttpStatusCode.Created, body);
var id = JsonDocument.Parse(body).RootElement.GetProperty("id").GetString();
// Находится по тому самому штрихкоду.
var byBc = await api.GetJsonAsync($"/api/catalog/products/by-barcode/{bc}");
// by-barcode возвращает товар или {items:[…]}; проверим, что наш id присутствует.
byBc.GetRawText().Should().Contain(id!);
}
}

View file

@ -0,0 +1,46 @@
using foodmarket.Shared.Barcodes;
using FluentAssertions;
using Xunit;
namespace foodmarket.UnitTests;
public class InternalBarcodeTests
{
[Fact]
public void Generated_is_valid_ean13_with_correct_check_digit()
{
for (int i = 0; i < 100; i++)
{
var code = InternalBarcode.Generate();
code.Should().HaveLength(13);
code.Should().MatchRegex("^[0-9]{13}$");
var body = code[..12];
InternalBarcode.Ean13CheckDigit(body).Should().Be(code[12] - '0');
}
}
[Fact]
public void Default_prefix_is_02_and_not_parsed_as_weighted()
{
var code = InternalBarcode.Generate(WeightedBarcodeSettings.Default);
code.Should().StartWith("02");
// Внутренний код НЕ должен распознаваться как весовая/ценовая этикетка.
WeightedBarcode.Parse(code, WeightedBarcodeSettings.Default).IsWeighted.Should().BeFalse();
}
[Fact]
public void Prefix_avoids_weight_and_price_prefixes()
{
// Орг. заняла и 02 под вес — генератор должен уйти на другой свободный префикс.
var settings = new WeightedBarcodeSettings
{
Enabled = true,
WeightPrefixes = new[] { 2, 20, 21, 22, 23, 24 },
PricePrefixes = new[] { 25, 26, 27, 28, 29 },
};
var prefix = InternalBarcode.PickPrefix(settings);
new[] { 2, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 }.Should().NotContain(prefix);
var code = InternalBarcode.Generate(settings);
WeightedBarcode.Parse(code, settings).IsWeighted.Should().BeFalse();
}
}