feat(catalog): штрихкоды товара — добавление + весовые этикетки

- POST /api/catalog/products/{id}/barcodes {code, kind}: добавить штрихкод к
  товару, уникальность в организации, 409 «штрихкод уже у товара X» (+название),
  идемпотентно при повторе тому же товару, автоопределение типа по длине.
- Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе): общий парсер
  food-market.shared/Barcodes/WeightedBarcode (настраиваемые префиксы веса/цены,
  длина PLU); касса pos.core делегирует туда. Настройки организации
  (WeightBarcode*: enabled/префиксы/длина кода) + миграция Phase35.
- GET /api/catalog/products/by-barcode/{value} разбирает весовую этикетку →
  товар + количество (вес) / встроенная цена; обычный ШК — как прежде.
- GET/PUT /api/organization/settings отдаёт/принимает настройки (блок весовых
  применяется только если передан — round-trip-safe для текущей веб-формы).
- Офлайн-каталог ProductSyncDto: явный isWeighed (packaging==2) для мобильного/кассы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 05:39:20 +05:00
parent dd5604b6ea
commit 5173eaa07c
8 changed files with 315 additions and 44 deletions

View file

@ -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<QuickSearchItem> 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<QuickSearchItem> 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);
/// <summary>Настройки разбора весовых этикеток текущей организации.</summary>
private async Task<WeightedBarcodeSettings> 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);
/// <summary>Добавить штрихкод к существующему товару. Проверяет уникальность
/// в организации: если код уже у другого товара — 409 с его названием.
/// Повторное добавление того же кода этому же товару — идемпотентно (200).
/// <c>kind</c> — тип (ean13/ean8/code128/code39/upca/upce/other); по умолчанию
/// определяется по длине/цифрам.</summary>
[HttpPost("{id:guid}/barcodes"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<BarcodeDto>> 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<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.ProductGroup)

View file

@ -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<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -93,6 +105,13 @@ public async Task<ActionResult<OrgSettingsDto>> 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<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.AllowNegativeBalance,
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct);
o.ShowDescriptionOnProduct,
o.WeightBarcodeEnabled,
o.WeightBarcodeWeightPrefixes,
o.WeightBarcodePricePrefixes,
o.WeightBarcodeCodeDigits);
}

View file

@ -104,6 +104,7 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
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,

View file

@ -102,6 +102,22 @@ public class Organization : Entity
/// контроллерах скрытых модулей.</summary>
public bool IsAdvancedMode { get; set; }
// ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ─────
/// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков).
/// Default true.</summary>
public bool WeightBarcodeEnabled { get; set; } = true;
/// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр.
/// «20,21,22,23,24». null → дефолт 20–24.</summary>
public string? WeightBarcodeWeightPrefixes { get; set; }
/// <summary>Префиксы весовых этикеток, где зашита ЦЕНА. CSV, напр.
/// «25,26,27,28,29». null → дефолт 25–29.</summary>
public string? WeightBarcodePricePrefixes { get; set; }
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
public int WeightBarcodeCodeDigits { get; set; } = 5;
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret

View file

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase35 — настройки разбора весовых этикеток на организации
/// (для мобильного «FM Склад» и кассы).
///
/// Добавляет в <c>organizations</c>:
/// <list type="bullet">
/// <item><c>WeightBarcodeEnabled</c> boolean DEFAULT true.</item>
/// <item><c>WeightBarcodeWeightPrefixes</c> text (CSV префиксов веса, null=20–24).</item>
/// <item><c>WeightBarcodePricePrefixes</c> text (CSV префиксов цены, null=25–29).</item>
/// <item><c>WeightBarcodeCodeDigits</c> integer DEFAULT 5.</item>
/// </list>
/// Идемпотентно (IF NOT EXISTS). Разбор — <c>WeightedBarcode</c> в food-market.shared.</summary>
[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"";
");
}
}
}

View file

@ -1,6 +1,9 @@
using foodmarket.Shared.Barcodes;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Результат разбора штрихкода.</summary>
/// <summary>Результат разбора штрихкода (проекция общего
/// <see cref="foodmarket.Shared.Barcodes.ParsedBarcode"/> для кассы).</summary>
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 с ценой)
/// <summary>Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды).
///
/// Формат (РФ/РК, распространённый): <c>PP CCCCC Q QQQQ K</c> —
/// 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 цифр, без лидирующих нулей
/// не режем — каталог сам сматчит по хранимому коду).</summary>
/// <summary>Разбор весовых EAN-13 (префикс 20–29). Логика вынесена в
/// <see cref="foodmarket.Shared.Barcodes.WeightedBarcode"/> — общая с сервером.
/// Касса по умолчанию берёт дефолтные настройки (20–24 вес, 25–29 цена, код 5
/// цифр); при наличии настроек организации их можно прокинуть через перегрузку.</summary>
public static class BarcodeParser
{
public static ScannedBarcode Parse(string raw)
{
var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray());
public static ScannedBarcode Parse(string raw) => Parse(raw, null);
if (code.Length == 13 && code[0] == '2')
public static ScannedBarcode Parse(string raw, WeightedBarcodeSettings? settings)
{
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);
}
}

View file

@ -0,0 +1,83 @@
namespace foodmarket.Shared.Barcodes;
/// <summary>Что зашито в весовой этикетке: вес (кг) или цена.</summary>
public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 }
/// <summary>Настройка разбора весовых этикеток для организации. По аналогии с
/// МойСкладом: внутримагазинные EAN-13 с префиксом 20–29, где внутри — «код
/// товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена). Префиксы
/// и длина кода настраиваются; по умолчанию 20–24 = вес, 25–29 = цена, код 5 цифр.
/// Хранится скалярными колонками организации (csv-списки префиксов).</summary>
public sealed record WeightedBarcodeSettings
{
public bool Enabled { get; init; } = true;
public IReadOnlyList<int> WeightPrefixes { get; init; } = new[] { 20, 21, 22, 23, 24 };
public IReadOnlyList<int> PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 };
public int CodeDigits { get; init; } = 5;
public static WeightedBarcodeSettings Default { get; } = new();
/// <summary>Сконструировать из хранимых значений организации (csv префиксов).</summary>
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<int> ParseCsv(string? csv)
{
var list = new List<int>();
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;
}
}
/// <summary>Результат разбора штрихкода.</summary>
public readonly record struct ParsedBarcode(
string Raw,
string Lookup, // код для поиска товара (для весовых — встроенный PLU)
bool IsWeighted,
WeightedValueKind ValueKind,
decimal? WeightKg, // кг (для весовых по весу)
decimal? Price) // цена (для весовых по цене)
{
/// <summary>Встроенное количество для чека (кол-во = вес; для ценовых весовой этикетки 1).</summary>
public decimal? EmbeddedQuantity => WeightKg;
}
/// <summary>Разбор штрихкодов, включая весовые EAN-13 (20–29). Единый на сервере
/// (поиск по ШК) и в кассе (пробитие). Для не-весовых возвращает код как есть.</summary>
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);
}
}

View file

@ -28,6 +28,9 @@ public record ProductSyncDto
public required string UnitCode { get; init; }
/// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary>
public required int Packaging { get; init; }
/// <summary>Явный признак весового товара (Packaging==2) — для мобильного/кассы,
/// чтобы не завязываться на числовой код упаковки.</summary>
public bool IsWeighed { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
public required bool IsMarked { get; init; }