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
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:
parent
c777b5b692
commit
ff4391d19f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue