Compare commits

..

3 commits

Author SHA1 Message Date
nns d465d82248 docs(weighted): unitShort в sync + обновлён mobile-api и прогресс
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:18:33 +05:00
nns 195dfb1a2c feat(pos): весовой товар на кассе — вес с весов/вручную + автопоиск весов
Часть 3 + автопоиск (данные sysadmin: касса МойСклад спамит «Порт не выбран»):
- Весовой товар при сканировании/плитке → вес с весов (если на связи), иначе
  модалка ручного ввода веса. Сумма = цена/кг × вес; подпись «1,235 кг × 690 ₸/кг».
- ScaleMonitor (pos.core): опрос ТОЛЬКО по требованию (при весовом товаре), мягкая
  деградация на ручной ввод, запись о потере/восстановлении связи РОВНО ОДИН раз
  (не сотни «Таймаут»). Юнит-тесты (100 опросов → 1 запись).
- ScaleDiscovery (pos): автоскан всех COM-портов (вкл. USB-COM CH340/Prolific) ×
  протоколы Масса-К/CAS × скорости; кнопка «Найти весы» в верхней панели; найденный
  порт запоминается (kv) и восстанавливается при старте.
- UnitShort («шт»/«кг»/«л») протянут через sync→SQLite→PosProduct/CartLine; цена
  весового показывается «690 ₸/кг» вместо кода ОКЕИ «690 ₸/796».
- Разбор этикеток в кассе — по настройкам орг (по умолчанию ВЫКЛ, точный штрихкод).
- Юнит-тесты 321/321 (обновлены PosBarcodeTests под дефолт-off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:17:35 +05:00
nns d0fee10e96 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>
2026-10-09 11:02:43 +05:00
24 changed files with 839 additions and 80 deletions

View file

@ -57,30 +57,35 @@ advanced-only скрыты; обычная роль — её флаги (в пр
`PromotionsManage`, `PriceTypesManage` (зеркалит серверный `[RequiresAdvancedMode]`).
Имена флагов — из `RolePermissions`. Веб использует тот же источник (`useCan()`).
## 4. Весовой признак в офлайн-каталоге
## 4. Весовой признак и единица в офлайн-каталоге
`GET /api/pos/v1/sync` (каталог для сканирования/офлайна) в `ProductSyncDto` отдаёт:
- `packaging` (1=штучный, 2=весовой, 3=разливной) и **`isWeighed`** (явный bool `packaging==2`),
- `unitCode` — единица измерения (шт/кг/л…).
- `unitCode` — код ОКЕИ единицы измерения («796»=шт, «166»=кг, «112»=л…),
- **`unitShort`** — короткое имя для отображения («шт»/«кг»/«л»), выведено из `unitCode`/названия,
чтобы касса/моб. не держали свою таблицу ОКЕИ. То же поле есть в строках возврата
(`PosReturnLineDto.unitShort`). Маппинг — `foodmarket.Shared.Units.UnitShort`.
## 5. Весовые этикетки (внутримагазинные EAN-13)
Как в МойСкладе: внутримагазинные EAN-13 с префиксом из диапазона 20–29, где зашиты
«код товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена).
## 5. Поиск по штрихкоду и весовые этикетки
**Реальный сценарий (09.10): весов с печатью этикеток нет.** Штрихкоды весовых товаров —
обычные коды в карточке (у владельца короткие «27xxxxx»). Поэтому:
**Поиск** — `GET /api/catalog/products/by-barcode/{value}`:
- **Сначала точное совпадение штрихкода** карточки (включая короткие «27xxxxx»):
1 → объект товара, несколько → `{ items:[…] }`.
- **Поиск по штрихкоду НИКОГДА не матчит по артикулу** (раньше весовой PLU матчился
по артикулу — товар с артикулом «12345» ошибочно находился; исправлено).
- Разбор весовой этикетки — **только если включён в организации И точного штрихкода нет**.
Тогда встроенный PLU ищется как ШТРИХКОД (не артикул) → `{ product:{…}, weighted:true,
valueKind:"WeightKg"|"Price", quantity:1.5, embeddedPrice:null }`.
- Нет совпадений → `404`.
**Настройка организации** — `GET/PUT /api/organization/settings` (PUT — право Admin):
- `weightBarcodeEnabled` (bool, по умолчанию true),
- `weightBarcodeWeightPrefixes` (CSV префиксов веса, null → `20,21,22,23,24`),
- `weightBarcodePricePrefixes` (CSV префиксов цены, null → `25,26,27,28,29`),
- `weightBarcodeCodeDigits` (длина PLU, по умолчанию 5).
Блок применяется, только если в PUT передан `weightBarcodeEnabled` (иначе настройки не трогаются).
**Разбор при поиске** — `GET /api/catalog/products/by-barcode/{value}`:
- Обычный штрихкод → товар (или `{ items:[…] }` при нескольких совпадениях), `404` если нет.
- **Весовая этикетка** (префикс весовой/ценовой) → `{ product:{…}, weighted:true,
valueKind:"WeightKg"|"Price", quantity:1.5, embeddedPrice:null }`. Товар ищется по
встроенному PLU (как короткий штрихкод товара либо как артикул); `quantity` — вес (кг),
`embeddedPrice` — зашитая цена (для ценовых этикеток).
Пример: этикетка `2112345015007` при дефолтных настройках → PLU `12345`, вес `1.500` кг.
- `weightBarcodeEnabled` (bool, **по умолчанию FALSE**) — разбор этикеток;
- `weightBarcodeWeightPrefixes` / `weightBarcodePricePrefixes` (CSV, null → `20..24`/`25..29`);
- `weightBarcodeCodeDigits` (длина PLU, по умолчанию 5);
- **`weightedCodeFormat`** («short»|«ean13», default «short») — формат кода, который
генерируется для нового ВЕСОВОГО товара; **`weightedCodePrefix`** (default 27),
**`weightedCodeDigits`** (default 5) → код вида «27xxxxx», следующий свободный номер.
Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`.

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

@ -0,0 +1,42 @@
# Весовой товар и поиск по штрихкоду — прогресс
Реальный сценарий владельца (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. [x] Разбор весовых этикеток ВЫКЛ по умолчанию (`WeightedBarcodeSettings.Enabled=false`,
`Organization.WeightBarcodeEnabled=false` + Phase40 выключает у существующих). Поиск по ШК
(`ByBarcode`) — сначала точное совпадение (вкл. «27xxxxx»); матч по артикулу убран везде.
2. [x] Настройка орг «Весы с печатью этикеток»: выкл по умолчанию; при вкл — префиксы +
формат; **сначала точный ШК, потом разбор**. `OrganizationSettingsController`.
3. [x] Касса: весовой товар → вес с весов (Масса-К/CAS) или ручной ввод (модалка); сумма =
цена/кг × вес; подпись «1,235 кг × 690 ₸/кг». `ScaleMonitor` + Scan/AddWeighed flow.
4. [x] Генератор ШК: штучный — EAN-13; весовой — короткий «27+N» (next-free) или EAN-13 по
`weightedCodeFormat`; уникальность. `GenerateUniqueWeightedShortCodeAsync`.
5. [x] `/api/pos/v1/sync`: + `unitShort` («шт»/«кг»/«л») рядом с `unitCode` (ОКЕИ); в строках
возврата тоже; shared `UnitShort`; docs/mobile-api.md обновлён.
6. [~] Тесты: unit 321/321 ✓ (WeightedBarcode/UnitShort/ScaleMonitor/PosBarcode); integration
MobileApi 9/9 ✓. full-chain + деплой — ниже.
### Автопоиск весов (данные sysadmin: МойСклад-касса спамит «Порт не выбран»/«Таймаут»)
- [x] Автоскан всех COM (вкл. USB-COM CH340/Prolific) × Масса-К/CAS × скорости; кнопка
«Найти весы» в верхней панели; найденный порт запоминается (kv) и восстанавливается.
`ScaleDiscovery` + `AppServices.DiscoverScaleAsync/RestoreScaleFromSettings`.
- [x] Весы не отвечают → «Весы не подключены» + ручной ввод; опрос только при весовом товаре.
- [x] В логе — одна запись о потере/восстановлении связи (тест: 100 опросов → 1 запись).
## Что НЕ трогаем
Прод, 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);
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
// Штрихкод обязателен у любого товара: нет — генерируем внутренний EAN-13.
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, input.Packaging == Packaging.Weight, ct);
var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
@ -350,7 +350,7 @@ public async Task<ActionResult<ProductDto>> QuickCreate([FromBody] QuickCreateIn
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
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) };
var conflict = await FindBarcodeConflictAsync(barcodeInputs.Select(b => b.Code), null, ct);
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 = "В организации не настроены группы товаров." });
var unitId = await ResolveUnitIdAsync(input.UnitOfMeasureId, ct);
if (unitId is null) return BadRequest(new { error = "В организации не настроены единицы измерения." });
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, ct);
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, input.Packaging == Packaging.Weight, ct);
var conflict = await FindBarcodeConflictAsync(barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
@ -1182,16 +1182,36 @@ public record ByBarcodeResult(IReadOnlyList<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;
// 1) Точное совпадение со штрихкодом карточки (включая короткие «27xxxxx»).
// Поиск по штрихкоду НИКОГДА не матчит по артикулу.
var exact = await FindByExactBarcodeAsync(v, storeId, ct);
if (exact.Count == 1) return exact[0];
if (exact.Count > 1) return new ByBarcodeResult(exact);
// 2) Весовая этикетка — только если разбор включён в организации И точного
// штрихкода не нашлось. Встроенный код ищем как ШТРИХКОД (не артикул).
var settings = await GetWeightedSettingsAsync(ct);
if (settings.Enabled)
{
var parsed = WeightedBarcode.Parse(v, settings);
if (parsed.IsWeighted)
{
var byPlu = await FindByExactBarcodeAsync(parsed.Lookup, storeId, ct);
if (byPlu.Count >= 1)
return new WeightedBarcodeHit(
byPlu[0], true, parsed.ValueKind.ToString(),
parsed.WeightKg ?? 1m, parsed.Price);
}
}
return NotFound();
}
/// <summary>Товары с ТОЧНО таким штрихкодом (по артикулу не ищем). С остатком
/// на складе (или суммой по складам, если storeId не задан).</summary>
private async Task<List<QuickSearchItem>> FindByExactBarcodeAsync(string code, Guid? storeId, CancellationToken ct)
{
var matches = await _db.Products.AsNoTracking()
.Where(p => p.Barcodes.Any(b => b.Code == lookup)
|| (parsed.IsWeighted && p.Article == lookup))
.Where(p => p.Barcodes.Any(b => b.Code == code))
.Select(p => new
{
p.Id, p.Name, p.Article, p.ReferencePrice,
@ -1202,20 +1222,10 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
})
.ToListAsync(ct);
if (matches.Count == 0) return NotFound();
var items = matches.Select(r => new QuickSearchItem(
return matches.Select(r => new QuickSearchItem(
r.Id, r.Name, r.Article,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? code,
r.ReferencePrice, r.StockQty)).ToList();
// Весовая этикетка → товар + количество (вес) / встроенная цена.
if (parsed.IsWeighted)
return new WeightedBarcodeHit(
items[0], true, parsed.ValueKind.ToString(),
parsed.WeightKg ?? 1m, parsed.Price);
if (items.Count == 1) return items[0];
return new ByBarcodeResult(items);
}
public record WeightedBarcodeHit(
@ -1247,7 +1257,7 @@ public record WeightedBarcodeHit(
/// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец:
/// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary>
private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync(
IReadOnlyList<ProductBarcodeInput>? provided, CancellationToken ct)
IReadOnlyList<ProductBarcodeInput>? provided, bool weighted, CancellationToken ct)
{
var list = (provided ?? [])
.Where(b => !string.IsNullOrWhiteSpace(b.Code))
@ -1255,9 +1265,20 @@ public record WeightedBarcodeHit(
.ToList();
if (list.Count == 0)
{
var settings = await GetWeightedSettingsAsync(ct);
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
// Весовой товар + формат «short» → короткий код «<префикс> + N цифр»
// (у владельца 27xxxxx). Иначе — внутренний EAN-13.
var wc = await GetWeightedCodeSettingsAsync(ct);
if (weighted && wc.Format == "short")
{
var code = await GenerateUniqueWeightedShortCodeAsync(wc.Prefix, wc.Digits, ct);
list.Add(new ProductBarcodeInput(code, BarcodeType.Code128, true));
}
else
{
var settings = await GetWeightedSettingsAsync(ct);
var code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
}
}
else if (!list.Any(b => b.IsPrimary))
{
@ -1266,6 +1287,53 @@ public record WeightedBarcodeHit(
return list;
}
private async Task<(string Format, int Prefix, int Digits)> GetWeightedCodeSettingsAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return ("short", 27, 5);
var o = await _db.Organizations.Where(x => x.Id == orgId)
.Select(x => new { x.WeightedCodeFormat, x.WeightedCodePrefix, x.WeightedCodeDigits })
.FirstOrDefaultAsync(ct);
if (o is null) return ("short", 27, 5);
var fmt = string.Equals(o.WeightedCodeFormat, "ean13", StringComparison.OrdinalIgnoreCase) ? "ean13" : "short";
var prefix = o.WeightedCodePrefix is >= 0 and <= 99 ? o.WeightedCodePrefix : 27;
var digits = o.WeightedCodeDigits is >= 3 and <= 10 ? o.WeightedCodeDigits : 5;
return (fmt, prefix, digits);
}
/// <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>
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)

View file

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

View file

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

View file

@ -104,8 +104,10 @@ public class Organization : Entity
// ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ─────
/// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков).
/// Default true.</summary>
public bool WeightBarcodeEnabled { get; set; } = true;
/// Default FALSE (у владельца весов с печатью этикеток нет; штрихкоды весовых —
/// обычные коды в карточке). Включается явно; разбор применяется только после
/// проверки точного совпадения штрихкода.</summary>
public bool WeightBarcodeEnabled { get; set; } = false;
/// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр.
/// «20,21,22,23,24». null → дефолт 20–24.</summary>
@ -118,6 +120,19 @@ public class Organization : Entity
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
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) ────────
/// <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

@ -49,6 +49,8 @@ private void InitSchema()
// IF NOT EXISTS для ADD COLUMN, поэтому игнорируем ошибку «duplicate column»).
TryExec("ALTER TABLE products ADD COLUMN IsFavorite INTEGER NOT NULL DEFAULT 0");
TryExec("ALTER TABLE products ADD COLUMN FavoriteOrder INTEGER NOT NULL DEFAULT 0");
// Короткое имя единицы («шт»/«кг»/«л») для отображения; раньше показывали код ОКЕИ.
TryExec("ALTER TABLE products ADD COLUMN UnitShort TEXT NOT NULL DEFAULT 'шт'");
}
private void TryExec(string sql)
@ -68,11 +70,11 @@ public void ApplySync(PosSyncResponse sync)
// Подготовленные команды с переиспользуемыми параметрами — массовая
// вставка 30k товаров за ~1с вместо десятков секунд (команда на строку).
var up = _cn.CreateCommand(); up.Transaction = tx;
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder)
VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,UnitShort,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt,IsFavorite,FavoriteOrder)
VALUES($id,$n,$nl,$a,$u,$us,$pk,$vp,$ve,$pr,$ar,$up,$fav,$favo)
ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,UnitShort=$us,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve,
Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up, IsFavorite=$fav, FavoriteOrder=$favo;";
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$us", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
up.Prepare();
var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id";
var delP = AddParams(delBc, "$id"); delBc.Prepare();
@ -85,7 +87,8 @@ public void ApplySync(PosSyncResponse sync)
var hasPrice = priceByProduct.ContainsKey(p.Id);
var idStr = p.Id.ToString();
upP["$id"].Value = idStr; upP["$n"].Value = p.Name; upP["$nl"].Value = p.Name.ToLowerInvariant();
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode; upP["$pk"].Value = p.Packaging;
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode;
upP["$us"].Value = string.IsNullOrWhiteSpace(p.UnitShort) ? "шт" : p.UnitShort; upP["$pk"].Value = p.Packaging;
upP["$vp"].Value = p.VatPercent.ToString(System.Globalization.CultureInfo.InvariantCulture);
upP["$ve"].Value = p.VatEnabled ? 1 : 0;
upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture);
@ -301,6 +304,15 @@ public void SetKv(string key, string value)
public DateTime? LastSync() => DateTime.TryParse(GetKv("last_sync"), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : null;
// ── helpers ────────────────────────────────────────────────────────────────
/// <summary>UnitShort из строки; у старых БД/запросов колонки может не быть → «шт».</summary>
private static string ReadUnitShort(SqliteDataReader r)
{
for (int i = 0; i < r.FieldCount; i++)
if (string.Equals(r.GetName(i), "UnitShort", StringComparison.OrdinalIgnoreCase))
return r.IsDBNull(i) ? "шт" : r.GetString(i);
return "шт";
}
private static PosProduct Map(SqliteDataReader r)
{
var id = Guid.Parse(r.GetString(r.GetOrdinal("Id")));
@ -310,6 +322,7 @@ private static PosProduct Map(SqliteDataReader r)
Name = r.GetString(r.GetOrdinal("Name")),
Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")),
UnitCode = r.GetString(r.GetOrdinal("UnitCode")),
UnitShort = ReadUnitShort(r),
Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")),
VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture),
VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1,

View file

@ -0,0 +1,70 @@
namespace foodmarket.Pos.Core.Hardware;
/// <summary>Надстройка над «читателем веса» (делегат, за которым реальные весы по COM
/// или ручной ввод). Обеспечивает:
/// • опрос ТОЛЬКО по требованию (при весовом товаре), а не постоянный поллинг;
/// • мягкую деградацию: нет связи → возвращаем null, кассир вводит вес вручную;
/// • запись о потере/восстановлении связи РОВНО ОДИН раз (а не сотни «Таймаут» в лог,
/// как в кассе МойСклад у владельца).
///
/// Не зависит от System.IO.Ports — реальный COM-читатель внедряется делегатом из POS,
/// поэтому логику можно покрыть юнит-тестами.</summary>
public sealed class ScaleMonitor
{
private Func<CancellationToken, Task<decimal>>? _reader;
private readonly Action<string>? _log;
private bool _lostLogged;
public ScaleMonitor(Action<string>? log = null) => _log = log;
/// <summary>Есть ли настроенный источник веса (весы выбраны/найдены).</summary>
public bool HasReader => _reader is not null;
/// <summary>Считаем ли мы весы «на связи» (последний опрос успешен).</summary>
public bool IsConnected { get; private set; }
/// <summary>Человекочитаемое имя текущего источника (для статуса на кассе).</summary>
public string StatusText => _reader is null
? "Весы не подключены"
: IsConnected ? "Весы на связи" : "Весы не отвечают";
/// <summary>Назначить источник веса (после автопоиска/выбора порта). null — сброс
/// (весов нет → ручной ввод).</summary>
public void SetReader(Func<CancellationToken, Task<decimal>>? reader, string? name = null)
{
_reader = reader;
_lostLogged = false;
IsConnected = false;
if (reader is not null) _log?.Invoke($"Весы: источник назначен{(name is null ? "" : " — " + name)}");
else _log?.Invoke("Весы: источник снят (ручной ввод веса)");
}
/// <summary>Снять вес. Нет источника или нет связи → null (кассир вводит вручную).
/// Логируем потерю связи один раз; восстановление — тоже один раз.</summary>
public async Task<decimal?> TryReadWeightAsync(CancellationToken ct = default)
{
var reader = _reader;
if (reader is null) return null;
try
{
var kg = await reader(ct).ConfigureAwait(false);
if (!IsConnected)
{
IsConnected = true;
if (_lostLogged) _log?.Invoke("Весы: связь восстановлена");
_lostLogged = false;
}
return kg < 0 ? 0m : kg;
}
catch (Exception ex)
{
IsConnected = false;
if (!_lostLogged)
{
_lostLogged = true;
_log?.Invoke($"Весы: нет связи — перехожу на ручной ввод веса ({ex.GetType().Name})");
}
return null;
}
}
}

View file

@ -23,7 +23,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty));
var wl = new CartLine
{
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = true,
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, UnitShort = p.UnitShort, IsWeighed = true,
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
@ -39,7 +39,7 @@ public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverrid
}
var line = new CartLine
{
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = false,
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, UnitShort = p.UnitShort, IsWeighed = false,
UnitPrice = priceOverride ?? p.Price, Quantity = qty,
VatPercent = p.VatEnabled ? p.VatPercent : 0m,
};
@ -89,7 +89,7 @@ public void Restore(CartSnapshot snapshot)
private static CartLine Clone(CartLine l) => new()
{
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, IsWeighed = l.IsWeighed,
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, UnitShort = l.UnitShort, IsWeighed = l.IsWeighed,
UnitPrice = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, VatPercent = l.VatPercent,
};
}

View file

@ -16,6 +16,8 @@ public sealed class PosProduct
public string? Article { get; init; }
public required IReadOnlyList<string> Barcodes { get; init; }
public required string UnitCode { get; init; }
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»). По умолчанию «шт».</summary>
public string UnitShort { get; init; } = "шт";
public required PosPackaging Packaging { get; init; }
public required decimal VatPercent { get; init; }
public required bool VatEnabled { get; init; }
@ -24,7 +26,7 @@ public sealed class PosProduct
public bool IsFavorite { get; init; }
public bool IsWeighed => Packaging == PosPackaging.Weight;
/// <summary>Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного.</summary>
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸";
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitShort}" : $"{Price:N0} ₸";
}
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
@ -34,6 +36,8 @@ public sealed class CartLine
public required Guid ProductId { get; init; }
public required string Name { get; init; }
public required string UnitCode { get; init; }
/// <summary>Короткое имя единицы для отображения («шт»/«кг»/«л»).</summary>
public string UnitShort { get; init; } = "шт";
public required bool IsWeighed { get; init; }
public required decimal UnitPrice { get; set; }
public decimal Quantity { get; set; } = 1m;
@ -42,6 +46,11 @@ public sealed class CartLine
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
/// <summary>Подпись количества для весового: «1,235 кг × 690 ₸/кг».</summary>
public string WeighedQtyText => IsWeighed
? $"{Quantity.ToString("0.###", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))} {UnitShort} × {UnitPrice:N0} ₸/{UnitShort}"
: $"×{Quantity:0}";
}
/// <summary>Состояние открытой смены (локальная проекция серверной CashShift).</summary>

View file

@ -69,9 +69,13 @@ public enum Proto { Cas, MassaK }
public ComScale(string port, int baud, Proto proto) { _port = port; _baud = baud; _proto = proto; }
public string Name => $"Весы {_proto} ({_port})";
public string Port => _port;
public int Baud => _baud;
public Proto Protocol => _proto;
public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
{
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1500, NewLine = "\r" };
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1200, WriteTimeout = 800, NewLine = "\r" };
sp.Open();
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
await Task.Delay(120, ct);
@ -80,3 +84,52 @@ public async Task<decimal> ReadWeightKgAsync(CancellationToken ct = default)
return kg ?? 0m;
}
}
/// <summary>Автопоиск весов по всем COM-портам (вкл. USB-COM — CH340/Prolific
/// определяются системой как обычные COMx) с протоколами Масса-К и CAS. Нужен, потому
/// что в кассе МойСклад у владельца «Порт не выбран»/«Таймаут» сотни раз в день —
/// мы сами находим порт и запоминаем его, без ручной возни.</summary>
public static class ScaleDiscovery
{
public sealed record Found(string Port, int Baud, ComScale.Proto Proto, decimal SampleKg);
// Типовые скорости весов розницы.
private static readonly int[] Bauds = { 9600, 4800, 19200, 115200 };
public static string[] ListPorts()
{
try { return SerialPort.GetPortNames().Distinct().OrderBy(x => x).ToArray(); }
catch { return Array.Empty<string>(); }
}
/// <summary>Перебрать порты × протоколы × скорости, вернуть первый ответивший
/// правдоподобным весом (&gt;= 0). Короткие таймауты, чтобы не висеть.</summary>
public static async Task<Found?> DiscoverAsync(Action<string>? log = null, CancellationToken ct = default)
{
foreach (var port in ListPorts())
{
foreach (var proto in new[] { ComScale.Proto.MassaK, ComScale.Proto.Cas })
{
foreach (var baud in Bauds)
{
ct.ThrowIfCancellationRequested();
try
{
var scale = new ComScale(port, baud, proto);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(1500);
var kg = await scale.ReadWeightKgAsync(cts.Token).ConfigureAwait(false);
if (kg >= 0m)
{
log?.Invoke($"Весы найдены: {port} {baud} {proto} (вес {kg:0.###} кг)");
return new Found(port, baud, proto, kg);
}
}
catch { /* порт занят/не те весы/нет ответа — пробуем следующую комбинацию */ }
}
}
}
log?.Invoke("Весы: автопоиск не нашёл весов ни на одном порту");
return null;
}
}

View file

@ -2,6 +2,7 @@
using System.Net.Http.Headers;
using foodmarket.Pos.Core.Auth;
using foodmarket.Pos.Core.Data;
using foodmarket.Pos.Core.Hardware;
using foodmarket.Pos.Core.Sync;
using foodmarket.Pos.Hardware;
using Refit;
@ -25,6 +26,15 @@ public sealed class AppServices
public IScale Scale { get; set; } = new ManualScale();
public IFiscalProvider Fiscal { get; } = new NullFiscalProvider();
/// <summary>Монитор весов: опрос по требованию, мягкая деградация на ручной ввод,
/// запись о потере связи один раз. Источник (COM-порт) — из автопоиска/настроек.</summary>
public ScaleMonitor Scales { get; } = new ScaleMonitor(AppendScaleLog);
/// <summary>Настройки разбора весовых этикеток организации (по умолчанию ВЫКЛ).
/// Касса использует точный штрихкод; этикетки разбирает только если включено.</summary>
public foodmarket.Shared.Barcodes.WeightedBarcodeSettings WeightedSettings { get; set; }
= foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default;
private CheckoutService? _checkout;
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
public CheckoutService Checkout => _checkout ??= new CheckoutService(this);
@ -42,6 +52,56 @@ public AppServices()
: new FileTokenStore(FileTokenStore.DefaultPath());
Session = Tokens.Load();
if (Session is not null) ConfigureApi(Session);
RestoreScaleFromSettings();
}
// ── Весы ───────────────────────────────────────────────────────────────
private static readonly string ScaleLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos", "scale.log");
private static void AppendScaleLog(string msg)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ScaleLogPath)!);
File.AppendAllText(ScaleLogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}{Environment.NewLine}");
}
catch { /* логирование весов не должно мешать кассе */ }
}
/// <summary>Поднять сохранённый порт весов (если был выбран ранее).</summary>
public void RestoreScaleFromSettings()
{
try
{
var port = Store.GetKv("scale_port");
if (string.IsNullOrWhiteSpace(port)) return;
var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 9600;
var proto = Store.GetKv("scale_proto") == "Cas" ? ComScale.Proto.Cas : ComScale.Proto.MassaK;
SetScale(port, baud, proto);
}
catch { /* нет сохранённого порта — работаем с ручным вводом */ }
}
/// <summary>Назначить весы на конкретный порт и запомнить выбор.</summary>
public void SetScale(string port, int baud, ComScale.Proto proto)
{
var scale = new ComScale(port, baud, proto);
Scale = scale;
Scales.SetReader(ct => scale.ReadWeightKgAsync(ct), scale.Name);
Store.SetKv("scale_port", port);
Store.SetKv("scale_baud", baud.ToString());
Store.SetKv("scale_proto", proto.ToString());
}
/// <summary>Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
/// нашлись ли весы.</summary>
public async Task<bool> DiscoverScaleAsync(CancellationToken ct = default)
{
var found = await ScaleDiscovery.DiscoverAsync(AppendScaleLog, ct);
if (found is null) return false;
SetScale(found.Port, found.Baud, found.Proto);
return true;
}
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо

View file

@ -122,6 +122,7 @@ private void StartSession()
// локального SQLite, без сети.
CatalogEmpty = _svc.Store.ProductCount() == 0;
LastSyncAt = _svc.Store.LastSync();
RefreshScaleBadge();
_ = ResolvePointNameAsync();
_sale = new SaleViewModel(_svc)
{
@ -185,6 +186,27 @@ private static string LocalVersion()
private void RefreshStatus() { if (_svc is not null) { QueueCount = _svc.Store.PendingCount(); RefreshNegatives(); } }
// Весы: статус в верхней панели + «Найти весы» (автоскан COM-портов).
[ObservableProperty] private string _scaleBadge = "Весы: не подключены";
[ObservableProperty] private bool _scaleBusy;
private void RefreshScaleBadge() => ScaleBadge = _svc?.Scales.StatusText ?? "Весы: не подключены";
[RelayCommand]
private async Task DiscoverScales()
{
if (_svc is null || ScaleBusy) return;
ScaleBusy = true;
ScaleBadge = "Весы: ищу…";
try
{
var ok = await _svc.DiscoverScaleAsync();
ScaleBadge = ok ? "Весы: на связи" : "Весы не найдены";
}
catch { ScaleBadge = "Весы: ошибка поиска"; }
finally { ScaleBusy = false; }
}
[RelayCommand] private void ShowSale() { CurrentPage = _sale ??= (_svc is null ? SaleViewModel.Sample() : new SaleViewModel(_svc) { PaymentRequested = OpenPayment, SaleCommitted = _ => RefreshStatus() }); }
[RelayCommand] private void ShowShift() => CurrentPage = _svc is null ? ShiftViewModel.Sample() : new ShiftViewModel(_svc) { OnShiftChanged = s => { ShiftNumber = s ?? "смена не открыта"; IsShiftOpen = s is not null; } };
[RelayCommand] private void ShowReturn() => CurrentPage = _svc is null ? ReturnViewModel.Sample() : new ReturnViewModel(_svc);
@ -459,6 +481,7 @@ public partial class SaleLineVm : ObservableObject
{
internal CartLine? Model;
public required Guid ProductId { get; init; }
public bool IsWeighed { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _unit = "шт";
[ObservableProperty] private decimal _price;
@ -536,6 +559,14 @@ public partial class SaleViewModel : ViewModelBase
[ObservableProperty] private decimal? _quickCreatePrice;
[ObservableProperty] private bool _quickCreateBusy;
// Ручной ввод веса для весового товара (когда весов нет/не ответили).
[ObservableProperty] private bool _isWeightEntryOpen;
[ObservableProperty] private decimal? _weightEntryValue;
[ObservableProperty] private string _weightEntryPriceText = "";
[ObservableProperty] private string _weightEntryStatus = "";
private PosProduct? WeightEntryProduct { get; set; }
public string WeightEntryProductName => WeightEntryProduct?.Name ?? "";
/// <summary>Поднять модальную оплату (обрабатывает оболочка).</summary>
public Action<PaymentViewModel>? PaymentRequested;
/// <summary>Чек проведён — оболочка обновляет индикатор очереди.</summary>
@ -557,7 +588,7 @@ private void LoadFavorites()
OnPropertyChanged(nameof(HasFavorites));
}
[RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m);
[RelayCommand] private async Task AddFavorite(PosProduct p) { if (p.IsWeighed) await AddWeighedAsync(p); else AddProduct(p, 1m); }
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
private void Recalc()
@ -573,15 +604,54 @@ public void AddProduct(PosProduct p, decimal qty, decimal? priceOverride = null)
if (vm is not null) { vm.Quantity = line.Quantity; }
else
{
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitCode, Price = line.UnitPrice, Quantity = line.Quantity, Model = line };
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitShort, Price = line.UnitPrice, Quantity = line.Quantity, IsWeighed = line.IsWeighed, Model = line };
Lines.Insert(0, vm);
}
SelectedLine = vm;
UpdateStockWarning(vm);
Recalc();
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + line.Quantity.ToString("0"))}";
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitShort + " × " + line.UnitPrice.ToString("N0") + " ₸/" + p.UnitShort : "×" + line.Quantity.ToString("0"))}";
}
/// <summary>Добавить ВЕСОВОЙ товар: берём вес с весов (если на связи), иначе
/// открываем ручной ввод веса. Сумма = цена/кг × вес.</summary>
public async Task AddWeighedAsync(PosProduct p)
{
decimal? kg = _svc is not null ? await _svc.Scales.TryReadWeightAsync() : null;
if (kg is { } w && w > 0m) { AddProduct(p, w); return; }
// Весов нет/не ответили/нулевой вес → ручной ввод.
WeightEntryProduct = p;
OnPropertyChanged(nameof(WeightEntryProductName));
WeightEntryValue = kg is { } z && z > 0 ? z : null;
WeightEntryPriceText = $"{p.Price:N0} ₸/{p.UnitShort}";
WeightEntryStatus = _svc?.Scales.StatusText ?? "Весы не подключены";
IsWeightEntryOpen = true;
}
[RelayCommand]
private async Task WeightEntryReadScale()
{
if (_svc is null) return;
var kg = await _svc.Scales.TryReadWeightAsync();
WeightEntryStatus = _svc.Scales.StatusText;
if (kg is { } w && w > 0m) WeightEntryValue = w;
}
[RelayCommand]
private void WeightEntryConfirm()
{
if (WeightEntryProduct is { } p && WeightEntryValue is { } w && w > 0m)
{
AddProduct(p, w);
IsWeightEntryOpen = false; WeightEntryProduct = null; WeightEntryValue = null;
}
else Message = "Укажите вес больше нуля";
}
[RelayCommand]
private void WeightEntryCancel()
{ IsWeightEntryOpen = false; WeightEntryProduct = null; WeightEntryValue = null; }
/// <summary>Предупреждение «уйдёт в минус» для строки: сравниваем количество в
/// чеке с локальным остатком (снимок последнего синка, работает офлайн).
/// Продажу НЕ блокируем (решение владельца) — только подсвечиваем.</summary>
@ -599,13 +669,16 @@ private void UpdateStockWarning(SaleLineVm vm)
}
[RelayCommand]
private void Scan()
private async Task Scan()
{
var raw = ScanInput.Trim();
ScanInput = "";
if (raw.Length == 0) return;
var parsed = BarcodeParser.Parse(raw);
var p = _svc?.Store.FindByBarcode(parsed.Lookup);
// Разбор этикеток по настройкам организации (по умолчанию ВЫКЛ → точный код).
var parsed = BarcodeParser.Parse(raw, _svc?.WeightedSettings);
// Сначала — точное совпадение штрихкода; этикеточный PLU как запасной путь.
var p = _svc?.Store.FindByBarcode(raw.Trim())
?? _svc?.Store.FindByBarcode(parsed.Lookup);
if (p is null)
{
// Неизвестный штрихкод: онлайн — предлагаем быстро создать товар
@ -621,6 +694,12 @@ private void Scan()
else Message = $"Штрихкод {parsed.Lookup} не найден";
return;
}
// Весовой товар без веса из этикетки → берём вес с весов/вручную.
if (p.IsWeighed && !(parsed.IsWeighted && parsed.EmbeddedWeight is > 0m))
{
await AddWeighedAsync(p);
return;
}
var qty = parsed.IsWeighted && parsed.EmbeddedWeight is { } w ? w : 1m;
var price = parsed.IsWeighted && parsed.EmbeddedPrice is { } pr ? pr / Math.Max(0.001m, qty) : (decimal?)null;
AddProduct(p, qty, price);
@ -786,7 +865,7 @@ private void RebuildLines()
{
Lines.Clear();
foreach (var l in _cart.Lines)
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitCode, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, Model = l });
Lines.Insert(0, new SaleLineVm { ProductId = l.ProductId, Name = l.Name, Unit = l.UnitShort, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, IsWeighed = l.IsWeighed, Model = l });
SelectedLine = Lines.FirstOrDefault();
Recalc();
}
@ -809,7 +888,7 @@ public static SaleViewModel Sample()
vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47;
vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные»)
PosProduct Fav(string n, string unit, decimal price, bool weighed) => new()
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
{ Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty<string>(), UnitCode = unit, UnitShort = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true };
foreach (var f in new[] {
Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false),
Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true),

View file

@ -73,6 +73,15 @@
<TextBlock Text="{Binding SyncBadge}" Foreground="{DynamicResource PosTextMuted}"/>
</StackPanel>
</Border>
<!-- Весы: статус + «Найти весы» (автоскан COM-портов). Клик — поиск. -->
<Button Padding="12,6" CornerRadius="999" BorderThickness="0" Background="{DynamicResource PosCard}"
Command="{Binding DiscoverScalesCommand}" IsEnabled="{Binding !ScaleBusy}"
ToolTip.Tip="Найти весы на COM-портах">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="⚖" VerticalAlignment="Center"/>
<TextBlock Text="{Binding ScaleBadge}" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>

View file

@ -237,5 +237,27 @@
</StackPanel>
</Border>
</Border>
<!-- Ручной ввод веса для весового товара (весов нет/не ответили) -->
<Border Grid.ColumnSpan="2" Background="#99000000" IsVisible="{Binding IsWeightEntryOpen}">
<Border Width="440" Background="{DynamicResource PosCard}" CornerRadius="14" Padding="24"
VerticalAlignment="Center" HorizontalAlignment="Center" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1">
<StackPanel Spacing="12">
<TextBlock Text="Вес товара" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource PosText}"/>
<TextBlock Text="{Binding WeightEntryProductName}" Foreground="{DynamicResource PosText}" FontSize="16" FontWeight="Medium"/>
<TextBlock Text="{Binding WeightEntryPriceText}" Foreground="{DynamicResource PosTextMuted}"/>
<TextBox Watermark="Вес, кг (например 1,235)" FontSize="18" Text="{Binding WeightEntryValue}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding WeightEntryStatus}" Foreground="{DynamicResource PosTextMuted}" FontSize="13" VerticalAlignment="Center"/>
<Button Content="Считать с весов" Padding="12,8" Command="{Binding WeightEntryReadScaleCommand}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Right">
<Button Content="Отмена" Padding="18,10" Command="{Binding WeightEntryCancelCommand}"/>
<Button Content="Добавить" Padding="18,10" Background="{DynamicResource PosBrand}" Foreground="White"
Command="{Binding WeightEntryConfirmCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>

View file

@ -10,7 +10,10 @@ public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 }
/// Хранится скалярными колонками организации (csv-списки префиксов).</summary>
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> PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 };
public int CodeDigits { get; init; } = 5;

View file

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

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();
}
// ── 5. Весовые этикетки в поиске по штрихкоду ──────────────────────────
// ── 5. Поиск по штрихкоду: по умолчанию только точное совпадение ───────
[Fact]
public async Task ByBarcode_parses_weighted_label_returns_product_and_quantity()
public async Task ByBarcode_default_exact_only_no_weighted_no_article()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// PLU = 12345 хранится как короткий штрихкод товара.
// Короткий штрихкод «27xxxxx» в карточке (как у владельца).
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "2712345");
// Точное совпадение короткого кода → товар, НЕ весовой.
var exact = await api.Http.GetFromJsonAsync<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");
// Весовая этикетка: 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");
resp.GetProperty("weighted").GetBoolean().Should().BeTrue();
resp.GetProperty("valueKind").GetString().Should().Be("WeightKg");

View file

@ -19,11 +19,23 @@ public void Normal_barcode_is_not_weighted()
r.Lookup.Should().Be("4870204010101");
}
// По умолчанию разбор этикеток ВЫКЛ (решение владельца): явно включаем настройками.
private static readonly foodmarket.Shared.Barcodes.WeightedBarcodeSettings On =
foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default with { Enabled = true };
[Fact]
public void Default_disabled_treats_weighted_as_plain()
{
var r = BarcodeParser.Parse("2112345015007");
r.IsWeighted.Should().BeFalse("по умолчанию разбор этикеток выключен");
r.Lookup.Should().Be("2112345015007");
}
[Fact]
public void Weight_prefix_20_extracts_plu_and_kg()
{
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
var r = BarcodeParser.Parse("2112345015007");
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг (при включённом разборе)
var r = BarcodeParser.Parse("2112345015007", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("12345");
r.EmbeddedWeight.Should().Be(1.500m);
@ -33,8 +45,8 @@ public void Weight_prefix_20_extracts_plu_and_kg()
[Fact]
public void Price_prefix_25_extracts_plu_and_price()
{
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
var r = BarcodeParser.Parse("2554321123456");
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ (при включённом разборе)
var r = BarcodeParser.Parse("2554321123456", On);
r.IsWeighted.Should().BeTrue();
r.Lookup.Should().Be("54321");
r.EmbeddedPrice.Should().Be(123.45m);

View file

@ -0,0 +1,73 @@
using FluentAssertions;
using foodmarket.Pos.Core.Hardware;
using foodmarket.Pos.Core.Models;
using Xunit;
namespace foodmarket.UnitTests;
public class ScaleMonitorTests
{
[Fact]
public async Task No_reader_returns_null_and_disconnected()
{
var m = new ScaleMonitor();
(await m.TryReadWeightAsync()).Should().BeNull();
m.IsConnected.Should().BeFalse();
m.HasReader.Should().BeFalse();
m.StatusText.Should().Be("Весы не подключены");
}
[Fact]
public async Task Reader_weight_is_returned_and_connected()
{
var m = new ScaleMonitor();
m.SetReader(_ => Task.FromResult(1.234m));
(await m.TryReadWeightAsync()).Should().Be(1.234m);
m.IsConnected.Should().BeTrue();
m.StatusText.Should().Be("Весы на связи");
}
[Fact]
public async Task Loss_of_connection_is_logged_exactly_once()
{
var log = new List<string>();
var m = new ScaleMonitor(log.Add);
m.SetReader(_ => throw new InvalidOperationException("Порт не выбран"));
// Сто опросов подряд при оборванной связи — одна запись о потере (не сотни).
for (int i = 0; i < 100; i++) (await m.TryReadWeightAsync()).Should().BeNull();
log.Count(l => l.Contains("нет связи")).Should().Be(1, "потеря связи логируется один раз");
m.IsConnected.Should().BeFalse();
}
[Fact]
public async Task Recovery_after_loss_is_logged_once()
{
var log = new List<string>();
var m = new ScaleMonitor(log.Add);
var fail = true;
m.SetReader(_ => fail ? throw new TimeoutException() : Task.FromResult(0.5m));
await m.TryReadWeightAsync(); // потеря
await m.TryReadWeightAsync(); // ещё раз — не логируем повторно
fail = false;
(await m.TryReadWeightAsync()).Should().Be(0.5m); // восстановление
log.Count(l => l.Contains("нет связи")).Should().Be(1);
log.Count(l => l.Contains("восстановлена")).Should().Be(1);
m.IsConnected.Should().BeTrue();
}
[Fact]
public void Weighed_line_text_shows_kg_times_price_per_kg()
{
var line = new CartLine
{
ProductId = Guid.NewGuid(), Name = "Яблоки", UnitCode = "166", UnitShort = "кг",
IsWeighed = true, UnitPrice = 690m, Quantity = 1.235m,
};
line.WeighedQtyText.Should().Be("1,235 кг × 690 ₸/кг");
line.LineTotal.Should().Be(852.15m);
}
}

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();
}
// По умолчанию (решение владельца 09.10) разбор этикеток ВЫКЛЮЧЕН: без явно
// включённых настроек весоподобный код трактуется как обычный штрихкод.
private static readonly WeightedBarcodeSettings On = WeightedBarcodeSettings.Default with { Enabled = true };
[Fact]
public void Default_settings_are_disabled()
{
WeightedBarcodeSettings.Default.Enabled.Should().BeFalse();
var r = WeightedBarcode.Parse("2112345015007");
r.IsWeighted.Should().BeFalse("по умолчанию разбор этикеток выключен");
r.Lookup.Should().Be("2112345015007");
}
[Fact]
public void Weight_prefix_extracts_plu_and_kg()
{
// 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг
var r = WeightedBarcode.Parse("2112345015007");
// 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг (при включённом разборе)
var r = WeightedBarcode.Parse("2112345015007", On);
r.IsWeighted.Should().BeTrue();
r.ValueKind.Should().Be(WeightedValueKind.WeightKg);
r.Lookup.Should().Be("12345");
@ -32,8 +45,8 @@ public void Weight_prefix_extracts_plu_and_kg()
[Fact]
public void Price_prefix_extracts_plu_and_price()
{
// 25 | 54321 | 12345 | K → PLU 54321, цена 123.45
var r = WeightedBarcode.Parse("2554321123456");
// 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 (при включённом разборе)
var r = WeightedBarcode.Parse("2554321123456", On);
r.IsWeighted.Should().BeTrue();
r.ValueKind.Should().Be(WeightedValueKind.Price);
r.Lookup.Should().Be("54321");