Files
meezi/docs/MEEZI_NEXT_SPRINT_PLAN.md
T

1213 lines
32 KiB
Markdown
Raw Normal View History

# Meezi — Sprint Plan: POS Operations Polish + Hardening
> **Copy-paste this entire file into Cursor as your implementation guide.**
> Stack: ASP.NET Core 10 · Next.js 14 · PostgreSQL 16 · Redis · SignalR
> Last updated: 2026-05-21
---
## Overview
Five self-contained PRs, ordered by dependency. Do them in sequence.
| PR | Title | Effort |
|----|-------|--------|
| PR-1 | Void Order Line | ~2h |
| PR-2 | Transfer Table | ~2h |
| PR-3 | SignalR Table Board Realtime | ~3h |
| PR-4 | Receipt Print Preview | ~2h |
| PR-5 | Integration Test Coverage | ~2h |
| PR-6 | Fix NU1903 Package Warnings | ~1h |
---
## PR-1 — Void Order Line
### Goal
Allow staff to void (cancel) an individual line item on an open order. Only `Manager` role can void.
### Files to create / edit
```
src/Meezi.Core/Entities/OrderItem.cs ← add IsVoided, VoidedAt, VoidedByUserId
src/Meezi.Core/Entities/Order.cs ← update TotalAmount computed property
src/Meezi.Infrastructure/Data/Migrations/ ← new migration: VoidOrderLine
src/Meezi.API/Services/OrderService.cs ← add VoidOrderItemAsync
src/Meezi.API/Controllers/OrdersController.cs ← add PATCH endpoint
src/Meezi.API/DTOs/OrderItemDto.cs ← add IsVoided
web/dashboard/src/components/pos/pos-screen.tsx ← void button per line
web/dashboard/src/lib/api/orders.ts ← voidOrderItem()
messages/fa.json, en.json, ar.json ← void strings
```
---
### Step 1 — Domain: `OrderItem.cs`
Add to the existing `OrderItem` entity:
```csharp
// src/Meezi.Core/Entities/OrderItem.cs
public bool IsVoided { get; set; } = false;
public DateTime? VoidedAt { get; set; }
public Guid? VoidedByUserId { get; set; }
```
---
### Step 2 — Domain: `Order.cs`
Update `TotalAmount` (or wherever line totals are summed) to exclude voided items:
```csharp
// In Order.cs — wherever TotalAmount is computed
public decimal TotalAmount => Items
.Where(i => !i.IsVoided)
.Sum(i => i.UnitPrice * i.Quantity);
```
If `TotalAmount` is a stored column rather than computed, update `OrderService` instead (see Step 4).
---
### Step 3 — EF Migration
```bash
cd src/Meezi.Infrastructure
dotnet ef migrations add VoidOrderLine \
--startup-project ../../src/Meezi.API \
--output-dir Data/Migrations
```
Verify the generated migration adds:
- `is_voided boolean not null default false`
- `voided_at timestamp with time zone null`
- `voided_by_user_id uuid null`
---
### Step 4 — Service: `OrderService.cs`
Add this method:
```csharp
// src/Meezi.API/Services/OrderService.cs
public async Task<ApiResponse<OrderDto>> VoidOrderItemAsync(
Guid orderId, Guid itemId, Guid voidedByUserId, CancellationToken ct = default)
{
var order = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct);
if (order is null)
return ApiResponse<OrderDto>.Fail("ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Closed)
return ApiResponse<OrderDto>.Fail("ORDER_ALREADY_CLOSED");
var item = order.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return ApiResponse<OrderDto>.Fail("ITEM_NOT_FOUND");
if (item.IsVoided)
return ApiResponse<OrderDto>.Fail("ITEM_ALREADY_VOIDED");
item.IsVoided = true;
item.VoidedAt = DateTime.UtcNow;
item.VoidedByUserId = voidedByUserId;
// Recompute stored total if not computed property
order.TotalAmount = order.Items
.Where(i => !i.IsVoided)
.Sum(i => i.UnitPrice * i.Quantity);
await _db.SaveChangesAsync(ct);
return ApiResponse<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
}
```
---
### Step 5 — Controller: `OrdersController.cs`
Add endpoint (inside the existing controller class):
```csharp
// src/Meezi.API/Controllers/OrdersController.cs
/// <summary>Void a single line item. Requires Manager role.</summary>
[HttpPatch("{orderId}/items/{itemId}/void")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> VoidOrderItem(
Guid cafeId, Guid orderId, Guid itemId, CancellationToken ct)
{
var userId = User.GetUserId(); // existing helper
var result = await _orderService.VoidOrderItemAsync(orderId, itemId, userId, ct);
return result.IsSuccess ? Ok(result) : BadRequest(result);
}
```
---
### Step 6 — DTO: `OrderItemDto.cs`
```csharp
public bool IsVoided { get; set; }
public DateTime? VoidedAt { get; set; }
```
Add to AutoMapper profile if explicit mappings exist.
---
### Step 7 — Dashboard: API helper
```typescript
// web/dashboard/src/lib/api/orders.ts
export async function voidOrderItem(
cafeId: string,
orderId: string,
itemId: string
): Promise<void> {
await apiClient.patch(
`/cafes/${cafeId}/orders/${orderId}/items/${itemId}/void`
);
}
```
---
### Step 8 — Dashboard: UI in `pos-screen.tsx`
Inside the line-item render loop, add a void button visible only to managers:
```tsx
// web/dashboard/src/components/pos/pos-screen.tsx
{isManager && !item.isVoided && (
<button
className="text-red-500 text-xs ms-2 hover:underline"
onClick={() => handleVoidItem(item.id)}
aria-label={t("pos.voidItem")}
>
{t("pos.void")}
</button>
)}
{item.isVoided && (
<span className="text-muted-foreground line-through text-xs">
{t("pos.voided")}
</span>
)}
```
Handler:
```tsx
const handleVoidItem = async (itemId: string) => {
if (!confirm(t("pos.confirmVoid"))) return;
await voidOrderItem(cafeId, activeOrderId, itemId);
await reloadOrder(); // existing reload helper
};
```
---
### Step 9 — i18n strings
```json
// messages/fa.json
"pos": {
"void": "ابطال",
"voided": "ابطال شده",
"confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟"
}
// messages/en.json
"pos": {
"void": "Void",
"voided": "Voided",
"confirmVoid": "Are you sure you want to void this item?"
}
// messages/ar.json
"pos": {
"void": "إلغاء",
"voided": "ملغى",
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟"
}
```
---
### PR-1 Test Plan
**Unit/integration (add to `OrderSessionTests.cs`):**
```
✓ Void item reduces order total
✓ Void already-voided item returns ITEM_ALREADY_VOIDED
✓ Void item on closed order returns ORDER_ALREADY_CLOSED
✓ Non-manager role returns 403
```
**Manual:**
1. Open table, add 3 items.
2. As Manager, click Void on item 2 — confirm dialog appears.
3. Confirm — item shows strikethrough, total updates immediately.
4. As Cashier (non-manager), verify void button is hidden.
5. Pay remaining items — order closes normally.
---
---
## PR-2 — Transfer Table
### Goal
Move an open order from one table to another. Clears source table, assigns order to target table. Blocked if target table is already occupied.
### Files to create / edit
```
src/Meezi.API/Services/OrderService.cs ← TransferTableAsync
src/Meezi.API/Services/TableService.cs ← helper status check
src/Meezi.API/Controllers/OrdersController.cs ← POST endpoint
src/Meezi.API/DTOs/TransferTableRequest.cs ← new DTO
web/dashboard/src/components/pos/pos-table-board.tsx ← transfer UI
web/dashboard/src/lib/api/orders.ts ← transferTable()
messages/fa.json, en.json, ar.json ← transfer strings
```
---
### Step 1 — Request DTO
```csharp
// src/Meezi.API/DTOs/TransferTableRequest.cs
public record TransferTableRequest(Guid TargetTableId);
```
---
### Step 2 — Service: `OrderService.cs`
```csharp
public async Task<ApiResponse<OrderDto>> TransferTableAsync(
Guid orderId, Guid targetTableId, CancellationToken ct = default)
{
var order = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == _tenant.CafeId, ct);
if (order is null)
return ApiResponse<OrderDto>.Fail("ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Closed)
return ApiResponse<OrderDto>.Fail("ORDER_ALREADY_CLOSED");
// Check target table exists and belongs to this cafe
var targetTable = await _db.Tables
.FirstOrDefaultAsync(t => t.Id == targetTableId && t.CafeId == _tenant.CafeId, ct);
if (targetTable is null)
return ApiResponse<OrderDto>.Fail("TABLE_NOT_FOUND");
// Check target not occupied
var targetOccupied = await _db.Orders.AnyAsync(
o => o.TableId == targetTableId &&
o.CafeId == _tenant.CafeId &&
o.Status == OrderStatus.Open &&
o.Id != orderId, ct);
if (targetOccupied)
return ApiResponse<OrderDto>.Fail("TABLE_OCCUPIED");
if (targetTable.IsCleaning)
return ApiResponse<OrderDto>.Fail("TABLE_CLEANING");
// Free source table (nothing to update on Table entity — occupancy is inferred from open orders)
var sourceTableId = order.TableId;
order.TableId = targetTableId;
await _db.SaveChangesAsync(ct);
return ApiResponse<OrderDto>.Ok(_mapper.Map<OrderDto>(order));
}
```
---
### Step 3 — Controller: `OrdersController.cs`
```csharp
/// <summary>Transfer an open order to another table.</summary>
[HttpPost("{orderId}/transfer")]
[Authorize(Roles = "Manager,Owner,Waiter")]
public async Task<IActionResult> TransferTable(
Guid cafeId,
Guid orderId,
[FromBody] TransferTableRequest request,
CancellationToken ct)
{
var result = await _orderService.TransferTableAsync(orderId, request.TargetTableId, ct);
return result.IsSuccess ? Ok(result) : BadRequest(result);
}
```
---
### Step 4 — Dashboard: API helper
```typescript
// web/dashboard/src/lib/api/orders.ts
export async function transferTable(
cafeId: string,
orderId: string,
targetTableId: string
): Promise<void> {
await apiClient.post(
`/cafes/${cafeId}/orders/${orderId}/transfer`,
{ targetTableId }
);
}
```
---
### Step 5 — Dashboard: Transfer UI in `pos-table-board.tsx`
Add a "Transfer" button in the order action bar (next to Pay). On click, show a table-picker modal listing free tables only:
```tsx
// In pos-table-board.tsx — inside the active order action row
<button
className="btn-outline text-sm"
onClick={() => setShowTransferPicker(true)}
>
{t("pos.transferTable")}
</button>
{showTransferPicker && (
<TablePickerModal
freeTables={tables.filter(t => !t.isOccupied && !t.isCleaning && t.id !== currentTableId)}
onSelect={async (tableId) => {
await transferTable(cafeId, activeOrderId, tableId);
setShowTransferPicker(false);
router.push(`/pos?tableId=${tableId}&orderId=${activeOrderId}`);
}}
onClose={() => setShowTransferPicker(false)}
/>
)}
```
`TablePickerModal` is a simple grid of table buttons — reuse the existing table card style from the board.
---
### Step 6 — i18n strings
```json
// messages/fa.json
"pos": {
"transferTable": "انتقال میز",
"selectTargetTable": "میز مقصد را انتخاب کنید",
"transferSuccess": "سفارش با موفقیت منتقل شد"
}
// messages/en.json
"pos": {
"transferTable": "Transfer Table",
"selectTargetTable": "Select destination table",
"transferSuccess": "Order transferred successfully"
}
// messages/ar.json
"pos": {
"transferTable": "نقل الطاولة",
"selectTargetTable": "اختر الطاولة المستهدفة",
"transferSuccess": "تم نقل الطلب بنجاح"
}
```
---
### PR-2 Test Plan
**Integration tests:**
```
✓ Transfer moves order to free target table
✓ Transfer to occupied table returns TABLE_OCCUPIED
✓ Transfer to cleaning table returns TABLE_CLEANING
✓ Transfer closed order returns ORDER_ALREADY_CLOSED
✓ Transfer to table in different cafe returns TABLE_NOT_FOUND
```
**Manual:**
1. Open two tables (A occupied, B free).
2. On table A's order, click Transfer Table.
3. Pick table B — verify board shows B occupied, A free.
4. Verify URL updates to `?tableId=B&orderId=...`.
5. Try transferring to occupied table — verify blocked with error toast.
---
---
## PR-3 — SignalR Table Board Realtime
### Goal
When any order/payment/cleaning event fires, all connected dashboard clients see the board update without refresh.
### Files to create / edit
```
src/Meezi.API/Hubs/TableBoardHub.cs ← new SignalR hub
src/Meezi.API/Services/BoardNotifier.cs ← new service, sends hub events
src/Meezi.API/Extensions/ServiceCollectionExtensions.cs ← register hub + notifier
src/Meezi.API/Program.cs ← map hub endpoint
src/Meezi.API/Services/OrderService.cs ← call notifier after mutations
src/Meezi.API/Services/TableService.cs ← call notifier after cleaning toggle
web/dashboard/src/lib/hooks/useTableBoard.ts ← new hook, SignalR subscription
web/dashboard/src/components/pos/pos-table-board.tsx ← use hook
```
---
### Step 1 — Hub: `TableBoardHub.cs`
```csharp
// src/Meezi.API/Hubs/TableBoardHub.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Meezi.API.Hubs;
[Authorize]
public class TableBoardHub : Hub
{
// Clients join a cafe-specific group on connect
public async Task JoinCafe(string cafeId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"cafe:{cafeId}");
}
public async Task LeaveCafe(string cafeId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"cafe:{cafeId}");
}
}
```
---
### Step 2 — Notifier: `BoardNotifier.cs`
```csharp
// src/Meezi.API/Services/BoardNotifier.cs
using Microsoft.AspNetCore.SignalR;
using Meezi.API.Hubs;
namespace Meezi.API.Services;
public interface IBoardNotifier
{
Task TableUpdatedAsync(Guid cafeId, Guid tableId);
Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId);
}
public class BoardNotifier : IBoardNotifier
{
private readonly IHubContext<TableBoardHub> _hub;
public BoardNotifier(IHubContext<TableBoardHub> hub)
{
_hub = hub;
}
public async Task TableUpdatedAsync(Guid cafeId, Guid tableId)
{
await _hub.Clients
.Group($"cafe:{cafeId}")
.SendAsync("TableUpdated", new { tableId });
}
public async Task OrderUpdatedAsync(Guid cafeId, Guid orderId, Guid? tableId)
{
await _hub.Clients
.Group($"cafe:{cafeId}")
.SendAsync("OrderUpdated", new { orderId, tableId });
}
}
```
---
### Step 3 — Register in DI and map endpoint
In `ServiceCollectionExtensions.cs`, add:
```csharp
services.AddScoped<IBoardNotifier, BoardNotifier>();
services.AddSignalR(); // already added if KDS uses it — ensure not duplicated
```
In `Program.cs`, after `app.MapControllers()`:
```csharp
app.MapHub<TableBoardHub>("/hubs/table-board")
.RequireAuthorization();
```
---
### Step 4 — Hook into services
In `OrderService.cs`, inject `IBoardNotifier` via constructor. After every mutation that changes table state, call the notifier:
```csharp
// After order closed (payment completes):
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId);
// After void item:
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, order.TableId);
// After transfer:
await _boardNotifier.TableUpdatedAsync(order.CafeId, sourceTableId);
await _boardNotifier.OrderUpdatedAsync(order.CafeId, order.Id, targetTableId);
```
In `TableService.cs`, after cleaning toggle:
```csharp
await _boardNotifier.TableUpdatedAsync(table.CafeId, table.Id);
```
---
### Step 5 — Dashboard hook: `useTableBoard.ts`
```typescript
// web/dashboard/src/lib/hooks/useTableBoard.ts
import { useEffect, useCallback } from "react";
import * as signalR from "@microsoft/signalr";
let connection: signalR.HubConnection | null = null;
export function useTableBoardRealtime(
cafeId: string,
onTableUpdated: (tableId: string) => void,
onOrderUpdated: (orderId: string, tableId: string | null) => void
) {
const connect = useCallback(async () => {
if (connection) return;
connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/table-board", {
accessTokenFactory: () => getAccessToken(), // existing auth helper
})
.withAutomaticReconnect()
.build();
connection.on("TableUpdated", ({ tableId }: { tableId: string }) => {
onTableUpdated(tableId);
});
connection.on("OrderUpdated", ({ orderId, tableId }: { orderId: string; tableId: string | null }) => {
onOrderUpdated(orderId, tableId);
});
await connection.start();
await connection.invoke("JoinCafe", cafeId);
}, [cafeId, onTableUpdated, onOrderUpdated]);
useEffect(() => {
connect();
return () => {
connection?.stop();
connection = null;
};
}, [connect]);
}
```
Install SignalR client if not already present:
```bash
cd web/dashboard
npm install @microsoft/signalr
```
---
### Step 6 — Wire into `pos-table-board.tsx`
```tsx
// web/dashboard/src/components/pos/pos-table-board.tsx
useTableBoardRealtime(
cafeId,
(_tableId) => {
// Refresh the full board (or targeted table)
reloadBoard();
},
(_orderId, _tableId) => {
reloadBoard();
}
);
```
`reloadBoard` should call the existing `GET /tables` + `GET /orders/open` (or whatever the board currently uses).
---
### PR-3 Test Plan
**Manual (two browser tabs):**
1. Open board in Tab A, same cafe in Tab B.
2. In Tab B, pay an order on Table 3.
3. Tab A — verify Table 3 changes to green (free) without refresh.
4. In Tab B, mark Table 5 as cleaning.
5. Tab A — verify Table 5 shows cleaning state within ~1 second.
6. Reload Tab A — verify reconnect works (SignalR `withAutomaticReconnect`).
**Iran/network note:** SignalR falls back to long-polling if WebSocket is blocked. No extra config needed — `HubConnectionBuilder` handles this automatically.
---
---
## PR-4 — Receipt Print Preview
### Goal
Thermal-style (80mm) print preview for a paid or open order. Uses `window.print()` — no hardware SDK required.
### Files to create / edit
```
web/dashboard/src/components/pos/pos-receipt-modal.tsx ← new component
web/dashboard/src/components/pos/pos-receipt-print.css ← thermal print styles
web/dashboard/src/components/pos/pos-pay-panel.tsx ← trigger after payment
web/dashboard/src/components/pos/pos-table-board.tsx ← "Print" button on closed orders (optional)
web/dashboard/src/lib/api/orders.ts ← getOrder() if not already
messages/fa.json, en.json, ar.json ← receipt strings
```
---
### Step 1 — Print CSS: `pos-receipt-print.css`
```css
/* web/dashboard/src/components/pos/pos-receipt-print.css */
@media print {
body * { visibility: hidden; }
#receipt-print-area,
#receipt-print-area * { visibility: visible; }
#receipt-print-area {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
}
}
#receipt-print-area {
width: 80mm;
font-family: 'Courier New', monospace;
font-size: 12px;
direction: rtl;
text-align: right;
padding: 4mm;
}
.receipt-divider {
border-top: 1px dashed #000;
margin: 3mm 0;
}
.receipt-row {
display: flex;
justify-content: space-between;
}
.receipt-total {
font-weight: bold;
font-size: 14px;
}
```
---
### Step 2 — Receipt Component: `pos-receipt-modal.tsx`
```tsx
// web/dashboard/src/components/pos/pos-receipt-modal.tsx
"use client";
import { useTranslations } from "next-intl";
import "./pos-receipt-print.css";
import type { OrderDto } from "@/lib/api/types";
interface Props {
order: OrderDto;
cafeName: string;
onClose: () => void;
}
export function PosReceiptModal({ order, cafeName, onClose }: Props) {
const t = useTranslations("receipt");
const handlePrint = () => window.print();
const activeItems = order.items.filter((i) => !i.isVoided);
const formattedDate = new Intl.DateTimeFormat("fa-IR", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(order.createdAt));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl p-4 w-[340px] shadow-xl">
{/* Screen preview */}
<div id="receipt-print-area" className="border border-dashed border-gray-300 p-3 mb-4">
<div className="text-center font-bold text-base mb-1">{cafeName}</div>
<div className="text-center text-xs text-gray-500 mb-2">{formattedDate}</div>
<div className="text-xs mb-1">
{t("table")}: {order.tableName ?? "—"} | {t("order")}: #{order.orderNumber}
</div>
{order.guestName && (
<div className="text-xs mb-1">{t("guest")}: {order.guestName}</div>
)}
<div className="receipt-divider" />
{activeItems.map((item) => (
<div key={item.id} className="receipt-row text-xs mb-1">
<span>{item.productName} × {item.quantity}</span>
<span>{formatCurrency(item.unitPrice * item.quantity)}</span>
</div>
))}
<div className="receipt-divider" />
<div className="receipt-row receipt-total">
<span>{t("total")}</span>
<span>{formatCurrency(order.totalAmount)}</span>
</div>
{order.payments?.map((p, i) => (
<div key={i} className="receipt-row text-xs mt-1">
<span>{t(`payment.${p.method.toLowerCase()}`)}</span>
<span>{formatCurrency(p.amount)}</span>
</div>
))}
<div className="receipt-divider" />
<div className="text-center text-xs mt-2">{t("thankYou")}</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
className="flex-1 btn-primary text-sm"
onClick={handlePrint}
>
{t("print")}
</button>
<button
className="flex-1 btn-outline text-sm"
onClick={onClose}
>
{t("close")}
</button>
</div>
</div>
</div>
);
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat("fa-IR").format(amount) + " تومان";
}
```
---
### Step 3 — Trigger from `pos-pay-panel.tsx`
After a successful payment response, show the receipt:
```tsx
// After successful payment call in pos-pay-panel.tsx
const [receiptOrder, setReceiptOrder] = useState<OrderDto | null>(null);
// On payment success:
setReceiptOrder(paidOrder); // paidOrder = the OrderDto returned by the payment API
// In JSX:
{receiptOrder && (
<PosReceiptModal
order={receiptOrder}
cafeName={cafe.name}
onClose={() => {
setReceiptOrder(null);
onPaymentComplete(); // existing callback
}}
/>
)}
```
---
### Step 4 — i18n strings
```json
// messages/fa.json
"receipt": {
"table": "میز",
"order": "سفارش",
"guest": "مهمان",
"total": "مجموع",
"print": "چاپ",
"close": "بستن",
"thankYou": "ممنون از انتخاب شما",
"payment": {
"cash": "نقد",
"card": "کارت",
"credit": "اعتبار"
}
}
// messages/en.json
"receipt": {
"table": "Table",
"order": "Order",
"guest": "Guest",
"total": "Total",
"print": "Print",
"close": "Close",
"thankYou": "Thank you for your visit",
"payment": {
"cash": "Cash",
"card": "Card",
"credit": "Credit"
}
}
// messages/ar.json
"receipt": {
"table": "الطاولة",
"order": "الطلب",
"guest": "الضيف",
"total": "الإجمالي",
"print": "طباعة",
"close": "إغلاق",
"thankYou": "شكراً على زيارتكم",
"payment": {
"cash": "نقداً",
"card": "بطاقة",
"credit": "رصيد"
}
}
```
---
### PR-4 Test Plan
**Manual:**
1. Open table, add items, pay (split cash + card).
2. Verify receipt modal auto-opens after payment.
3. Verify all items listed, voided items excluded.
4. Verify payment methods shown (Cash X, Card Y).
5. Click Print — browser print dialog opens; preview shows 80mm receipt layout.
6. Test in Persian (fa) — text is RTL, numbers in Persian numerals.
7. Close modal — board reflects freed table.
---
---
## PR-5 — Integration Test Coverage
### Goal
Fill the gaps in `OrderSessionTests.cs` and add auth + payment integration tests.
### Files to create / edit
```
tests/Meezi.API.Tests/OrderSessionTests.cs ← extend existing
tests/Meezi.API.Tests/OrderVoidTransferTests.cs ← new
tests/Meezi.API.Tests/PaymentSplitTests.cs ← new
tests/Meezi.API.Tests/AuthTests.cs ← new
```
---
### `OrderVoidTransferTests.cs` — full file
```csharp
// tests/Meezi.API.Tests/OrderVoidTransferTests.cs
using System.Net;
using System.Net.Http.Json;
using Meezi.API.DTOs;
using Xunit;
namespace Meezi.API.Tests;
public class OrderVoidTransferTests : IClassFixture<MeeziWebApplicationFactory>
{
private readonly HttpClient _client;
public OrderVoidTransferTests(MeeziWebApplicationFactory factory)
{
_client = factory.CreateAuthenticatedClient(role: "Manager");
}
[Fact]
public async Task VoidItem_ReducesOrderTotal()
{
// Arrange: create order with 2 items
var (cafeId, orderId, items) = await CreateOrderWithItems(2);
var itemId = items[0].Id;
var originalTotal = items.Sum(i => i.UnitPrice * i.Quantity);
// Act
var response = await _client.PatchAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
// Assert
response.EnsureSuccessStatusCode();
var order = await GetOrder(cafeId, orderId);
Assert.Equal(originalTotal - items[0].UnitPrice, order.TotalAmount);
}
[Fact]
public async Task VoidItem_AlreadyVoided_ReturnsBadRequest()
{
var (cafeId, orderId, items) = await CreateOrderWithItems(1);
var itemId = items[0].Id;
await _client.PatchAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
var response = await _client.PatchAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/items/{itemId}/void", new { });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ApiError>();
Assert.Equal("ITEM_ALREADY_VOIDED", error?.Code);
}
[Fact]
public async Task TransferTable_MovesOrderToFreeTable()
{
var (cafeId, orderId, sourceTableId, targetTableId) = await SetupTwoTables();
var response = await _client.PostAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/transfer",
new { targetTableId });
response.EnsureSuccessStatusCode();
// Source table should be free
Assert.False(await IsTableOccupied(cafeId, sourceTableId));
// Target table should be occupied
Assert.True(await IsTableOccupied(cafeId, targetTableId));
}
[Fact]
public async Task TransferTable_ToOccupiedTable_ReturnsTableOccupied()
{
// Both tables occupied
var (cafeId, order1Id, table1Id, _) = await SetupTwoTables();
var (_, order2Id, table2Id, _) = await SetupTwoTables(cafeId);
var response = await _client.PostAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{order1Id}/transfer",
new { targetTableId = table2Id });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ApiError>();
Assert.Equal("TABLE_OCCUPIED", error?.Code);
}
// Helper methods omitted for brevity — follow MeeziWebApplicationFactory patterns
}
```
---
### `PaymentSplitTests.cs` — key cases
```csharp
[Fact]
public async Task SplitPayment_CashAndCard_ClosesOrder()
{
var (cafeId, orderId) = await CreateOrderWithTotal(1000m);
var response = await _client.PostAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/payments",
new {
payments = new[] {
new { method = "Cash", amount = 600 },
new { method = "Card", amount = 400 }
}
});
response.EnsureSuccessStatusCode();
var order = await GetOrder(cafeId, orderId);
Assert.Equal("Closed", order.Status);
Assert.Equal(2, order.Payments.Length);
}
[Fact]
public async Task Payment_FreesTableOnBoard()
{
var (cafeId, orderId, tableId) = await CreateOrderOnTable();
await _client.PostAsJsonAsync(
$"/api/cafes/{cafeId}/orders/{orderId}/payments",
new { payments = new[] { new { method = "Cash", amount = 500 } } });
var tableStatus = await GetTableStatus(cafeId, tableId);
Assert.Equal("Free", tableStatus);
}
```
---
### `AuthTests.cs` — key cases
```csharp
[Fact]
public async Task UnauthorizedRequest_Returns401()
{
var anonClient = _factory.CreateClient(); // no auth
var response = await anonClient.GetAsync("/api/cafes/00000000/orders");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task WrongCafeId_Returns403OrEmpty()
{
var response = await _client.GetAsync("/api/cafes/99999999/orders");
// Either 403 or empty list — not another cafe's data
Assert.True(
response.StatusCode == HttpStatusCode.Forbidden ||
(await response.Content.ReadFromJsonAsync<ApiResponse<List<OrderDto>>>())?.Data?.Count == 0
);
}
```
---
### Run tests
```bash
dotnet test tests/Meezi.API.Tests/Meezi.API.Tests.csproj -c Release --logger "console;verbosity=normal"
```
Expected: all existing 13 + new ~12 tests pass.
---
---
## PR-6 — Fix NU1903 Package Warnings
### Goal
Eliminate the two vulnerability warnings that appear on every build.
### Warning 1: AutoMapper 12.0.1
**Option A — Bump AutoMapper (preferred):**
```xml
<!-- Directory.Packages.props -->
<!-- Replace: -->
<PackageVersion Include="AutoMapper" Version="12.0.1" />
<!-- With: -->
<PackageVersion Include="AutoMapper" Version="13.0.1" />
```
Then verify no breaking changes (AutoMapper 13 removed some static APIs):
```bash
dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep -i error
```
**Option B — Replace with Mapperly (zero-reflection, .NET 10 ideal):**
Only do this if AutoMapper 13 has breaking changes. Mapperly generates mapping code at compile time. Migration guide: https://mapperly.riok.app/docs/getting-started/migration/automapper/
---
### Warning 2: System.Security.Cryptography.Xml 9.0.0
This is a transitive dependency. Pin it to the fixed version:
```xml
<!-- Directory.Packages.props — add: -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="9.0.5" />
```
Then in the project that pulls it transitively (likely `Meezi.Infrastructure`):
```xml
<!-- Meezi.Infrastructure.csproj — add: -->
<PackageReference Include="System.Security.Cryptography.Xml" />
```
(No version needed — central package management handles it.)
---
### Verify
```bash
dotnet build src/Meezi.API/Meezi.API.csproj -c Release 2>&1 | grep NU1903
# Expected: no output
```
---
---
## Implementation Order & Branching
```
main
└── feature/void-order-line (PR-1, ~2h)
└── feature/transfer-table (PR-2, depends on PR-1 merge)
└── feature/signalr-board (PR-3, depends on PR-2)
└── feature/receipt (PR-4, independent of PR-3 but nice with it)
feature/integration-tests (PR-5, can start any time in parallel)
feature/fix-nu1903 (PR-6, can start any time in parallel)
```
PRs 5 and 6 are independent — open them alongside PR-1 in parallel.
---
## Non-Negotiables (from `.cursorrules`)
- Every EF query: `CafeId == _tenant.CafeId`
- Responses: `ApiResponse<T>` / `ApiError` with error codes
- No hardcoded UI strings — all in `messages/{fa,ar,en}.json`
- Dashboard CSS: `ms-*` / `me-*` only (RTL-safe)
- Iran Docker: if any PR adds a new NuGet or npm package, document it; Docker pulls may need VPN/mirror
- Tests: in-memory EF, `Testing:Enabled=true`, no live Redis/Hangfire in test mode
---
## Quick Reference — Error Codes Added This Sprint
| Code | Meaning |
|------|---------|
| `ITEM_NOT_FOUND` | Line item ID not on this order |
| `ITEM_ALREADY_VOIDED` | Tried to void an already-voided line |
| `TABLE_CLEANING` | Target table is in cleaning state |
| `ORDER_ALREADY_CLOSED` | Mutation attempted on a closed order |
---
*End of plan — paste this file into Cursor and start with PR-1.*