diff --git a/docs/mobile-api.md b/docs/mobile-api.md
index 4e17201..014a07c 100644
--- a/docs/mobile-api.md
+++ b/docs/mobile-api.md
@@ -108,3 +108,29 @@ advanced-only скрыты; обычная роль — её флаги (в пр
`complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results`
вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены
deprecated после обновления iOS (iOS обновит Mac-Claude).
+
+## Отрицательные остатки: предупреждения + push (09.10)
+
+Продажа/списание/перемещение/отгрузка/возврат/отмена приёмки **в минус разрешены**
+(решение владельца), но создают предупреждение об отрицательном остатке. iOS показывает
+счётчик/список и может запустить пересчёт.
+
+- `GET /api/negative-stock/count[?storeId=]` → `{ total, byStore: [{storeId, storeName, count}] }`.
+ Для баннера/индикатора «N товаров в минусе».
+- `GET /api/negative-stock[?storeId=&overdueHours=24]` → список открытых предупреждений:
+ `{ id, storeId, storeName, productId, productName, quantity(<0), causeDocType, causeDocNumber,
+ causeUserId, causeAt, firstDetectedAt, lastUpdatedAt, overdue(bool), hint }`.
+ `hint` — подсказка причины, напр. «возможно, не проведена приёмка П-… от <поставщик>».
+- `POST /api/negative-stock/recount` body `{ storeId }` → создаёт **черновик инвентаризации**
+ по складу ровно с минусовыми товарами (BookQty = текущий отрицательный остаток) →
+ `{ inventoryId, number, lines }`. Открыть через обычный экран инвентаризации.
+- Отчёт остатков: `GET /api/inventory/stock?negativeOnly=true` — только минусы.
+
+### Push-токены устройств (заложено; реальный APNs позже)
+- `POST /api/me/device-tokens` body `{ platform: "ios"|"android"|"web", token }` — регистрация
+ токена (идемпотентно по токену). `DELETE /api/me/device-tokens?token=…` — отзыв.
+- Реальная отправка push **пока выключена** (нужен платный Apple Developer): в DI включён
+ `StubPushSender` (пишет в лог). Контракт — `IPushSender.SendAsync(userIds, title, body, data)`;
+ когда появятся ключи APNs/FCM/VAPID, заглушка подменяется реальной реализацией без правок
+ вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
+ тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.
diff --git a/src/food-market.api/Controllers/DeviceTokensController.cs b/src/food-market.api/Controllers/DeviceTokensController.cs
new file mode 100644
index 0000000..e9e6cf8
--- /dev/null
+++ b/src/food-market.api/Controllers/DeviceTokensController.cs
@@ -0,0 +1,69 @@
+using foodmarket.Application.Common.Tenancy;
+using foodmarket.Domain.Organizations;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Api.Controllers;
+
+/// Регистрация токенов устройств для push (iOS/Android/Web). Приложение/PWA
+/// вызывает после получения разрешения. Реальная отправка пока заглушка — см.
+/// IPushSender/StubPushSender; таблица и регистрация заложены заранее.
+[ApiController]
+[Authorize]
+[Route("api/me/device-tokens")]
+public class DeviceTokensController : ControllerBase
+{
+ private readonly AppDbContext _db;
+ private readonly ITenantContext _tenant;
+
+ public DeviceTokensController(AppDbContext db, ITenantContext tenant)
+ {
+ _db = db;
+ _tenant = tenant;
+ }
+
+ public record RegisterInput(string Platform, string Token);
+
+ /// Зарегистрировать/обновить токен устройства текущего пользователя.
+ /// Идемпотентно по токену (повторный вызов обновляет LastSeenAt/владельца).
+ [HttpPost]
+ public async Task Register([FromBody] RegisterInput input, CancellationToken ct)
+ {
+ var orgId = _tenant.OrganizationId;
+ var userId = _tenant.UserId;
+ if (orgId is null || userId is null) return Unauthorized();
+ if (string.IsNullOrWhiteSpace(input.Token)) return BadRequest(new { error = "Пустой токен." });
+
+ var platform = string.IsNullOrWhiteSpace(input.Platform) ? "ios" : input.Platform.Trim().ToLowerInvariant();
+ var now = DateTime.UtcNow;
+ var existing = await _db.DeviceTokens.FirstOrDefaultAsync(t => t.Token == input.Token, ct);
+ if (existing is null)
+ {
+ _db.DeviceTokens.Add(new DeviceToken
+ {
+ OrganizationId = orgId.Value, UserId = userId.Value,
+ Platform = platform, Token = input.Token.Trim(), LastSeenAt = now,
+ });
+ }
+ else
+ {
+ existing.OrganizationId = orgId.Value;
+ existing.UserId = userId.Value;
+ existing.Platform = platform;
+ existing.LastSeenAt = now;
+ }
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ /// Удалить токен (при выходе/отзыве разрешения).
+ [HttpDelete]
+ public async Task Unregister([FromQuery] string token, CancellationToken ct)
+ {
+ var existing = await _db.DeviceTokens.FirstOrDefaultAsync(t => t.Token == token, ct);
+ if (existing is not null) { _db.DeviceTokens.Remove(existing); await _db.SaveChangesAsync(ct); }
+ return NoContent();
+ }
+}
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index ef7d1b8..ecfccb3 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -438,6 +438,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory
builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
// Sprint 33: модуль «Деньги»
diff --git a/src/food-market.application/Notifications/IPushSender.cs b/src/food-market.application/Notifications/IPushSender.cs
new file mode 100644
index 0000000..e2a5cef
--- /dev/null
+++ b/src/food-market.application/Notifications/IPushSender.cs
@@ -0,0 +1,15 @@
+namespace foodmarket.Application.Notifications;
+
+/// Абстракция отправки push-уведомлений на устройства пользователей.
+/// Реализации: APNs (iOS), FCM (Android), Web Push (браузер/PWA). Сейчас включена
+/// только заглушка (StubPushSender — пишет в лог), т.к. реальный APNs требует
+/// платного Apple Developer. Фоновая рассылка предупреждений об отрицательных
+/// остатках будет звать этот интерфейс.
+public interface IPushSender
+{
+ /// Отправить push набору пользователей (по их зарегистрированным
+ /// device-токенам). data — произвольная полезная нагрузка (например,
+ /// {"type":"negative-stock","storeId":...}) для deep-link в приложении.
+ Task SendAsync(IReadOnlyCollection userIds, string title, string body,
+ IReadOnlyDictionary? data = null, CancellationToken ct = default);
+}
diff --git a/src/food-market.domain/Organizations/DeviceToken.cs b/src/food-market.domain/Organizations/DeviceToken.cs
new file mode 100644
index 0000000..b5f7da1
--- /dev/null
+++ b/src/food-market.domain/Organizations/DeviceToken.cs
@@ -0,0 +1,20 @@
+using foodmarket.Domain.Common;
+
+namespace foodmarket.Domain.Organizations;
+
+/// Токен устройства для push-уведомлений (iOS APNs / Android FCM / Web Push).
+/// Регистрируется приложением/PWA после получения разрешения пользователя. Реальная
+/// отправка APNs пока НЕ включена (нужен платный Apple Developer у владельца) — см.
+/// IPushSender и заглушку; таблица и регистрация заложены заранее.
+public class DeviceToken : TenantEntity
+{
+ public Guid UserId { get; set; }
+
+ /// ios | android | web.
+ public string Platform { get; set; } = "ios";
+
+ /// APNs device token / FCM registration id / web-push endpoint+keys (JSON).
+ public string Token { get; set; } = "";
+
+ public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/food-market.infrastructure/Notifications/StubPushSender.cs b/src/food-market.infrastructure/Notifications/StubPushSender.cs
new file mode 100644
index 0000000..6ffd205
--- /dev/null
+++ b/src/food-market.infrastructure/Notifications/StubPushSender.cs
@@ -0,0 +1,23 @@
+using foodmarket.Application.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace foodmarket.Infrastructure.Notifications;
+
+/// Заглушка push-отправки: ничего не шлёт, только пишет в лог. Включена по
+/// умолчанию, пока нет платного Apple Developer (APNs) / ключей FCM / VAPID.
+/// Реальные отправители (ApnsPushSender/FcmPushSender/WebPushSender) подменят её в DI,
+/// когда появятся ключи — контракт уже готов.
+public sealed class StubPushSender : IPushSender
+{
+ private readonly ILogger _log;
+ public StubPushSender(ILogger log) => _log = log;
+
+ public Task SendAsync(IReadOnlyCollection userIds, string title, string body,
+ IReadOnlyDictionary? data = null, CancellationToken ct = default)
+ {
+ _log.LogInformation(
+ "PUSH(stub) → {UserCount} польз.: «{Title}» / «{Body}» data={Data}",
+ userIds.Count, title, body, data is null ? "-" : string.Join(",", data.Select(kv => $"{kv.Key}={kv.Value}")));
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs
index aaa924e..187233b 100644
--- a/src/food-market.infrastructure/Persistence/AppDbContext.cs
+++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs
@@ -76,6 +76,7 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan
public DbSet DemandLines => Set();
public DbSet EmployeeRoles => Set();
+ public DbSet DeviceTokens => Set();
public DbSet Employees => Set();
public DbSet EmployeeRetailPointAssignments => Set();
public DbSet SuperAdminAuditLogs => Set();
diff --git a/src/food-market.infrastructure/Persistence/Configurations/OrganizationsHrConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/OrganizationsHrConfigurations.cs
index 0a43a17..59daba2 100644
--- a/src/food-market.infrastructure/Persistence/Configurations/OrganizationsHrConfigurations.cs
+++ b/src/food-market.infrastructure/Persistence/Configurations/OrganizationsHrConfigurations.cs
@@ -7,6 +7,15 @@ public static class OrganizationsHrConfigurations
{
public static void ConfigureOrganizationsHr(this ModelBuilder b)
{
+ b.Entity(e =>
+ {
+ e.ToTable("device_tokens");
+ e.Property(x => x.Platform).HasMaxLength(20).IsRequired();
+ e.Property(x => x.Token).HasMaxLength(1000).IsRequired();
+ e.HasIndex(x => new { x.OrganizationId, x.UserId });
+ e.HasIndex(x => x.Token).IsUnique();
+ });
+
b.Entity(e =>
{
e.ToTable("employee_roles");
diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009020000_Phase40_DeviceTokens.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009020000_Phase40_DeviceTokens.cs
new file mode 100644
index 0000000..79db6ec
--- /dev/null
+++ b/src/food-market.infrastructure/Persistence/Migrations/20261009020000_Phase40_DeviceTokens.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using foodmarket.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace foodmarket.Infrastructure.Persistence.Migrations
+{
+ /// Phase40 — токены устройств для push (device_tokens). Заложено под
+ /// APNs/FCM/Web Push; реальная отправка пока заглушка (StubPushSender). Идемпотентно.
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20261009020000_Phase40_DeviceTokens")]
+ public partial class Phase40_DeviceTokens : Migration
+ {
+ protected override void Up(MigrationBuilder b)
+ {
+ b.Sql(@"
+ CREATE TABLE IF NOT EXISTS public.device_tokens (
+ ""Id"" uuid PRIMARY KEY,
+ ""OrganizationId"" uuid NOT NULL,
+ ""UserId"" uuid NOT NULL,
+ ""Platform"" varchar(20) NOT NULL,
+ ""Token"" varchar(1000) NOT NULL,
+ ""LastSeenAt"" timestamp with time zone NOT NULL,
+ ""CreatedAt"" timestamp with time zone NOT NULL,
+ ""UpdatedAt"" timestamp with time zone
+ );
+ CREATE INDEX IF NOT EXISTS ""IX_device_tokens_OrganizationId_UserId""
+ ON public.device_tokens (""OrganizationId"", ""UserId"");
+ CREATE UNIQUE INDEX IF NOT EXISTS ""IX_device_tokens_Token""
+ ON public.device_tokens (""Token"");
+ ");
+ }
+
+ protected override void Down(MigrationBuilder b)
+ {
+ b.Sql(@"DROP TABLE IF EXISTS public.device_tokens;");
+ }
+ }
+}