feat(money): автооткрытие смены первой продажей + Z-отчёт по товарам + 24ч
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

П.1: продажа на точке без открытой смены — смена автооткрывается (подбор Cash-счёта
точки/орг., размен 0, можно внести позже), как МойСклад; ручное открытие остаётся.
Длительность смены и отметка overLimit24h в /current и Z-отчёте (мягкое правило:
предупреждение + отметка, не жёсткий лимит).
П.2: Z-отчёт /shifts/{id}/report теперь с разбивкой byProduct (продано кол-во/сумма,
возвращено кол-во/сумма).

Тест переписан: продажа без смены → автооткрытие + проводится; /current open=true;
Z-отчёт содержит byProduct с верными кол-вом/суммой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 05:58:31 +05:00
parent c777b5b692
commit ff4391d19f
3 changed files with 94 additions and 21 deletions

View file

@ -154,7 +154,10 @@ public async Task<ActionResult<object>> Current([FromQuery] Guid retailPointId,
var userId = CurrentUserId();
var s = await _db.CashShifts.AsNoTracking()
.FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct);
return s is null ? Ok(new { open = false }) : Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId });
if (s is null) return Ok(new { open = false });
var hours = (DateTime.UtcNow - s.OpenedAt).TotalHours;
return Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId,
durationHours = Math.Round(hours, 1), overLimit24h = hours > 24 });
}
[HttpPost("open"), RequiresPermission("CashShiftOperate")]
@ -207,15 +210,44 @@ public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct)
.Where(o => o.CashShiftId == id)
.OrderBy(o => o.At)
.ToListAsync(ct);
// Разбивка по товарам (как Z-отчёт МойСклада): по каждому товару смены —
// продано (кол-во/выручка) и возвращено (кол-во/сумма). Берём проведённые
// чеки смены; продажи и возвраты разделяем по IsReturn.
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
join rs in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals rs.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where rs.CashShiftId == id && rs.Status == Domain.Sales.RetailSaleStatus.Posted
select new { p.Name, l.ProductId, l.Quantity, l.LineTotal, rs.IsReturn })
.ToListAsync(ct);
var byProduct = lines
.GroupBy(x => new { x.ProductId, x.Name })
.Select(g => new
{
productId = g.Key.ProductId,
name = g.Key.Name,
soldQty = g.Where(x => !x.IsReturn).Sum(x => x.Quantity),
soldAmount = g.Where(x => !x.IsReturn).Sum(x => x.LineTotal),
returnedQty = g.Where(x => x.IsReturn).Sum(x => x.Quantity),
returnedAmount = g.Where(x => x.IsReturn).Sum(x => x.LineTotal),
})
.OrderByDescending(x => x.soldAmount)
.ToList();
return Ok(new
{
number = s.Number,
openedAt = s.OpenedAt,
closedAt = s.ClosedAt,
openingFloat = s.OpeningFloat,
// Длительность смены и отметка превышения 24ч (мягкое правило: у МойСклада
// жёсткий лимит, у нас — предупреждение + отметка в отчёте).
durationHours = Math.Round(((s.ClosedAt ?? DateTime.UtcNow) - s.OpenedAt).TotalHours, 1),
overLimit24h = ((s.ClosedAt ?? DateTime.UtcNow) - s.OpenedAt).TotalHours > 24,
sales = new { cash = s.SalesCash, card = s.SalesCard },
returns = new { cash = s.ReturnsCash, card = s.ReturnsCard },
drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops },
byProduct,
expectedCash = s.ExpectedCash,
actualCash = s.ActualCash,
cashDifference = s.CashDifference,

View file

@ -22,15 +22,19 @@ public class RetailSalesController : ControllerBase
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
private readonly foodmarket.Application.Money.IMoneyService _money;
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
foodmarket.Api.Realtime.INotificationsPublisher notify,
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal)
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal,
foodmarket.Application.Money.IMoneyService money)
{
_db = db;
_stock = stock;
_log = log;
_notify = notify;
_fiscal = fiscal;
_money = money;
}
public record RetailSaleListRow(
@ -102,6 +106,13 @@ public record RetailSaleInput(
/// основания; само изменение способа — только с правом администратора.</summary>
string? RefundReason = null);
private Guid CurrentUserId()
{
var sub = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(sub, out var g) ? g : Guid.Empty;
}
private static string PaymentLabel(PaymentMethod m) => m switch
{
PaymentMethod.Cash => "наличные",
@ -626,15 +637,35 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
.FirstOrDefaultAsync(ct);
if (openShift == Guid.Empty)
{
// АВТООТКРЫТИЕ смены первой операцией (как МойСклад): подбираем
// денежный счёт кассы точки (Cash на складе точки, иначе первый
// Cash орг.) и открываем смену с нулевым разменом (размен можно
// внести позже внесением). Если счёта нет — просим открыть вручную.
var rp = await _db.RetailPoints.AsNoTracking()
.Where(x => x.Id == rpId).Select(x => new { x.StoreId }).FirstOrDefaultAsync(ct);
var cashAccountId = await _db.MoneyAccounts
.Where(a => a.Kind == foodmarket.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 (cashAccountId is not { } cashAcc)
return Conflict(new
{
error = "На этой точке продаж не открыта кассовая смена. Откройте смену и попробуйте снова.",
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
code = "cash-shift-required",
});
var opened = await _money.OpenShiftAsync(rpId, cashAcc, null, 0m, CurrentUserId(), ct);
if (opened.Status != foodmarket.Application.Money.MoneyServiceStatus.Ok || opened.ShiftId is not { } newShift)
return Conflict(new { error = opened.Message ?? "Не удалось открыть смену автоматически.", code = "cash-shift-required" });
_log.LogInformation("Смена автооткрыта первой продажей: shift={ShiftId} point={PointId}", newShift, rpId);
sale.CashShiftId = newShift;
}
else
{
// Привязываем чек к открытой смене.
sale.CashShiftId = openShift;
}
}
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
if (sale.IsReturn) return await PostReturnAsync(sale, ct);

View file

@ -156,8 +156,10 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments()
}
[Fact]
public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open()
public async Task Sale_without_shift_auto_opens_shift_and_report_has_breakdown()
{
// Паритет п.1: смена автооткрывается первой продажей (как МойСклад), если
// есть денежный счёт. Z-отчёт (п.2) содержит разбивку по товарам.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"shift-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
@ -167,18 +169,26 @@ public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open()
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Точка 1");
var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m);
// Продажа на точке БЕЗ смены → 409 cash-shift-required.
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 1m, price: 500m);
using (var noShift = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
{
((int)noShift.StatusCode).Should().Be(409);
(await noShift.Content.ReadAsStringAsync()).Should().Contain("cash-shift-required");
}
// Продажа на точке БЕЗ открытой смены → смена автооткрывается, чек проводится.
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 2m, price: 500m);
using (var auto = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
auto.IsSuccessStatusCode.Should().BeTrue($"смена должна автооткрыться: {(int)auto.StatusCode} {await auto.Content.ReadAsStringAsync()}");
// Открываем смену и повторяем — проходит.
await OpenShiftAsync(api, retailPoint, cash, openingFloat: 0m);
using var ok = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
ok.IsSuccessStatusCode.Should().BeTrue($"после открытия смены продажа должна провестись: {(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}");
// На точке теперь есть открытая смена.
var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}");
current.GetProperty("open").GetBoolean().Should().BeTrue();
current.GetProperty("overLimit24h").GetBoolean().Should().BeFalse();
var shiftId = current.GetProperty("shift").GetProperty("id").GetString();
// Z-отчёт содержит разбивку по товарам (п.2).
var report = await api.GetJsonAsync($"/api/money/shifts/{shiftId}/report");
report.TryGetProperty("byProduct", out var bp).Should().BeTrue();
var rows = bp.EnumerateArray().ToList();
rows.Should().ContainSingle(r => r.GetProperty("productId").GetString() == product);
var row = rows.First(r => r.GetProperty("productId").GetString() == product);
row.GetProperty("soldQty").GetDecimal().Should().Be(2m);
row.GetProperty("soldAmount").GetDecimal().Should().Be(1000m);
report.GetProperty("overLimit24h").GetBoolean().Should().BeFalse();
}
[Fact]