feat(money): автооткрытие смены на POS-синке + уведомление о сменах >24ч (п.1)
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

POS: чек с точки без открытой смены — сервер автооткрывает смену (Cash-счёт точки,
размен 0) и принимает чек (как в вебе). Фоновый ShiftWatchJob (раз в час) уведомляет
кассира и админов о сменах, открытых дольше 24ч (персистентное уведомление + SignalR,
дедуп раз в сутки). Тест POS-автооткрытия.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 07:11:29 +05:00
parent 3579e3583c
commit 210e8171d3
5 changed files with 155 additions and 3 deletions

View file

@ -153,6 +153,14 @@ public Task StartAsync(CancellationToken ct)
cronExpression: cronNegStock, cronExpression: cronNegStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// Смены открытые >24ч — предупреждение кассиру/админам (мягкое правило).
var cronShiftWatch = _cfg["Hangfire:Cron:ShiftWatch"] ?? "7 * * * *"; // раз в час
_jobs.AddOrUpdate<ShiftWatchJob>(
recurringJobId: "shift-watch-24h",
methodCall: j => j.RunAsync(CancellationToken.None),
cronExpression: cronShiftWatch,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask; return Task.CompletedTask;
} }

View file

@ -0,0 +1,81 @@
using System.Text.Json;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Realtime;
using foodmarket.Domain.Money;
using foodmarket.Domain.Notifications;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Следит за кассовыми сменами, открытыми дольше 24 часов (мягкое правило
/// владельца: не жёсткий лимит как у МойСклада, а предупреждение). Раз в час
/// уведомляет кассира смены и администраторов организации (персистентное
/// уведомление + SignalR). Дедуп: одно уведомление на смену в сутки.</summary>
public sealed class ShiftWatchJob
{
private readonly AppDbContext _db;
private readonly INotificationsPublisher _publisher;
private readonly ILogger<ShiftWatchJob> _log;
public ShiftWatchJob(AppDbContext db, INotificationsPublisher publisher, ILogger<ShiftWatchJob> log)
{ _db = db; _publisher = publisher; _log = log; }
public async Task RunAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var cutoff = now.AddHours(-24);
var orgs = await _db.Organizations.IgnoreQueryFilters()
.Where(o => !o.IsArchived && o.IsActive)
.Select(o => new { o.Id }).ToListAsync(ct);
foreach (var org in orgs)
{
try
{
using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false);
var overdue = await _db.CashShifts
.Where(s => s.Status == CashShiftStatus.Open && s.OpenedAt <= cutoff)
.Select(s => new { s.Id, s.Number, s.OpenedAt, s.CashierUserId })
.ToListAsync(ct);
if (overdue.Count == 0) continue;
// Получатели — админы орг. + кассиры затронутых смен.
var adminIds = await _db.UserRoles.AsNoTracking()
.Join(_db.Roles.AsNoTracking(), ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name })
.Where(x => x.Name == "Admin").Select(x => x.UserId).ToListAsync(ct);
var activeAdmins = await _db.Users.Where(u => adminIds.Contains(u.Id) && u.IsActive).Select(u => u.Id).ToListAsync(ct);
foreach (var s in overdue)
{
// Дедуп: не уведомляли по этой смене за последние 24ч.
var already = await _db.Notifications.AnyAsync(
n => n.Type == "shift-overdue" && n.CreatedAt > cutoff && n.StoreId == null
&& n.DataJson != null && n.DataJson.Contains(s.Id.ToString()), ct);
if (already) continue;
var recipients = new HashSet<Guid>(activeAdmins);
if (s.CashierUserId != Guid.Empty) recipients.Add(s.CashierUserId);
var hours = (int)(now - s.OpenedAt).TotalHours;
var title = $"Смена {s.Number} открыта {hours} ч — закройте её";
var body = $"Кассовая смена {s.Number} открыта дольше 24 часов (с {s.OpenedAt:dd.MM HH:mm} UTC). Проведите Z-отчёт и закройте смену.";
var dataJson = JsonSerializer.Serialize(new { shiftId = s.Id, number = s.Number, hours });
foreach (var uid in recipients)
_db.Notifications.Add(new Notification
{
OrganizationId = org.Id, RecipientUserId = uid, Type = "shift-overdue",
Severity = "warning", Title = title, Body = body, Link = "/money/cash-shifts",
DataJson = dataJson,
});
await _db.SaveChangesAsync(ct);
await _publisher.PublishAsync(org.Id, NotificationEvents.NotificationCreated,
new NotificationCreatedPayload("shift-overdue", title, Guid.Empty, overdue.Count, true));
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "ShiftWatch failed for org={OrgId}", org.Id);
}
}
}
}

View file

@ -40,12 +40,16 @@ public class PosController : ControllerBase
private readonly IStockService _stock; private readonly IStockService _stock;
private readonly ILogger<PosController> _log; private readonly ILogger<PosController> _log;
public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log) private readonly foodmarket.Application.Money.IMoneyService _money;
public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log,
foodmarket.Application.Money.IMoneyService money)
{ {
_db = db; _db = db;
_tenant = tenant; _tenant = tenant;
_stock = stock; _stock = stock;
_log = log; _log = log;
_money = money;
} }
/// <summary>Информация о последней версии кассы для автообновления и для /// <summary>Информация о последней версии кассы для автообновления и для
@ -384,13 +388,44 @@ orderby l.SortOrder
} }
} }
// Привязка к смене: если указана — проверяем, что она открыта и наша. // Привязка к смене. Если указана — проверяем, что открыта. Если НЕ указана, а
// чек привязан к точке — автооткрытие смены первой продажей (как МойСклад,
// как в вебе): берём открытую смену точки либо открываем новую (Cash-счёт
// точки, размен 0). Возврат без смены допускаем (как было).
Guid? linkedShift = null;
if (src.CashShiftId is Guid shiftId) if (src.CashShiftId is Guid shiftId)
{ {
var shiftOk = await _db.CashShifts.AsNoTracking() var shiftOk = await _db.CashShifts.AsNoTracking()
.AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct); .AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct);
if (!shiftOk) if (!shiftOk)
throw new PosSaleRejectedException("Смена не найдена или закрыта.", "cashShiftId"); throw new PosSaleRejectedException("Смена не найдена или закрыта.", "cashShiftId");
linkedShift = shiftId;
}
else if (!src.IsReturn && src.RetailPointId is Guid rpId)
{
var open = await _db.CashShifts
.Where(s => s.RetailPointId == rpId && s.Status == Domain.Money.CashShiftStatus.Open)
.Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
if (open is { } o) linkedShift = o;
else
{
var rp = await _db.RetailPoints.AsNoTracking().Where(x => x.Id == rpId)
.Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct);
var cashAcc = await _db.MoneyAccounts
.Where(a => a.Kind == Domain.Money.MoneyAccountKind.Cash && a.IsActive
&& (a.StoreId == rp!.StoreId || a.StoreId == null))
.OrderByDescending(a => a.StoreId == rp!.StoreId)
.Select(a => (Guid?)a.Id).FirstOrDefaultAsync(ct);
if (cashAcc is { } acc)
{
var opened = await _money.OpenShiftAsync(rpId, acc, null, 0m, src.CashierUserId ?? Guid.Empty, ct);
if (opened.Status == foodmarket.Application.Money.MoneyServiceStatus.Ok && opened.ShiftId is { } ns)
{
linkedShift = ns;
_log.LogInformation("POS: смена автооткрыта первой продажей point={PointId} shift={ShiftId}", rpId, ns);
}
}
}
} }
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
@ -412,7 +447,7 @@ orderby l.SortOrder
CustomerId = src.CustomerId, CustomerId = src.CustomerId,
CashierUserId = src.CashierUserId, CashierUserId = src.CashierUserId,
RetailPointId = src.RetailPointId, RetailPointId = src.RetailPointId,
CashShiftId = src.CashShiftId, CashShiftId = linkedShift,
IsReturn = src.IsReturn, IsReturn = src.IsReturn,
ReferenceSaleId = src.ReferenceSaleId, ReferenceSaleId = src.ReferenceSaleId,
Payment = (PaymentMethod)src.Payment, Payment = (PaymentMethod)src.Payment,

View file

@ -495,6 +495,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
}); });
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>(); builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>(); builder.Services.AddScoped<foodmarket.Api.Background.NegativeStockNotificationJob>();
builder.Services.AddScoped<foodmarket.Api.Background.ShiftWatchJob>();
// Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio,
// fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs.

View file

@ -383,4 +383,31 @@ private static PosSaleDto MakeSale(string productId, decimal qty, decimal price)
}, },
}, },
}; };
[Fact]
public async Task Pos_sale_without_shift_auto_opens_shift_on_sync()
{
// Паритет п.1 (POS-синк): чек с точки без открытой смены → смена
// автооткрывается на сервере (как в вебе), чек принимается.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"posauto-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 50m);
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "POS-точка");
await api.CreateMoneyAccountAsync("Касса точки", 1 /*Cash*/, refs.CurrencyId, refs.StoreId, 0m);
var sale = MakeSale(product, 2m, 100m) with { RetailPointId = Guid.Parse(retailPoint) };
var batch = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sale } };
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.EnsureSuccessStatusCode();
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
resp!.Accepted.Should().HaveCount(1, "смена должна автооткрыться и чек принят");
resp.Failed.Should().BeEmpty();
// На точке теперь открыта смена.
var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}");
current.GetProperty("open").GetBoolean().Should().BeTrue();
}
} }