feat(pos): Z-отчёт по товарам на печать + вопрос «размен?» при автооткрытии (п.1,2)
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

П.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:
nns 2026-10-09 07:38:07 +05:00
parent 87c4d16ed1
commit 4181bfe8ad
5 changed files with 119 additions and 6 deletions

View file

@ -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);

View file

@ -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]

View file

@ -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"

View file

@ -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>

View file

@ -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', 'Смены кассы: открытие/закрытие, размен')