Compare commits
No commits in common. "d465d8224864d4df3f73d94b4ef9f850f3749907" and "7ede21321b11a0ac8665a5706191ad1b983771c7" have entirely different histories.
d465d82248
...
7ede21321b
|
|
@ -57,35 +57,30 @@ advanced-only скрыты; обычная роль — её флаги (в пр
|
|||
`PromotionsManage`, `PriceTypesManage` (зеркалит серверный `[RequiresAdvancedMode]`).
|
||||
Имена флагов — из `RolePermissions`. Веб использует тот же источник (`useCan()`).
|
||||
|
||||
## 4. Весовой признак и единица в офлайн-каталоге
|
||||
## 4. Весовой признак в офлайн-каталоге
|
||||
`GET /api/pos/v1/sync` (каталог для сканирования/офлайна) в `ProductSyncDto` отдаёт:
|
||||
- `packaging` (1=штучный, 2=весовой, 3=разливной) и **`isWeighed`** (явный bool `packaging==2`),
|
||||
- `unitCode` — код ОКЕИ единицы измерения («796»=шт, «166»=кг, «112»=л…),
|
||||
- **`unitShort`** — короткое имя для отображения («шт»/«кг»/«л»), выведено из `unitCode`/названия,
|
||||
чтобы касса/моб. не держали свою таблицу ОКЕИ. То же поле есть в строках возврата
|
||||
(`PosReturnLineDto.unitShort`). Маппинг — `foodmarket.Shared.Units.UnitShort`.
|
||||
- `unitCode` — единица измерения (шт/кг/л…).
|
||||
|
||||
## 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`.
|
||||
## 5. Весовые этикетки (внутримагазинные EAN-13)
|
||||
Как в МойСкладе: внутримагазинные EAN-13 с префиксом из диапазона 20–29, где зашиты
|
||||
«код товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена).
|
||||
|
||||
**Настройка организации** — `GET/PUT /api/organization/settings` (PUT — право Admin):
|
||||
- `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», следующий свободный номер.
|
||||
- `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` кг.
|
||||
|
||||
Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
# Весовой товар и поиск по штрихкоду — прогресс
|
||||
|
||||
Реальный сценарий владельца (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.
|
||||
|
|
@ -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, input.Packaging == Packaging.Weight, ct);
|
||||
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, 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, input.Weighed, ct)
|
||||
? await EnsureBarcodeInputsAsync(null, 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, input.Packaging == Packaging.Weight, ct);
|
||||
var barcodes = await EnsureBarcodeInputsAsync(input.Barcodes, 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,36 +1182,16 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
|||
var v = (value ?? "").Trim();
|
||||
if (v.Length == 0) return NotFound();
|
||||
|
||||
// 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();
|
||||
}
|
||||
var parsed = WeightedBarcode.Parse(v, settings);
|
||||
// Для весовой — ищем товар по встроенному PLU (как штрихкод или артикул),
|
||||
// иначе по самому штрихкоду.
|
||||
var lookup = parsed.IsWeighted ? parsed.Lookup : v;
|
||||
|
||||
/// <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 == code))
|
||||
.Where(p => p.Barcodes.Any(b => b.Code == lookup)
|
||||
|| (parsed.IsWeighted && p.Article == lookup))
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id, p.Name, p.Article, p.ReferencePrice,
|
||||
|
|
@ -1222,10 +1202,20 @@ private async Task<List<QuickSearchItem>> FindByExactBarcodeAsync(string code, G
|
|||
.Select(st => (decimal?)st.Quantity).FirstOrDefault(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return matches.Select(r => new QuickSearchItem(
|
||||
if (matches.Count == 0) return NotFound();
|
||||
var items = matches.Select(r => new QuickSearchItem(
|
||||
r.Id, r.Name, r.Article,
|
||||
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? code,
|
||||
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup,
|
||||
r.ReferencePrice, r.StockQty)).ToList();
|
||||
|
||||
// Весовая этикетка → товар + количество (вес) / встроенная цена.
|
||||
if (parsed.IsWeighted)
|
||||
return new WeightedBarcodeHit(
|
||||
items[0], true, parsed.ValueKind.ToString(),
|
||||
parsed.WeightKg ?? 1m, parsed.Price);
|
||||
|
||||
if (items.Count == 1) return items[0];
|
||||
return new ByBarcodeResult(items);
|
||||
}
|
||||
|
||||
public record WeightedBarcodeHit(
|
||||
|
|
@ -1257,7 +1247,7 @@ public record WeightedBarcodeHit(
|
|||
/// если ни одного нет — сгенерированный уникальный внутренний EAN-13 (владелец:
|
||||
/// штрихкод обязателен у любого товара). Первый штрихкод помечается primary.</summary>
|
||||
private async Task<IReadOnlyList<ProductBarcodeInput>> EnsureBarcodeInputsAsync(
|
||||
IReadOnlyList<ProductBarcodeInput>? provided, bool weighted, CancellationToken ct)
|
||||
IReadOnlyList<ProductBarcodeInput>? provided, CancellationToken ct)
|
||||
{
|
||||
var list = (provided ?? [])
|
||||
.Where(b => !string.IsNullOrWhiteSpace(b.Code))
|
||||
|
|
@ -1265,20 +1255,9 @@ public record WeightedBarcodeHit(
|
|||
.ToList();
|
||||
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 code = await GenerateUniqueInternalBarcodeAsync(settings, ct);
|
||||
list.Add(new ProductBarcodeInput(code, BarcodeType.Ean13, true));
|
||||
}
|
||||
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))
|
||||
{
|
||||
|
|
@ -1287,53 +1266,6 @@ 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>Короткий код весового товара «<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>
|
||||
private async Task<string> GenerateUniqueInternalBarcodeAsync(WeightedBarcodeSettings settings, CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -44,10 +44,6 @@ public record OrgSettingsDto(
|
|||
string? WeightBarcodeWeightPrefixes,
|
||||
string? WeightBarcodePricePrefixes,
|
||||
int WeightBarcodeCodeDigits,
|
||||
// Формат кода, генерируемого для нового весового товара.
|
||||
string WeightedCodeFormat,
|
||||
int WeightedCodePrefix,
|
||||
int WeightedCodeDigits,
|
||||
// Уведомления об отрицательных остатках.
|
||||
bool NegStockNotifyEnabled,
|
||||
int NegStockNotifyIntervalMinutes,
|
||||
|
|
@ -76,10 +72,6 @@ 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,
|
||||
|
|
@ -133,12 +125,6 @@ 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;
|
||||
|
|
@ -188,9 +174,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -91,12 +91,9 @@ 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
|
||||
{
|
||||
|
|
@ -153,7 +150,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -104,10 +104,8 @@ public class Organization : Entity
|
|||
|
||||
// ─ Весовые этикетки (внутримагазинные EAN-13, как в МойСкладе) ─────
|
||||
/// <summary>Разбирать ли весовые этикетки (EAN-13 с префиксом из списков).
|
||||
/// Default FALSE (у владельца весов с печатью этикеток нет; штрихкоды весовых —
|
||||
/// обычные коды в карточке). Включается явно; разбор применяется только после
|
||||
/// проверки точного совпадения штрихкода.</summary>
|
||||
public bool WeightBarcodeEnabled { get; set; } = false;
|
||||
/// Default true.</summary>
|
||||
public bool WeightBarcodeEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>Префиксы (2 цифры) весовых этикеток, где зашит ВЕС. CSV, напр.
|
||||
/// «20,21,22,23,24». null → дефолт 20–24.</summary>
|
||||
|
|
@ -120,19 +118,6 @@ public class Organization : Entity
|
|||
/// <summary>Длина кода товара (PLU) внутри весовой этикетки. Default 5.</summary>
|
||||
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) ────────
|
||||
/// <summary>Слать ли уведомления «товары ушли в минус» (центр уведомлений,
|
||||
/// Telegram, push). Default true. Выключение — только администратор.</summary>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
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"";
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,8 +49,6 @@ 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)
|
||||
|
|
@ -70,11 +68,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,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,
|
||||
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,
|
||||
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", "$us", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up", "$fav", "$favo");
|
||||
var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$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();
|
||||
|
|
@ -87,8 +85,7 @@ 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["$us"].Value = string.IsNullOrWhiteSpace(p.UnitShort) ? "шт" : p.UnitShort; upP["$pk"].Value = p.Packaging;
|
||||
upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode; 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);
|
||||
|
|
@ -304,15 +301,6 @@ 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")));
|
||||
|
|
@ -322,7 +310,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, UnitShort = p.UnitShort, IsWeighed = true,
|
||||
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, 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, UnitShort = p.UnitShort, IsWeighed = false,
|
||||
ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, 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, UnitShort = l.UnitShort, IsWeighed = l.IsWeighed,
|
||||
ProductId = l.ProductId, Name = l.Name, UnitCode = l.UnitCode, IsWeighed = l.IsWeighed,
|
||||
UnitPrice = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, VatPercent = l.VatPercent,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ 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; }
|
||||
|
|
@ -26,7 +24,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} ₸/{UnitShort}" : $"{Price:N0} ₸";
|
||||
public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸";
|
||||
}
|
||||
|
||||
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
|
||||
|
|
@ -36,8 +34,6 @@ 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;
|
||||
|
|
@ -46,11 +42,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -69,13 +69,9 @@ 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 = 1200, WriteTimeout = 800, NewLine = "\r" };
|
||||
using var sp = new SerialPort(_port, _baud) { ReadTimeout = 1500, NewLine = "\r" };
|
||||
sp.Open();
|
||||
if (_proto == Proto.Cas) sp.Write(new byte[] { (byte)'W' }, 0, 1); // запрос веса (ряд CAS)
|
||||
await Task.Delay(120, ct);
|
||||
|
|
@ -84,52 +80,3 @@ 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>Перебрать порты × протоколы × скорости, вернуть первый ответивший
|
||||
/// правдоподобным весом (>= 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
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;
|
||||
|
|
@ -26,15 +25,6 @@ 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);
|
||||
|
|
@ -52,56 +42,6 @@ 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-тестов/демо
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ private void StartSession()
|
|||
// локального SQLite, без сети.
|
||||
CatalogEmpty = _svc.Store.ProductCount() == 0;
|
||||
LastSyncAt = _svc.Store.LastSync();
|
||||
RefreshScaleBadge();
|
||||
_ = ResolvePointNameAsync();
|
||||
_sale = new SaleViewModel(_svc)
|
||||
{
|
||||
|
|
@ -186,27 +185,6 @@ 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);
|
||||
|
|
@ -481,7 +459,6 @@ 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;
|
||||
|
|
@ -559,14 +536,6 @@ 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>
|
||||
|
|
@ -588,7 +557,7 @@ private void LoadFavorites()
|
|||
OnPropertyChanged(nameof(HasFavorites));
|
||||
}
|
||||
|
||||
[RelayCommand] private async Task AddFavorite(PosProduct p) { if (p.IsWeighed) await AddWeighedAsync(p); else AddProduct(p, 1m); }
|
||||
[RelayCommand] private void AddFavorite(PosProduct p) => AddProduct(p, 1m);
|
||||
[RelayCommand] private void ToggleFavorites() => FavoritesOpen = !FavoritesOpen;
|
||||
|
||||
private void Recalc()
|
||||
|
|
@ -604,54 +573,15 @@ 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.UnitShort, Price = line.UnitPrice, Quantity = line.Quantity, IsWeighed = line.IsWeighed, Model = line };
|
||||
vm = new SaleLineVm { ProductId = line.ProductId, Name = line.Name, Unit = line.UnitCode, Price = line.UnitPrice, Quantity = line.Quantity, Model = line };
|
||||
Lines.Insert(0, vm);
|
||||
}
|
||||
SelectedLine = vm;
|
||||
UpdateStockWarning(vm);
|
||||
Recalc();
|
||||
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitShort + " × " + line.UnitPrice.ToString("N0") + " ₸/" + p.UnitShort : "×" + line.Quantity.ToString("0"))}";
|
||||
Message = $"{p.Name} · {(p.IsWeighed ? qty.ToString("0.###") + " " + p.UnitCode : "×" + 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>
|
||||
|
|
@ -669,16 +599,13 @@ private void UpdateStockWarning(SaleLineVm vm)
|
|||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Scan()
|
||||
private void Scan()
|
||||
{
|
||||
var raw = ScanInput.Trim();
|
||||
ScanInput = "";
|
||||
if (raw.Length == 0) return;
|
||||
// Разбор этикеток по настройкам организации (по умолчанию ВЫКЛ → точный код).
|
||||
var parsed = BarcodeParser.Parse(raw, _svc?.WeightedSettings);
|
||||
// Сначала — точное совпадение штрихкода; этикеточный PLU как запасной путь.
|
||||
var p = _svc?.Store.FindByBarcode(raw.Trim())
|
||||
?? _svc?.Store.FindByBarcode(parsed.Lookup);
|
||||
var parsed = BarcodeParser.Parse(raw);
|
||||
var p = _svc?.Store.FindByBarcode(parsed.Lookup);
|
||||
if (p is null)
|
||||
{
|
||||
// Неизвестный штрихкод: онлайн — предлагаем быстро создать товар
|
||||
|
|
@ -694,12 +621,6 @@ private async Task 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);
|
||||
|
|
@ -865,7 +786,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.UnitShort, Price = l.UnitPrice, Quantity = l.Quantity, Discount = l.Discount, IsWeighed = l.IsWeighed, Model = l });
|
||||
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 });
|
||||
SelectedLine = Lines.FirstOrDefault();
|
||||
Recalc();
|
||||
}
|
||||
|
|
@ -888,7 +809,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, UnitShort = 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, 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),
|
||||
|
|
|
|||
|
|
@ -73,15 +73,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -237,27 +237,5 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 }
|
|||
/// Хранится скалярными колонками организации (csv-списки префиксов).</summary>
|
||||
public sealed record WeightedBarcodeSettings
|
||||
{
|
||||
// По умолчанию ВЫКЛ (решение владельца 09.10): весов с печатью этикеток нет,
|
||||
// штрихкоды весовых — обычные коды в карточке. Разбор этикеток включается явно
|
||||
// в настройках организации и только тогда применяется (и то после точного ШК).
|
||||
public bool Enabled { get; init; } = false;
|
||||
public bool Enabled { get; init; } = true;
|
||||
public IReadOnlyList<int> WeightPrefixes { get; init; } = new[] { 20, 21, 22, 23, 24 };
|
||||
public IReadOnlyList<int> PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 };
|
||||
public int CodeDigits { get; init; } = 5;
|
||||
|
|
|
|||
|
|
@ -24,11 +24,8 @@ public record ProductSyncDto
|
|||
public string? Article { get; init; }
|
||||
/// <summary>Все штрихкоды товара (для поиска по сканеру). Первый — primary.</summary>
|
||||
public required IReadOnlyList<string> Barcodes { get; init; }
|
||||
/// <summary>Код единицы измерения ОКЕИ ("796"=шт, "166"=кг, "112"=л…), не FK.</summary>
|
||||
/// <summary>Код единицы измерения (шт., кг, л, м…), не FK — раскрывается на UI.</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) — для мобильного/кассы,
|
||||
|
|
@ -214,7 +211,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
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,95 +221,17 @@ public async Task Me_returns_effective_permissions()
|
|||
perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── 5. Поиск по штрихкоду: по умолчанию только точное совпадение ───────
|
||||
// ── 5. Весовые этикетки в поиске по штрихкоду ──────────────────────────
|
||||
[Fact]
|
||||
public async Task ByBarcode_default_exact_only_no_weighted_no_article()
|
||||
public async Task ByBarcode_parses_weighted_label_returns_product_and_quantity()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
// Короткий штрихкод «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 как ШТРИХКОД карточки.
|
||||
// PLU = 12345 хранится как короткий штрихкод товара.
|
||||
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "12345");
|
||||
|
||||
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.
|
||||
// Весовая этикетка: 21 | 12345 | 01500 | K → вес 1.500 кг.
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -19,23 +19,11 @@ 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", On);
|
||||
// 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг
|
||||
var r = BarcodeParser.Parse("2112345015007");
|
||||
r.IsWeighted.Should().BeTrue();
|
||||
r.Lookup.Should().Be("12345");
|
||||
r.EmbeddedWeight.Should().Be(1.500m);
|
||||
|
|
@ -45,8 +33,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", On);
|
||||
// 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸
|
||||
var r = BarcodeParser.Parse("2554321123456");
|
||||
r.IsWeighted.Should().BeTrue();
|
||||
r.Lookup.Should().Be("54321");
|
||||
r.EmbeddedPrice.Should().Be(123.45m);
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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,24 +17,11 @@ 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", On);
|
||||
// 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг
|
||||
var r = WeightedBarcode.Parse("2112345015007");
|
||||
r.IsWeighted.Should().BeTrue();
|
||||
r.ValueKind.Should().Be(WeightedValueKind.WeightKg);
|
||||
r.Lookup.Should().Be("12345");
|
||||
|
|
@ -45,8 +32,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", On);
|
||||
// 25 | 54321 | 12345 | K → PLU 54321, цена 123.45
|
||||
var r = WeightedBarcode.Parse("2554321123456");
|
||||
r.IsWeighted.Should().BeTrue();
|
||||
r.ValueKind.Should().Be(WeightedValueKind.Price);
|
||||
r.Lookup.Should().Be("54321");
|
||||
|
|
|
|||
Loading…
Reference in a new issue