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:
parent
7ede21321b
commit
d0fee10e96
41
docs/weighted-progress.md
Normal file
41
docs/weighted-progress.md
Normal 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.
|
||||||
|
|
@ -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>Короткий код весового товара «<prefix D2> + 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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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» — короткий код
|
||||||
|
/// «<префикс> + 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>
|
||||||
|
|
|
||||||
|
|
@ -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"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
33
src/food-market.shared/Units/UnitShort.cs
Normal file
33
src/food-market.shared/Units/UnitShort.cs
Normal 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 "шт";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
24
tests/food-market.UnitTests/UnitShortTests.cs
Normal file
24
tests/food-market.UnitTests/UnitShortTests.cs
Normal 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("шт");
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue