fix(barcode): весовой разбор выкл по умолчанию; поиск по ШК — точное совпадение

Реальный сценарий владельца: весов с печатью этикеток нет, штрихкоды весовых —
обычные коды «27xxxxx» в карточке. Исправлены два бага:
- поиск по ШК матчил весовой PLU по АРТИКУЛУ (вода арт.12345 → 1,5 кг) — убрано,
  поиск по штрихкоду больше нигде не матчит по артикулу;
- разбор весовых этикеток был дефолт-ON (WeightedBarcodeSettings.Enabled,
  Organization.WeightBarcodeEnabled) и ловил обычные коды — теперь дефолт OFF.

- ByBarcode: сначала точное совпадение штрихкода (вкл. короткие «27xxxxx»),
  разбор этикетки — только если включён в орг И точного ШК нет.
- Phase40: WeightBarcodeEnabled DEFAULT false + выключение у существующих орг;
  WeightedCodeFormat('short'|'ean13')/WeightedCodePrefix(27)/WeightedCodeDigits(5).
- Генератор ШК нового весового товара: короткий «<prefix>+N цифр» (27xxxxx, next-free)
  или EAN-13 по настройке; уникальность.
- Настройки орг отдают/принимают формат кода весовых.
- Sync отдаёт unitShort («шт»/«кг»/«л») рядом с unitCode (ОКЕИ) — shared UnitShort.
- Тесты: MobileApi (exact-only по умолчанию; не матчит артикул; PLU как ШК при вкл) 9/9;
  unit WeightedBarcode/UnitShort 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 11:02:43 +05:00
parent 7ede21321b
commit d0fee10e96
12 changed files with 392 additions and 39 deletions

41
docs/weighted-progress.md Normal file
View file

@ -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.

View file

@ -300,7 +300,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct); var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
// Штрихкод обязателен у любого товара: нет — генерируем внутренний EAN-13. // Штрихкод обязателен у любого товара: нет — генерируем внутренний 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); var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c) if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
@ -350,7 +350,7 @@ public async Task<ActionResult<ProductDto>> QuickCreate([FromBody] QuickCreateIn
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
IReadOnlyList<ProductBarcodeInput> barcodeInputs = string.IsNullOrWhiteSpace(input.Barcode) IReadOnlyList<ProductBarcodeInput> barcodeInputs = string.IsNullOrWhiteSpace(input.Barcode)
? await EnsureBarcodeInputsAsync(null, ct) ? await EnsureBarcodeInputsAsync(null, input.Weighed, ct)
: new[] { new ProductBarcodeInput(input.Barcode.Trim(), BarcodeType.Ean13, true) }; : new[] { new ProductBarcodeInput(input.Barcode.Trim(), BarcodeType.Ean13, true) };
var conflict = await FindBarcodeConflictAsync(barcodeInputs.Select(b => b.Code), null, ct); var conflict = await FindBarcodeConflictAsync(barcodeInputs.Select(b => b.Code), null, ct);
if (conflict is { } c) if (conflict is { } c)
@ -391,7 +391,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." }); if (groupId is null) return BadRequest(new { error = "В организации не настроены группы товаров." });
var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct); var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." }); 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); var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c) if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." }); return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
@ -1182,16 +1182,36 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
var v = (value ?? "").Trim(); var v = (value ?? "").Trim();
if (v.Length == 0) return NotFound(); if (v.Length == 0) return NotFound();
// Весовая этикетка? Разбираем по настройкам организации (общий парсер). // 1) Точное совпадение со штрихкодом карточки (включая короткие «27xxxxx»).
var settings = await GetWeightedSettingsAsync(ct); // Поиск по штрихкоду НИКОГДА не матчит по артикулу.
var parsed = WeightedBarcode.Parse(v, settings); var exact = await FindByExactBarcodeAsync(v, storeId, ct);
// Для весовой — ищем товар по встроенному PLU (как штрихкод или артикул), if (exact.Count == 1) return exact[0];
// иначе по самому штрихкоду. if (exact.Count > 1) return new ByBarcodeResult(exact);
var lookup = parsed.IsWeighted ? parsed.Lookup : v;
// 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();
}
/// <summary>Товары с ТОЧНО таким штрихкодом (по артикулу не ищем). С остатком
/// на складе (или суммой по складам, если storeId не задан).</summary>
private async Task<List<QuickSearchItem>> FindByExactBarcodeAsync(string code, Guid? storeId, CancellationToken ct)
{
var matches = await _db.Products.AsNoTracking() var matches = await _db.Products.AsNoTracking()
.Where(p => p.Barcodes.Any(b => b.Code == lookup) .Where(p => p.Barcodes.Any(b => b.Code == code))
|| (parsed.IsWeighted && p.Article == lookup))
.Select(p => new .Select(p => new
{ {
p.Id, p.Name, p.Article, p.ReferencePrice, p.Id, p.Name, p.Article, p.ReferencePrice,
@ -1202,20 +1222,10 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
.Select(st => (decimal?)st.Quantity).FirstOrDefault(), .Select(st => (decimal?)st.Quantity).FirstOrDefault(),
}) })
.ToListAsync(ct); .ToListAsync(ct);
if (matches.Count == 0) return NotFound(); return matches.Select(r => new QuickSearchItem(
var items = matches.Select(r => new QuickSearchItem(
r.Id, r.Name, r.Article, 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(); 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( public record WeightedBarcodeHit(
@ -1247,18 +1257,29 @@ public record WeightedBarcodeHit(
/// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец: /// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец:
/// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary> /// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary>
private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync( private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync(
IReadOnlyList<ProductBarcodeInput>? provided, CancellationToken ct) IReadOnlyList<ProductBarcodeInput>? provided, bool weighted, CancellationToken ct)
{ {
var list = (provided ?? []) var list = (provided ?? [])
.Where(b => !string.IsNullOrWhiteSpace(b.Code)) .Where(b => !string.IsNullOrWhiteSpace(b.Code))
.Select(b => b with { Code = b.Code.Trim() }) .Select(b => b with { Code = b.Code.Trim() })
.ToList(); .ToList();
if (list.Count == 0) if (list.Count == 0)
{
// Весовой товар + формат «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 settings = await GetWeightedSettingsAsync(ct);
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct); var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true)); list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
} }
}
else if (!list.Any(b => b.IsPrimary)) else if (!list.Any(b => b.IsPrimary))
{ {
list[0] = list[0] with { IsPrimary = true }; list[0] = list[0] with { IsPrimary = true };
@ -1266,6 +1287,53 @@ public record WeightedBarcodeHit(
return list; 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);
}
/// <summary>Короткий код весового товара «&lt;prefix D2&gt; + N цифр», следующий
/// свободный номер (max+1). При переполнении разрядности — внутренний EAN-13.</summary>
private async Task<string> 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);
}
/// <summary>Сгенерировать внутренний EAN-13, уникальный в пределах организации /// <summary>Сгенерировать внутренний EAN-13, уникальный в пределах организации
/// (ретрай при коллизии).</summary> /// (ретрай при коллизии).</summary>
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct) private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)

View file

@ -44,6 +44,10 @@ public record OrgSettingsDto(
string? WeightBarcodeWeightPrefixes, string? WeightBarcodeWeightPrefixes,
string? WeightBarcodePricePrefixes, string? WeightBarcodePricePrefixes,
int WeightBarcodeCodeDigits, int WeightBarcodeCodeDigits,
// Формат кода, генерируемого для нового весового товара.
string WeightedCodeFormat,
int WeightedCodePrefix,
int WeightedCodeDigits,
// Уведомления об отрицательных остатках. // Уведомления об отрицательных остатках.
bool NegStockNotifyEnabled, bool NegStockNotifyEnabled,
int NegStockNotifyIntervalMinutes, int NegStockNotifyIntervalMinutes,
@ -72,6 +76,10 @@ public record OrgSettingsInput(
string? WeightBarcodeWeightPrefixes = null, string? WeightBarcodeWeightPrefixes = null,
string? WeightBarcodePricePrefixes = null, string? WeightBarcodePricePrefixes = null,
int? WeightBarcodeCodeDigits = null, int? WeightBarcodeCodeDigits = null,
// Формат кода весового товара — опционально (round-trip-безопасно).
string? WeightedCodeFormat = null,
int? WeightedCodePrefix = null,
int? WeightedCodeDigits = null,
// Уведомления об отрицательных остатках — опциональный блок (применяется // Уведомления об отрицательных остатках — опциональный блок (применяется
// только если задан NegStockNotifyEnabled; round-trip-безопасно). // только если задан NegStockNotifyEnabled; round-trip-безопасно).
bool? NegStockNotifyEnabled = null, bool? NegStockNotifyEnabled = null,
@ -125,6 +133,12 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim(); o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim();
o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5; 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) if (input.NegStockNotifyEnabled is bool nse)
{ {
o.NegStockNotifyEnabled = nse; o.NegStockNotifyEnabled = nse;
@ -174,6 +188,9 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.WeightBarcodeWeightPrefixes, o.WeightBarcodeWeightPrefixes,
o.WeightBarcodePricePrefixes, o.WeightBarcodePricePrefixes,
o.WeightBarcodeCodeDigits, o.WeightBarcodeCodeDigits,
o.WeightedCodeFormat,
o.WeightedCodePrefix,
o.WeightedCodeDigits,
o.NegStockNotifyEnabled, o.NegStockNotifyEnabled,
o.NegStockNotifyIntervalMinutes, o.NegStockNotifyIntervalMinutes,
o.NegStockQuietFromHour, o.NegStockQuietFromHour,

View file

@ -91,9 +91,12 @@ orderby l.SortOrder
select new PosReturnLineDto select new PosReturnLineDto
{ {
ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code, ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code,
UnitShort = u.Code, // временно код; заменим после материализации
UnitPrice = l.UnitPrice, SoldQty = l.Quantity, UnitPrice = l.UnitPrice, SoldQty = l.Quantity,
ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned, ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned,
}).ToListAsync(ct); }).ToListAsync(ct);
// UnitShort — короткое имя по коду ОКЕИ (в SQL-проекции положили код-заглушку).
lines = lines.Select(l => l with { UnitShort = foodmarket.Shared.Units.UnitShort.From(l.UnitCode) }).ToList();
return Ok(new PosReturnLookupDto return Ok(new PosReturnLookupDto
{ {
@ -150,6 +153,7 @@ orderby l.SortOrder
Article = x.p.Article, Article = x.p.Article,
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(), Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
UnitCode = x.u.Code, UnitCode = x.u.Code,
UnitShort = foodmarket.Shared.Units.UnitShort.From(x.u.Code, x.u.Name),
Packaging = (int)x.p.Packaging, Packaging = (int)x.p.Packaging,
IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight, IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight,
VatPercent = x.p.Vat, VatPercent = x.p.Vat,

View file

@ -104,8 +104,10 @@ public class Organization : Entity
// ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ───── // ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ─────
/// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков). /// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков).
/// Default true.</summary> /// Default FALSE (у владельца весов с печатью этикеток нет; штрихкоды весовых —
public bool WeightBarcodeEnabled { get; set; } = true; /// обычные коды в карточке). Включается явно; разбор применяется только после
/// проверки точного совпадения штрихкода.</summary>
public bool WeightBarcodeEnabled { get; set; } = false;
/// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр. /// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр.
/// «20,21,22,23,24». null → дефолт 20–24.</summary> /// «20,21,22,23,24». null → дефолт 20–24.</summary>
@ -118,6 +120,19 @@ public class Organization : Entity
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary> /// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
public int WeightBarcodeCodeDigits { get; set; } = 5; public int WeightBarcodeCodeDigits { get; set; } = 5;
/// <summary>Формат кода, который генерируется для НОВОГО весового товара:
/// «ean13» — внутренний EAN-13 (как у штучных), «short» — короткий код
/// «&lt;префикс&gt; + N цифр» (у владельца: 27 + 5 цифр). Default «short».</summary>
public string WeightedCodeFormat { get; set; } = "short";
/// <summary>Префикс короткого кода весового товара (2 цифры). Default 27 (как у
/// владельца). Следующий свободный N-значный номер добавляется автоматически.</summary>
public int WeightedCodePrefix { get; set; } = 27;
/// <summary>Сколько цифр в номере после префикса короткого кода весового товара.
/// Default 5 (итого 7 знаков: «27xxxxx»).</summary>
public int WeightedCodeDigits { get; set; } = 5;
// ─ Уведомления об отрицательных остатках (идея владельца 08.10) ──────── // ─ Уведомления об отрицательных остатках (идея владельца 08.10) ────────
/// <summary>Слать ли уведомления «товары ушли в минус» (центр уведомлений, /// <summary>Слать ли уведомления «товары ушли в минус» (центр уведомлений,
/// Telegram, push). Default true. Выключение — только администратор.</summary> /// Telegram, push). Default true. Выключение — только администратор.</summary>

View file

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase40 — весовой товар по реальному сценарию владельца (09.10):
/// разбор весовых этикеток ВЫКЛ по умолчанию + формат короткого кода весового товара.
///
/// <list type="bullet">
/// <item><c>WeightBarcodeEnabled</c> DEFAULT false + существующие организации
/// выключаем (фича была дефолт-on и ловила обычные коды как весовые).</item>
/// <item><c>WeightedCodeFormat</c> text DEFAULT 'short' («ean13» | «short»).</item>
/// <item><c>WeightedCodePrefix</c> integer DEFAULT 27.</item>
/// <item><c>WeightedCodeDigits</c> integer DEFAULT 5.</item>
/// </list>
/// Идемпотентно (IF NOT EXISTS / ALTER DEFAULT).</summary>
[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"";
");
}
}
}

View file

@ -10,7 +10,10 @@ public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 }
/// Хранится скалярными колонками организации (csv-списки префиксов).</summary> /// Хранится скалярными колонками организации (csv-списки префиксов).</summary>
public sealed record WeightedBarcodeSettings public sealed record WeightedBarcodeSettings
{ {
public bool Enabled { get; init; } = true; // По умолчанию ВЫКЛ (решение владельца 09.10): весов с печатью этикеток нет,
// штрихкоды весовых — обычные коды в карточке. Разбор этикеток включается явно
// в настройках организации и только тогда применяется (и то после точного ШК).
public bool Enabled { get; init; } = false;
public IReadOnlyList<int> WeightPrefixes { get; init; } = new[] { 20, 21, 22, 23, 24 }; 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 IReadOnlyList<int> PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 };
public int CodeDigits { get; init; } = 5; public int CodeDigits { get; init; } = 5;

View file

@ -24,8 +24,11 @@ public record ProductSyncDto
public string? Article { get; init; } public string? Article { get; init; }
/// <summary>Все штрихкоды товара (для поиска по сканеру). Первый — primary.</summary> /// <summary>Все штрихкоды товара (для поиска по сканеру). Первый — primary.</summary>
public required IReadOnlyList<string> Barcodes { get; init; } public required IReadOnlyList<string> Barcodes { get; init; }
/// <summary>Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI.</summary> /// <summary>Код единицы измерения ОКЕИ ("796"=шт, "166"=кг, "112"=л…), не FK.</summary>
public required string UnitCode { get; init; } public required string UnitCode { get; init; }
/// <summary>Короткое имя единицы для отображения на кассе ("шт", "кг", "л").
/// Выводится из UnitCode/названия; чтобы касса не держала свою таблицу ОКЕИ.</summary>
public string UnitShort { get; init; } = "шт";
/// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary> /// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary>
public required int Packaging { get; init; } public required int Packaging { get; init; }
/// <summary>Явный признак весового товара (Packaging==2) — для мобильного/кассы, /// <summary>Явный признак весового товара (Packaging==2) — для мобильного/кассы,
@ -211,6 +214,7 @@ public record PosReturnLineDto
public required Guid ProductId { get; init; } public required Guid ProductId { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
public required string UnitCode { get; init; } public required string UnitCode { get; init; }
public string UnitShort { get; init; } = "шт";
public required decimal UnitPrice { get; init; } public required decimal UnitPrice { get; init; }
public required decimal SoldQty { get; init; } public required decimal SoldQty { get; init; }
public required decimal ReturnedQty { get; init; } public required decimal ReturnedQty { get; init; }

View file

@ -0,0 +1,33 @@
namespace foodmarket.Shared.Units;
/// <summary>Короткое имя единицы измерения для кассы/мобильного по коду ОКЕИ
/// (или по названию, если код незнаком). Чтобы клиент не держал свою таблицу ОКЕИ.</summary>
public static class UnitShort
{
// Частые коды ОКЕИ розницы.
private static readonly IReadOnlyDictionary<string, string> ByCode = new Dictionary<string, string>
{
["796"] = "шт",
["166"] = "кг",
["163"] = "г",
["112"] = "л",
["111"] = "мл",
["006"] = "м",
["625"] = "лист",
["657"] = "усл.ед",
["778"] = "упак",
};
/// <summary>Короткое имя по коду ОКЕИ; если код незнаком — первое слово названия
/// (усечённое), иначе «шт».</summary>
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 "шт";
}
}

View file

@ -221,17 +221,95 @@ public async Task Me_returns_effective_permissions()
perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse(); perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse();
} }
// ── 5. Весовые этикетки в поиске по штрихкоду ────────────────────────── // ── 5. Поиск по штрихкоду: по умолчанию только точное совпадение ───────
[Fact] [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()); var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}"); await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync(); 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<JsonElement>("/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"); 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<JsonElement>("/api/catalog/products/by-barcode/2112345015007"); var resp = await api.Http.GetFromJsonAsync<JsonElement>("/api/catalog/products/by-barcode/2112345015007");
resp.GetProperty("weighted").GetBoolean().Should().BeTrue(); resp.GetProperty("weighted").GetBoolean().Should().BeTrue();
resp.GetProperty("valueKind").GetString().Should().Be("WeightKg"); resp.GetProperty("valueKind").GetString().Should().Be("WeightKg");

View file

@ -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("шт");
}

View file

@ -17,11 +17,24 @@ public void Normal_barcode_passes_through()
r.Price.Should().BeNull(); 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] [Fact]
public void Weight_prefix_extracts_plu_and_kg() public void Weight_prefix_extracts_plu_and_kg()
{ {
// 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг // 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг (при включённом разборе)
var r = WeightedBarcode.Parse("2112345015007"); var r = WeightedBarcode.Parse("2112345015007", On);
r.IsWeighted.Should().BeTrue(); r.IsWeighted.Should().BeTrue();
r.ValueKind.Should().Be(WeightedValueKind.WeightKg); r.ValueKind.Should().Be(WeightedValueKind.WeightKg);
r.Lookup.Should().Be("12345"); r.Lookup.Should().Be("12345");
@ -32,8 +45,8 @@ public void Weight_prefix_extracts_plu_and_kg()
[Fact] [Fact]
public void Price_prefix_extracts_plu_and_price() public void Price_prefix_extracts_plu_and_price()
{ {
// 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 // 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 (при включённом разборе)
var r = WeightedBarcode.Parse("2554321123456"); var r = WeightedBarcode.Parse("2554321123456", On);
r.IsWeighted.Should().BeTrue(); r.IsWeighted.Should().BeTrue();
r.ValueKind.Should().Be(WeightedValueKind.Price); r.ValueKind.Should().Be(WeightedValueKind.Price);
r.Lookup.Should().Be("54321"); r.Lookup.Should().Be("54321");