Files
meezi/docs/MEEZI_NEXT_SPRINT_PLAN.md
T
soroush.asadi 03376b3ea1 feat(docker): multi-stage Dockerfiles with npmmirror registry
Rewrites dashboard and finder Dockerfiles to use a clean multi-stage
build (deps → builder → runner) that installs npm packages inside
Alpine Linux, avoiding the SWC musl binary issue when building from
Windows host. Uses registry.npmmirror.com for reliable installs from
restricted networks (Iran).

- docker/api/Dockerfile: .NET 10 multi-stage build
- docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/website/Dockerfile: marketing website build
- scripts/: PowerShell helper scripts for local dev

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:33:29 +03:30

1213 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.*