diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs
new file mode 100644
index 0000000..d643055
--- /dev/null
+++ b/tests/food-market.IntegrationTests/MobileApiTests.cs
@@ -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;
+
+/// Доработки сервера для iOS «FM Склад»: фильтр остатков по группе
+/// (вложенной), добавление штрихкода, права в /api/me, весовые этикетки.
+[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 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 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()).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($"/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($"/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();
+ 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();
+ 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("/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("/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);
+ }
+}
diff --git a/tests/food-market.UnitTests/WeightedBarcodeTests.cs b/tests/food-market.UnitTests/WeightedBarcodeTests.cs
new file mode 100644
index 0000000..2c41f3b
--- /dev/null
+++ b/tests/food-market.UnitTests/WeightedBarcodeTests.cs
@@ -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();
+ }
+}