feat(notifications): задел push под iOS — device_tokens + IPushSender (заглушка)
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

Таблица токенов устройств (device_tokens, Phase40) + POST/DELETE /api/me/device-tokens
для регистрации iOS/Android/Web push-токенов. Контракт IPushSender.SendAsync; в DI
включён StubPushSender (пишет в лог) — реальный APNs/FCM/VAPID подменит заглушку, когда
появятся ключи, без правок вызывающего кода. Описано в docs/mobile-api.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 02:27:05 +05:00
parent 2d1cdd8519
commit 3547e823c3
9 changed files with 204 additions and 0 deletions

View file

@ -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, заглушка подменяется реальной реализацией без правок
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.

View file

@ -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;
/// <summary>Регистрация токенов устройств для push (iOS/Android/Web). Приложение/PWA
/// вызывает после получения разрешения. Реальная отправка пока заглушка — см.
/// IPushSender/StubPushSender; таблица и регистрация заложены заранее.</summary>
[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);
/// <summary>Зарегистрировать/обновить токен устройства текущего пользователя.
/// Идемпотентно по токену (повторный вызов обновляет LastSeenAt/владельца).</summary>
[HttpPost]
public async Task<IActionResult> 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();
}
/// <summary>Удалить токен (при выходе/отзыве разрешения).</summary>
[HttpDelete]
public async Task<IActionResult> 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();
}
}

View file

@ -438,6 +438,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
builder.Services.AddScoped<foodmarket.Application.Notifications.IPushSender, foodmarket.Infrastructure.Notifications.StubPushSender>();
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// Sprint 33: модуль «Деньги»

View file

@ -0,0 +1,15 @@
namespace foodmarket.Application.Notifications;
/// <summary>Абстракция отправки push-уведомлений на устройства пользователей.
/// Реализации: APNs (iOS), FCM (Android), Web Push (браузер/PWA). Сейчас включена
/// только заглушка (<c>StubPushSender</c> — пишет в лог), т.к. реальный APNs требует
/// платного Apple Developer. Фоновая рассылка предупреждений об отрицательных
/// остатках будет звать этот интерфейс.</summary>
public interface IPushSender
{
/// <summary>Отправить push набору пользователей (по их зарегистрированным
/// device-токенам). data — произвольная полезная нагрузка (например,
/// {"type":"negative-stock","storeId":...}) для deep-link в приложении.</summary>
Task SendAsync(IReadOnlyCollection<Guid> userIds, string title, string body,
IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default);
}

View file

@ -0,0 +1,20 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Токен устройства для push-уведомлений (iOS APNs / Android FCM / Web Push).
/// Регистрируется приложением/PWA после получения разрешения пользователя. Реальная
/// отправка APNs пока НЕ включена (нужен платный Apple Developer у владельца) — см.
/// <c>IPushSender</c> и заглушку; таблица и регистрация заложены заранее.</summary>
public class DeviceToken : TenantEntity
{
public Guid UserId { get; set; }
/// <summary>ios | android | web.</summary>
public string Platform { get; set; } = "ios";
/// <summary>APNs device token / FCM registration id / web-push endpoint+keys (JSON).</summary>
public string Token { get; set; } = "";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}

View file

@ -0,0 +1,23 @@
using foodmarket.Application.Notifications;
using Microsoft.Extensions.Logging;
namespace foodmarket.Infrastructure.Notifications;
/// <summary>Заглушка push-отправки: ничего не шлёт, только пишет в лог. Включена по
/// умолчанию, пока нет платного Apple Developer (APNs) / ключей FCM / VAPID.
/// Реальные отправители (ApnsPushSender/FcmPushSender/WebPushSender) подменят её в DI,
/// когда появятся ключи — контракт <see cref="IPushSender"/> уже готов.</summary>
public sealed class StubPushSender : IPushSender
{
private readonly ILogger<StubPushSender> _log;
public StubPushSender(ILogger<StubPushSender> log) => _log = log;
public Task SendAsync(IReadOnlyCollection<Guid> userIds, string title, string body,
IReadOnlyDictionary<string, string>? 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;
}
}

View file

@ -76,6 +76,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<DemandLine> DemandLines => Set<DemandLine>();
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
public DbSet<foodmarket.Domain.Organizations.DeviceToken> DeviceTokens => Set<foodmarket.Domain.Organizations.DeviceToken>();
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();

View file

@ -7,6 +7,15 @@ public static class OrganizationsHrConfigurations
{
public static void ConfigureOrganizationsHr(this ModelBuilder b)
{
b.Entity<DeviceToken>(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<EmployeeRole>(e =>
{
e.ToTable("employee_roles");

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase40 — токены устройств для push (<c>device_tokens</c>). Заложено под
/// APNs/FCM/Web Push; реальная отправка пока заглушка (StubPushSender). Идемпотентно.</summary>
[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;");
}
}
}