Compare commits
5 commits
dd5604b6ea
...
ad812ee2b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad812ee2b5 | ||
|
|
14001069a2 | ||
|
|
2a4ba14d38 | ||
|
|
7231c83d23 | ||
|
|
5173eaa07c |
45
docs/mobile-api-progress.md
Normal file
45
docs/mobile-api-progress.md
Normal 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
77
docs/mobile-api.md
Normal 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`.
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using foodmarket.Shared.Barcodes;
|
||||||
using foodmarket.Api.Infrastructure.Authorization;
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Api.Controllers.Reports;
|
using foodmarket.Api.Controllers.Reports;
|
||||||
using foodmarket.Application.Mapping;
|
using foodmarket.Application.Mapping;
|
||||||
|
|
@ -1112,8 +1113,17 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
||||||
{
|
{
|
||||||
var v = (value ?? "").Trim();
|
var v = (value ?? "").Trim();
|
||||||
if (v.Length == 0) return NotFound();
|
if (v.Length == 0) return NotFound();
|
||||||
|
|
||||||
|
// Весовая этикетка? Разбираем по настройкам организации (общий парсер).
|
||||||
|
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()
|
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
|
.Select(p => new
|
||||||
{
|
{
|
||||||
p.Id, p.Name, p.Article, p.ReferencePrice,
|
p.Id, p.Name, p.Article, p.ReferencePrice,
|
||||||
|
|
@ -1127,12 +1137,125 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
||||||
if (matches.Count == 0) return NotFound();
|
if (matches.Count == 0) return NotFound();
|
||||||
var items = matches.Select(r => new QuickSearchItem(
|
var items = matches.Select(r => new QuickSearchItem(
|
||||||
r.Id, r.Name, r.Article,
|
r.Id, r.Name, r.Article,
|
||||||
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v,
|
r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? lookup,
|
||||||
r.ReferencePrice, r.StockQty)).ToList();
|
r.ReferencePrice, r.StockQty)).ToList();
|
||||||
|
|
||||||
|
// Весовая этикетка → товар + количество (вес) / встроенная цена.
|
||||||
|
if (parsed.IsWeighted)
|
||||||
|
return new WeightedBarcodeHit(
|
||||||
|
items[0], true, parsed.ValueKind.ToString(),
|
||||||
|
parsed.WeightKg ?? 1m, parsed.Price);
|
||||||
|
|
||||||
if (items.Count == 1) return items[0];
|
if (items.Count == 1) return items[0];
|
||||||
return new ByBarcodeResult(items);
|
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
|
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||||
.Include(p => p.UnitOfMeasure)
|
.Include(p => p.UnitOfMeasure)
|
||||||
.Include(p => p.ProductGroup)
|
.Include(p => p.ProductGroup)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public record StockRow(
|
||||||
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
|
public async Task<ActionResult<PagedResult<StockRow>>> GetStock(
|
||||||
[FromQuery] Guid? storeId,
|
[FromQuery] Guid? storeId,
|
||||||
[FromQuery] Guid? productId,
|
[FromQuery] Guid? productId,
|
||||||
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
|
|
@ -40,6 +41,11 @@ public record StockRow(
|
||||||
|
|
||||||
if (storeId.HasValue) q = q.Where(x => x.s.StoreId == storeId.Value);
|
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 (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 (!includeZero) q = q.Where(x => x.s.Quantity != 0);
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
|
|
@ -111,6 +117,19 @@ public record StockRow(
|
||||||
public record StockExportRow(string Name, string? Article, string Unit, string Store,
|
public record StockExportRow(string Name, string? Article, string Unit, string Store,
|
||||||
decimal Quantity, decimal Reserved, decimal Available);
|
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(
|
public record MovementRow(
|
||||||
Guid Id, DateTime OccurredAt,
|
Guid Id, DateTime OccurredAt,
|
||||||
Guid ProductId, string ProductName, string? Article,
|
Guid ProductId, string ProductName, string? Article,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@ public record OrgSettingsDto(
|
||||||
bool AllowNegativeBalance,
|
bool AllowNegativeBalance,
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct,
|
||||||
|
// Весовые этикетки (внутримагазинные EAN-13).
|
||||||
|
bool WeightBarcodeEnabled,
|
||||||
|
string? WeightBarcodeWeightPrefixes,
|
||||||
|
string? WeightBarcodePricePrefixes,
|
||||||
|
int WeightBarcodeCodeDigits);
|
||||||
|
|
||||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
public record OrgSettingsInput(
|
public record OrgSettingsInput(
|
||||||
|
|
@ -53,7 +58,14 @@ public record OrgSettingsInput(
|
||||||
bool AllowNegativeBalance,
|
bool AllowNegativeBalance,
|
||||||
bool ShowReferencePriceOnProduct,
|
bool ShowReferencePriceOnProduct,
|
||||||
bool ShowCountryOfOriginOnProduct,
|
bool ShowCountryOfOriginOnProduct,
|
||||||
bool ShowDescriptionOnProduct);
|
bool ShowDescriptionOnProduct,
|
||||||
|
// Весовые этикетки — опционально. Блок применяется только если задан
|
||||||
|
// WeightBarcodeEnabled (иначе настройки не трогаем — безопасный round-trip
|
||||||
|
// для клиентов, которые про эти поля не знают, напр. текущая веб-форма).
|
||||||
|
bool? WeightBarcodeEnabled = null,
|
||||||
|
string? WeightBarcodeWeightPrefixes = null,
|
||||||
|
string? WeightBarcodePricePrefixes = null,
|
||||||
|
int? WeightBarcodeCodeDigits = null);
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
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.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||||
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
||||||
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
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.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(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.AllowNegativeBalance,
|
||||||
o.ShowReferencePriceOnProduct,
|
o.ShowReferencePriceOnProduct,
|
||||||
o.ShowCountryOfOriginOnProduct,
|
o.ShowCountryOfOriginOnProduct,
|
||||||
o.ShowDescriptionOnProduct);
|
o.ShowDescriptionOnProduct,
|
||||||
|
o.WeightBarcodeEnabled,
|
||||||
|
o.WeightBarcodeWeightPrefixes,
|
||||||
|
o.WeightBarcodePricePrefixes,
|
||||||
|
o.WeightBarcodeCodeDigits);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
|
||||||
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
|
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
|
||||||
UnitCode = x.u.Code,
|
UnitCode = x.u.Code,
|
||||||
Packaging = (int)x.p.Packaging,
|
Packaging = (int)x.p.Packaging,
|
||||||
|
IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight,
|
||||||
VatPercent = x.p.Vat,
|
VatPercent = x.p.Vat,
|
||||||
VatEnabled = x.p.VatEnabled,
|
VatEnabled = x.p.VatEnabled,
|
||||||
IsMarked = x.p.IsMarked,
|
IsMarked = x.p.IsMarked,
|
||||||
|
|
|
||||||
|
|
@ -718,6 +718,11 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
// false по умолчанию → UI показывает только «простое» меню.
|
// false по умолчанию → UI показывает только «простое» меню.
|
||||||
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
|
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
|
||||||
bool advancedMode = false;
|
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))
|
if (Guid.TryParse(orgIdRaw, out var orgId))
|
||||||
{
|
{
|
||||||
var org = await db.Organizations.IgnoreQueryFilters()
|
var org = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
|
@ -731,11 +736,18 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
}
|
}
|
||||||
if (Guid.TryParse(subRaw, out var sub))
|
if (Guid.TryParse(subRaw, out var sub))
|
||||||
{
|
{
|
||||||
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
|
var emp = await db.Employees.IgnoreQueryFilters()
|
||||||
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
|
.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
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
sub = subRaw,
|
sub = subRaw,
|
||||||
|
|
@ -746,6 +758,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
hasLiveOrg,
|
hasLiveOrg,
|
||||||
hasActiveEmployee,
|
hasActiveEmployee,
|
||||||
advancedMode,
|
advancedMode,
|
||||||
|
// Эффективные права (имя→bool) с учётом роли и простого/расширенного
|
||||||
|
// режима. Клиенты (моб./веб) гейтят UI по этим флагам.
|
||||||
|
permissions,
|
||||||
});
|
});
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,22 @@ public class Organization : Entity
|
||||||
/// контроллерах скрытых модулей.</summary>
|
/// контроллерах скрытых модулей.</summary>
|
||||||
public bool IsAdvancedMode { get; set; }
|
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: ОФД (фискализация чеков) ────────────────────────────
|
// ─ Sprint 11: ОФД (фискализация чеков) ────────────────────────────
|
||||||
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
// Каждая организация выбирает СВОЕГО ОФД-оператора. Креды per-tenant:
|
||||||
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
// у одной фирмы может быть Webkassa, у другой — Касса24. ApiKey/Secret
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,43 @@ public class RolePermissions
|
||||||
public bool MoneyEdit { get; set; }
|
public bool MoneyEdit { get; set; }
|
||||||
public bool CashShiftOperate { 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>
|
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
|
||||||
public static RolePermissions All() => new()
|
public static RolePermissions All() => new()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"";
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
using foodmarket.Shared.Barcodes;
|
||||||
|
|
||||||
namespace foodmarket.Pos.Core.Logic;
|
namespace foodmarket.Pos.Core.Logic;
|
||||||
|
|
||||||
/// <summary>Результат разбора штрихкода.</summary>
|
/// <summary>Результат разбора штрихкода (проекция общего
|
||||||
|
/// <see cref="foodmarket.Shared.Barcodes.ParsedBarcode"/> для кассы).</summary>
|
||||||
public readonly record struct ScannedBarcode(
|
public readonly record struct ScannedBarcode(
|
||||||
string Raw,
|
string Raw,
|
||||||
string Lookup, // код для поиска товара (для весовых — встроенный код товара)
|
string Lookup, // код для поиска товара (для весовых — встроенный код товара)
|
||||||
|
|
@ -8,46 +11,17 @@ namespace foodmarket.Pos.Core.Logic;
|
||||||
decimal? EmbeddedWeight, // кг (для EAN-13 с весом)
|
decimal? EmbeddedWeight, // кг (для EAN-13 с весом)
|
||||||
decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой)
|
decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой)
|
||||||
|
|
||||||
/// <summary>Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды).
|
/// <summary>Разбор весовых EAN-13 (префикс 20–29). Логика вынесена в
|
||||||
///
|
/// <see cref="foodmarket.Shared.Barcodes.WeightedBarcode"/> — общая с сервером.
|
||||||
/// Формат (РФ/РК, распространённый): <c>PP CCCCC Q QQQQ K</c> —
|
/// Касса по умолчанию берёт дефолтные настройки (20–24 вес, 25–29 цена, код 5
|
||||||
/// PP — префикс 20..29,
|
/// цифр); при наличии настроек организации их можно прокинуть через перегрузку.</summary>
|
||||||
/// 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>
|
|
||||||
public static class BarcodeParser
|
public static class BarcodeParser
|
||||||
{
|
{
|
||||||
public static ScannedBarcode Parse(string raw)
|
public static ScannedBarcode Parse(string raw) => Parse(raw, null);
|
||||||
|
|
||||||
|
public static ScannedBarcode Parse(string raw, WeightedBarcodeSettings? settings)
|
||||||
{
|
{
|
||||||
var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray());
|
var p = WeightedBarcode.Parse(raw, settings);
|
||||||
|
return new ScannedBarcode(p.Raw, p.Lookup, p.IsWeighted, p.WeightKg, p.Price);
|
||||||
if (code.Length == 13 && code[0] == '2')
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
src/food-market.shared/Barcodes/WeightedBarcode.cs
Normal file
83
src/food-market.shared/Barcodes/WeightedBarcode.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,9 @@ public record ProductSyncDto
|
||||||
public required string UnitCode { get; init; }
|
public required string UnitCode { get; init; }
|
||||||
/// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary>
|
/// <summary>Тип упаковки: 1=штучный, 2=весовой, 3=разливной (см. Domain.Packaging).</summary>
|
||||||
public required int Packaging { get; init; }
|
public required int Packaging { get; init; }
|
||||||
|
/// <summary>Явный признак весового товара (Packaging==2) — для мобильного/кассы,
|
||||||
|
/// чтобы не завязываться на числовой код упаковки.</summary>
|
||||||
|
public bool IsWeighed { get; init; }
|
||||||
public required decimal VatPercent { get; init; }
|
public required decimal VatPercent { get; init; }
|
||||||
public required bool VatEnabled { get; init; }
|
public required bool VatEnabled { get; init; }
|
||||||
public required bool IsMarked { get; init; }
|
public required bool IsMarked { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export interface MeResponse {
|
||||||
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
|
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
|
||||||
* пресеты, Telegram-бот, SSO, несколько типов цен. */
|
* пресеты, Telegram-бот, SSO, несколько типов цен. */
|
||||||
advancedMode: boolean
|
advancedMode: boolean
|
||||||
|
/** Эффективные права текущего пользователя (имя флага RolePermissions →
|
||||||
|
* bool) с учётом роли и простого/расширенного режима. Единый источник
|
||||||
|
* для веба и мобильного — скрываем одно и то же. Советуй `useCan()`. */
|
||||||
|
permissions?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
||||||
|
|
@ -33,3 +37,14 @@ export function useIsSuperAdmin(): boolean {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
return !!me.data?.roles?.includes('SuperAdmin')
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
157
tests/food-market.IntegrationTests/MobileApiTests.cs
Normal file
157
tests/food-market.IntegrationTests/MobileApiTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
tests/food-market.UnitTests/WeightedBarcodeTests.cs
Normal file
120
tests/food-market.UnitTests/WeightedBarcodeTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue