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
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:
parent
3579e3583c
commit
210e8171d3
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
81
src/food-market.api/Background/ShiftWatchJob.cs
Normal file
81
src/food-market.api/Background/ShiftWatchJob.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue