diff --git a/docs/moysklad-parity-progress.md b/docs/moysklad-parity-progress.md new file mode 100644 index 0000000..5ec6442 --- /dev/null +++ b/docs/moysklad-parity-progress.md @@ -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; пока приближённо по текущей себестоимости). diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 8bc165d..278fa0a 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -276,29 +276,33 @@ public async Task> Get(Guid id, CancellationToken ct) [HttpPost, RequiresPermission("ProductsEdit")] public async Task> Create([FromBody] ProductInput input, CancellationToken ct) { + // Обязательно только Наименование (владелец 08.10). Группа/единица — + // с дефолтами (корень / «шт»). Цена продажи — НЕ обязательна для сохранения + // (без неё нельзя ПРОДАТЬ — проверка на кассе/продаже). Недостающее ловит + // фоновая «Проверка данных», а не запрет сохранения. if (string.IsNullOrWhiteSpace(input.Name)) return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) }); - if (RequiredGuid.FirstMissing( - (nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), - (nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) - return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); - if (input.Barcodes is null || input.Barcodes.Count == 0) - return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); - if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) - return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." }); - var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct); + var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct); + if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." }); + var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct); + if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); + // Штрихкод обязателен у любого товара: нет — генерируем внутренний EAN-13. + var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct); + var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); var allowFractional = await AllowFractionalAsync(ct); var e = new Product(); Apply(e, input); + e.ProductGroupId = groupId.Value; + e.UnitOfMeasureId = unitId.Value; e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); // Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны. if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(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 }); foreach (var pr in input.Prices ?? []) e.Prices.Add(new ProductPrice { PriceTypeId = pr.PriceTypeId, Amount = RoundIfNeeded(pr.Amount, allowFractional), CurrencyId = pr.CurrencyId }); @@ -316,18 +320,66 @@ public async Task> Create([FromBody] ProductInput input return CreatedAtAction(nameof(Get), new { id = e.Id }, dto); } + public record QuickCreateInput(string Name, string? Barcode, decimal? SalePrice, bool Weighed = false); + + /// Быстрое создание товара из кассы/приёмки по неизвестному штрихкоду: + /// одно поле (наименование) + опц. цена. Единица «шт», группа — корень, НДС — + /// из страны. Если штрихкод передан (сканированный) — используем его, иначе + /// генерируем внутренний. Цена продажи опциональна (без неё товар не продать). + [HttpPost("quick"), RequiresPermission("ProductsEdit")] + public async Task> 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 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")] public async Task Update(Guid id, [FromBody] ProductInput input, CancellationToken ct) { - if (RequiredGuid.FirstMissing( - (nameof(input.UnitOfMeasureId), input.UnitOfMeasureId), - (nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk) - return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk }); - if (input.Barcodes is null || input.Barcodes.Count == 0) - return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." }); - if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing) - return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." }); - var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct); + if (string.IsNullOrWhiteSpace(input.Name)) + return BadRequest(new { error = "Название товара обязательно.", field = nameof(input.Name) }); + var groupId = await ResolveGroupIdAsync(input.ProductGroupId, ct); + if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." }); + var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct); + if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); + var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct); + var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); var e = await _db.Products @@ -338,6 +390,8 @@ public async Task Update(Guid id, [FromBody] ProductInput input, var allowFractional = await AllowFractionalAsync(ct); Apply(e, input); + e.ProductGroupId = groupId.Value; + e.UnitOfMeasureId = unitId.Value; e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional); // Children-collections правим через ExecuteDelete (минует трекер) + @@ -346,7 +400,7 @@ public async Task Update(Guid id, [FromBody] ProductInput input, // что в Supplies/Demands/RetailSales.Update — см. их комментарии. await _db.ProductBarcodes.Where(b => b.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 { 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); 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 = r.Barcode.Trim(), - Type = BarcodeType.Ean13, IsPrimary = true, - }); - } + ProductId = p.Id, Code = bc, + Type = BarcodeType.Ean13, IsPrimary = true, + }); if (r.Price is decimal price && price >= 0m && defaultPriceType is not null && fallbackCurrency is not null) { _db.ProductPrices.Add(new ProductPrice @@ -1153,6 +1208,65 @@ public record ByBarcodeResult(IReadOnlyList Items); public record WeightedBarcodeHit( QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice); + /// Группа по умолчанию: переданная (если существует), иначе корневая + /// «Все товары»/«Без группы». null — в орг. нет ни одной группы (аномалия). + private async Task 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); + } + + /// Единица по умолчанию: переданная (если существует), иначе «шт» (код 796), + /// иначе первая доступная. null — в орг. нет единиц (аномалия). + private async Task 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); + } + + /// Штрихкоды для сохранения: заданные пользователем (непустые), либо — + /// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец: + /// штрихкод обязателен у любого товара). Первый штрихкод помечается primary. + private async Task> EnsureBarcodeInputsAsync( + IReadOnlyList? 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; + } + + /// Сгенерировать внутренний EAN-13, уникальный в пределах организации + /// (ретрай при коллизии). + private async Task 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); + } + /// Настройки разбора весовых этикеток текущей организации. private async Task GetWeightedSettingsAsync(CancellationToken ct) { diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 57310a3..e8baa40 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -346,6 +346,21 @@ orderby l.SortOrder if (src.Lines.Count == 0) 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 по остатку // убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата // по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано). diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index ea38708..35c79f8 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -289,6 +289,20 @@ public async Task> Create([FromBody] RetailSaleInput return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); if (input.Lines is null || input.Lines.Count == 0) 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 allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); diff --git a/src/food-market.api/Infrastructure/Validation/Validators.cs b/src/food-market.api/Infrastructure/Validation/Validators.cs index f3327f0..634fe58 100644 --- a/src/food-market.api/Infrastructure/Validation/Validators.cs +++ b/src/food-market.api/Infrastructure/Validation/Validators.cs @@ -93,8 +93,10 @@ public ProductInputValidator() .Must(s => ValidationExt.NoControlChars(s)).WithMessage("Поле Article не должно содержать управляющих символов."); RuleFor(x => x.Description).Must(s => ValidationExt.NoControlChars(s)) .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) .WithMessage("Ставка НДС должна быть от 0 до 100%."); RuleFor(x => x.ReferencePrice).GreaterThanOrEqualTo(0).When(x => x.ReferencePrice.HasValue); diff --git a/src/food-market.shared/Barcodes/InternalBarcode.cs b/src/food-market.shared/Barcodes/InternalBarcode.cs new file mode 100644 index 0000000..4aeb5a8 --- /dev/null +++ b/src/food-market.shared/Barcodes/InternalBarcode.cs @@ -0,0 +1,56 @@ +namespace foodmarket.Shared.Barcodes; + +/// Генерация внутреннего (внутримагазинного) штрихкода EAN-13 для товаров +/// без заводского кода. По решению владельца штрихкод обязателен у ЛЮБОГО товара; +/// если пользователь/импорт не задал — генерируем здесь. +/// +/// Префикс выбирается так, чтобы НЕ пересекаться с весовыми/ценовыми префиксами +/// организации (иначе внутренний код распознается кассой как весовая этикетка). +/// Предпочтительно «02» (GS1 restricted circulation — внутреннее обращение, не +/// конфликтует с заводскими), иначе первый свободный 2-значный префикс. +/// Контрольная цифра — стандартная EAN-13. +public static class InternalBarcode +{ + /// Контрольная цифра EAN-13 по 12 цифрам тела. + 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; + } + + /// Подобрать 2-значный префикс, не входящий в весовые/ценовые префиксы орг. + /// Приоритет: 02 → свободный из 20–29 → свободный из 90–99 → 02 (как крайний). + public static int PickPrefix(WeightedBarcodeSettings settings) + { + var forbidden = new HashSet(); + 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; + } + + /// Сгенерировать внутренний EAN-13. — источник + /// случайности (для детерминизма в тестах можно передать свой). + 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); + } +} diff --git a/tests/food-market.IntegrationTests/ProductCardParityTests.cs b/tests/food-market.IntegrationTests/ProductCardParityTests.cs new file mode 100644 index 0000000..d396595 --- /dev/null +++ b/tests/food-market.IntegrationTests/ProductCardParityTests.cs @@ -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; + +/// Паритет карточки товара с МойСкладом + правки владельца 08.10: +/// обязательно только наименование; единица/группа по умолчанию; штрихкод +/// обязателен (автогенерация, если не задан); цена продажи не обязательна для +/// сохранения, но продать без неё нельзя. +[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!); + } +} diff --git a/tests/food-market.UnitTests/InternalBarcodeTests.cs b/tests/food-market.UnitTests/InternalBarcodeTests.cs new file mode 100644 index 0000000..dfac35e --- /dev/null +++ b/tests/food-market.UnitTests/InternalBarcodeTests.cs @@ -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(); + } +}