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? ImageUrl,
string? VideoUrl, string? VideoUrl,
string? Model3dUrl, string? Model3dUrl,
bool IsAvailable); bool IsAvailable,
string? KitchenStationId);
public record CreateMenuItemRequest( public record CreateMenuItemRequest(
string CategoryId, string CategoryId,
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
string? ImageUrl = null, string? ImageUrl = null,
string? VideoUrl = null, string? VideoUrl = null,
string? Model3dUrl = null, string? Model3dUrl = null,
bool IsAvailable = true); bool IsAvailable = true,
string? KitchenStationId = null);
public record UpdateMenuItemRequest( public record UpdateMenuItemRequest(
string? CategoryId, string? CategoryId,
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
string? ImageUrl, string? ImageUrl,
string? VideoUrl, string? VideoUrl,
string? Model3dUrl, string? Model3dUrl,
bool? IsAvailable); bool? IsAvailable,
string? KitchenStationId);
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable); public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
@@ -114,6 +114,12 @@ public class KitchenStationService : IKitchenStationService
foreach (var cat in categories) foreach (var cat in categories)
cat.KitchenStationId = null; 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; entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return true; return true;
+6 -2
View File
@@ -139,7 +139,8 @@ public class MenuService : IMenuService
ImageUrl = imageUrl, ImageUrl = imageUrl,
VideoUrl = request.VideoUrl, VideoUrl = request.VideoUrl,
Model3dUrl = NormalizeOptionalText(request.Model3dUrl), Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
IsAvailable = request.IsAvailable IsAvailable = request.IsAvailable,
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
}; };
_db.MenuItems.Add(entity); _db.MenuItems.Add(entity);
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
if (request.Model3dUrl is not null) if (request.Model3dUrl is not null)
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim(); entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value; 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); await _db.SaveChangesAsync(cancellationToken);
return ToItemDto(entity); return ToItemDto(entity);
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
MenuItemImageDefaults.ResolveDisplayImageUrl(i), MenuItemImageDefaults.ResolveDisplayImageUrl(i),
i.VideoUrl, i.VideoUrl,
i.Model3dUrl, i.Model3dUrl,
i.IsAvailable); i.IsAvailable,
i.KitchenStationId);
} }
@@ -76,15 +76,16 @@ public class NetworkPrinterService : IPrinterService
return PrintResult.Ok(); return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList(); 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() from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId 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); ).ToListAsync(ct);
var stationIds = categoryStations var stationIds = itemStations
.Select(x => x.KitchenStationId) .Select(x => x.StationId)
.Where(id => !string.IsNullOrEmpty(id)) .Where(id => !string.IsNullOrEmpty(id))
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -99,8 +100,8 @@ public class NetworkPrinterService : IPrinterService
var groups = activeItems var groups = activeItems
.GroupBy(item => .GroupBy(item =>
{ {
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId); var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return cat?.KitchenStationId; return map?.StationId;
}) })
.ToList(); .ToList();
@@ -12,4 +12,5 @@ public class KitchenStation : TenantEntity
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public ICollection<MenuCategory> Categories { 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> /// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
public string? Model3dUrl { get; set; } public string? Model3dUrl { get; set; }
public bool IsAvailable { get; set; } = true; 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 Cafe Cafe { get; set; } = null!;
public MenuCategory Category { get; set; } = null!; public MenuCategory Category { get; set; } = null!;
public KitchenStation? KitchenStation { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = []; public ICollection<OrderItem> OrderItems { get; set; } = [];
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = []; public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
public ICollection<MenuItemIngredient> RecipeIngredients { 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.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.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.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); 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"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -928,46 +968,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("DemoRequests"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -1516,6 +1516,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsAvailable") b.Property<bool>("IsAvailable")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("KitchenStationId")
.HasColumnType("text");
b.Property<string>("Model3dUrl") b.Property<string>("Model3dUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@@ -1543,6 +1546,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.HasIndex("CategoryId"); b.HasIndex("CategoryId");
b.HasIndex("KitchenStationId");
b.ToTable("MenuItems"); b.ToTable("MenuItems");
}); });
@@ -2824,6 +2829,17 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{ {
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2857,17 +2873,6 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{ {
b.HasOne("Meezi.Core.Entities.Branch", "Branch") b.HasOne("Meezi.Core.Entities.Branch", "Branch")
@@ -3031,9 +3036,16 @@ namespace Meezi.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation")
.WithMany("MenuItems")
.HasForeignKey("KitchenStationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Cafe"); b.Navigation("Cafe");
b.Navigation("Category"); b.Navigation("Category");
b.Navigation("KitchenStation");
}); });
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
@@ -3401,16 +3413,16 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Orders"); b.Navigation("Orders");
}); });
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b => modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
{ {
b.Navigation("Employees"); b.Navigation("Employees");
}); });
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
{
b.Navigation("Orders");
});
modelBuilder.Entity("Meezi.Core.Entities.Employee", b => modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
{ {
b.Navigation("Attendances"); b.Navigation("Attendances");
@@ -3436,6 +3448,8 @@ namespace Meezi.Infrastructure.Data.Migrations
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
{ {
b.Navigation("Categories"); b.Navigation("Categories");
b.Navigation("MenuItems");
}); });
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
+2 -1
View File
@@ -917,7 +917,8 @@
"deleteItemSuccess": "تم حذف الصنف", "deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة", "deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟", "deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة" "deleteCategorySuccess": "تم حذف الفئة",
"printStationInherit": "نفس الفئة"
}, },
"branchMenu": { "branchMenu": {
"title": "قائمة الفرع", "title": "قائمة الفرع",
+2 -1
View File
@@ -951,7 +951,8 @@
"deleteItemSuccess": "Item deleted", "deleteItemSuccess": "Item deleted",
"deleteCategoryConfirmTitle": "Delete category", "deleteCategoryConfirmTitle": "Delete category",
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?", "deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
"deleteCategorySuccess": "Category deleted" "deleteCategorySuccess": "Category deleted",
"printStationInherit": "Same as category"
}, },
"branchMenu": { "branchMenu": {
"title": "Branch Menu", "title": "Branch Menu",
+2 -1
View File
@@ -951,7 +951,8 @@
"deleteItemSuccess": "آیتم حذف شد", "deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی", "deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟", "deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد" "deleteCategorySuccess": "دسته حذف شد",
"printStationInherit": "مثل دستهٔ منو"
}, },
"branchMenu": { "branchMenu": {
"title": "منوی شعبه", "title": "منوی شعبه",
@@ -73,6 +73,7 @@ interface MenuItem {
videoUrl?: string; videoUrl?: string;
model3dUrl?: string; model3dUrl?: string;
isAvailable: boolean; isAvailable: boolean;
kitchenStationId?: string | null;
} }
interface ItemForm { interface ItemForm {
@@ -84,6 +85,7 @@ interface ItemForm {
imageUrl: string; imageUrl: string;
videoUrl: string; videoUrl: string;
model3dUrl: string; model3dUrl: string;
kitchenStationId: string;
} }
interface CatForm { interface CatForm {
@@ -114,6 +116,7 @@ const defaultItemForm: ItemForm = {
imageUrl: "", imageUrl: "",
videoUrl: "", videoUrl: "",
model3dUrl: "", model3dUrl: "",
kitchenStationId: "",
}; };
const defaultCatForm: CatForm = { const defaultCatForm: CatForm = {
@@ -296,6 +299,7 @@ export function MenuAdminScreen() {
imageUrl: itemForm.imageUrl || null, imageUrl: itemForm.imageUrl || null,
videoUrl: itemForm.videoUrl || null, videoUrl: itemForm.videoUrl || null,
model3dUrl: itemForm.model3dUrl || null, model3dUrl: itemForm.model3dUrl || null,
kitchenStationId: itemForm.kitchenStationId || null,
}), }),
onSuccess: () => { onSuccess: () => {
setItemModalOpen(false); setItemModalOpen(false);
@@ -314,6 +318,7 @@ export function MenuAdminScreen() {
imageUrl: mediaField(itemForm.imageUrl), imageUrl: mediaField(itemForm.imageUrl),
videoUrl: mediaField(itemForm.videoUrl), videoUrl: mediaField(itemForm.videoUrl),
model3dUrl: mediaField(itemForm.model3dUrl), model3dUrl: mediaField(itemForm.model3dUrl),
kitchenStationId: itemForm.kitchenStationId || null,
}), }),
onSuccess: () => { onSuccess: () => {
setItemModalOpen(false); setItemModalOpen(false);
@@ -411,6 +416,7 @@ export function MenuAdminScreen() {
imageUrl: item.imageUrl ?? "", imageUrl: item.imageUrl ?? "",
videoUrl: item.videoUrl ?? "", videoUrl: item.videoUrl ?? "",
model3dUrl: item.model3dUrl ?? "", model3dUrl: item.model3dUrl ?? "",
kitchenStationId: item.kitchenStationId ?? "",
}); });
setItemModalOpen(true); setItemModalOpen(true);
}; };
@@ -956,6 +962,26 @@ export function MenuAdminScreen() {
) : null} ) : null}
</LabeledField> </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 */} {/* Actions */}
<div className="flex items-center justify-between gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingItem ? ( {editingItem ? (