Compare commits

...

5 commits

Author SHA1 Message Date
nns ad812ee2b5 docs(mobile-api): описание 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
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:37 +05:00
nns 14001069a2 test(mobile-api): unit + integration доработок для «FM Склад»
- unit: WeightedBarcode (префиксы/код/вес/цена/настройки) + RolePermissions.Effective
  (SuperAdmin/Admin/роль × простой/расширенный) — 10 тестов.
- integration (Testcontainers): фильтр остатков по вложенной группе; добавление
  штрихкода (201/идемпотентно/409 дубль/400); /api/me permissions; весовая этикетка
  в by-barcode (товар + количество) — 4 теста.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:37 +05:00
nns 2a4ba14d38 feat(auth): эффективные права пользователя в /api/me (единый источник)
- RolePermissions.Effective(role, isSuperAdmin, isAdmin, advancedMode) — флаги с
  учётом роли и простого/расширенного режима; AdvancedOnly — единый список
  advanced-only прав (зеркалит [RequiresAdvancedMode]).
- /api/me теперь отдаёт permissions (имя→bool). Мобильное и веб скрывают одно и то же.
- Веб: хук useCan() читает me.permissions (раньше гейтил только по именам ролей).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:37 +05:00
nns 7231c83d23 feat(inventory): фильтр остатков по группе товаров с вложенными
GET /api/inventory/stock?groupId= — остатки по группе включая все вложенные
подгруппы (через денормализованный ProductGroup.Path). Комбинируется с
storeId/search/сортировкой. Нужно мобильному «FM Склад» для навигации по категориям.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:39:20 +05:00
16 changed files with 802 additions and 46 deletions

View file

@ -0,0 +1,45 @@
# Доработки сервера для iOS «FM Склад» — прогресс
Автономная задача владельца (08.10.2026). Приложение (SwiftUI) на Маке владельца
использует API стенда; серверу не хватает ряда возможностей. Стенд — не трогаем прод.
## План (пункты владельца)
1. [x] `/api/inventory/stock?groupId=` — фильтр по группе с вложенными подгруппами
(через денормализованный `ProductGroup.Path`, helper `DescendantGroupIdsAsync`).
2. [x] POST `/api/catalog/products/{id}/barcodes` {code, kind} — добавление штрихкода,
уникальность в орг (`(OrganizationId,Code)`), 409 «штрихкод уже у товара X» + название,
идемпотентно при повторе тому же товару, 400 на пустой, автоопределение типа по длине.
3. [x] `/api/me` → `permissions` (эффективные флаги `RolePermissions` с учётом роли и
простого/расширенного режима; единый источник `RolePermissions.Effective`). Веб —
хук `useCan()` на том же источнике (раньше гейтил по именам ролей/advancedMode).
4. [x] Офлайн-каталог (`ProductSyncDto`): добавлен явный `isWeighed` (+ уже были `packaging`,
`unitCode`).
5. [x] Весовые этикетки: настройки организации (`WeightBarcode*`: enabled/префиксы веса/
префиксы цены/длина кода), миграция Phase35; парсер вынесен в
`food-market.shared/Barcodes/WeightedBarcode` (кассы pos.core делегирует туда);
`by-barcode` разбирает весовую этикетку → товар + количество (вес) / встроенная цена.
`GET/PUT /api/organization/settings` отдаёт/принимает настройки (round-trip-safe).
6. [x] Тесты: unit 10 (WeightedBarcode + RolePermissions.Effective) + 4 integration
(группа/штрихкод/me-права/весовая этикетка); `docs/mobile-api.md`. Деплой на стенд — см. ниже.
НЕ трогать: прод, shared-postgres, global.json, gateway nginx, sklad. — соблюдено.
## Изученная база
- `/api/inventory/stock` — `StockController.GetStock` (storeId/productId/search/includeZero/
page/sort). Группы: `ProductGroup` иерархична, есть денормализованный `Path`
(«Родитель/Дочь/Внук») → фильтр по вложенным через префикс Path.
- Штрихкоды: `ProductBarcode(Code, Type:BarcodeType, IsPrimary)`, уникальный индекс
`(OrganizationId, Code)`. Поиск — `GET /api/catalog/products/by-barcode/{value}` и `quick-search`.
- `/api/me` — minimal-endpoint в `Program.cs`: sub/name/email/roles/orgId/hasLiveOrg/
hasActiveEmployee/advancedMode. Прав (RolePermissions) НЕ отдаёт.
- Права: `RolePermissions` (флаги) на роль сотрудника; `PermissionAuthorizationHandler`
(SuperAdmin/Admin → все; иначе роль активного Employee). `RequiresAdvancedMode` гейтит
модули Demands/Transfers/Loyalty/Promotions/PriceTypes/ABC/Audit/Telegram/Presets.
Веб гейтит по именам ролей + `advancedMode` (дублирование — свести к единому источнику).
- POS-синк каталога: `ProductSyncDto` (есть `Packaging` 1/2/3 + `UnitCode`), нужен явный `IsWeighed`.
- Весовой парсер уже есть в `pos.core/Logic/BarcodeParser.cs` (префикс 20–24 вес, 25–29 цена) —
вынести в `food-market.shared`, сделать настраиваемым, использовать и на сервере, и в кассе.
- Миграции — ручные Phase-файлы (raw SQL, IF NOT EXISTS) с `[DbContext]`+`[Migration]`.
## Журнал
- 08.10 — старт; изучена база; план зафиксирован.

77
docs/mobile-api.md Normal file
View file

@ -0,0 +1,77 @@
# API для мобильного «FM Склад»
Серверные возможности, добавленные для iOS-приложения (стенд `api/*`). Авторизация —
OpenIddict password/refresh grant, `client_id=food-market-web`, Bearer-токен. Все
ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`.
## 1. Остатки по группе товаров (с вложенными)
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&page=&pageSize=&sort=&order=`
- `groupId` — фильтр по группе товаров **включая все вложенные подгруппы**
(по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой.
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, unitSymbol,
storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
## 2. Добавить штрихкод к товару
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.
Тело: `{ "code": "4870001112223", "kind": "ean13" }`.
`kind` ∈ `ean13|ean8|code128|code39|upca|upce|other` (регистр не важен); если не задан —
тип определяется по длине/цифрам кода.
Ответы:
- `201 Created` → `{ id, productId, code, type, isPrimary }` (первый штрихкод товара
становится primary).
- `200 OK` — тот же код уже у **этого** товара (идемпотентно), тело как у 201.
- `409 Conflict` → `{ error:"Штрихкод уже у товара «…».", code:"barcode-duplicate",
conflictProduct:"…", field:"code" }` — код занят **другим** товаром организации.
- `400` — пустой/слишком длинный код; `404` — товар не найден.
Уникальность проверяется в пределах организации (`(OrganizationId, Code)`).
## 3. Права текущего пользователя
`GET /api/me` теперь возвращает `permissions` — эффективные права (имя флага →
bool) **с учётом роли и простого/расширенного режима**. Мобильное приложение и веб
гейтят UI по одному источнику (скрывают одно и то же).
```json
{
"sub":"…","name":"…","email":"…","roles":["Admin"],"orgId":"…",
"hasLiveOrg":true,"hasActiveEmployee":true,"advancedMode":false,
"permissions":{ "ProductsView":true,"ProductsEdit":true,"StocksView":true,
"DemandsView":false,"TransferEdit":false, … }
}
```
Правила: SuperAdmin — все права без ограничений; Admin — все, но в **простом режиме**
advanced-only скрыты; обычная роль — её флаги (в простом режиме advanced-only тоже
обнуляются). Advanced-only: `DemandsView/Edit/Post`, `TransferEdit`, `LoyaltyManage`,
`PromotionsManage`, `PriceTypesManage` (зеркалит серверный `[RequiresAdvancedMode]`).
Имена флагов — из `RolePermissions`. Веб использует тот же источник (`useCan()`).
## 4. Весовой признак в офлайн-каталоге
`GET /api/pos/v1/sync` (каталог для сканирования/офлайна) в `ProductSyncDto` отдаёт:
- `packaging` (1=штучный, 2=весовой, 3=разливной) и **`isWeighed`** (явный bool `packaging==2`),
- `unitCode` — единица измерения (шт/кг/л…).
## 5. Весовые этикетки (внутримагазинные EAN-13)
Как в МойСкладе: внутримагазинные EAN-13 с префиксом из диапазона 20–29, где зашиты
«код товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена).
**Настройка организации** — `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` кг.
Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`.

View file

@ -3,6 +3,7 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Shared.Barcodes;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Api.Controllers.Reports;
using foodmarket.Application.Mapping;
@ -1112,8 +1113,17 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
{
var v = (value ?? "").Trim();
if (v.Length == 0) return NotFound();
// Весовая этикетка? Разбираем по настройкам организации (общий парсер).
var settings = await GetWeightedSettingsAsync(ct);
var parsed = WeightedBarcode.Parse(v, settings);
// Для весовой — ищем товар по встроенному PLU (как штрихкод или артикул),
// иначе по самому штрихкоду.
var lookup = parsed.IsWeighted ? parsed.Lookup : v;
var matches = await _db.Products.AsNoTracking()
.Where(p => p.Barcodes.Any(b => b.Code == v))
.Where(p => p.Barcodes.Any(b => b.Code == lookup)
|| (parsed.IsWeighted && p.Article == lookup))
.Select(p => new
{
p.Id, p.Name, p.Article, p.ReferencePrice,
@ -1127,12 +1137,125 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
if (matches.Count == 0) return NotFound();
var items = matches.Select(r => new QuickSearchItem(
r.Id, r.Name, r.Article,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v,
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup,
r.ReferencePrice, r.StockQty)).ToList();
// Весовая этикетка → товар + количество (вес) / встроенная цена.
if (parsed.IsWeighted)
return new WeightedBarcodeHit(
items[0], true, parsed.ValueKind.ToString(),
parsed.WeightKg ?? 1m, parsed.Price);
if (items.Count == 1) return items[0];
return new ByBarcodeResult(items);
}
public record WeightedBarcodeHit(
QuickSearchItem product, bool weighted, string valueKind, decimal quantity, decimal? embeddedPrice);
/// <summary>Настройки разбора весовых этикеток текущей организации.</summary>
private async Task<WeightedBarcodeSettings> GetWeightedSettingsAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return WeightedBarcodeSettings.Default;
var o = await _db.Organizations.Where(x => x.Id == orgId)
.Select(x => new
{
x.WeightBarcodeEnabled, x.WeightBarcodeWeightPrefixes,
x.WeightBarcodePricePrefixes, x.WeightBarcodeCodeDigits,
})
.FirstOrDefaultAsync(ct);
return o is null
? WeightedBarcodeSettings.Default
: WeightedBarcodeSettings.Create(o.WeightBarcodeEnabled, o.WeightBarcodeWeightPrefixes,
o.WeightBarcodePricePrefixes, o.WeightBarcodeCodeDigits);
}
public record AddBarcodeInput(string Code, string? Kind);
public record BarcodeDto(Guid Id, Guid ProductId, string Code, string Type, bool IsPrimary);
/// <summary>Добавить штрихкод к существующему товару. Проверяет уникальность
/// в организации: если код уже у другого товара — 409 с его названием.
/// Повторное добавление того же кода этому же товару — идемпотентно (200).
/// <c>kind</c> — тип (ean13/ean8/code128/code39/upca/upce/other); по умолчанию
/// определяется по длине/цифрам.</summary>
[HttpPost("{id:guid}/barcodes"), RequiresPermission("ProductsEdit")]
public async Task<ActionResult<BarcodeDto>> AddBarcode(
Guid id, [FromBody] AddBarcodeInput input, CancellationToken ct)
{
var code = (input?.Code ?? "").Trim();
if (code.Length == 0)
return BadRequest(new { error = "Штрихкод не указан.", field = "code" });
if (code.Length > 64)
return BadRequest(new { error = "Штрихкод слишком длинный (макс. 64).", field = "code" });
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null) return NotFound(new { error = "Товар не найден." });
// Код уже у ДРУГОГО товара организации → 409 с названием.
var other = await FindBarcodeConflictAsync(new[] { code }, excludeProductId: id, ct);
if (other is { } o)
return Conflict(new
{
error = $"Штрихкод уже у товара «{o.ProductName}».",
field = "code", code = "barcode-duplicate", conflictProduct = o.ProductName,
});
// Уже есть у этого товара → идемпотентно вернём существующий.
var existing = await _db.ProductBarcodes
.FirstOrDefaultAsync(b => b.ProductId == id && b.Code == code, ct);
if (existing is not null)
return Ok(new BarcodeDto(existing.Id, existing.ProductId, existing.Code, existing.Type.ToString(), existing.IsPrimary));
var isFirst = !await _db.ProductBarcodes.AnyAsync(b => b.ProductId == id, ct);
var bc = new ProductBarcode
{
ProductId = id, Code = code, Type = ParseBarcodeKind(input!.Kind, code), IsPrimary = isFirst,
};
_db.ProductBarcodes.Add(bc);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_product_barcodes") == true)
{
// Гонка: код заняли между проверкой и вставкой.
var owner = await FindBarcodeConflictAsync(new[] { code }, excludeProductId: id, ct);
return Conflict(new
{
error = owner is { } w ? $"Штрихкод уже у товара «{w.ProductName}»." : "Штрихкод уже используется.",
field = "code", code = "barcode-duplicate", conflictProduct = owner?.ProductName,
});
}
return CreatedAtAction(nameof(ByBarcode), new { value = bc.Code },
new BarcodeDto(bc.Id, bc.ProductId, bc.Code, bc.Type.ToString(), bc.IsPrimary));
}
private static BarcodeType ParseBarcodeKind(string? kind, string code)
{
if (!string.IsNullOrWhiteSpace(kind))
return kind.Trim().ToLowerInvariant() switch
{
"ean13" => BarcodeType.Ean13,
"ean8" => BarcodeType.Ean8,
"code128" => BarcodeType.Code128,
"code39" => BarcodeType.Code39,
"upca" => BarcodeType.Upca,
"upce" => BarcodeType.Upce,
_ => BarcodeType.Other,
};
// Автоопределение по длине цифрового кода.
var digits = code.All(char.IsDigit);
return (digits, code.Length) switch
{
(true, 13) => BarcodeType.Ean13,
(true, 8) => BarcodeType.Ean8,
(true, 12) => BarcodeType.Upca,
(true, 6) => BarcodeType.Upce,
_ => BarcodeType.Code128,
};
}
private IQueryable<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.ProductGroup)

View file

@ -24,6 +24,7 @@ public record StockRow(
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
[FromQuery] Guid? storeId,
[FromQuery] Guid? productId,
[FromQuery] Guid? groupId,
[FromQuery] string? search,
[FromQuery] bool includeZero = false,
[FromQuery] int page = 1,
@ -40,6 +41,11 @@ public record StockRow(
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
if (productId.HasValue) q = q.Where(x => x.s.ProductId == productId.Value);
if (groupId.HasValue)
{
var groupIds = await DescendantGroupIdsAsync(groupId.Value, ct);
q = q.Where(x => groupIds.Contains(x.p.ProductGroupId));
}
if (!includeZero) q = q.Where(x => x.s.Quantity != 0);
if (!string.IsNullOrWhiteSpace(search))
{
@ -111,6 +117,19 @@ public record StockRow(
public record StockExportRow(string Name, string? Article, string Unit, string Store,
decimal Quantity, decimal Reserved, decimal Available);
/// <summary>Id группы + всех её вложенных подгрупп (по денормализованному
/// Path). Пустой список, если группа не найдена → фильтр вернёт пусто.</summary>
private async Task<List<Guid>> DescendantGroupIdsAsync(Guid groupId, CancellationToken ct)
{
var path = await _db.ProductGroups.Where(g => g.Id == groupId)
.Select(g => g.Path).FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(path))
return await _db.ProductGroups.Where(g => g.Id == groupId).Select(g => g.Id).ToListAsync(ct);
return await _db.ProductGroups
.Where(g => g.Path == path || g.Path.StartsWith(path + "/"))
.Select(g => g.Id).ToListAsync(ct);
}
public record MovementRow(
Guid Id, DateTime OccurredAt,
Guid ProductId, string ProductName, string? Article,

View file

@ -38,7 +38,12 @@ public record OrgSettingsDto(
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
bool ShowDescriptionOnProduct,
// Весовые этикетки (внутримагазинные EAN-13).
bool WeightBarcodeEnabled,
string? WeightBarcodeWeightPrefixes,
string? WeightBarcodePricePrefixes,
int WeightBarcodeCodeDigits);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
@ -53,7 +58,14 @@ public record OrgSettingsInput(
bool AllowNegativeBalance,
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
bool ShowDescriptionOnProduct,
// Весовые этикетки — опционально. Блок применяется только если задан
// WeightBarcodeEnabled (иначе настройки не трогаем — безопасный round-trip
// для клиентов, которые про эти поля не знают, напр. текущая веб-форма).
bool? WeightBarcodeEnabled = null,
string? WeightBarcodeWeightPrefixes = null,
string? WeightBarcodePricePrefixes = null,
int? WeightBarcodeCodeDigits = null);
[HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -93,6 +105,13 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
if (input.WeightBarcodeEnabled is bool wbe)
{
o.WeightBarcodeEnabled = wbe;
o.WeightBarcodeWeightPrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodeWeightPrefixes) ? null : input.WeightBarcodeWeightPrefixes.Trim();
o.WeightBarcodePricePrefixes = string.IsNullOrWhiteSpace(input.WeightBarcodePricePrefixes) ? null : input.WeightBarcodePricePrefixes.Trim();
o.WeightBarcodeCodeDigits = input.WeightBarcodeCodeDigits is >= 1 and <= 8 ? input.WeightBarcodeCodeDigits.Value : 5;
}
await _db.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
@ -124,5 +143,9 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.AllowNegativeBalance,
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct);
o.ShowDescriptionOnProduct,
o.WeightBarcodeEnabled,
o.WeightBarcodeWeightPrefixes,
o.WeightBarcodePricePrefixes,
o.WeightBarcodeCodeDigits);
}

View file

@ -104,6 +104,7 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
UnitCode = x.u.Code,
Packaging = (int)x.p.Packaging,
IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight,
VatPercent = x.p.Vat,
VatEnabled = x.p.VatEnabled,
IsMarked = x.p.IsMarked,

View file

@ -718,6 +718,11 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// false по умолчанию → UI показывает только «простое» меню.
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
bool advancedMode = false;
// Sprint (mobile-api): эффективные права текущего пользователя —
// единый источник для мобильного и веба (скрывают одно и то же).
foodmarket.Domain.Organizations.RolePermissions? rolePerms = null;
var isSuperAdmin = roles.Contains(HttpContextTenantContext.SuperAdminRole);
var isAdmin = roles.Contains("Admin");
if (Guid.TryParse(orgIdRaw, out var orgId))
{
var org = await db.Organizations.IgnoreQueryFilters()
@ -731,11 +736,18 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
}
if (Guid.TryParse(subRaw, out var sub))
{
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
var emp = await db.Employees.IgnoreQueryFilters()
.Where(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive && !e.IsDeleted)
.Select(e => new { e.Role })
.FirstOrDefaultAsync();
hasActiveEmployee = emp is not null;
rolePerms = emp?.Role?.Permissions;
}
}
var permissions = foodmarket.Domain.Organizations.RolePermissions.Effective(
rolePerms, isSuperAdmin, isAdmin, advancedMode);
return Results.Ok(new
{
sub = subRaw,
@ -746,6 +758,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
hasLiveOrg,
hasActiveEmployee,
advancedMode,
// Эффективные права (имя→bool) с учётом роли и простого/расширенного
// режима. Клиенты (моб./веб) гейтят UI по этим флагам.
permissions,
});
}).RequireAuthorization();

View file

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

View file

@ -68,6 +68,43 @@ public class RolePermissions
public bool MoneyEdit { get; set; }
public bool CashShiftOperate { get; set; }
/// <summary>Права, доступные только в расширенном режиме организации
/// (в простом режиме скрыты и на сервере, и в UI). Единый источник правды,
/// зеркалит контроллеры с <c>[RequiresAdvancedMode]</c>: Оптовые отгрузки,
/// Перемещения, Лояльность, Акции, Типы цен.</summary>
public static readonly IReadOnlySet<string> AdvancedOnly = new HashSet<string>(StringComparer.Ordinal)
{
nameof(DemandsView), nameof(DemandsEdit), nameof(DemandsPost),
nameof(TransferEdit), nameof(LoyaltyManage), nameof(PromotionsManage),
nameof(PriceTypesManage),
};
/// <summary>Все bool-флаги прав в виде словаря имя→значение (через рефлексию).</summary>
public Dictionary<string, bool> ToDictionary()
{
var dict = new Dictionary<string, bool>(StringComparer.Ordinal);
foreach (var p in typeof(RolePermissions).GetProperties())
if (p.PropertyType == typeof(bool) && p.CanRead)
dict[p.Name] = (bool)p.GetValue(this)!;
return dict;
}
/// <summary>Эффективные права текущего пользователя для клиента (моб./веб):
/// SuperAdmin — всё без ограничений; Admin — всё, но в простом режиме
/// advanced-only скрыты; обычная роль — её флаги, в простом режиме с
/// обнулением advanced-only. Единый источник, чтобы клиенты скрывали одно и то же.</summary>
public static Dictionary<string, bool> Effective(RolePermissions? role, bool isSuperAdmin, bool isAdmin, bool advancedMode)
{
var basePerms = (isSuperAdmin || isAdmin) ? All() : (role ?? new RolePermissions());
var dict = basePerms.ToDictionary();
// SuperAdmin обходит гейт расширенного режима; остальным в простом режиме
// advanced-only права выключаем.
if (!isSuperAdmin && !advancedMode)
foreach (var name in AdvancedOnly)
dict[name] = false;
return dict;
}
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new()
{

View file

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase35 — настройки разбора весовых этикеток на организации
/// (для мобильного «FM Склад» и кассы).
///
/// Добавляет в <c>organizations</c>:
/// <list type="bullet">
/// <item><c>WeightBarcodeEnabled</c> boolean DEFAULT true.</item>
/// <item><c>WeightBarcodeWeightPrefixes</c> text (CSV префиксов веса, null=20–24).</item>
/// <item><c>WeightBarcodePricePrefixes</c> text (CSV префиксов цены, null=25–29).</item>
/// <item><c>WeightBarcodeCodeDigits</c> integer DEFAULT 5.</item>
/// </list>
/// Идемпотентно (IF NOT EXISTS). Разбор — <c>WeightedBarcode</c> в food-market.shared.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261008060000_Phase35_WeightBarcodeSettings")]
public partial class Phase35_WeightBarcodeSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""WeightBarcodeEnabled"" boolean NOT NULL DEFAULT true;
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""WeightBarcodeWeightPrefixes"" text;
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""WeightBarcodePricePrefixes"" text;
ALTER TABLE public.organizations
ADD COLUMN IF NOT EXISTS ""WeightBarcodeCodeDigits"" integer NOT NULL DEFAULT 5;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeEnabled"";
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeWeightPrefixes"";
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodePricePrefixes"";
ALTER TABLE public.organizations DROP COLUMN IF EXISTS ""WeightBarcodeCodeDigits"";
");
}
}
}

View file

@ -1,6 +1,9 @@
using foodmarket.Shared.Barcodes;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Результат разбора штрихкода.</summary>
/// <summary>Результат разбора штрихкода (проекция общего
/// <see cref="foodmarket.Shared.Barcodes.ParsedBarcode"/> для кассы).</summary>
public readonly record struct ScannedBarcode(
string Raw,
string Lookup, // код для поиска товара (для весовых — встроенный код товара)
@ -8,46 +11,17 @@ namespace foodmarket.Pos.Core.Logic;
decimal? EmbeddedWeight, // кг (для EAN-13 с весом)
decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой)
/// <summary>Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды).
///
/// Формат (РФ/РК, распространённый): <c>PP CCCCC Q QQQQ K</c> —
/// PP — префикс 20..29,
/// CCCCC — 5-значный код товара (PLU),
/// Q — встроенная величина: вес в кг (5 цифр, 3 знака после запятой) ИЛИ
/// цена (5 цифр, 2 знака), в зависимости от префикса/настройки,
/// K — контрольная цифра EAN-13.
///
/// По умолчанию: префиксы 20–24 → ВЕС (кг, /1000), 25–29 → ЦЕНА (₸, /100).
/// Это настраивается, но даём рабочий дефолт. Для поиска товара возвращаем
/// Lookup = "2" + PP[1] + CCCCC? Нет — ищем товар по его обычному штрихкоду,
/// которым в весовом коде является PLU. Поэтому Lookup = PLU (5 цифр, без лидирующих нулей
/// не режем — каталог сам сматчит по хранимому коду).</summary>
/// <summary>Разбор весовых EAN-13 (префикс 20–29). Логика вынесена в
/// <see cref="foodmarket.Shared.Barcodes.WeightedBarcode"/> — общая с сервером.
/// Касса по умолчанию берёт дефолтные настройки (20–24 вес, 25–29 цена, код 5
/// цифр); при наличии настроек организации их можно прокинуть через перегрузку.</summary>
public static class BarcodeParser
{
public static ScannedBarcode Parse(string raw)
{
var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray());
public static ScannedBarcode Parse(string raw) => Parse(raw, null);
if (code.Length == 13 && code[0] == '2')
public static ScannedBarcode Parse(string raw, WeightedBarcodeSettings? settings)
{
var prefix = int.Parse(code.Substring(0, 2));
if (prefix is >= 20 and <= 29)
{
var plu = code.Substring(2, 5); // код товара
var valueDigits = code.Substring(7, 5); // вес или цена
var value = int.Parse(valueDigits);
if (prefix <= 24)
{
// вес в кг, 3 знака после запятой (12345 → 12.345 кг)
var weight = decimal.Round(value / 1000m, 3);
return new ScannedBarcode(code, plu, true, weight, null);
}
// цена в ₸, 2 знака (12345 → 123.45)
var price = decimal.Round(value / 100m, 2);
return new ScannedBarcode(code, plu, true, null, price);
}
}
return new ScannedBarcode(code.Length > 0 ? code : (raw ?? string.Empty), code.Length > 0 ? code : (raw ?? string.Empty), false, null, null);
var p = WeightedBarcode.Parse(raw, settings);
return new ScannedBarcode(p.Raw, p.Lookup, p.IsWeighted, p.WeightKg, p.Price);
}
}

View file

@ -0,0 +1,83 @@
namespace foodmarket.Shared.Barcodes;
/// <summary>Что зашито в весовой этикетке: вес (кг) или цена.</summary>
public enum WeightedValueKind { None = 0, WeightKg = 1, Price = 2 }
/// <summary>Настройка разбора весовых этикеток для организации. По аналогии с
/// МойСкладом: внутримагазинные EAN-13 с префиксом 20–29, где внутри — «код
/// товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена). Префиксы
/// и длина кода настраиваются; по умолчанию 20–24 = вес, 25–29 = цена, код 5 цифр.
/// Хранится скалярными колонками организации (csv-списки префиксов).</summary>
public sealed record WeightedBarcodeSettings
{
public bool Enabled { get; init; } = true;
public IReadOnlyList<int> WeightPrefixes { get; init; } = new[] { 20, 21, 22, 23, 24 };
public IReadOnlyList<int> PricePrefixes { get; init; } = new[] { 25, 26, 27, 28, 29 };
public int CodeDigits { get; init; } = 5;
public static WeightedBarcodeSettings Default { get; } = new();
/// <summary>Сконструировать из хранимых значений организации (csv префиксов).</summary>
public static WeightedBarcodeSettings Create(bool enabled, string? weightCsv, string? priceCsv, int codeDigits)
=> new()
{
Enabled = enabled,
WeightPrefixes = ParseCsv(weightCsv) is { Count: > 0 } w ? w : Default.WeightPrefixes,
PricePrefixes = ParseCsv(priceCsv) is { Count: > 0 } p ? p : Default.PricePrefixes,
CodeDigits = codeDigits is >= 1 and <= 8 ? codeDigits : 5,
};
private static List<int> ParseCsv(string? csv)
{
var list = new List<int>();
if (string.IsNullOrWhiteSpace(csv)) return list;
foreach (var part in csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (int.TryParse(part, out var n) && n is >= 0 and <= 99) list.Add(n);
return list;
}
}
/// <summary>Результат разбора штрихкода.</summary>
public readonly record struct ParsedBarcode(
string Raw,
string Lookup, // код для поиска товара (для весовых — встроенный PLU)
bool IsWeighted,
WeightedValueKind ValueKind,
decimal? WeightKg, // кг (для весовых по весу)
decimal? Price) // цена (для весовых по цене)
{
/// <summary>Встроенное количество для чека (кол-во = вес; для ценовых весовой этикетки 1).</summary>
public decimal? EmbeddedQuantity => WeightKg;
}
/// <summary>Разбор штрихкодов, включая весовые EAN-13 (20–29). Единый на сервере
/// (поиск по ШК) и в кассе (пробитие). Для не-весовых возвращает код как есть.</summary>
public static class WeightedBarcode
{
public static ParsedBarcode Parse(string? raw, WeightedBarcodeSettings? settings = null)
{
settings ??= WeightedBarcodeSettings.Default;
var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray());
if (settings.Enabled && code.Length == 13)
{
var prefix = int.Parse(code.Substring(0, 2));
var isWeight = settings.WeightPrefixes.Contains(prefix);
var isPrice = settings.PricePrefixes.Contains(prefix);
if (isWeight || isPrice)
{
var cd = Math.Clamp(settings.CodeDigits, 1, 8);
var plu = code.Substring(2, Math.Min(cd, 10));
var valueStart = 2 + cd;
var valueLen = Math.Max(0, 12 - valueStart); // последняя цифра — контрольная
var value = valueLen > 0 ? int.Parse(code.Substring(valueStart, valueLen)) : 0;
if (isWeight)
return new ParsedBarcode(code, plu, true, WeightedValueKind.WeightKg, decimal.Round(value / 1000m, 3), null);
return new ParsedBarcode(code, plu, true, WeightedValueKind.Price, null, decimal.Round(value / 100m, 2));
}
}
var lookup = code.Length > 0 ? code : (raw ?? string.Empty);
return new ParsedBarcode(lookup, lookup, false, WeightedValueKind.None, null, null);
}
}

View file

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

View file

@ -17,6 +17,10 @@ export interface MeResponse {
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
* пресеты, Telegram-бот, SSO, несколько типов цен. */
advancedMode: boolean
/** Эффективные права текущего пользователя (имя флага RolePermissions →
* bool) с учётом роли и простого/расширенного режима. Единый источник
* для веба и мобильного — скрываем одно и то же. Советуй `useCan()`. */
permissions?: Record<string, boolean>
}
/** Текущий залогиненный юзер с ролями. Используется для гейтов
@ -33,3 +37,14 @@ export function useIsSuperAdmin(): boolean {
const me = useMe()
return !!me.data?.roles?.includes('SuperAdmin')
}
/** Проверка эффективного права текущего пользователя по серверному источнику
* (`/api/me` → permissions). Тот же набор, что у мобильного приложения, —
* веб и моб. скрывают одно и то же. `can('ProductsEdit')` и т.п.
* SuperAdmin и Admin получают все флаги (advanced-only — только в расш. режиме),
* это уже учтено сервером. */
export function useCan(): (permission: string) => boolean {
const me = useMe()
const perms = me.data?.permissions
return (permission: string) => !!perms && perms[permission] === true
}

View file

@ -0,0 +1,157 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Доработки сервера для iOS «FM Склад»: фильтр остатков по группе
/// (вложенной), добавление штрихкода, права в /api/me, весовые этикетки.</summary>
[Collection(ApiCollection.Name)]
public class MobileApiTests
{
private readonly ApiFactory _factory;
public MobileApiTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
private static async Task<string> CreateGroupAsync(ApiActor api, string name, string? parentId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups", new { name, parentId });
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create group: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static async Task<string> CreateProductInGroupAsync(
ApiActor api, ApiActor.Refs refs, string name, decimal price, string barcode, string groupId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name, article = (string?)null, unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
productGroupId = groupId, isService = false, packaging = 0, isMarked = false,
referencePrice = price * 0.7m, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = price, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create product: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
// ── 1. Фильтр остатков по группе (с вложенными) ────────────────────────
[Fact]
public async Task Stock_filter_by_group_includes_nested_groups()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-grp-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var parent = await CreateGroupAsync(api, $"Напитки-{Guid.NewGuid():N}", null);
var child = await CreateGroupAsync(api, $"Соки-{Guid.NewGuid():N}", parent);
var pInParent = await CreateProductInGroupAsync(api, refs, $"Вода-{Guid.NewGuid():N}", 100m, RandomBarcode(), parent);
var pInChild = await CreateProductInGroupAsync(api, refs, $"Сок-{Guid.NewGuid():N}", 200m, RandomBarcode(), child);
await SeedStockAsync(api, refs, pInParent, 5m);
await SeedStockAsync(api, refs, pInChild, 7m);
// Фильтр по родителю → оба товара (вложенность).
var byParent = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={parent}&pageSize=100");
var parentIds = byParent.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
parentIds.Should().Contain(pInParent);
parentIds.Should().Contain(pInChild, "вложенная группа входит в фильтр родителя");
// Фильтр по дочерней → только её товар.
var byChild = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={child}&pageSize=100");
var childIds = byChild.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
childIds.Should().Contain(pInChild);
childIds.Should().NotContain(pInParent);
}
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
[Fact]
public async Task Add_barcode_creates_and_rejects_duplicate()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-bc-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"A-{Guid.NewGuid():N}", 100m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"B-{Guid.NewGuid():N}", 100m, RandomBarcode());
var newCode = RandomBarcode();
// Добавляем новый код к p1.
using var add = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = "ean13" });
add.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await add.Content.ReadFromJsonAsync<JsonElement>();
created.GetProperty("code").GetString().Should().Be(newCode);
created.GetProperty("type").GetString().Should().Be("Ean13");
// Повтор того же кода тому же товару → идемпотентно 200.
using var again = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = (string?)null });
again.StatusCode.Should().Be(HttpStatusCode.OK);
// Тот же код другому товару → 409 с названием владельца.
using var dup = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p2}/barcodes", new { code = newCode, kind = (string?)null });
dup.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await dup.Content.ReadFromJsonAsync<JsonElement>();
err.GetProperty("code").GetString().Should().Be("barcode-duplicate");
err.GetProperty("error").GetString().Should().Contain("уже у товара");
// Пустой код → 400.
using var bad = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = "", kind = (string?)null });
bad.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── 3. Права текущего пользователя в /api/me ───────────────────────────
[Fact]
public async Task Me_returns_effective_permissions()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-me-{Guid.NewGuid():N}");
var me = await api.Http.GetFromJsonAsync<JsonElement>("/api/me");
me.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).Should().Contain("Admin");
var perms = me.GetProperty("permissions");
perms.GetProperty("ProductsEdit").GetBoolean().Should().BeTrue();
perms.GetProperty("StocksView").GetBoolean().Should().BeTrue();
// Новая орг по умолчанию в простом режиме → advanced-only скрыты даже у Admin.
me.GetProperty("advancedMode").GetBoolean().Should().BeFalse();
perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse();
perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse();
}
// ── 5. Весовые этикетки в поиске по штрихкоду ──────────────────────────
[Fact]
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();
// PLU = 12345 хранится как короткий штрихкод товара.
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "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");
resp.GetProperty("quantity").GetDecimal().Should().Be(1.500m);
resp.GetProperty("product").GetProperty("id").GetString().Should().Be(pid);
}
}

View file

@ -0,0 +1,120 @@
using FluentAssertions;
using foodmarket.Domain.Organizations;
using foodmarket.Shared.Barcodes;
using Xunit;
namespace foodmarket.UnitTests;
public class WeightedBarcodeTests
{
[Fact]
public void Normal_barcode_passes_through()
{
var r = WeightedBarcode.Parse("4870204010101");
r.IsWeighted.Should().BeFalse();
r.Lookup.Should().Be("4870204010101");
r.WeightKg.Should().BeNull();
r.Price.Should().BeNull();
}
[Fact]
public void Weight_prefix_extracts_plu_and_kg()
{
// 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");
r.WeightKg.Should().Be(1.500m);
r.Price.Should().BeNull();
}
[Fact]
public void Price_prefix_extracts_plu_and_price()
{
// 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");
r.Price.Should().Be(123.45m);
r.WeightKg.Should().BeNull();
}
[Fact]
public void Disabled_settings_treats_weighted_as_plain()
{
var settings = WeightedBarcodeSettings.Default with { Enabled = false };
var r = WeightedBarcode.Parse("2112345015007", settings);
r.IsWeighted.Should().BeFalse();
r.Lookup.Should().Be("2112345015007");
}
[Fact]
public void Custom_prefixes_and_code_digits_honored()
{
// Префикс 28 как весовой, код 6 цифр: 28 | 123456 | 999 | K
var settings = WeightedBarcodeSettings.Create(
enabled: true, weightCsv: "28", priceCsv: "", codeDigits: 6);
var r = WeightedBarcode.Parse("2812345699990", settings);
r.IsWeighted.Should().BeTrue();
r.ValueKind.Should().Be(WeightedValueKind.WeightKg);
r.Lookup.Should().Be("123456");
// value digits = остаток до контрольной: позиции 8..11 = "9999" → 9.999 кг
r.WeightKg.Should().Be(9.999m);
}
[Fact]
public void Create_from_csv_defaults_on_blank()
{
var s = WeightedBarcodeSettings.Create(true, null, "", 0);
s.WeightPrefixes.Should().Contain(new[] { 20, 21, 22, 23, 24 });
s.PricePrefixes.Should().Contain(new[] { 25, 26, 27, 28, 29 });
s.CodeDigits.Should().Be(5);
}
}
public class RolePermissionsEffectiveTests
{
[Fact]
public void SuperAdmin_gets_everything_even_in_simple_mode()
{
var p = RolePermissions.Effective(null, isSuperAdmin: true, isAdmin: false, advancedMode: false);
p["ProductsEdit"].Should().BeTrue();
p["DemandsView"].Should().BeTrue("SuperAdmin обходит простой режим");
p["MoneyEdit"].Should().BeTrue();
}
[Fact]
public void Admin_in_simple_mode_loses_advanced_only()
{
var p = RolePermissions.Effective(null, isSuperAdmin: false, isAdmin: true, advancedMode: false);
p["ProductsEdit"].Should().BeTrue("базовые права у Admin есть");
p["DemandsView"].Should().BeFalse("advanced-only скрыт в простом режиме");
p["TransferEdit"].Should().BeFalse();
p["PriceTypesManage"].Should().BeFalse();
p["StocksView"].Should().BeTrue("не advanced-only");
}
[Fact]
public void Admin_in_advanced_mode_keeps_advanced()
{
var p = RolePermissions.Effective(null, isSuperAdmin: false, isAdmin: true, advancedMode: true);
p["DemandsView"].Should().BeTrue();
p["LoyaltyManage"].Should().BeTrue();
}
[Fact]
public void Plain_role_reflects_its_flags_and_mode()
{
var role = new RolePermissions { ProductsView = true, StocksView = true, DemandsView = true };
var simple = RolePermissions.Effective(role, false, false, advancedMode: false);
simple["ProductsView"].Should().BeTrue();
simple["StocksView"].Should().BeTrue();
simple["DemandsView"].Should().BeFalse("advanced-only в простом режиме");
simple["ProductsEdit"].Should().BeFalse("флаг не выдан роли");
var advanced = RolePermissions.Effective(role, false, false, advancedMode: true);
advanced["DemandsView"].Should().BeTrue();
}
}