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
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:
parent
2d1cdd8519
commit
3547e823c3
|
|
@ -108,3 +108,29 @@ advanced-only скрыты; обычная роль — её флаги (в пр
|
||||||
`complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results`
|
`complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results`
|
||||||
вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены
|
вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены
|
||||||
deprecated после обновления iOS (iOS обновит Mac-Claude).
|
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, заглушка подменяется реальной реализацией без правок
|
||||||
|
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
|
||||||
|
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.
|
||||||
|
|
|
||||||
69
src/food-market.api/Controllers/DeviceTokensController.cs
Normal file
69
src/food-market.api/Controllers/DeviceTokensController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -438,6 +438,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
builder.Services.AddScoped<foodmarket.Application.Inventory.INegativeStockAlertService, foodmarket.Infrastructure.Inventory.NegativeStockAlertService>();
|
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>();
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||||
|
|
||||||
// Sprint 33: модуль «Деньги»
|
// Sprint 33: модуль «Деньги»
|
||||||
|
|
|
||||||
15
src/food-market.application/Notifications/IPushSender.cs
Normal file
15
src/food-market.application/Notifications/IPushSender.cs
Normal 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);
|
||||||
|
}
|
||||||
20
src/food-market.domain/Organizations/DeviceToken.cs
Normal file
20
src/food-market.domain/Organizations/DeviceToken.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<DemandLine> DemandLines => Set<DemandLine>();
|
public DbSet<DemandLine> DemandLines => Set<DemandLine>();
|
||||||
|
|
||||||
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
|
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<Employee> Employees => Set<Employee>();
|
||||||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||||||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@ public static class OrganizationsHrConfigurations
|
||||||
{
|
{
|
||||||
public static void ConfigureOrganizationsHr(this ModelBuilder b)
|
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 =>
|
b.Entity<EmployeeRole>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("employee_roles");
|
e.ToTable("employee_roles");
|
||||||
|
|
|
||||||
|
|
@ -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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue