From 210e8171d3381b08bc29e2cc0725c635526835ef Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 07:11:29 +0500 Subject: [PATCH] =?UTF-8?q?feat(money):=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=B0=20POS-=D1=81=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B5=20+=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=20>24=D1=87=20(=D0=BF.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POS: чек с точки без открытой смены — сервер автооткрывает смену (Cash-счёт точки, размен 0) и принимает чек (как в вебе). Фоновый ShiftWatchJob (раз в час) уведомляет кассира и админов о сменах, открытых дольше 24ч (персистентное уведомление + SignalR, дедуп раз в сутки). Тест POS-автооткрытия. Co-Authored-By: Claude Opus 4.8 --- .../Background/HangfireJobsConfigurator.cs | 8 ++ .../Background/ShiftWatchJob.cs | 81 +++++++++++++++++++ .../Controllers/Pos/PosController.cs | 41 +++++++++- src/food-market.api/Program.cs | 1 + .../PosSyncTests.cs | 27 +++++++ 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/food-market.api/Background/ShiftWatchJob.cs diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index d73f996..d25589e 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -153,6 +153,14 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronNegStock, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Смены открытые >24ч — предупреждение кассиру/админам (мягкое правило). + var cronShiftWatch = _cfg["Hangfire:Cron:ShiftWatch"] ?? "7 * * * *"; // раз в час + _jobs.AddOrUpdate( + recurringJobId: "shift-watch-24h", + methodCall: j => j.RunAsync(CancellationToken.None), + cronExpression: cronShiftWatch, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + return Task.CompletedTask; } diff --git a/src/food-market.api/Background/ShiftWatchJob.cs b/src/food-market.api/Background/ShiftWatchJob.cs new file mode 100644 index 0000000..1a6a80c --- /dev/null +++ b/src/food-market.api/Background/ShiftWatchJob.cs @@ -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; + +/// Следит за кассовыми сменами, открытыми дольше 24 часов (мягкое правило +/// владельца: не жёсткий лимит как у МойСклада, а предупреждение). Раз в час +/// уведомляет кассира смены и администраторов организации (персистентное +/// уведомление + SignalR). Дедуп: одно уведомление на смену в сутки. +public sealed class ShiftWatchJob +{ + private readonly AppDbContext _db; + private readonly INotificationsPublisher _publisher; + private readonly ILogger _log; + + public ShiftWatchJob(AppDbContext db, INotificationsPublisher publisher, ILogger 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(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); + } + } + } +} diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index e8baa40..559175b 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -40,12 +40,16 @@ public class PosController : ControllerBase private readonly IStockService _stock; private readonly ILogger _log; - public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger log) + private readonly foodmarket.Application.Money.IMoneyService _money; + + public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger log, + foodmarket.Application.Money.IMoneyService money) { _db = db; _tenant = tenant; _stock = stock; _log = log; + _money = money; } /// Информация о последней версии кассы для автообновления и для @@ -384,13 +388,44 @@ orderby l.SortOrder } } - // Привязка к смене: если указана — проверяем, что она открыта и наша. + // Привязка к смене. Если указана — проверяем, что открыта. Если НЕ указана, а + // чек привязан к точке — автооткрытие смены первой продажей (как МойСклад, + // как в вебе): берём открытую смену точки либо открываем новую (Cash-счёт + // точки, размен 0). Возврат без смены допускаем (как было). + Guid? linkedShift = null; if (src.CashShiftId is Guid shiftId) { var shiftOk = await _db.CashShifts.AsNoTracking() .AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct); if (!shiftOk) 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); @@ -412,7 +447,7 @@ orderby l.SortOrder CustomerId = src.CustomerId, CashierUserId = src.CashierUserId, RetailPointId = src.RetailPointId, - CashShiftId = src.CashShiftId, + CashShiftId = linkedShift, IsReturn = src.IsReturn, ReferenceSaleId = src.ReferenceSaleId, Payment = (PaymentMethod)src.Payment, diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 411cf18..77e7cac 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -495,6 +495,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme }); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. diff --git a/tests/food-market.IntegrationTests/PosSyncTests.cs b/tests/food-market.IntegrationTests/PosSyncTests.cs index f9fda95..0c9e1c5 100644 --- a/tests/food-market.IntegrationTests/PosSyncTests.cs +++ b/tests/food-market.IntegrationTests/PosSyncTests.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(); + 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(); + } }