feat(pos): Z-отчёт по товарам на печать + вопрос «размен?» при автооткрытии (п.1,2)
П.2 (POS): закрытие смены печатает Z-отчёт с разбивкой по товарам (продано/возврат) через ESC/POS; кнопка «Печать Z-отчёта (по товарам)». ShiftReportDto += byProduct/overLimit24h. П.1 (POS): первая продажа без открытой смены (онлайн) — диалог «Открыть смену. Размен? (можно пропустить)» → автооткрытие и переход к оплате; офлайн — как было. П.10: full-chain обновлён — продажа без смены теперь автооткрытие (не 409) + проверка byProduct в Z-отчёте; прогон на стенде 16 ✓ / 0 ✗. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87c4d16ed1
commit
4181bfe8ad
|
|
@ -70,5 +70,7 @@ public record OpenShiftResultDto(Guid id, string number);
|
||||||
public record CloseShiftBody(decimal ActualCash, string? Notes);
|
public record CloseShiftBody(decimal ActualCash, string? Notes);
|
||||||
public record CloseShiftResultDto(decimal expectedCash, decimal difference);
|
public record CloseShiftResultDto(decimal expectedCash, decimal difference);
|
||||||
public record DrawerBody(int Direction, decimal Amount, string Reason); // 1=In, 2=Out
|
public record DrawerBody(int Direction, decimal Amount, string Reason); // 1=In, 2=Out
|
||||||
public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference);
|
public record ShiftProductRow(string name, decimal soldQty, decimal soldAmount, decimal returnedQty, decimal returnedAmount);
|
||||||
|
public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference,
|
||||||
|
System.Collections.Generic.IReadOnlyList<ShiftProductRow>? byProduct = null, bool overLimit24h = false);
|
||||||
public record ShiftSales(decimal cash, decimal card);
|
public record ShiftSales(decimal cash, decimal card);
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,11 @@ public partial class SaleViewModel : ViewModelBase
|
||||||
partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty));
|
partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty));
|
||||||
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount));
|
partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount));
|
||||||
|
|
||||||
|
// ── Автооткрытие смены первой продажей: вопрос «размен?» (можно пропустить) ─
|
||||||
|
[ObservableProperty] private bool _isShiftOpenPromptOpen;
|
||||||
|
[ObservableProperty] private decimal? _shiftOpenFloat;
|
||||||
|
[ObservableProperty] private bool _shiftOpenBusy;
|
||||||
|
|
||||||
// ── Быстрое создание товара по неизвестному штрихкоду (онлайн) ────────────
|
// ── Быстрое создание товара по неизвестному штрихкоду (онлайн) ────────────
|
||||||
[ObservableProperty] private bool _isQuickCreateOpen;
|
[ObservableProperty] private bool _isQuickCreateOpen;
|
||||||
[ObservableProperty] private string _quickCreateBarcode = "";
|
[ObservableProperty] private string _quickCreateBarcode = "";
|
||||||
|
|
@ -690,13 +695,48 @@ private void RemoveLine(SaleLineVm line)
|
||||||
private void Pay()
|
private void Pay()
|
||||||
{
|
{
|
||||||
if (_cart.IsEmpty) { Message = "Чек пуст — нечего оплачивать"; return; }
|
if (_cart.IsEmpty) { Message = "Чек пуст — нечего оплачивать"; return; }
|
||||||
if (_svc is not null && !_svc.Checkout.HasOpenShift) { Message = "Смена не открыта — F10, откройте смену"; return; }
|
if (_svc is not null && !_svc.Checkout.HasOpenShift)
|
||||||
|
{
|
||||||
|
// Смена не открыта: онлайн — автооткрытие первой продажей с вопросом
|
||||||
|
// «размен?» (можно пропустить). Офлайн — просим открыть смену вручную.
|
||||||
|
if (_svc.Api is not null) { ShiftOpenFloat = null; IsShiftOpenPromptOpen = true; return; }
|
||||||
|
Message = "Смена не открыта — F10, откройте смену"; return;
|
||||||
|
}
|
||||||
|
OpenPaymentModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenPaymentModal()
|
||||||
|
{
|
||||||
var pvm = new PaymentViewModel { Total = Total };
|
var pvm = new PaymentViewModel { Total = Total };
|
||||||
pvm.OnConfirm = (cash, card, change) => CommitPaymentAsync(cash, card, change);
|
pvm.OnConfirm = (cash, card, change) => CommitPaymentAsync(cash, card, change);
|
||||||
if (PaymentRequested is not null) PaymentRequested(pvm);
|
if (PaymentRequested is not null) PaymentRequested(pvm);
|
||||||
else _ = CommitPaymentAsync(Total, 0m, 0m); // без оболочки (тест/демо) — оплата наличными «под расчёт»
|
else _ = CommitPaymentAsync(Total, 0m, 0m); // без оболочки (тест/демо) — оплата наличными «под расчёт»
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void ShiftOpenCancel() => IsShiftOpenPromptOpen = false;
|
||||||
|
|
||||||
|
/// <summary>Открыть смену первой продажей (с введённым разменом или без) и
|
||||||
|
/// продолжить к оплате. Размен необязателен — можно пропустить (0).</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ShiftOpenConfirmAsync()
|
||||||
|
{
|
||||||
|
if (_svc?.Api is null || ShiftOpenBusy) return;
|
||||||
|
if (_svc.Session?.RetailPointId is not Guid rp) { Message = "Нет привязки к точке"; IsShiftOpenPromptOpen = false; return; }
|
||||||
|
ShiftOpenBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var accounts = await _svc.Api.MoneyAccountsAsync();
|
||||||
|
var cash = accounts.items.FirstOrDefault(a => a.kind == 1 && a.isActive) ?? accounts.items.FirstOrDefault(a => a.kind == 1);
|
||||||
|
if (cash is null) { Message = "Не найдена наличная касса (создайте счёт в админке)"; return; }
|
||||||
|
var res = await _svc.Api.OpenShiftAsync(new OpenShiftBody(rp, cash.id, null, ShiftOpenFloat ?? 0m));
|
||||||
|
_svc.Checkout.CurrentShift = new PosShift { Id = res.id, Number = res.number, RetailPointId = rp, CashAccountId = cash.id, OpeningFloat = ShiftOpenFloat ?? 0m };
|
||||||
|
IsShiftOpenPromptOpen = false;
|
||||||
|
OpenPaymentModal();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Message = "Не удалось открыть смену: " + ex.Message; }
|
||||||
|
finally { ShiftOpenBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Провести оплату: офлайн-устойчивая запись чека + печать + попытка
|
/// <summary>Провести оплату: офлайн-устойчивая запись чека + печать + попытка
|
||||||
/// отправки. Очищает корзину.</summary>
|
/// отправки. Очищает корзину.</summary>
|
||||||
public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change)
|
public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change)
|
||||||
|
|
@ -916,14 +956,59 @@ private async Task CloseShift()
|
||||||
{
|
{
|
||||||
var res = await _svc.Api.CloseShiftAsync(s.Id, new CloseShiftBody(ActualCash, null));
|
var res = await _svc.Api.CloseShiftAsync(s.Id, new CloseShiftBody(ActualCash, null));
|
||||||
IsOpen = false;
|
IsOpen = false;
|
||||||
|
_lastClosedShiftId = s.Id;
|
||||||
_svc.Checkout.CurrentShift = null;
|
_svc.Checkout.CurrentShift = null;
|
||||||
OnShiftChanged?.Invoke(null);
|
OnShiftChanged?.Invoke(null);
|
||||||
Message = $"Смена закрыта. Должно {res.expectedCash:N0} ₸, факт {ActualCash:N0} ₸, разница {res.difference:N0} ₸";
|
Message = $"Смена закрыта. Должно {res.expectedCash:N0} ₸, факт {ActualCash:N0} ₸, разница {res.difference:N0} ₸";
|
||||||
|
await PrintZReportAsync(s.Id); // Z-отчёт с разбивкой по товарам на печать
|
||||||
}
|
}
|
||||||
catch (Exception ex) { Message = "Закрыть смену не удалось: " + ex.Message; }
|
catch (Exception ex) { Message = "Закрыть смену не удалось: " + ex.Message; }
|
||||||
finally { Busy = false; }
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Guid? _lastClosedShiftId;
|
||||||
|
|
||||||
|
/// <summary>Печать Z-отчёта текущей (или только что закрытой) смены с разбивкой по товарам.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PrintZReport()
|
||||||
|
{
|
||||||
|
var id = _svc?.Checkout.CurrentShift?.Id ?? _lastClosedShiftId;
|
||||||
|
if (_svc?.Api is null || id is not Guid sid) { Message = "Нет смены для Z-отчёта"; return; }
|
||||||
|
await PrintZReportAsync(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrintZReportAsync(Guid shiftId)
|
||||||
|
{
|
||||||
|
if (_svc?.Api is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _svc.Api.ShiftReportAsync(shiftId);
|
||||||
|
var doc = new foodmarket.Pos.Hardware.ReceiptDoc { Title = $"Z-ОТЧЁТ {r.number}" };
|
||||||
|
doc.Add("Размен", $"{r.openingFloat:N0}");
|
||||||
|
doc.Add("Продажи нал", $"{r.sales.cash:N0}");
|
||||||
|
doc.Add("Продажи карта", $"{r.sales.card:N0}");
|
||||||
|
doc.Add("Возвраты нал", $"-{r.returns.cash:N0}");
|
||||||
|
doc.Separator();
|
||||||
|
doc.Add("Ожидается нал", $"{r.expectedCash:N0}", bold: true);
|
||||||
|
if (r.actualCash is { } ac) doc.Add("Факт нал", $"{ac:N0}");
|
||||||
|
doc.Add("Разница", $"{r.cashDifference:N0}");
|
||||||
|
if (r.overLimit24h) doc.Add("! Смена открыта > 24ч");
|
||||||
|
if (r.byProduct is { Count: > 0 } bp)
|
||||||
|
{
|
||||||
|
doc.Separator();
|
||||||
|
doc.Add("ПО ТОВАРАМ", center: true, bold: true);
|
||||||
|
foreach (var p in bp)
|
||||||
|
{
|
||||||
|
doc.Add(p.name, $"{p.soldQty:0.###} = {p.soldAmount:N0}");
|
||||||
|
if (p.returnedQty > 0) doc.Add(" возврат", $"-{p.returnedQty:0.###} = {p.returnedAmount:N0}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _svc.Printer.PrintAsync(doc);
|
||||||
|
Message = "Z-отчёт отправлен на печать";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Message = "Печать Z-отчёта не удалась: " + ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task DepositCash() => await DrawerAsync(1, DrawerIn, "Внесение");
|
private async Task DepositCash() => await DrawerAsync(1, DrawerIn, "Внесение");
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,23 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Автооткрытие смены первой продажей: вопрос «размен?» (можно пропустить) -->
|
||||||
|
<Border Grid.ColumnSpan="2" Background="#99000000" IsVisible="{Binding IsShiftOpenPromptOpen}">
|
||||||
|
<Border Width="440" Background="{DynamicResource PosCard}" CornerRadius="14" Padding="24"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Center" BorderBrush="{DynamicResource PosBorder}" BorderThickness="1">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="Открыть смену" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource PosText}"/>
|
||||||
|
<TextBlock Text="Смена ещё не открыта. Внести размен в кассу? (можно пропустить)" Foreground="{DynamicResource PosTextMuted}" TextWrapping="Wrap"/>
|
||||||
|
<TextBox Watermark="Размен, ₸ (необязательно)" FontSize="16" Text="{Binding ShiftOpenFloat}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Отмена" Padding="18,10" Command="{Binding ShiftOpenCancelCommand}"/>
|
||||||
|
<Button Content="Открыть и продолжить" Padding="18,10" Background="{DynamicResource PosBrand}" Foreground="White"
|
||||||
|
Command="{Binding ShiftOpenConfirmCommand}" IsEnabled="{Binding !ShiftOpenBusy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Быстрое создание товара по неизвестному штрихкоду (онлайн) -->
|
<!-- Быстрое создание товара по неизвестному штрихкоду (онлайн) -->
|
||||||
<Border Grid.ColumnSpan="2" Background="#99000000" IsVisible="{Binding IsQuickCreateOpen}">
|
<Border Grid.ColumnSpan="2" Background="#99000000" IsVisible="{Binding IsQuickCreateOpen}">
|
||||||
<Border Width="440" Background="{DynamicResource PosCard}" CornerRadius="14" Padding="24"
|
<Border Width="440" Background="{DynamicResource PosCard}" CornerRadius="14" Padding="24"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
<Button Grid.Column="2" Classes="pos ghost" Margin="5,0,0,0" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
|
<Button Grid.Column="2" Classes="pos ghost" Margin="5,0,0,0" Content="Выплата" Command="{Binding WithdrawCashCommand}" IsEnabled="{Binding IsOpen}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Button Classes="pos danger" HorizontalAlignment="Stretch" Height="52" Content="Закрыть смену (Z-отчёт)" Command="{Binding CloseShiftCommand}" IsEnabled="{Binding IsOpen}"/>
|
<Button Classes="pos danger" HorizontalAlignment="Stretch" Height="52" Content="Закрыть смену (Z-отчёт)" Command="{Binding CloseShiftCommand}" IsEnabled="{Binding IsOpen}"/>
|
||||||
|
<Button Classes="pos ghost" HorizontalAlignment="Stretch" Height="44" Margin="0,8,0,0" Content="Печать Z-отчёта (по товарам)" Command="{Binding PrintZReportCommand}"/>
|
||||||
<TextBlock Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" TextWrapping="Wrap" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
<TextBlock Text="{Binding Message}" Foreground="{DynamicResource PosTextMuted}" TextWrapping="Wrap" IsVisible="{Binding Message, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
|
||||||
|
|
@ -965,6 +965,9 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
|
||||||
const z = await api.get(`/api/money/shifts/${shiftId}/report`)
|
const z = await api.get(`/api/money/shifts/${shiftId}/report`)
|
||||||
const zOk = z.status === 200 && approxEq(Number(z.data?.sales?.cash), salesCash) && approxEq(Number(z.data?.expectedCash), expected)
|
const zOk = z.status === 200 && approxEq(Number(z.data?.sales?.cash), salesCash) && approxEq(Number(z.data?.expectedCash), expected)
|
||||||
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
|
rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`)
|
||||||
|
// Паритет п.2: Z-отчёт содержит разбивку по товарам.
|
||||||
|
const zByProd = Array.isArray(z.data?.byProduct) && z.data.byProduct.length > 0
|
||||||
|
rec(step, 'api', 'Z-отчёт: разбивка по товарам (byProduct)', zByProd, '>0', (z.data?.byProduct?.length ?? 0))
|
||||||
|
|
||||||
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
|
ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash }
|
||||||
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
|
ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 })
|
||||||
|
|
@ -978,11 +981,16 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) {
|
||||||
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
|
const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0
|
||||||
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
|
rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal)
|
||||||
|
|
||||||
// продажа без смены → отказ (смена уже закрыта)
|
// Паритет п.1: продажа без открытой смены → АВТООТКРЫТИЕ новой смены (как МойСклад),
|
||||||
|
// раньше был отказ 409. Чек проводится; подчищаем автооткрытую смену и чек.
|
||||||
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
|
const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id })
|
||||||
rec(step, 'api', 'Продажа без открытой смены → отказ', noShift.postStatus === 409, 409, noShift.postStatus)
|
const autoOpened = noShift.postStatus === 200 || noShift.postStatus === 204
|
||||||
if (noShift.id && noShift.postStatus !== 409) report.bug({ step: '09', severity: 'high', title: 'Продажа без смены прошла', detail: `post=${noShift.postStatus}` })
|
rec(step, 'api', 'Продажа без смены → автооткрытие смены', autoOpened, '204', noShift.postStatus)
|
||||||
if (noShift.id) await api.delete(`/api/sales/retail/${noShift.id}`)
|
if (noShift.id && !autoOpened) report.bug({ step: '09', severity: 'high', title: 'Продажа без смены не автооткрыла смену', detail: `post=${noShift.postStatus}` })
|
||||||
|
// Закрыть автооткрытую смену, чтобы не мешала последующим шагам.
|
||||||
|
const cur = await api.get(`/api/money/shifts/current?retailPointId=${ctx.point1Id}`)
|
||||||
|
const autoShiftId = cur.data?.open ? cur.data?.shift?.id : null
|
||||||
|
if (autoShiftId) await api.post(`/api/money/shifts/${autoShiftId}/close`, { ActualCash: 0, Notes: 'Автооткрытая — закрыта в e2e' })
|
||||||
|
|
||||||
// UI: экран смен + Z-отчёт закрытой смены + чеки продаж
|
// UI: экран смен + Z-отчёт закрытой смены + чеки продаж
|
||||||
await capture(ctx, step, '/money/shifts', '09-smeny-spisok', 'Смены кассы: открытие/закрытие, размен')
|
await capture(ctx, step, '/money/shifts', '09-smeny-spisok', 'Смены кассы: открытие/закрытие, размен')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue