diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 0b8b97e..ddd0d7e 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -3,6 +3,7 @@ using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; +using foodmarket.Shared.Barcodes; using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Api.Controllers.Reports; using foodmarket.Application.Mapping; @@ -1112,8 +1113,17 @@ 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; + var matches = await _db.Products.AsNoTracking() - .Where(p => p.Barcodes.Any(b => b.Code == v)) + .Where(p => p.Barcodes.Any(b => b.Code == lookup) + || (parsed.IsWeighted && p.Article == lookup)) .Select(p => new { p.Id, p.Name, p.Article, p.ReferencePrice, @@ -1127,12 +1137,125 @@ public record ByBarcodeResult(IReadOnlyList Items); if (matches.Count == 0) return NotFound(); var items = matches.Select(r => new QuickSearchItem( r.Id, r.Name, r.Article, - r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v, + r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup, 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( + QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice); + + /// Настройки разбора весовых этикеток текущей организации. + private async Task GetWeightedSettingsAsync(CancellationToken ct) + { + var orgId = _tenant.OrganizationId; + if (orgId is null) return WeightedBarcodeSettings.Default; + var o = await _db.Organizations.Where(x => x.Id == orgId) + .Select(x => new + { + x.WeightBarcodeEnabled, x.WeightBarcodeWeightPrefixes, + x.WeightBarcodePricePrefixes, x.WeightBarcodeCodeDigits, + }) + .FirstOrDefaultAsync(ct); + return o is null + ? WeightedBarcodeSettings.Default + : WeightedBarcodeSettings.Create(o.WeightBarcodeEnabled, o.WeightBarcodeWeightPrefixes, + o.WeightBarcodePricePrefixes, o.WeightBarcodeCodeDigits); + } + + public record AddBarcodeInput(string Code, string? Kind); + public record BarcodeDto(Guid Id, Guid ProductId, string Code, string Type, bool IsPrimary); + + /// Добавить штрихкод к существующему товару. Проверяет уникальность + /// в организации: если код уже у другого товара — 409 с его названием. + /// Повторное добавление того же кода этому же товару — идемпотентно (200). + /// kind — тип (ean13/ean8/code128/code39/upca/upce/other); по умолчанию + /// определяется по длине/цифрам. + [HttpPost("{id:guid}/barcodes"), RequiresPermission("ProductsEdit")] + public async Task> AddBarcode( + Guid id, [FromBody] AddBarcodeInput input, CancellationToken ct) + { + var code = (input?.Code ?? "").Trim(); + if (code.Length == 0) + return BadRequest(new { error = "Штрихкод не указан.", field = "code" }); + if (code.Length > 64) + return BadRequest(new { error = "Штрихкод слишком длинный (макс. 64).", field = "code" }); + + var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == id, ct); + if (product is null) return NotFound(new { error = "Товар не найден." }); + + // Код уже у ДРУГОГО товара организации → 409 с названием. + var other = await FindBarcodeConflictAsync(new[] { code }, excludeProductId: id, ct); + if (other is { } o) + return Conflict(new + { + error = $"Штрихкод уже у товара «{o.ProductName}».", + field = "code", code = "barcode-duplicate", conflictProduct = o.ProductName, + }); + + // Уже есть у этого товара → идемпотентно вернём существующий. + var existing = await _db.ProductBarcodes + .FirstOrDefaultAsync(b => b.ProductId == id && b.Code == code, ct); + if (existing is not null) + return Ok(new BarcodeDto(existing.Id, existing.ProductId, existing.Code, existing.Type.ToString(), existing.IsPrimary)); + + var isFirst = !await _db.ProductBarcodes.AnyAsync(b => b.ProductId == id, ct); + var bc = new ProductBarcode + { + ProductId = id, Code = code, Type = ParseBarcodeKind(input!.Kind, code), IsPrimary = isFirst, + }; + _db.ProductBarcodes.Add(bc); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_product_barcodes") == true) + { + // Гонка: код заняли между проверкой и вставкой. + var owner = await FindBarcodeConflictAsync(new[] { code }, excludeProductId: id, ct); + return Conflict(new + { + error = owner is { } w ? $"Штрихкод уже у товара «{w.ProductName}»." : "Штрихкод уже используется.", + field = "code", code = "barcode-duplicate", conflictProduct = owner?.ProductName, + }); + } + return CreatedAtAction(nameof(ByBarcode), new { value = bc.Code }, + new BarcodeDto(bc.Id, bc.ProductId, bc.Code, bc.Type.ToString(), bc.IsPrimary)); + } + + private static BarcodeType ParseBarcodeKind(string? kind, string code) + { + if (!string.IsNullOrWhiteSpace(kind)) + return kind.Trim().ToLowerInvariant() switch + { + "ean13" => BarcodeType.Ean13, + "ean8" => BarcodeType.Ean8, + "code128" => BarcodeType.Code128, + "code39" => BarcodeType.Code39, + "upca" => BarcodeType.Upca, + "upce" => BarcodeType.Upce, + _ => BarcodeType.Other, + }; + // Автоопределение по длине цифрового кода. + var digits = code.All(char.IsDigit); + return (digits, code.Length) switch + { + (true, 13) => BarcodeType.Ean13, + (true, 8) => BarcodeType.Ean8, + (true, 12) => BarcodeType.Upca, + (true, 6) => BarcodeType.Upce, + _ => BarcodeType.Code128, + }; + } + private IQueryable QueryIncludes() => _db.Products .Include(p => p.UnitOfMeasure) .Include(p => p.ProductGroup) diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index db7e878..ca7fcb6 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -38,7 +38,12 @@ public record OrgSettingsDto( bool AllowNegativeBalance, bool ShowReferencePriceOnProduct, bool ShowCountryOfOriginOnProduct, - bool ShowDescriptionOnProduct); + bool ShowDescriptionOnProduct, + // Весовые этикетки (внутримагазинные EAN-13). + bool WeightBarcodeEnabled, + string? WeightBarcodeWeightPrefixes, + string? WeightBarcodePricePrefixes, + int WeightBarcodeCodeDigits); // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). public record OrgSettingsInput( @@ -53,7 +58,14 @@ public record OrgSettingsInput( bool AllowNegativeBalance, bool ShowReferencePriceOnProduct, bool ShowCountryOfOriginOnProduct, - bool ShowDescriptionOnProduct); + bool ShowDescriptionOnProduct, + // Весовые этикетки — опционально. Блок применяется только если задан + // WeightBarcodeEnabled (иначе настройки не трогаем — безопасный round-trip + // для клиентов, которые про эти поля не знают, напр. текущая веб-форма). + bool? WeightBarcodeEnabled = null, + string? WeightBarcodeWeightPrefixes = null, + string? WeightBarcodePricePrefixes = null, + int? WeightBarcodeCodeDigits = null); [HttpGet("settings")] public async Task> Get(CancellationToken ct) @@ -93,6 +105,13 @@ public async Task> Update([FromBody] OrgSettingsInp o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct; o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct; + if (input.WeightBarcodeEnabled is bool wbe) + { + o.WeightBarcodeEnabled = wbe; + o.WeightBarcodeWeightPrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodeWeightPrefixes) ? null : input.WeightBarcodeWeightPrefixes.Trim(); + o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim(); + o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5; + } await _db.SaveChangesAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); @@ -124,5 +143,9 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok o.AllowNegativeBalance, o.ShowReferencePriceOnProduct, o.ShowCountryOfOriginOnProduct, - o.ShowDescriptionOnProduct); + o.ShowDescriptionOnProduct, + o.WeightBarcodeEnabled, + o.WeightBarcodeWeightPrefixes, + o.WeightBarcodePricePrefixes, + o.WeightBarcodeCodeDigits); } diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index e8f4d56..83504b7 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -104,6 +104,7 @@ public ActionResult Version([FromServices] IConfiguration cfg) Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty(), UnitCode = x.u.Code, Packaging = (int)x.p.Packaging, + IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight, VatPercent = x.p.Vat, VatEnabled = x.p.VatEnabled, IsMarked = x.p.IsMarked, diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 3c60dcc..f43bc84 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -102,6 +102,22 @@ public class Organization : Entity /// контроллерах скрытых модулей. public bool IsAdvancedMode { get; set; } + // ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ───── + /// Разбирать ли весовые этикетки (EAN-13 с префиксом из списков). + /// Default true. + public bool WeightBarcodeEnabled { get; set; } = true; + + /// Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр. + /// «20,21,22,23,24». null → дефолт 20–24. + public string? WeightBarcodeWeightPrefixes { get; set; } + + /// Префиксы весовых этикеток, где зашита ЦЕНА. CSV, напр. + /// «25,26,27,28,29». null → дефолт 25–29. + public string? WeightBarcodePricePrefixes { get; set; } + + /// Длина кода товара (PLU) внутри весовой этикетки. Default 5. + public int WeightBarcodeCodeDigits { get; set; } = 5; + // ─ Sprint 11: ОФД (фискализация чеков) ──────────────────────────── // Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant: // у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261008060000_Phase35_WeightBarcodeSettings.cs b/src/food-market.infrastructure/Persistence/Migrations/20261008060000_Phase35_WeightBarcodeSettings.cs new file mode 100644 index 0000000..287df9e --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261008060000_Phase35_WeightBarcodeSettings.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase35 — настройки разбора весовых этикеток на организации + /// (для мобильного «FM Склад» и кассы). + /// + /// Добавляет в organizations: + /// + /// WeightBarcodeEnabled boolean DEFAULT true. + /// WeightBarcodeWeightPrefixes text (CSV префиксов веса, null=20–24). + /// WeightBarcodePricePrefixes text (CSV префиксов цены, null=25–29). + /// WeightBarcodeCodeDigits integer DEFAULT 5. + /// + /// Идемпотентно (IF NOT EXISTS). Разбор — WeightedBarcode в food-market.shared. + [DbContext(typeof(AppDbContext))] + [Migration("20261008060000_Phase35_WeightBarcodeSettings")] + public partial class Phase35_WeightBarcodeSettings : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightBarcodeEnabled"" boolean NOT NULL DEFAULT true; + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightBarcodeWeightPrefixes"" text; + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightBarcodePricePrefixes"" text; + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""WeightBarcodeCodeDigits"" integer NOT NULL DEFAULT 5; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeEnabled""; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeWeightPrefixes""; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodePricePrefixes""; + ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeCodeDigits""; + "); + } + } +} diff --git a/src/food-market.pos.core/Logic/BarcodeParser.cs b/src/food-market.pos.core/Logic/BarcodeParser.cs index f8bf633..558b18a 100644 --- a/src/food-market.pos.core/Logic/BarcodeParser.cs +++ b/src/food-market.pos.core/Logic/BarcodeParser.cs @@ -1,6 +1,9 @@ +using foodmarket.Shared.Barcodes; + namespace foodmarket.Pos.Core.Logic; -/// Результат разбора штрихкода. +/// Результат разбора штрихкода (проекция общего +/// для кассы). public readonly record struct ScannedBarcode( string Raw, string Lookup, // код для поиска товара (для весовых — встроенный код товара) @@ -8,46 +11,17 @@ namespace foodmarket.Pos.Core.Logic; decimal? EmbeddedWeight, // кг (для EAN-13 с весом) decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой) -/// Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды). -/// -/// Формат (РФ/РК, распространённый): PP CCCCC Q QQQQ K — -/// PP — префикс 20..29, -/// CCCCC — 5-значный код товара (PLU), -/// Q — встроенная величина: вес в кг (5 цифр, 3 знака после запятой) ИЛИ -/// цена (5 цифр, 2 знака), в зависимости от префикса/настройки, -/// K — контрольная цифра EAN-13. -/// -/// По умолчанию: префиксы 20–24 → ВЕС (кг, /1000), 25–29 → ЦЕНА (₸, /100). -/// Это настраивается, но даём рабочий дефолт. Для поиска товара возвращаем -/// Lookup = "2" + PP[1] + CCCCC? Нет — ищем товар по его обычному штрихкоду, -/// которым в весовом коде является PLU. Поэтому Lookup = PLU (5 цифр, без лидирующих нулей -/// не режем — каталог сам сматчит по хранимому коду). +/// Разбор весовых EAN-13 (префикс 20–29). Логика вынесена в +/// — общая с сервером. +/// Касса по умолчанию берёт дефолтные настройки (20–24 вес, 25–29 цена, код 5 +/// цифр); при наличии настроек организации их можно прокинуть через перегрузку. public static class BarcodeParser { - public static ScannedBarcode Parse(string raw) + public static ScannedBarcode Parse(string raw) => Parse(raw, null); + + public static ScannedBarcode Parse(string raw, WeightedBarcodeSettings? settings) { - var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray()); - - if (code.Length == 13 && code[0] == '2') - { - var prefix = int.Parse(code.Substring(0, 2)); - if (prefix is >= 20 and <= 29) - { - var plu = code.Substring(2, 5); // код товара - var valueDigits = code.Substring(7, 5); // вес или цена - var value = int.Parse(valueDigits); - if (prefix <= 24) - { - // вес в кг, 3 знака после запятой (12345 → 12.345 кг) - var weight = decimal.Round(value / 1000m, 3); - return new ScannedBarcode(code, plu, true, weight, null); - } - // цена в ₸, 2 знака (12345 → 123.45) - var price = decimal.Round(value / 100m, 2); - return new ScannedBarcode(code, plu, true, null, price); - } - } - - return new ScannedBarcode(code.Length > 0 ? code : (raw ?? string.Empty), code.Length > 0 ? code : (raw ?? string.Empty), false, null, null); + var p = WeightedBarcode.Parse(raw, settings); + return new ScannedBarcode(p.Raw, p.Lookup, p.IsWeighted, p.WeightKg, p.Price); } } diff --git a/src/food-market.shared/Barcodes/WeightedBarcode.cs b/src/food-market.shared/Barcodes/WeightedBarcode.cs new file mode 100644 index 0000000..f965a5a --- /dev/null +++ b/src/food-market.shared/Barcodes/WeightedBarcode.cs @@ -0,0 +1,83 @@ +namespace foodmarket.Shared.Barcodes; + +/// Что зашито в весовой этикетке: вес (кг) или цена. +public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 } + +/// Настройка разбора весовых этикеток для организации. По аналогии с +/// МойСкладом: внутримагазинные EAN-13 с префиксом 20–29, где внутри — «код +/// товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена). Префиксы +/// и длина кода настраиваются; по умолчанию 20–24 = вес, 25–29 = цена, код 5 цифр. +/// Хранится скалярными колонками организации (csv-списки префиксов). +public sealed record WeightedBarcodeSettings +{ + public bool Enabled { get; init; } = true; + 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; + + public static WeightedBarcodeSettings Default { get; } = new(); + + /// Сконструировать из хранимых значений организации (csv префиксов). + public static WeightedBarcodeSettings Create(bool enabled, string? weightCsv, string? priceCsv, int codeDigits) + => new() + { + Enabled = enabled, + WeightPrefixes = ParseCsv(weightCsv) is { Count: > 0 } w ? w : Default.WeightPrefixes, + PricePrefixes = ParseCsv(priceCsv) is { Count: > 0 } p ? p : Default.PricePrefixes, + CodeDigits = codeDigits is >= 1 and <= 8 ? codeDigits : 5, + }; + + private static List ParseCsv(string? csv) + { + var list = new List(); + if (string.IsNullOrWhiteSpace(csv)) return list; + foreach (var part in csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (int.TryParse(part, out var n) && n is >= 0 and <= 99) list.Add(n); + return list; + } +} + +/// Результат разбора штрихкода. +public readonly record struct ParsedBarcode( + string Raw, + string Lookup, // код для поиска товара (для весовых — встроенный PLU) + bool IsWeighted, + WeightedValueKind ValueKind, + decimal? WeightKg, // кг (для весовых по весу) + decimal? Price) // цена (для весовых по цене) +{ + /// Встроенное количество для чека (кол-во = вес; для ценовых весовой этикетки 1). + public decimal? EmbeddedQuantity => WeightKg; +} + +/// Разбор штрихкодов, включая весовые EAN-13 (20–29). Единый на сервере +/// (поиск по ШК) и в кассе (пробитие). Для не-весовых возвращает код как есть. +public static class WeightedBarcode +{ + public static ParsedBarcode Parse(string? raw, WeightedBarcodeSettings? settings = null) + { + settings ??= WeightedBarcodeSettings.Default; + var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray()); + + if (settings.Enabled && code.Length == 13) + { + var prefix = int.Parse(code.Substring(0, 2)); + var isWeight = settings.WeightPrefixes.Contains(prefix); + var isPrice = settings.PricePrefixes.Contains(prefix); + if (isWeight || isPrice) + { + var cd = Math.Clamp(settings.CodeDigits, 1, 8); + var plu = code.Substring(2, Math.Min(cd, 10)); + var valueStart = 2 + cd; + var valueLen = Math.Max(0, 12 - valueStart); // последняя цифра — контрольная + var value = valueLen > 0 ? int.Parse(code.Substring(valueStart, valueLen)) : 0; + if (isWeight) + return new ParsedBarcode(code, plu, true, WeightedValueKind.WeightKg, decimal.Round(value / 1000m, 3), null); + return new ParsedBarcode(code, plu, true, WeightedValueKind.Price, null, decimal.Round(value / 100m, 2)); + } + } + + var lookup = code.Length > 0 ? code : (raw ?? string.Empty); + return new ParsedBarcode(lookup, lookup, false, WeightedValueKind.None, null, null); + } +} diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs index 5c9c4c2..d579c1f 100644 --- a/src/food-market.shared/Pos/V1/SyncDtos.cs +++ b/src/food-market.shared/Pos/V1/SyncDtos.cs @@ -28,6 +28,9 @@ public record ProductSyncDto public required string UnitCode { get; init; } /// Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging). public required int Packaging { get; init; } + /// Явный признак весового товара (Packaging==2) — для мобильного/кассы, + /// чтобы не завязываться на числовой код упаковки. + public bool IsWeighed { get; init; } public required decimal VatPercent { get; init; } public required bool VatEnabled { get; init; } public required bool IsMarked { get; init; }