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 userId = CurrentUserId();
|
||||||
var s = await _db.CashShifts.AsNoTracking()
|
var s = await _db.CashShifts.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct);
|
.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")]
|
[HttpPost("open"), RequiresPermission("CashShiftOperate")]
|
||||||
|
|
@ -207,15 +210,44 @@ public async Task<ActionResult<object>> Report(Guid id, CancellationToken ct)
|
||||||
.Where(o => o.CashShiftId == id)
|
.Where(o => o.CashShiftId == id)
|
||||||
.OrderBy(o => o.At)
|
.OrderBy(o => o.At)
|
||||||
.ToListAsync(ct);
|
.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
|
return Ok(new
|
||||||
{
|
{
|
||||||
number = s.Number,
|
number = s.Number,
|
||||||
openedAt = s.OpenedAt,
|
openedAt = s.OpenedAt,
|
||||||
closedAt = s.ClosedAt,
|
closedAt = s.ClosedAt,
|
||||||
openingFloat = s.OpeningFloat,
|
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 },
|
sales = new { cash = s.SalesCash, card = s.SalesCard },
|
||||||
returns = new { cash = s.ReturnsCash, card = s.ReturnsCard },
|
returns = new { cash = s.ReturnsCash, card = s.ReturnsCard },
|
||||||
drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops },
|
drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops },
|
||||||
|
byProduct,
|
||||||
expectedCash = s.ExpectedCash,
|
expectedCash = s.ExpectedCash,
|
||||||
actualCash = s.ActualCash,
|
actualCash = s.ActualCash,
|
||||||
cashDifference = s.CashDifference,
|
cashDifference = s.CashDifference,
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,19 @@ public class RetailSalesController : ControllerBase
|
||||||
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||||||
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
|
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
|
||||||
|
|
||||||
|
private readonly foodmarket.Application.Money.IMoneyService _money;
|
||||||
|
|
||||||
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
||||||
foodmarket.Api.Realtime.INotificationsPublisher notify,
|
foodmarket.Api.Realtime.INotificationsPublisher notify,
|
||||||
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal)
|
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal,
|
||||||
|
foodmarket.Application.Money.IMoneyService money)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_stock = stock;
|
_stock = stock;
|
||||||
_log = log;
|
_log = log;
|
||||||
_notify = notify;
|
_notify = notify;
|
||||||
_fiscal = fiscal;
|
_fiscal = fiscal;
|
||||||
|
_money = money;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RetailSaleListRow(
|
public record RetailSaleListRow(
|
||||||
|
|
@ -102,6 +106,13 @@ public record RetailSaleInput(
|
||||||
/// основания; само изменение способа — только с правом администратора.</summary>
|
/// основания; само изменение способа — только с правом администратора.</summary>
|
||||||
string? RefundReason = null);
|
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
|
private static string PaymentLabel(PaymentMethod m) => m switch
|
||||||
{
|
{
|
||||||
PaymentMethod.Cash => "наличные",
|
PaymentMethod.Cash => "наличные",
|
||||||
|
|
@ -626,15 +637,35 @@ private async Task<IActionResult> PostCoreAsync(Guid id, CancellationToken ct)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (openShift == Guid.Empty)
|
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
|
return Conflict(new
|
||||||
{
|
{
|
||||||
error = "На этой точке продаж не открыта кассовая смена. Откройте смену и попробуйте снова.",
|
error = "На этой точке продаж не открыта кассовая смена, и не найден денежный счёт для автооткрытия. Откройте смену вручную.",
|
||||||
code = "cash-shift-required",
|
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;
|
sale.CashShiftId = openShift;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
|
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
|
||||||
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
|
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,10 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments()
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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());
|
var api = new ApiActor(_factory.CreateClient());
|
||||||
await api.SignupAndLoginAsync($"shift-{Guid.NewGuid():N}");
|
await api.SignupAndLoginAsync($"shift-{Guid.NewGuid():N}");
|
||||||
var refs = await api.LoadRefsAsync();
|
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 retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Точка 1");
|
||||||
var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m);
|
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);
|
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 2m, price: 500m);
|
||||||
using (var noShift = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
|
using (var auto = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
|
||||||
{
|
auto.IsSuccessStatusCode.Should().BeTrue($"смена должна автооткрыться: {(int)auto.StatusCode} {await auto.Content.ReadAsStringAsync()}");
|
||||||
((int)noShift.StatusCode).Should().Be(409);
|
|
||||||
(await noShift.Content.ReadAsStringAsync()).Should().Contain("cash-shift-required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открываем смену и повторяем — проходит.
|
// На точке теперь есть открытая смена.
|
||||||
await OpenShiftAsync(api, retailPoint, cash, openingFloat: 0m);
|
var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}");
|
||||||
using var ok = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { });
|
current.GetProperty("open").GetBoolean().Should().BeTrue();
|
||||||
ok.IsSuccessStatusCode.Should().BeTrue($"после открытия смены продажа должна провестись: {(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}");
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue