diff --git a/docs/weighted-progress.md b/docs/weighted-progress.md new file mode 100644 index 0000000..68d438b --- /dev/null +++ b/docs/weighted-progress.md @@ -0,0 +1,41 @@ +# Весовой товар и поиск по штрихкоду — прогресс + +Реальный сценарий владельца (09.10.2026): **весов с печатью этикеток НЕТ**. Штрихкоды +весовых товаров (631 шт, вид «27xxxxx», 7 цифр) — коды, которые владелец сам генерирует +и задаёт в карточке товара. Кассир сканирует код товара, вес — с весов на кассе (или +вручную). Задача ЗАМЕНЯЕТ прежнюю про весовые этикетки. + +Легенда: [x] готово · [~] частично · [ ] не начато. + +## Найденные баги (корень) +- (а) Весовая «этикетка» сопоставлялась с товаром **по артикулу**: `ByBarcode` имел + `|| (parsed.IsWeighted && p.Article == lookup)` → вода с артикулом 12345 находилась + по PLU 12345 из «этикетки» 2112345015007 и возвращала 1,5 кг. Поиск по ШК НЕ должен + матчить по артикулу нигде. +- (б) Разбор весовых этикеток был **включён по умолчанию** (`WeightedBarcodeSettings. + Enabled=true`, `Organization.WeightBarcodeEnabled` DEFAULT true, префикс 27 в ценовых) + → внутренние/обычные коды принимались за весовые. + +## План / статус +1. [ ] Разбор весовых этикеток ВЫКЛ по умолчанию (server/POS/web/mobile). Поиск по ШК — + только точное совпадение (включая короткие 7-значные «27xxxxx»); убрать матч по артикулу. +2. [ ] Настройка орг «Весы с печатью этикеток» (как префикс МойСклада): выкл по умолчанию; + при вкл — префикс(ы) + формат ПП ККККК ВВВВВ К; **сначала точный ШК, потом разбор**. +3. [ ] Касса: весовой товар → вес с весов (COM/USB, Масса-К/CAS) или ручной ввод; сумма = + цена/кг × вес; «1,235 кг × 690 ₸/кг». +4. [ ] Генератор ШК: штучный — EAN-13 (как сейчас); весовой — EAN-13 или короткий «27 + 5» + (настройка «формат кода весовых», по умолчанию 27 + следующий свободный 5-значный); + проверка уникальности. +5. [ ] `/api/pos/v1/sync`: unitCode «796» → отдавать unitCode (ОКЕИ) + unitShort («шт»/«кг»/«л»); + обновить docs/mobile-api.md. +6. [ ] Тесты (unit/integration) + full-chain без регрессий + деплой. + +### Автопоиск весов (данные sysadmin: МойСклад-касса спамит «Порт не выбран»/«Таймаут») +- [ ] Касса сама находит весы: автоскан всех COM (вкл. USB-COM CH340/Prolific) с Масса-К/CAS, + кнопка «Найти весы» в настройках; найденный порт запоминается. +- [ ] Весы не отвечают → значок «Весы не подключены» + мгновенный ручной ввод, без спама, + опрос только при весовом товаре и не чаще нужного. +- [ ] В логе — одна запись о потере связи, не сотни. + +## Что НЕ трогаем +Прод, shared-postgres, global.json, gateway nginx, sklad. diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index b71d994..5c9ff9e 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -300,7 +300,7 @@ public async Task> Create([FromBody] ProductInput input 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 barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, input.Packaging == Packaging.Weight, ct); var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); @@ -350,7 +350,7 @@ public async Task> QuickCreate([FromBody] QuickCreateIn if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); IReadOnlyList barcodeInputs = string.IsNullOrWhiteSpace(input.Barcode) - ? await EnsureBarcodeInputsAsync(null, ct) + ? await EnsureBarcodeInputsAsync(null, input.Weighed, 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) @@ -391,7 +391,7 @@ public async Task Update(Guid id, [FromBody] ProductInput input, 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 barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, input.Packaging == Packaging.Weight, ct); var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct); if (conflict is { } c) return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); @@ -1182,16 +1182,36 @@ public record ByBarcodeResult(IReadOnlyList Items); var v = (value ?? "").Trim(); if (v.Length == 0) return NotFound(); - // Весовая этикетка? Разбираем по настройкам организации (общий парсер). - var settings = await GetWeightedSettingsAsync(ct); - var parsed = WeightedBarcode.Parse(v, settings); - // Для весовой — ищем товар по встроенному PLU (как штрихкод или артикул), - // иначе по самому штрихкоду. - var lookup = parsed.IsWeighted ? parsed.Lookup : v; + // 1) Точное совпадение со штрихкодом карточки (включая короткие «27xxxxx»). + // Поиск по штрихкоду НИКОГДА не матчит по артикулу. + var exact = await FindByExactBarcodeAsync(v, storeId, ct); + if (exact.Count == 1) return exact[0]; + if (exact.Count > 1) return new ByBarcodeResult(exact); + // 2) Весовая этикетка — только если разбор включён в организации И точного + // штрихкода не нашлось. Встроенный код ищем как ШТРИХКОД (не артикул). + var settings = await GetWeightedSettingsAsync(ct); + if (settings.Enabled) + { + var parsed = WeightedBarcode.Parse(v, settings); + if (parsed.IsWeighted) + { + var byPlu = await FindByExactBarcodeAsync(parsed.Lookup, storeId, ct); + if (byPlu.Count >= 1) + return new WeightedBarcodeHit( + byPlu[0], true, parsed.ValueKind.ToString(), + parsed.WeightKg ?? 1m, parsed.Price); + } + } + return NotFound(); + } + + /// Товары с ТОЧНО таким штрихкодом (по артикулу не ищем). С остатком + /// на складе (или суммой по складам, если storeId не задан). + private async Task> FindByExactBarcodeAsync(string code, Guid? storeId, CancellationToken ct) + { var matches = await _db.Products.AsNoTracking() - .Where(p => p.Barcodes.Any(b => b.Code == lookup) - || (parsed.IsWeighted && p.Article == lookup)) + .Where(p => p.Barcodes.Any(b => b.Code == code)) .Select(p => new { p.Id, p.Name, p.Article, p.ReferencePrice, @@ -1202,20 +1222,10 @@ public record ByBarcodeResult(IReadOnlyList Items); .Select(st => (decimal?)st.Quantity).FirstOrDefault(), }) .ToListAsync(ct); - if (matches.Count == 0) return NotFound(); - var items = matches.Select(r => new QuickSearchItem( + return matches.Select(r => new QuickSearchItem( r.Id, r.Name, r.Article, - r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup, + r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? code, r.ReferencePrice, r.StockQty)).ToList(); - - // Весовая этикетка → товар + количество (вес) / встроенная цена. - if (parsed.IsWeighted) - return new WeightedBarcodeHit( - items[0], true, parsed.ValueKind.ToString(), - parsed.WeightKg ?? 1m, parsed.Price); - - if (items.Count == 1) return items[0]; - return new ByBarcodeResult(items); } public record WeightedBarcodeHit( @@ -1247,7 +1257,7 @@ public record WeightedBarcodeHit( /// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец: /// штрихкод обязателен у любого товара). Первый штрихкод помечается primary. private async Task> EnsureBarcodeInputsAsync( - IReadOnlyList? provided, CancellationToken ct) + IReadOnlyList? provided, bool weighted, CancellationToken ct) { var list = (provided ?? []) .Where(b => !string.IsNullOrWhiteSpace(b.Code)) @@ -1255,9 +1265,20 @@ public record WeightedBarcodeHit( .ToList(); if (list.Count == 0) { - var settings = await GetWeightedSettingsAsync(ct); - var code = await GenerateUniqueInternalBarcodeAsync(settings, ct); - list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true)); + // Весовой товар + формат «short» → короткий код «<префикс> + N цифр» + // (у владельца 27xxxxx). Иначе — внутренний EAN-13. + var wc = await GetWeightedCodeSettingsAsync(ct); + if (weighted && wc.Format == "short") + { + var code = await GenerateUniqueWeightedShortCodeAsync(wc.Prefix, wc.Digits, ct); + list.Add(new ProductBarcodeInput(code, BarcodeType.Code128, true)); + } + else + { + 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)) { @@ -1266,6 +1287,53 @@ public record WeightedBarcodeHit( return list; } + private async Task<(string Format, int Prefix, int Digits)> GetWeightedCodeSettingsAsync(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return ("short", 27, 5); + var o = await _db.Organizations.Where(x => x.Id == orgId) + .Select(x => new { x.WeightedCodeFormat, x.WeightedCodePrefix, x.WeightedCodeDigits }) + .FirstOrDefaultAsync(ct); + if (o is null) return ("short", 27, 5); + var fmt = string.Equals(o.WeightedCodeFormat, "ean13", StringComparison.OrdinalIgnoreCase) ? "ean13" : "short"; + var prefix = o.WeightedCodePrefix is >= 0 and <= 99 ? o.WeightedCodePrefix : 27; + var digits = o.WeightedCodeDigits is >= 3 and <= 10 ? o.WeightedCodeDigits : 5; + return (fmt, prefix, digits); + } + + /// Короткий код весового товара «<prefix D2> + N цифр», следующий + /// свободный номер (max+1). При переполнении разрядности — внутренний EAN-13. + private async Task GenerateUniqueWeightedShortCodeAsync(int prefix, int digits, CancellationToken ct) + { + var prefixStr = prefix.ToString("D2"); + var len = 2 + digits; + // Существующие коды такого вида: длина len, начинаются с префикса, только цифры. + var existing = await _db.ProductBarcodes + .Where(b => b.Code.StartsWith(prefixStr) && b.Code.Length == len) + .Select(b => b.Code).ToListAsync(ct); + long maxSuffix = 0; + foreach (var code in existing) + { + var suffix = code.Substring(2); + if (suffix.All(char.IsDigit) && long.TryParse(suffix, out var n) && n > maxSuffix) maxSuffix = n; + } + var next = maxSuffix + 1; + var max = (long)Math.Pow(10, digits) - 1; + if (next > max) + { + // Разрядность исчерпана — переходим на внутренний EAN-13. + var settings = await GetWeightedSettingsAsync(ct); + return await GenerateUniqueInternalBarcodeAsync(settings, ct); + } + for (var candidate = next; candidate <= max; candidate++) + { + var code = prefixStr + candidate.ToString(new string('0', digits)); + if (!await _db.ProductBarcodes.AnyAsync(b => b.Code == code, ct)) return code; + } + var fallback = await GetWeightedSettingsAsync(ct); + return await GenerateUniqueInternalBarcodeAsync(fallback, ct); + } + /// Сгенерировать внутренний EAN-13, уникальный в пределах организации /// (ретрай при коллизии). private async Task GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct) diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 229047f..67b9abb 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -44,6 +44,10 @@ public record OrgSettingsDto( string? WeightBarcodeWeightPrefixes, string? WeightBarcodePricePrefixes, int WeightBarcodeCodeDigits, + // Формат кода, генерируемого для нового весового товара. + string WeightedCodeFormat, + int WeightedCodePrefix, + int WeightedCodeDigits, // Уведомления об отрицательных остатках. bool NegStockNotifyEnabled, int NegStockNotifyIntervalMinutes, @@ -72,6 +76,10 @@ public record OrgSettingsInput( string? WeightBarcodeWeightPrefixes = null, string? WeightBarcodePricePrefixes = null, int? WeightBarcodeCodeDigits = null, + // Формат кода весового товара — опционально (round-trip-безопасно). + string? WeightedCodeFormat = null, + int? WeightedCodePrefix = null, + int? WeightedCodeDigits = null, // Уведомления об отрицательных остатках — опциональный блок (применяется // только если задан NegStockNotifyEnabled; round-trip-безопасно). bool? NegStockNotifyEnabled = null, @@ -125,6 +133,12 @@ public async Task> Update([FromBody] OrgSettingsInp o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim(); o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5; } + if (input.WeightedCodeFormat is { } wcf) + { + o.WeightedCodeFormat = wcf.Trim().ToLowerInvariant() == "ean13" ? "ean13" : "short"; + if (input.WeightedCodePrefix is >= 0 and <= 99) o.WeightedCodePrefix = input.WeightedCodePrefix.Value; + if (input.WeightedCodeDigits is >= 3 and <= 10) o.WeightedCodeDigits = input.WeightedCodeDigits.Value; + } if (input.NegStockNotifyEnabled is bool nse) { o.NegStockNotifyEnabled = nse; @@ -174,6 +188,9 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok o.WeightBarcodeWeightPrefixes, o.WeightBarcodePricePrefixes, o.WeightBarcodeCodeDigits, + o.WeightedCodeFormat, + o.WeightedCodePrefix, + o.WeightedCodeDigits, o.NegStockNotifyEnabled, o.NegStockNotifyIntervalMinutes, o.NegStockQuietFromHour, diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 1bcebdb..38e9e35 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -91,9 +91,12 @@ orderby l.SortOrder select new PosReturnLineDto { ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code, + UnitShort = u.Code, // временно код; заменим после материализации UnitPrice = l.UnitPrice, SoldQty = l.Quantity, ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned, }).ToListAsync(ct); + // UnitShort — короткое имя по коду ОКЕИ (в SQL-проекции положили код-заглушку). + lines = lines.Select(l => l with { UnitShort = foodmarket.Shared.Units.UnitShort.From(l.UnitCode) }).ToList(); return Ok(new PosReturnLookupDto { @@ -150,6 +153,7 @@ orderby l.SortOrder Article = x.p.Article, Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty(), UnitCode = x.u.Code, + UnitShort = foodmarket.Shared.Units.UnitShort.From(x.u.Code, x.u.Name), Packaging = (int)x.p.Packaging, IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight, VatPercent = x.p.Vat, diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 5e7f603..d99b1e0 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -104,8 +104,10 @@ public class Organization : Entity // ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ───── /// Разбирать ли весовые этикетки (EAN-13 с префиксом из списков). - /// Default true. - public bool WeightBarcodeEnabled { get; set; } = true; + /// Default FALSE (у владельца весов с печатью этикеток нет; штрихкоды весовых — + /// обычные коды в карточке). Включается явно; разбор применяется только после + /// проверки точного совпадения штрихкода. + public bool WeightBarcodeEnabled { get; set; } = false; /// Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр. /// «20,21,22,23,24». null → дефолт 20–24. @@ -118,6 +120,19 @@ public class Organization : Entity /// Длина кода товара (PLU) внутри весовой этикетки. Default 5. public int WeightBarcodeCodeDigits { get; set; } = 5; + /// Формат кода, который генерируется для НОВОГО весового товара: + /// «ean13» — внутренний EAN-13 (как у штучных), «short» — короткий код + /// «<префикс> + N цифр» (у владельца: 27 + 5 цифр). Default «short». + public string WeightedCodeFormat { get; set; } = "short"; + + /// Префикс короткого кода весового товара (2 цифры). Default 27 (как у + /// владельца). Следующий свободный N-значный номер добавляется автоматически. + public int WeightedCodePrefix { get; set; } = 27; + + /// Сколько цифр в номере после префикса короткого кода весового товара. + /// Default 5 (итого 7 знаков: «27xxxxx»). + public int WeightedCodeDigits { get; set; } = 5; + // ─ Уведомления об отрицательных остатках (идея владельца 08.10) ──────── /// Слать ли уведомления «товары ушли в минус» (центр уведомлений, /// Telegram, push). Default true. Выключение — только администратор. diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009120000_Phase40_WeightedCodeAndDefaults.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009120000_Phase40_WeightedCodeAndDefaults.cs new file mode 100644 index 0000000..2b5243c --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009120000_Phase40_WeightedCodeAndDefaults.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase40 — весовой товар по реальному сценарию владельца (09.10): + /// разбор весовых этикеток ВЫКЛ по умолчанию + формат короткого кода весового товара. + /// + /// + /// WeightBarcodeEnabled DEFAULT false + существующие организации + /// выключаем (фича была дефолт-on и ловила обычные коды как весовые). + /// WeightedCodeFormat text DEFAULT 'short' («ean13» | «short»). + /// WeightedCodePrefix integer DEFAULT 27. + /// WeightedCodeDigits integer DEFAULT 5. + /// + /// Идемпотентно (IF NOT EXISTS / ALTER DEFAULT). + [DbContext(typeof(AppDbContext))] + [Migration("20261009120000_Phase40_WeightedCodeAndDefaults")] + public partial class Phase40_WeightedCodeAndDefaults : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.organizations + ALTER COLUMN ""WeightBarcodeEnabled"" SET DEFAULT false; + -- Выключаем разбор этикеток у существующих организаций (фича была + -- дефолт-on и принимала обычные/внутренние коды за весовые этикетки). + UPDATE public.organizations SET ""WeightBarcodeEnabled"" = false; + + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightedCodeFormat"" text NOT NULL DEFAULT 'short'; + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightedCodePrefix"" integer NOT NULL DEFAULT 27; + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightedCodeDigits"" integer NOT NULL DEFAULT 5; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.organizations + ALTER COLUMN ""WeightBarcodeEnabled"" SET DEFAULT true; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightedCodeFormat""; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightedCodePrefix""; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightedCodeDigits""; + "); + } + } +} diff --git a/src/food-market.shared/Barcodes/WeightedBarcode.cs b/src/food-market.shared/Barcodes/WeightedBarcode.cs index f965a5a..466d55a 100644 --- a/src/food-market.shared/Barcodes/WeightedBarcode.cs +++ b/src/food-market.shared/Barcodes/WeightedBarcode.cs @@ -10,7 +10,10 @@ public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 } /// Хранится скалярными колонками организации (csv-списки префиксов). public sealed record WeightedBarcodeSettings { - public bool Enabled { get; init; } = true; + // По умолчанию ВЫКЛ (решение владельца 09.10): весов с печатью этикеток нет, + // штрихкоды весовых — обычные коды в карточке. Разбор этикеток включается явно + // в настройках организации и только тогда применяется (и то после точного ШК). + public bool Enabled { get; init; } = false; public IReadOnlyList WeightPrefixes { get; init; } = new[] { 20, 21, 22, 23, 24 }; public IReadOnlyList PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 }; public int CodeDigits { get; init; } = 5; diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs index 7845e55..d90f5a5 100644 --- a/src/food-market.shared/Pos/V1/SyncDtos.cs +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -24,8 +24,11 @@ public record ProductSyncDto public string? Article { get; init; } /// Все штрихкоды товара (для поиска по сканеру). Первый — primary. public required IReadOnlyList Barcodes { get; init; } - /// Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI. + /// Код единицы измерения ОКЕИ ("796"=шт, "166"=кг, "112"=л…), не FK. public required string UnitCode { get; init; } + /// Короткое имя единицы для отображения на кассе ("шт", "кг", "л"). + /// Выводится из UnitCode/названия; чтобы касса не держала свою таблицу ОКЕИ. + public string UnitShort { get; init; } = "шт"; /// Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging). public required int Packaging { get; init; } /// Явный признак весового товара (Packaging==2) — для мобильного/кассы, @@ -211,6 +214,7 @@ public record PosReturnLineDto public required Guid ProductId { get; init; } public required string Name { get; init; } public required string UnitCode { get; init; } + public string UnitShort { get; init; } = "шт"; public required decimal UnitPrice { get; init; } public required decimal SoldQty { get; init; } public required decimal ReturnedQty { get; init; } diff --git a/src/food-market.shared/Units/UnitShort.cs b/src/food-market.shared/Units/UnitShort.cs new file mode 100644 index 0000000..38ed668 --- /dev/null +++ b/src/food-market.shared/Units/UnitShort.cs @@ -0,0 +1,33 @@ +namespace foodmarket.Shared.Units; + +/// Короткое имя единицы измерения для кассы/мобильного по коду ОКЕИ +/// (или по названию, если код незнаком). Чтобы клиент не держал свою таблицу ОКЕИ. +public static class UnitShort +{ + // Частые коды ОКЕИ розницы. + private static readonly IReadOnlyDictionary ByCode = new Dictionary + { + ["796"] = "шт", + ["166"] = "кг", + ["163"] = "г", + ["112"] = "л", + ["111"] = "мл", + ["006"] = "м", + ["625"] = "лист", + ["657"] = "усл.ед", + ["778"] = "упак", + }; + + /// Короткое имя по коду ОКЕИ; если код незнаком — первое слово названия + /// (усечённое), иначе «шт». + public static string From(string? code, string? name = null) + { + if (!string.IsNullOrWhiteSpace(code) && ByCode.TryGetValue(code.Trim(), out var s)) return s; + if (!string.IsNullOrWhiteSpace(name)) + { + var first = name.Trim().Split(' ', '.', ',')[0]; + if (first.Length > 0) return first.Length > 6 ? first[..6] : first; + } + return "шт"; + } +} diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs index a5dcde6..7f18247 100644 --- a/tests/food-market.IntegrationTests/MobileApiTests.cs +++ b/tests/food-market.IntegrationTests/MobileApiTests.cs @@ -221,17 +221,95 @@ public async Task Me_returns_effective_permissions() perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse(); } - // ── 5. Весовые этикетки в поиске по штрихкоду ────────────────────────── + // ── 5. Поиск по штрихкоду: по умолчанию только точное совпадение ─────── [Fact] - public async Task ByBarcode_parses_weighted_label_returns_product_and_quantity() + public async Task ByBarcode_default_exact_only_no_weighted_no_article() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); - // PLU = 12345 хранится как короткий штрихкод товара. + // Короткий штрихкод «27xxxxx» в карточке (как у владельца). + var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "2712345"); + + // Точное совпадение короткого кода → товар, НЕ весовой. + var exact = await api.Http.GetFromJsonAsync("/api/catalog/products/by-barcode/2712345"); + exact.GetProperty("id").GetString().Should().Be(pid); + exact.TryGetProperty("weighted", out _).Should().BeFalse("по умолчанию весовой разбор выключен"); + + // Весоподобная «этикетка» 2112345015007 по умолчанию НЕ разбирается и точного + // штрихкода нет → 404. (Раньше матчилось по PLU/артикулу — баг владельца.) + using var label = await api.Http.GetAsync("/api/catalog/products/by-barcode/2112345015007"); + label.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── 5b. Поиск по ШК НЕ матчит по артикулу (баг «вода 12345 → 1,5 кг») ── + [Fact] + public async Task ByBarcode_never_matches_by_article_even_when_weighted_enabled() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"mob-art-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + + // Товар с АРТИКУЛОМ 12345 и другим штрихкодом (как «вода» у владельца). + var water = await api.Http.PostAsJsonAsync("/api/catalog/products", new + { + name = $"Вода-{Guid.NewGuid():N}", article = "12345", unitOfMeasureId = refs.UnitId, + vat = 12, vatEnabled = true, productGroupId = refs.GroupId, isService = false, + packaging = 0, isMarked = false, referencePrice = 70m, purchaseCurrencyId = refs.CurrencyId, + prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } }, + barcodes = new[] { new { code = RandomBarcode(), type = 0, isPrimary = true } }, + }); + water.EnsureSuccessStatusCode(); + + // Включаем весовой разбор в организации (эхо текущих настроек + флаг). + var cur = await api.GetJsonAsync("/api/organization/settings"); + string S(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : ""; + bool B(string k) => cur.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True); + using var put = await api.Http.PutAsJsonAsync("/api/organization/settings", new + { + name = S("name"), countryCode = S("countryCode"), multiCurrencyEnabled = B("multiCurrencyEnabled"), + showVatEnabledOnProduct = B("showVatEnabledOnProduct"), showServiceOnProduct = B("showServiceOnProduct"), + showMarkedOnProduct = B("showMarkedOnProduct"), showMinMaxStock = B("showMinMaxStock"), + allowFractionalPrices = B("allowFractionalPrices"), allowNegativeBalance = B("allowNegativeBalance"), + showReferencePriceOnProduct = B("showReferencePriceOnProduct"), + showCountryOfOriginOnProduct = B("showCountryOfOriginOnProduct"), + showDescriptionOnProduct = B("showDescriptionOnProduct"), + weightBarcodeEnabled = true, // включаем разбор + }); + put.EnsureSuccessStatusCode(); + + // Весовая этикетка с PLU 12345: артикула у воды 12345, но по ШК «12345» товара + // нет → НЕ должно найтись по артикулу → 404 (а не «вода 1,5 кг»). + using var label = await api.Http.GetAsync("/api/catalog/products/by-barcode/2112345015007"); + label.StatusCode.Should().Be(HttpStatusCode.NotFound, + "поиск по штрихкоду не матчит по артикулу"); + } + + // ── 5c. С включённым разбором PLU ищется как ШТРИХКОД ────────────────── + [Fact] + public async Task ByBarcode_weighted_enabled_matches_plu_as_barcode() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"mob-wen-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + // PLU 12345 как ШТРИХКОД карточки. var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "12345"); - // Весовая этикетка: 21 | 12345 | 01500 | K → вес 1.500 кг. + var cur = await api.GetJsonAsync("/api/organization/settings"); + string S(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : ""; + bool B(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.True; + (await api.Http.PutAsJsonAsync("/api/organization/settings", new + { + name = S("name"), countryCode = S("countryCode"), multiCurrencyEnabled = B("multiCurrencyEnabled"), + showVatEnabledOnProduct = B("showVatEnabledOnProduct"), showServiceOnProduct = B("showServiceOnProduct"), + showMarkedOnProduct = B("showMarkedOnProduct"), showMinMaxStock = B("showMinMaxStock"), + allowFractionalPrices = B("allowFractionalPrices"), allowNegativeBalance = B("allowNegativeBalance"), + showReferencePriceOnProduct = B("showReferencePriceOnProduct"), + showCountryOfOriginOnProduct = B("showCountryOfOriginOnProduct"), + showDescriptionOnProduct = B("showDescriptionOnProduct"), weightBarcodeEnabled = true, + })).EnsureSuccessStatusCode(); + + // 21 | 12345 | 01500 → вес 1.500 кг, товар найден по штрихкоду 12345. var resp = await api.Http.GetFromJsonAsync("/api/catalog/products/by-barcode/2112345015007"); resp.GetProperty("weighted").GetBoolean().Should().BeTrue(); resp.GetProperty("valueKind").GetString().Should().Be("WeightKg"); diff --git a/tests/food-market.UnitTests/UnitShortTests.cs b/tests/food-market.UnitTests/UnitShortTests.cs new file mode 100644 index 0000000..9558606 --- /dev/null +++ b/tests/food-market.UnitTests/UnitShortTests.cs @@ -0,0 +1,24 @@ +using FluentAssertions; +using foodmarket.Shared.Units; +using Xunit; + +namespace foodmarket.UnitTests; + +public class UnitShortTests +{ + [Theory] + [InlineData("796", "шт")] + [InlineData("166", "кг")] + [InlineData("112", "л")] + [InlineData("163", "г")] + public void Known_okei_codes_map_to_short(string code, string expected) + => UnitShort.From(code).Should().Be(expected); + + [Fact] + public void Unknown_code_falls_back_to_name_first_word() + => UnitShort.From("999", "погонный метр").Should().Be("погонн"); + + [Fact] + public void No_code_no_name_defaults_to_sht() + => UnitShort.From(null, null).Should().Be("шт"); +} diff --git a/tests/food-market.UnitTests/WeightedBarcodeTests.cs b/tests/food-market.UnitTests/WeightedBarcodeTests.cs index 2c41f3b..08116b5 100644 --- a/tests/food-market.UnitTests/WeightedBarcodeTests.cs +++ b/tests/food-market.UnitTests/WeightedBarcodeTests.cs @@ -17,11 +17,24 @@ public void Normal_barcode_passes_through() r.Price.Should().BeNull(); } + // По умолчанию (решение владельца 09.10) разбор этикеток ВЫКЛЮЧЕН: без явно + // включённых настроек весоподобный код трактуется как обычный штрихкод. + private static readonly WeightedBarcodeSettings On = WeightedBarcodeSettings.Default with { Enabled = true }; + + [Fact] + public void Default_settings_are_disabled() + { + WeightedBarcodeSettings.Default.Enabled.Should().BeFalse(); + var r = WeightedBarcode.Parse("2112345015007"); + r.IsWeighted.Should().BeFalse("по умолчанию разбор этикеток выключен"); + r.Lookup.Should().Be("2112345015007"); + } + [Fact] public void Weight_prefix_extracts_plu_and_kg() { - // 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг - var r = WeightedBarcode.Parse("2112345015007"); + // 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг (при включённом разборе) + var r = WeightedBarcode.Parse("2112345015007", On); r.IsWeighted.Should().BeTrue(); r.ValueKind.Should().Be(WeightedValueKind.WeightKg); r.Lookup.Should().Be("12345"); @@ -32,8 +45,8 @@ public void Weight_prefix_extracts_plu_and_kg() [Fact] public void Price_prefix_extracts_plu_and_price() { - // 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 - var r = WeightedBarcode.Parse("2554321123456"); + // 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 (при включённом разборе) + var r = WeightedBarcode.Parse("2554321123456", On); r.IsWeighted.Should().BeTrue(); r.ValueKind.Should().Be(WeightedValueKind.Price); r.Lookup.Should().Be("54321");