feat(menu): per-item print station (cold bar / kitchen / barista)
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m28s

Each menu item can now pick its own print station, overriding the category's —
so a category can fan out to different printers (e.g. a drink → cold bar, a
food → kitchen). Adds MenuItem.KitchenStationId (+ migration, FK SetNull), wires
create/update/DTO, and updates kitchen-ticket routing to group by the item's
station ?? the category's station ?? the branch kitchen printer. Deleting a
station now also clears item assignments. Menu item editor gains a "Print
station" dropdown (default = "same as category"). fa/en/ar added.

Backend built clean via the Nexus mirror; migration applies on deploy (MigrateAsync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 10:08:07 +03:30
parent aede5bfd97
commit 27b3ac60c7
14 changed files with 3693 additions and 70 deletions
+6 -3
View File
@@ -55,7 +55,8 @@ public record MenuItemDto(
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool IsAvailable);
bool IsAvailable,
string? KitchenStationId);
public record CreateMenuItemRequest(
string CategoryId,
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
string? ImageUrl = null,
string? VideoUrl = null,
string? Model3dUrl = null,
bool IsAvailable = true);
bool IsAvailable = true,
string? KitchenStationId = null);
public record UpdateMenuItemRequest(
string? CategoryId,
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool? IsAvailable);
bool? IsAvailable,
string? KitchenStationId);
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
@@ -114,6 +114,12 @@ public class KitchenStationService : IKitchenStationService
foreach (var cat in categories)
cat.KitchenStationId = null;
var items = await _db.MenuItems
.Where(i => i.KitchenStationId == id && i.CafeId == cafeId)
.ToListAsync(ct);
foreach (var item in items)
item.KitchenStationId = null;
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
+6 -2
View File
@@ -139,7 +139,8 @@ public class MenuService : IMenuService
ImageUrl = imageUrl,
VideoUrl = request.VideoUrl,
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
IsAvailable = request.IsAvailable
IsAvailable = request.IsAvailable,
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
};
_db.MenuItems.Add(entity);
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
if (request.Model3dUrl is not null)
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
if (request.KitchenStationId is not null)
entity.KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId;
await _db.SaveChangesAsync(cancellationToken);
return ToItemDto(entity);
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
i.VideoUrl,
i.Model3dUrl,
i.IsAvailable);
i.IsAvailable,
i.KitchenStationId);
}
@@ -76,15 +76,16 @@ public class NetworkPrinterService : IPrinterService
return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
var categoryStations = await (
// Per-item station overrides the category's station; fall back to category.
var itemStations = await (
from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
select new { m.Id, c.KitchenStationId }
select new { m.Id, StationId = m.KitchenStationId ?? c.KitchenStationId }
).ToListAsync(ct);
var stationIds = categoryStations
.Select(x => x.KitchenStationId)
var stationIds = itemStations
.Select(x => x.StationId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
@@ -99,8 +100,8 @@ public class NetworkPrinterService : IPrinterService
var groups = activeItems
.GroupBy(item =>
{
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return cat?.KitchenStationId;
var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return map?.StationId;
})
.ToList();
@@ -12,4 +12,5 @@ public class KitchenStation : TenantEntity
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<MenuCategory> Categories { get; set; } = [];
public ICollection<MenuItem> MenuItems { get; set; } = [];
}
+4
View File
@@ -15,9 +15,13 @@ public class MenuItem : TenantEntity
/// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
public string? Model3dUrl { get; set; }
public bool IsAvailable { get; set; } = true;
/// <summary>Optional per-item print station (cold bar, kitchen, barista …).
/// Overrides the item's category station when set.</summary>
public string? KitchenStationId { get; set; }
public Cafe Cafe { get; set; } = null!;
public MenuCategory Category { get; set; } = null!;
public KitchenStation? KitchenStation { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = [];
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
public ICollection<MenuItemIngredient> RecipeIngredients { get; set; } = [];
@@ -271,6 +271,7 @@ public class AppDbContext : DbContext
e.Property(x => x.Price).HasPrecision(18, 2);
e.HasOne(x => x.Cafe).WithMany(c => c.MenuItems).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Category).WithMany(c => c.MenuItems).HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.KitchenStation).WithMany(s => s.MenuItems).HasForeignKey(x => x.KitchenStationId).OnDelete(DeleteBehavior.SetNull);
e.HasQueryFilter(x => x.DeletedAt == null);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddMenuItemKitchenStation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KitchenStationId",
table: "MenuItems",
type: "text",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_MenuItems_KitchenStationId",
table: "MenuItems",
column: "KitchenStationId");
migrationBuilder.AddForeignKey(
name: "FK_MenuItems_KitchenStations_KitchenStationId",
table: "MenuItems",
column: "KitchenStationId",
principalTable: "KitchenStations",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MenuItems_KitchenStations_KitchenStationId",
table: "MenuItems");
migrationBuilder.DropIndex(
name: "IX_MenuItems_KitchenStationId",
table: "MenuItems");
migrationBuilder.DropColumn(
name: "KitchenStationId",
table: "MenuItems");
}
}
}
@@ -707,6 +707,46 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Coupons");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("CafeId");
b.ToTable("CustomRoles");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Property<string>("Id")
@@ -928,46 +968,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("DemoRequests");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("CafeId");
b.ToTable("CustomRoles");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.Property<string>("Id")
@@ -1516,6 +1516,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("boolean");
b.Property<string>("KitchenStationId")
.HasColumnType("text");
b.Property<string>("Model3dUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@@ -1543,6 +1546,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasIndex("CategoryId");
b.HasIndex("KitchenStationId");
b.ToTable("MenuItems");
});
@@ -2824,6 +2829,17 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2857,17 +2873,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
.WithMany()
.HasForeignKey("CafeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cafe");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3031,9 +3036,16 @@ namespace Meezi.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation")
.WithMany("MenuItems")
.HasForeignKey("KitchenStationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Cafe");
b.Navigation("Category");
b.Navigation("KitchenStation");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
@@ -3401,16 +3413,16 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{
b.Navigation("Employees");
});
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{
b.Navigation("Attendances");
@@ -3436,6 +3448,8 @@ namespace Meezi.Infrastructure.Data.Migrations
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
{
b.Navigation("Categories");
b.Navigation("MenuItems");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
+2 -1
View File
@@ -917,7 +917,8 @@
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
"deleteCategorySuccess": "تم حذف الفئة",
"printStationInherit": "نفس الفئة"
},
"branchMenu": {
"title": "قائمة الفرع",
+2 -1
View File
@@ -951,7 +951,8 @@
"deleteItemSuccess": "Item deleted",
"deleteCategoryConfirmTitle": "Delete category",
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
"deleteCategorySuccess": "Category deleted"
"deleteCategorySuccess": "Category deleted",
"printStationInherit": "Same as category"
},
"branchMenu": {
"title": "Branch Menu",
+2 -1
View File
@@ -951,7 +951,8 @@
"deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد"
"deleteCategorySuccess": "دسته حذف شد",
"printStationInherit": "مثل دستهٔ منو"
},
"branchMenu": {
"title": "منوی شعبه",
@@ -73,6 +73,7 @@ interface MenuItem {
videoUrl?: string;
model3dUrl?: string;
isAvailable: boolean;
kitchenStationId?: string | null;
}
interface ItemForm {
@@ -84,6 +85,7 @@ interface ItemForm {
imageUrl: string;
videoUrl: string;
model3dUrl: string;
kitchenStationId: string;
}
interface CatForm {
@@ -114,6 +116,7 @@ const defaultItemForm: ItemForm = {
imageUrl: "",
videoUrl: "",
model3dUrl: "",
kitchenStationId: "",
};
const defaultCatForm: CatForm = {
@@ -296,6 +299,7 @@ export function MenuAdminScreen() {
imageUrl: itemForm.imageUrl || null,
videoUrl: itemForm.videoUrl || null,
model3dUrl: itemForm.model3dUrl || null,
kitchenStationId: itemForm.kitchenStationId || null,
}),
onSuccess: () => {
setItemModalOpen(false);
@@ -314,6 +318,7 @@ export function MenuAdminScreen() {
imageUrl: mediaField(itemForm.imageUrl),
videoUrl: mediaField(itemForm.videoUrl),
model3dUrl: mediaField(itemForm.model3dUrl),
kitchenStationId: itemForm.kitchenStationId || null,
}),
onSuccess: () => {
setItemModalOpen(false);
@@ -411,6 +416,7 @@ export function MenuAdminScreen() {
imageUrl: item.imageUrl ?? "",
videoUrl: item.videoUrl ?? "",
model3dUrl: item.model3dUrl ?? "",
kitchenStationId: item.kitchenStationId ?? "",
});
setItemModalOpen(true);
};
@@ -956,6 +962,26 @@ export function MenuAdminScreen() {
) : null}
</LabeledField>
{stations.length > 0 ? (
<LabeledField label={t("printStation")} htmlFor="modal-item-station">
<select
id="modal-item-station"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={itemForm.kitchenStationId}
onChange={(e) =>
setItemForm((f) => ({ ...f, kitchenStationId: e.target.value }))
}
>
<option value="">{t("printStationInherit")}</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</LabeledField>
) : null}
{/* Actions */}
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingItem ? (