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;"); + } + } +}