diff --git a/CLAUDE.md b/CLAUDE.md index f7abac2..3207208 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ PrinterAPP/ - **Service-oriented with dependency injection** via `MauiProgram.cs`. Every service registered as `AddSingleton()`. - **MVVM with code-behind**: standard MAUI pattern. View binds to code-behind directly; pure ViewModel layer is intentionally absent at this scale. -- **Intelligent printer routing**: orders dispatch to Cashier, Front Kitchen, or Back Kitchen printers based on item category. +- **Intelligent printer routing**: orders dispatch to Cashier, Front Kitchen, or Back Kitchen printers based on each line's `KitchenType`. Since backend PR #237 made `OrderDto.Items` **root-only**, a bundle's components live only in `OrderItem.SideItems`, nested to arbitrary depth — so routing walks the whole tree (`KitchenTicketFilter`, a pure function like `FeedWatchdogDecision`), never just the top level. A component whose kitchen differs from its parent's goes to *its own* kitchen's ticket, which carries the parent line as context. **Anything that reasons about "the order's items" must recurse** — a top-level-only scan silently prints no ticket at all for that kitchen. - **5-second SSE polling** with a 1-hour deduplication window so re-emitted orders during reconnects don't double-print. The poll cursor and dedup set are **persisted** (`IFeedCursorStore`), so a restart resumes instead of re-printing the last 30 minutes. A dedup entry is persisted only once the print path confirms the order (`ConfirmOrderHandled`) — a kill between dispatch and print must re-drive the order, never silently suppress it. Three timings are coupled and must stay ordered — `unconfirmed retention (25 min) < cursor look-back clamp (30 min) < dedup window (1 h)`. The clamp must stay under the dedup window or a re-fetch reaches orders no surviving dedup entry guards (reprints); unconfirmed retention must stay under the clamp or an unconfirmed order's cursor floor is discarded by the very load it exists to influence (silently dropped ticket). An unconfirmed order that ages out is reported to the Errors page — past that point it genuinely cannot be recovered. - **Feed watchdog** (`IFeedWatchdog`): restarts a feed that died or stopped completing polls. It must never override a deliberate stop — see `FeedWatchdogDecision`, where all of that logic lives as a pure, tested function. - **Headless order pipeline** (`IOrderPipeline`): the feed→print→history→ack path is owned by a service, not a page, so it runs with no Activity. On Android an `OrderFeedForegroundService` hosts it (see [ADR-007](docs/adr/ADR-007-android-foreground-service.md)); on Windows it runs in-process. **Never move print or feed logic back into a page** — that is what made the app stop printing whenever it was minimised. @@ -86,6 +86,7 @@ PrinterAPP/ The printer-app is a **strict consumer** of the backend API. Models in `PrinterAPP/Models/` MUST mirror backend DTO field names and types exactly — silent drift breaks order printing in production. +- **`OrderDto.Items` is ROOT-ONLY** (backend PR #237 / issue #234, merged 2026-07-27). Bundle components and add-on sides are NOT top-level entries — they hang off their parent in `OrderItemDto.SideItems`, to arbitrary depth, each with its own `KitchenType`. No field changed, so drift here does not fail deserialization; it fails silently at the till. - Auth: `X-Api-Key` header on the printer-feed endpoint (since printer-app !1 / backend MR !20). Legacy `Authorization: Bearer ` flow is removed. - Backend base URL is configured per-deployment in `PrinterConfiguration.cs` and persisted in the user's local config JSON. diff --git a/PrinterAPP.Tests/KitchenTicketFilterTests.cs b/PrinterAPP.Tests/KitchenTicketFilterTests.cs new file mode 100644 index 0000000..046b41d --- /dev/null +++ b/PrinterAPP.Tests/KitchenTicketFilterTests.cs @@ -0,0 +1,228 @@ +using PrinterAPP.Models; +using PrinterAPP.Services; +using Xunit; + +namespace PrinterAPP.Tests; + +/// +/// Kitchen routing over the nested item tree (backend PR #237 / issue #234 made OrderDto.Items +/// ROOT-ONLY — a bundle's components now exist only inside their parent's SideItems). +/// The regression these guard: a "Menu Deal" whose product is FrontKitchen containing +/// BackKitchen fries. Routing off the top level alone saw no BackKitchen item, so the back kitchen +/// got NO ticket and the fries printed on the front kitchen's instead — a silently dropped order +/// line in a live restaurant. +/// +public class KitchenTicketFilterTests +{ + private const string Front = "FrontKitchen"; + private const string Back = "BackKitchen"; + + // ── single-kitchen bundle ─────────────────────────────────────────────────────────────────── + + [Fact] + public void SingleKitchenBundle_GoesToThatKitchenOnly_ComponentsNested() + { + var items = new List + { + Item("Menu Deal", Front, children: + [ + Item("Kofte", Front), + Item("Pide", Front), + ]), + }; + + var front = KitchenTicketFilter.ItemsForKitchen(items, Front); + var back = KitchenTicketFilter.ItemsForKitchen(items, Back); + + // One ticket, one root line, components still nested under their combo. + var combo = Assert.Single(front); + Assert.Equal("Menu Deal", combo.ProductName); + Assert.Equal(new[] { "Kofte", "Pide" }, combo.SideItems!.Select(s => s.ProductName)); + + // Each component exactly once across the whole ticket — not also promoted to a root line. + Assert.Equal(new[] { "Menu Deal", "Kofte", "Pide" }, Flatten(front).Select(i => i.ProductName)); + + // The kitchen with nothing to make gets no ticket at all. + Assert.Empty(back); + } + + [Fact] + public void SingleKitchenBundle_KeepsComponentsWithNoKitchenOfTheirOwn() + { + // A drink or an extra sauce carries no KitchenType: it is a modifier of its parent, so it + // rides with the parent rather than vanishing from every ticket. + var items = new List + { + Item("Menu Deal", Front, children: + [ + Item("Kofte", Front), + Item("Coke", kitchenType: null), + Item("Extra sauce", kitchenType: "None"), + ]), + }; + + var front = KitchenTicketFilter.ItemsForKitchen(items, Front); + + var combo = Assert.Single(front); + Assert.Equal( + new[] { "Kofte", "Coke", "Extra sauce" }, + combo.SideItems!.Select(s => s.ProductName)); + + // They ride along as work this kitchen does, not as context — the ticket prints them as + // real lines. "No KitchenType" is what makes this underivable from the item alone. + Assert.All(Flatten(front), item => Assert.False(item.IsContextOnly)); + } + + // ── mixed-kitchen bundle (the reported failure) ───────────────────────────────────────────── + + [Fact] + public void MixedKitchenBundle_BothKitchensGetATicket_EachComponentOnExactlyOne() + { + var items = new List + { + Item("Menu Deal", Front, children: + [ + Item("Kofte", Front), + Item("Fries", Back, quantity: 2), + Item("Coke", kitchenType: null), + ]), + }; + + var front = KitchenTicketFilter.ItemsForKitchen(items, Front); + var back = KitchenTicketFilter.ItemsForKitchen(items, Back); + + // Front makes the combo itself, so it keeps the combo and everything that is its own — + // and the back kitchen's fries no longer ride along on its ticket. + var frontCombo = Assert.Single(front); + Assert.Equal("Menu Deal", frontCombo.ProductName); + Assert.Equal(new[] { "Kofte", "Coke" }, frontCombo.SideItems!.Select(s => s.ProductName)); + Assert.All(Flatten(front), item => Assert.False(item.IsContextOnly)); + + // Back gets a ticket at all (this is what was missing), carrying the combo line only as + // context for the one component it makes. + var backCombo = Assert.Single(back); + Assert.Equal("Menu Deal", backCombo.ProductName); + Assert.True(backCombo.IsContextOnly, "the back kitchen does not make the combo itself"); + var fries = Assert.Single(backCombo.SideItems!); + Assert.Equal("Fries", fries.ProductName); + Assert.Equal(2, fries.Quantity); + Assert.False(fries.IsContextOnly); + + // Every component appears exactly once across the two tickets combined. + var printed = Flatten(front).Concat(Flatten(back)).Select(i => i.ProductName).ToList(); + Assert.Equal(new[] { "Kofte", "Coke", "Fries" }, printed.Where(n => n != "Menu Deal")); + } + + [Fact] + public void MixedKitchenBundle_DoesNotMutateTheOrderItRoutes() + { + // The same tree is filtered once per kitchen, and the order is shared with the history list + // and the UI — filtering has to copy. + var combo = Item("Menu Deal", Front, children: + [ + Item("Kofte", Front), + Item("Fries", Back), + ]); + var items = new List { combo }; + + KitchenTicketFilter.ItemsForKitchen(items, Front); + var back = KitchenTicketFilter.ItemsForKitchen(items, Back); + + Assert.Equal(new[] { "Kofte", "Fries" }, combo.SideItems!.Select(s => s.ProductName)); + Assert.Equal(new[] { "Fries" }, Assert.Single(back).SideItems!.Select(s => s.ProductName)); + } + + // ── depth, and the rest of the tree ───────────────────────────────────────────────────────── + + [Fact] + public void NestedGrandchild_ReachesItsOwnKitchen_ThroughTheParentsAboveIt() + { + // The backend builds an arbitrary-depth tree (OrderItemFactory nests grandchildren), so a + // one-level scan is not enough — the whole chain above a component may belong elsewhere. + var items = new List + { + Item("Feast Menu", Front, children: + [ + Item("Burger Combo", Front, children: + [ + Item("Patty", Front), + Item("Fries", Back), + ]), + ]), + }; + + var back = KitchenTicketFilter.ItemsForKitchen(items, Back); + var front = KitchenTicketFilter.ItemsForKitchen(items, Front); + + var backRoot = Assert.Single(back); + Assert.Equal("Feast Menu", backRoot.ProductName); + var backCombo = Assert.Single(backRoot.SideItems!); + Assert.Equal("Burger Combo", backCombo.ProductName); + Assert.Equal("Fries", Assert.Single(backCombo.SideItems!).ProductName); + + Assert.Equal( + new[] { "Feast Menu", "Burger Combo", "Patty" }, + Flatten(front).Select(i => i.ProductName)); + } + + [Fact] + public void PlainItems_RouteByTheirOwnKitchen_AsBefore() + { + var items = new List + { + Item("Adana Kebab", Back), + Item("Ayran", Front), + Item("Napkins", kitchenType: null), + }; + + Assert.Equal(new[] { "Adana Kebab" }, KitchenTicketFilter.ItemsForKitchen(items, Back).Select(i => i.ProductName)); + Assert.Equal(new[] { "Ayran" }, KitchenTicketFilter.ItemsForKitchen(items, Front).Select(i => i.ProductName)); + } + + [Fact] + public void OrderWithNothingForThisKitchen_YieldsNoTicket() + { + var items = new List { Item("Ayran", Front) }; + + Assert.Empty(KitchenTicketFilter.ItemsForKitchen(items, Back)); + Assert.Empty(KitchenTicketFilter.ItemsForKitchen(null, Back)); + Assert.Empty(KitchenTicketFilter.ItemsForKitchen(new List(), Back)); + } + + [Fact] + public void KitchenTypeMatchIsCaseInsensitive() + { + var items = new List { Item("Fries", "backkitchen") }; + + Assert.Equal("Fries", Assert.Single(KitchenTicketFilter.ItemsForKitchen(items, Back)).ProductName); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────────── + + private static OrderItem Item( + string productName, + string? kitchenType, + int quantity = 1, + List? children = null) => + new() + { + Id = productName, + ProductName = productName, + KitchenType = kitchenType, + Quantity = quantity, + SideItems = children, + }; + + /// Every line a ticket would print, parents before their children. + private static IEnumerable Flatten(IEnumerable items) + { + foreach (var item in items) + { + yield return item; + foreach (var child in Flatten(item.SideItems ?? Enumerable.Empty())) + { + yield return child; + } + } + } +} diff --git a/PrinterAPP.Tests/OrderPrintToSinkTests.cs b/PrinterAPP.Tests/OrderPrintToSinkTests.cs index cea3293..3d002ff 100644 --- a/PrinterAPP.Tests/OrderPrintToSinkTests.cs +++ b/PrinterAPP.Tests/OrderPrintToSinkTests.cs @@ -78,6 +78,169 @@ public async Task PrintOrderAsync_Cashier_SendsFramedEscPosReceipt_ToNetworkSink } } + /// + /// A bundle every component of which one kitchen makes: one ticket, components nested under + /// their combo, each printed exactly once — no top-level duplicate of a nested component, and + /// nothing at all for the other kitchen. + /// + [Fact] + public async Task PrintOrderToAllPrinters_SingleKitchenBundle_PrintsOneTicket_WithComponentsNestedOnce() + { + using var cashier = new Sink(); + using var front = new Sink(); + using var back = new Sink(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var order = BundleOrder( + Item("Menu Deal", "FrontKitchen", children: + [ + Item("Kofte", "FrontKitchen"), + Item("Ayran", kitchenType: null), // no kitchen of its own: rides with its parent + ])); + + var result = await PrintToSinksAsync(order, cashier, front, back, cts.Token); + + Assert.True(result.FrontKitchen, "front kitchen print reported failure"); + var ticket = await front.ReadTicketAsync(cts.Token); + Assert.Equal(1, Occurrences(ticket, "Menu Deal")); + Assert.Equal(1, Occurrences(ticket, "Kofte")); + Assert.Equal(1, Occurrences(ticket, "Ayran")); + // Everything on this ticket is this kitchen's work, so nothing is parenthesised as + // context — including the drink, which has no KitchenType of its own. + Assert.DoesNotContain("(1x", ticket); + + // The kitchen with nothing to make is never contacted. + Assert.False(back.ReceivedAnything, "back kitchen was sent a ticket it has nothing to make"); + } + + /// + /// The reported failure: a FrontKitchen "Menu Deal" containing BackKitchen fries. Before the + /// fix the top-level-only scan saw no BackKitchen item, so the back kitchen got NO ticket and + /// the fries printed on the front kitchen's ticket as a nested "+ 2x Fries" line. + /// + [Fact] + public async Task PrintOrderToAllPrinters_MixedKitchenBundle_PrintsEachComponentOnItsOwnKitchenTicket() + { + using var cashier = new Sink(); + using var front = new Sink(); + using var back = new Sink(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var order = BundleOrder( + Item("Menu Deal", "FrontKitchen", children: + [ + Item("Kofte", "FrontKitchen"), + Item("Fries", "BackKitchen", quantity: 2), + ])); + + var result = await PrintToSinksAsync(order, cashier, front, back, cts.Token); + + Assert.True(result.FrontKitchen, "front kitchen print reported failure"); + Assert.True(result.BackKitchen, "back kitchen print reported failure"); + + var frontTicket = await front.ReadTicketAsync(cts.Token); + Assert.Equal(1, Occurrences(frontTicket, "Menu Deal")); + Assert.Equal(1, Occurrences(frontTicket, "Kofte")); + Assert.Equal(0, Occurrences(frontTicket, "Fries")); // the regression: fries rode along here + + var backTicket = await back.ReadTicketAsync(cts.Token); + Assert.Equal(1, Occurrences(backTicket, "Fries")); // the regression: this ticket never printed + Assert.Contains("2x Fries", backTicket); + Assert.Equal(0, Occurrences(backTicket, "Kofte")); + // The combo line rides along as context for the component, parenthesised so it does not + // read as a dish the back kitchen has to make. + Assert.Contains("(1x Menu Deal)", backTicket); + } + + private static Order BundleOrder(params OrderItem[] items) => new() + { + OrderNumber = "BUNDLE-1", + Type = "DineIn", + TableNumber = 3, + Status = "Confirmed", + OrderDate = DateTime.Now, + Items = items.ToList(), + }; + + private static OrderItem Item( + string productName, + string? kitchenType, + int quantity = 1, + List? children = null) => + new() + { + Id = productName, + ProductName = productName, + KitchenType = kitchenType, + Quantity = quantity, + SideItems = children, + }; + + private static async Task<(bool Cashier, bool FrontKitchen, bool BackKitchen)> PrintToSinksAsync( + Order order, Sink cashier, Sink front, Sink back, CancellationToken ct) + { + using var paths = new TempPathProvider(); + var service = new OrderPrintService( + new StubPrinterService(new PrinterConfiguration + { + CashierPrinterName = cashier.PrinterName, + CashierAutoPrint = true, + CashierPrintCopies = 1, + FrontKitchenPrinterName = front.PrinterName, + FrontKitchenAutoPrint = true, + BackKitchenPrinterName = back.PrinterName, + BackKitchenAutoPrint = true, + }), + new NoopRequestLogService(), + NullLogger.Instance, + paths); + + return await service.PrintOrderToAllPrintersAsync(order, isManualPrint: false, ct); + } + + private static int Occurrences(string haystack, string needle) + { + var count = 0; + for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + return count; + } + + /// An in-process loopback "printer": one port, and the ticket that arrived on it. + private sealed class Sink : IDisposable + { + private readonly TcpListener _listener; + + public Sink() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + PrinterName = $"127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}"; // IP literal → NetworkTcpTransport + } + + /// Configure this as a printer name. + public string PrinterName { get; } + + /// A connection is waiting to be accepted, i.e. something was printed here. + public bool ReceivedAnything => _listener.Pending(); + + /// + /// The ticket as text. The sender connects, writes and closes per print, so the connection + /// sits in the accept backlog until read — no need to race an accept against the print. + /// Decoded as Latin1: the assertions are ASCII, which PC857 leaves unchanged. + /// + public async Task ReadTicketAsync(CancellationToken ct) + { + var bytes = await AcceptAndReadAllAsync(_listener, ct); + return Encoding.Latin1.GetString(bytes); + } + + public void Dispose() => _listener.Stop(); + } + private static async Task AcceptAndReadAllAsync(TcpListener listener, CancellationToken ct) { using var server = await listener.AcceptTcpClientAsync(ct); diff --git a/PrinterAPP.Tests/PrinterAPP.Tests.csproj b/PrinterAPP.Tests/PrinterAPP.Tests.csproj index 21ac286..938a442 100644 --- a/PrinterAPP.Tests/PrinterAPP.Tests.csproj +++ b/PrinterAPP.Tests/PrinterAPP.Tests.csproj @@ -74,6 +74,10 @@ real ESC/POS receipt builder, driven to a loopback TcpListener for the print-to-sink test. Pulls in the request-log boundary (MAUI-free since the LogEntry extraction). --> + + diff --git a/PrinterAPP/Models/Order.cs b/PrinterAPP/Models/Order.cs index 5726bd3..f069766 100644 --- a/PrinterAPP/Models/Order.cs +++ b/PrinterAPP/Models/Order.cs @@ -39,6 +39,20 @@ public class Order public List Items { get; set; } = new(); public List? Payments { get; set; } public List? StatusHistory { get; set; } + + /// + /// The same order with a different item list — how a kitchen ticket is built without mutating + /// the order the history list and the UI are holding. Cloned rather than re-listed field by + /// field so a field added here can't be silently dropped off a kitchen ticket. Shallow, like + /// the hand-written copy it replaced: and + /// are shared, and neither the receipt composer nor the filter writes to them. + /// + public Order WithItems(List items) + { + var copy = (Order)MemberwiseClone(); + copy.Items = items; + return copy; + } } public class OrderItem @@ -59,8 +73,32 @@ public class OrderItem [JsonPropertyName("ingredientCustomizations")] public List? IngredientCustomizations { get; set; } - // Side items / additionals (child order items) + // Side items / additionals (child order items). Backend PR #237 made OrderDto.Items root-only, + // so this is the ONLY place a bundle component or an add-on side appears — nested, to arbitrary + // depth. Anything that reasons about "the order's items" has to recurse through it. public List? SideItems { get; set; } + + /// + /// Set by KitchenTicketFilter on its own copies: this kitchen does not make this line, + /// it is on the ticket only to say what the components nested under it belong to. Presentation, + /// not order data — it is never deserialized, so it does not touch the backend DTO mirror + /// (§5.3). The renderer cannot derive it from , because a component + /// with no kitchen of its own (a drink, an extra sauce) is made wherever its parent is. + /// + [JsonIgnore] + public bool IsContextOnly { get; set; } + + /// + /// The same line with a different child list — see for why this + /// clones rather than copying field by field. Used by KitchenTicketFilter to route a + /// subtree to one kitchen without mutating the shared order. + /// + public OrderItem WithSideItems(List? sideItems) + { + var copy = (OrderItem)MemberwiseClone(); + copy.SideItems = sideItems; + return copy; + } } /// diff --git a/PrinterAPP/PrinterAPP.csproj b/PrinterAPP/PrinterAPP.csproj index 7acdfce..af6134f 100644 --- a/PrinterAPP/PrinterAPP.csproj +++ b/PrinterAPP/PrinterAPP.csproj @@ -28,10 +28,10 @@ Kept as-is here to avoid changing the live Windows app identity in a code-only PR; the rename is a deploy/Play-Console-registration step (needs the Play account). --> com.companyname.printerapp - 1.0.24 + 1.0.25 - 24 - 1.0.24 + 25 + 1.0.25 None diff --git a/PrinterAPP/Services/KitchenTicketFilter.cs b/PrinterAPP/Services/KitchenTicketFilter.cs new file mode 100644 index 0000000..ab0faa6 --- /dev/null +++ b/PrinterAPP/Services/KitchenTicketFilter.cs @@ -0,0 +1,86 @@ +using PrinterAPP.Models; + +namespace PrinterAPP.Services; + +/// +/// Selects the part of an order that one kitchen is responsible for, as a pure function over the +/// item tree — the same "extract the decidable part" shape as +/// and . +/// Backend PR #237 (issue #234) made OrderDto.Items ROOT-ONLY: a bundle's components +/// and an item's add-on sides are no longer top-level rows, they hang off their parent in +/// , to arbitrary depth. Every routing decision therefore has to +/// walk the whole tree. Reading only the top level meant a "Menu Deal" whose product is +/// FrontKitchen but which contains BackKitchen fries produced NO back-kitchen ticket at all, and +/// printed the fries on the front kitchen's ticket instead. +/// +public static class KitchenTicketFilter +{ + /// Backend sentinel for "no kitchen prepares this line" (see OrderItemDto.KitchenType). + private const string NoKitchen = "None"; + + /// + /// The item tree as should see it: every line this kitchen makes, + /// each exactly once, still nested under its parent so a component reads as part of its combo. + /// A parent this kitchen does NOT make is kept only when it has a matching descendant, and then + /// only as context for it (the caller renders such a line differently — see + /// OrderPrintService.AppendKitchenItem). Empty means "print nothing for this kitchen". + /// + public static List ItemsForKitchen(IEnumerable? items, string kitchenType) + { + if (items is null) + { + return new List(); + } + + return items + .Select(item => FilterItem(item, kitchenType, ridesWithParent: false)) + .OfType() + .ToList(); + } + + /// + /// This item names no kitchen of its own and its parent is being made here, so it comes along. + /// + /// The item with its children filtered, or null when nothing under it belongs here. + private static OrderItem? FilterItem(OrderItem item, string kitchenType, bool ridesWithParent) + { + var madeHere = string.Equals(item.KitchenType, kitchenType, StringComparison.OrdinalIgnoreCase) + || ridesWithParent; + + var children = new List(); + foreach (var child in item.SideItems ?? Enumerable.Empty()) + { + // A child that names a kitchen is a dish, and is routed by that name — which is how a + // BackKitchen component of a FrontKitchen combo reaches the back kitchen instead of + // riding along on the front kitchen's ticket. A child that names none is a modifier of + // its parent (an extra sauce, a drink): it belongs wherever the parent is being made, + // and is dropped from tickets that only carry the parent as context. + var childRidesAlong = madeHere && !DeclaresKitchen(child); + var filteredChild = FilterItem(child, kitchenType, childRidesAlong); + if (filteredChild is not null) + { + children.Add(filteredChild); + } + } + + if (!madeHere && children.Count == 0) + { + return null; + } + + // A copy: the order instance is shared with the history list and the UI, and the other + // kitchen's ticket filters the very same tree. + var routed = item.WithSideItems(children); + // Reaching here without madeHere means the line survived only to carry a component below + // it. The renderer cannot work this out for itself — "no KitchenType" means "made wherever + // the parent is", so a rode-along drink and a context-only combo both fail a plain + // KitchenType comparison against this ticket's kitchen. + routed.IsContextOnly = !madeHere; + return routed; + } + + /// True when the item names a kitchen of its own, rather than inheriting its parent's. + private static bool DeclaresKitchen(OrderItem item) => + !string.IsNullOrWhiteSpace(item.KitchenType) + && !string.Equals(item.KitchenType, NoKitchen, StringComparison.OrdinalIgnoreCase); +} diff --git a/PrinterAPP/Services/OrderPrintService.cs b/PrinterAPP/Services/OrderPrintService.cs index d0a4688..b605980 100644 --- a/PrinterAPP/Services/OrderPrintService.cs +++ b/PrinterAPP/Services/OrderPrintService.cs @@ -36,6 +36,10 @@ public class OrderPrintService : IOrderPrintService private const string EXTRA_DARK_OFF = ESC_BOLD_OFF + ESC_EMPHASIZED_OFF; // Turn off all emphasis private const string ESC_FEED_LINES = "\x1B\x64\x05"; // Feed 5 lines before cut + // KitchenType values as the backend emits them (OrderItemDto.KitchenType). + private const string FRONT_KITCHEN = "FrontKitchen"; + private const string BACK_KITCHEN = "BackKitchen"; + public OrderPrintService( IPrinterService printerService, IRequestLogService requestLogService, @@ -94,11 +98,18 @@ private string ResetStyle(SectionStyle style) _logger.LogError(ex, "Error printing to cashier"); } - // 2. Check which kitchens have items - var hasFrontKitchenItems = order.Items?.Any(i => - string.Equals(i.KitchenType, "FrontKitchen", StringComparison.OrdinalIgnoreCase)) ?? false; - var hasBackKitchenItems = order.Items?.Any(i => - string.Equals(i.KitchenType, "BackKitchen", StringComparison.OrdinalIgnoreCase)) ?? false; + // 2. Route the item tree to each kitchen. + // + // Both tickets are built up front so that "does this kitchen get a ticket at all" is + // answered by the very thing that will be printed — the two used to be decided separately, + // and separately is how they drifted apart. Since backend PR #237 (issue #234) made + // OrderDto.Items root-only, a top-level scan misses every bundle component: a FrontKitchen + // combo containing BackKitchen fries produced no back-kitchen ticket, and printed the fries + // on the front kitchen's. KitchenTicketFilter walks the whole tree instead. + var frontKitchenOrder = CreateFilteredOrder(order, FRONT_KITCHEN); + var backKitchenOrder = CreateFilteredOrder(order, BACK_KITCHEN); + var hasFrontKitchenItems = frontKitchenOrder.Items.Count > 0; + var hasBackKitchenItems = backKitchenOrder.Items.Count > 0; // 3. Print to FrontKitchen if there are FrontKitchen items if (hasFrontKitchenItems) @@ -117,12 +128,11 @@ private string ResetStyle(SectionStyle style) frontKitchenSuccess = !shouldPrintFront; if (shouldPrintFront && !string.IsNullOrWhiteSpace(frontKitchenPrinter)) { - // Filter order to only FrontKitchen items - var filteredOrder = CreateFilteredOrder(order, "FrontKitchen"); - var content = FormatKitchenReceipt(filteredOrder, config, config.FrontKitchenPaperWidth, "FRONT KITCHEN"); + var content = FormatKitchenReceipt( + frontKitchenOrder, config, config.FrontKitchenPaperWidth, "FRONT KITCHEN"); frontKitchenSuccess = await PrintRawContentAsync(frontKitchenPrinter, content); _logger.LogInformation("FrontKitchen print ({ItemCount} items) to {Printer}: {Result}", - filteredOrder.Items?.Count ?? 0, frontKitchenPrinter, frontKitchenSuccess ? "✓" : "✗"); + frontKitchenOrder.Items.Count, frontKitchenPrinter, frontKitchenSuccess ? "✓" : "✗"); } } catch (Exception ex) @@ -150,12 +160,11 @@ private string ResetStyle(SectionStyle style) backKitchenSuccess = !shouldPrintBack; if (shouldPrintBack && !string.IsNullOrWhiteSpace(backKitchenPrinter)) { - // Filter order to only BackKitchen items - var filteredOrder = CreateFilteredOrder(order, "BackKitchen"); - var content = FormatKitchenReceipt(filteredOrder, config, config.BackKitchenPaperWidth, "Back Kitchen"); + var content = FormatKitchenReceipt( + backKitchenOrder, config, config.BackKitchenPaperWidth, "Back Kitchen"); backKitchenSuccess = await PrintRawContentAsync(backKitchenPrinter, content); _logger.LogInformation("BackKitchen print ({ItemCount} items): {Result}", - filteredOrder.Items?.Count ?? 0, backKitchenSuccess ? "✓" : "✗"); + backKitchenOrder.Items.Count, backKitchenSuccess ? "✓" : "✗"); } } catch (Exception ex) @@ -175,45 +184,13 @@ private string ResetStyle(SectionStyle style) } /// - /// Creates a copy of the order with only items matching the specified KitchenType + /// A copy of the order carrying only what is responsible for. + /// The selection itself is : it recurses into + /// , because since backend PR #237 that is where a bundle's + /// components live. /// - private Order CreateFilteredOrder(Order original, string kitchenType) - { - return new Order - { - Id = original.Id, - OrderNumber = original.OrderNumber, - UserId = original.UserId, - CustomerName = original.CustomerName, - CustomerEmail = original.CustomerEmail, - CustomerPhone = original.CustomerPhone, - Type = original.Type, - TableNumber = original.TableNumber, - SubTotal = original.SubTotal, - Tax = original.Tax, - DeliveryFee = original.DeliveryFee, - Discount = original.Discount, - DiscountPercentage = original.DiscountPercentage, - Tip = original.Tip, - Total = original.Total, - TotalPaid = original.TotalPaid, - RemainingAmount = original.RemainingAmount, - IsFullyPaid = original.IsFullyPaid, - Status = original.Status, - PaymentStatus = original.PaymentStatus, - OrderDate = original.OrderDate, - CreatedAt = original.CreatedAt, - UpdatedAt = original.UpdatedAt, - Notes = original.Notes, - DeliveryAddress = original.DeliveryAddress, - Payments = original.Payments, - StatusHistory = original.StatusHistory, - // Filter items to only those matching the kitchen type - Items = original.Items? - .Where(i => string.Equals(i.KitchenType, kitchenType, StringComparison.OrdinalIgnoreCase)) - .ToList() ?? new List() - }; - } + private static Order CreateFilteredOrder(Order original, string kitchenType) => + original.WithItems(KitchenTicketFilter.ItemsForKitchen(original.Items, kitchenType)); public async Task PrintOrderAsync(Order order, PrinterType printerType, bool isManualPrint = false, CancellationToken cancellationToken = default) { @@ -387,58 +364,7 @@ private string FormatKitchenReceipt(Order order, PrinterConfiguration config, in // Log each item for debugging _logger.LogInformation("Item: {Quantity}x {ProductName}", item.Quantity, item.ProductName); - // Item name and quantity - WIDE size (2x width, 1x height - bigger than normal) - sb.Append(ESC_SIZE_WIDE); - sb.AppendLine($"{item.Quantity}x {item.ProductName}"); - sb.Append(ESC_SIZE_NORMAL); - - // Show variation if available (normal size) - if (!string.IsNullOrWhiteSpace(item.VariationName)) - { - sb.AppendLine($" - {item.VariationName}"); - } - - // Show ingredient customizations - ONLY modified ingredients (removed or extra) - var modifiedIngredients = item.IngredientCustomizations? - .Where(ing => ing.IsRemoved || ing.Quantity > 1) - .ToList(); - if (modifiedIngredients != null && modifiedIngredients.Any()) - { - foreach (var ing in modifiedIngredients) - { - if (ing.IsRemoved) - { - // TALL size "NO" prefix for removed ingredients - sb.Append(ESC_SIZE_TALL); - sb.AppendLine($" - NO {ing.IngredientName}"); - sb.Append(ESC_SIZE_NORMAL); - } - else if (ing.Quantity > 1) - { - // TALL size "EXTRA" prefix for extra ingredients - sb.Append(ESC_SIZE_TALL); - sb.AppendLine($" + EXTRA {ing.IngredientName}"); - sb.Append(ESC_SIZE_NORMAL); - } - } - } - - // Show side items / additionals - if (item.SideItems != null && item.SideItems.Any()) - { - foreach (var side in item.SideItems) - { - sb.AppendLine($" + {side.Quantity}x {side.ProductName}"); - } - } - - // Show special instructions - TALL size for visibility - if (!string.IsNullOrWhiteSpace(item.SpecialInstructions)) - { - sb.Append(ESC_SIZE_TALL); - sb.AppendLine($" NOTE: {item.SpecialInstructions}"); - sb.Append(ESC_SIZE_NORMAL); - } + AppendKitchenItem(sb, item, depth: 0); sb.AppendLine(); } } @@ -462,6 +388,78 @@ private string FormatKitchenReceipt(Order order, PrinterConfiguration config, in return sb.ToString(); } + /// + /// Writes one line of the kitchen ticket and everything hanging off it. Recursive because a + /// bundle nests to arbitrary depth since backend PR #237 — the previous one-level-deep render + /// showed a grandchild nowhere, and showed a child's name and quantity but never its "NO onion" + /// or its note (both of which used to print, back when every child was its own top-level row). + /// + private static void AppendKitchenItem(StringBuilder sb, OrderItem item, int depth) + { + var indent = new string(' ', depth * 3); + + // A line this kitchen does not make (KitchenTicketFilter kept it only to say what the + // components below it belong to — a FrontKitchen combo on the back kitchen's ticket). + // Parenthesised and left at normal size so it doesn't read as a dish to prepare; the + // components under it are the work. + if (item.IsContextOnly) + { + sb.AppendLine($"{indent}({item.Quantity}x {item.ProductName})"); + } + else + { + // Item name and quantity - WIDE size (2x width, 1x height - bigger than normal). + // "+ " marks a component/add-on so it still reads as part of the line above it. + sb.Append(ESC_SIZE_WIDE); + sb.AppendLine(depth == 0 + ? $"{item.Quantity}x {item.ProductName}" + : $"{indent}+ {item.Quantity}x {item.ProductName}"); + sb.Append(ESC_SIZE_NORMAL); + } + + // Show variation if available (normal size) + if (!string.IsNullOrWhiteSpace(item.VariationName)) + { + sb.AppendLine($"{indent} - {item.VariationName}"); + } + + AppendIngredientCustomizations(sb, item, indent); + + // Show special instructions - TALL size for visibility. Before the nested components, not + // after: trailing the children makes a parent's note read as if it belonged to the last + // child printed. + if (!string.IsNullOrWhiteSpace(item.SpecialInstructions)) + { + sb.Append(ESC_SIZE_TALL); + sb.AppendLine($"{indent} NOTE: {item.SpecialInstructions}"); + sb.Append(ESC_SIZE_NORMAL); + } + + // Show side items / additionals (bundle components and add-on sides) + foreach (var side in item.SideItems ?? Enumerable.Empty()) + { + AppendKitchenItem(sb, side, depth + 1); + } + } + + /// Only the ingredients the customer actually changed — removed, or asked extra of. + private static void AppendIngredientCustomizations(StringBuilder sb, OrderItem item, string indent) + { + var modified = item.IngredientCustomizations? + .Where(ing => ing.IsRemoved || ing.Quantity > 1) + ?? Enumerable.Empty(); + + foreach (var ing in modified) + { + // TALL size "NO" / "EXTRA" prefix so a change stands out from the item it modifies + sb.Append(ESC_SIZE_TALL); + sb.AppendLine(ing.IsRemoved + ? $"{indent} - NO {ing.IngredientName}" + : $"{indent} + EXTRA {ing.IngredientName}"); + sb.Append(ESC_SIZE_NORMAL); + } + } + private string FormatCashierReceipt(Order order, PrinterConfiguration config, int paperWidth) { var sb = new StringBuilder();