Skip to content

fix(print): route nested bundle components to the right kitchen ticket - #87

Merged
mahmutkaya merged 2 commits into
developfrom
fix/kitchen-routing-nested-bundle-items
Jul 27, 2026
Merged

fix(print): route nested bundle components to the right kitchen ticket#87
mahmutkaya merged 2 commits into
developfrom
fix/kitchen-routing-nested-bundle-items

Conversation

@mahmutkaya

Copy link
Copy Markdown
Collaborator

Summary

  • Backend PR fix(orders): populate SideItems on the printer feed and stop double-projecting bundle children restaurant-app-backend#237 (issue #234, merged to develop 2026-07-27) made OrderDto.Items ROOT-ONLY: bundle components and add-on sides are no longer top-level rows, they hang off their parent in OrderItemDto.SideItems, to arbitrary depth. No field changed, so the feed still deserializes — the printer app just stopped seeing most of the order.
  • Kitchen routing read only the top level, in both of its steps. A "Menu Deal" whose product is FrontKitchen containing BackKitchen fries produced no back-kitchen ticket at all, and printed the fries on the front kitchen's ticket as a nested + 2x Fries line. A live order line, silently sent to the wrong kitchen.
  • Routing now walks the whole tree in a new pure KitchenTicketFilter, and kitchen-ticket rendering is recursive.

Must ship before the backend developmain release PR.

Sprint task / issue

Type

  • fix — bug fix

What changed

1. Routing recurses (KitchenTicketFilter, new). A pure function — same shape as FeedWatchdogDecision — so the tree walk is testable without a printer.

  • A line goes to the kitchen it names.
  • A line that names none (a drink, an extra sauce, KitchenType: "None") is a modifier of its parent and rides with it wherever the parent is made. It is dropped from tickets that carry the parent only as context.
  • A parent whose kitchen differs from a component's still reaches that component's ticket, but as context: printed parenthesised at normal size, so it does not read as a dish that kitchen has to make.

2. has{Front,Back}KitchenItems is now derived from the filtered ticket itself rather than a second scan beside it — "does this kitchen get a ticket" is answered by the very thing that will be printed. The two used to be decided separately, and separately is how they drifted apart.

3. Two further regressions from the same contract change, found while tracing it. Both are cases of a child that used to be its own top-level row and therefore printed in full:

  • The kitchen receipt rendered children one level deep, so a grandchild in a nested bundle printed nowhere. Rendering is recursive now.
  • It rendered a child's name and quantity only, dropping its NO onion and its note. Every line now prints its own detail at its own depth.

4. A parent's NOTE: moved above its components — trailing them, it read as if it belonged to the last child printed.

Ticket output (verified against a loopback sink, ESC/POS stripped)

Order: Menu Deal (Front, note "allergy: no nuts") → Kofte (Front, NO Onion) + Fries (Back, "extra crispy") + Ayran (no kitchen); plus Adana Kebab (Back).

*** FRONT KITCHEN ***          |  *** Back Kitchen ***
1x Menu Deal                   |  (1x Menu Deal)
   NOTE: allergy: no nuts      |     NOTE: allergy: no nuts
   + 1x Kofte                  |     + 2x Fries
      - NO Onion               |        NOTE: extra crispy
   + 1x Ayran                  |  1x Adana Kebab

Each component appears exactly once, on the right ticket; the allergy note reaches both kitchens; the combo line is unmistakably context on the back ticket.

NFR triage

  • D9 testing: 10 new tests — 8 unit (KitchenTicketFilterTests) + 2 print-to-sink integration through the real composer and transport. Verified non-vacuous: 4 of them fail against the pre-fix top-level-only filter.
  • D10 conventions: new pure-function service file (80 LOC, under the 300 limit); OrderPrintService grew by only 15 LOC net despite the new recursive renderer, because CreateFilteredOrder's 28-line hand-copy was retired.
  • D8 observability: kitchen print logs now report the routed item count, unchanged in shape.
  • Rest: n/a because this is a pure client-side formatting/routing change — no new I/O, endpoints, auth, persistence, PII, UI or strings.

Acceptance criteria coverage

Criterion Status Notes
has*KitchenItems recurses into SideItems, arbitrary depth Covered Derived from the filtered ticket; NestedGrandchild_ReachesItsOwnKitchen_ThroughTheParentsAboveIt covers 3 levels
CreateFilteredOrder kitchen-aware for children — mismatched child does not ride on the parent's ticket Covered MixedKitchenBundle_BothKitchensGetATicket_EachComponentOnExactlyOne
A matching child whose parent does not match still reaches that kitchen Covered Parent printed as context, only matching children nested
No model change needed Partial (deliberate) No wire field added/renamed/removed. Added: two clone methods, and one [JsonIgnore] bool IsContextOnly presentation flag — see below
Test: single-kitchen bundle — one ticket, components nested, each printed once Covered Unit + print-to-sink
Test: mixed-kitchen bundle — both kitchens ticket, each component exactly once, on the right one Covered Unit + print-to-sink

Why IsContextOnly exists (the one deviation from "no model change")

The renderer cannot derive "is this line context" from KitchenType alone: a component with no kitchen of its own is made wherever its parent is, so a rode-along drink and a context-only combo both fail a plain comparison against the ticket's kitchen. My first cut did exactly that and printed (1x Ayran) — a drink the front kitchen does serve — as context on the front ticket. Caught by eyeballing a rendered ticket, now pinned by assertions. The filter knows the answer; the flag is how it tells the renderer. [JsonIgnore], never deserialized, so the DTO mirror is untouched (TypeDisplay is the existing precedent for a non-DTO member on this model).

Backend contract verification

Backend DTOs referenced / mirrored:

  • backend/RestaurantSystem.Api/Features/Orders/Dtos/OrderItemDto.csList<OrderItemDto>? SideItems, string? KitchenType (FrontKitchen / BackKitchen / None), ItemKind? Kind. PrinterAPP/Models/Order.cs already mirrors SideItems + KitchenType; Kind is still unmirrored and unused (routing keys off KitchenType, which is what the backend resolves per line).
  • backend/RestaurantSystem.Api/Features/Orders/Services/OrderMappingService.csMapToOrderDto projects roots only and nests children via MapOrderItem(child, childrenByParent); arbitrary depth confirmed.
  • backend/RestaurantSystem.Api/Features/Orders/Services/OrderItemFactory.cs:92 — child rows carry ItemTotal = 0, the parent's total is rolled up ⇒ the cashier receipt needs no change; root-only actually removes the old CHF 0.00 child lines from it.

Cross-repo impact: none — consumer-side only. No printer-app change is required in backend/.

Standard checklist

  • Build — 0 errors. dotnet build -f net10.0-android on macOS (the Windows TFM needs a Windows host; see risk below). No new warnings from any changed file.
  • dotnet test PrinterAPP.Tests — 173/173 pass
  • No hardcoded backend URLs / API keys / printer names / paths
  • All services have interfaces — n/a for KitchenTicketFilter, a pure static decision function (CLAUDE.md §3 precedent: FeedWatchdogDecision, PrinterTransportResolver, OrderFeedParser); no DI registration needed
  • Code-behind untouched
  • Models match backend DTOs exactly (no wire field changed)
  • No null! on model fields
  • Sibling file conventions matched; new file source-linked into PrinterAPP.Tests.csproj
  • Pre-commit hooks pass locally
  • Branch is off develop; PR targets develop

Test plan

  • Unit: single-kitchen bundle, mixed-kitchen bundle, 3-level nesting, no-kitchen children riding along, case-insensitive match, no-mutation of the routed order, empty/null input.
  • Integration: both kitchen tickets driven through the real OrderPrintService + NetworkTcpTransport into loopback sinks; asserts per-component occurrence counts per ticket, and that a kitchen with nothing to make is never contacted.
  • Physical printer verification still owed — printing-path change; see risk below.
  • Windows build + MSIdotnet build PrinterAPP.sln on a Windows host.

Risk / what is NOT verified here

  1. No physical print. Ticket layout was verified by decoding the bytes at a loopback socket, not on paper. The ESC/POS control sequences are unchanged from the previous renderer (same ESC_SIZE_WIDE / ESC_SIZE_TALL / ESC_SIZE_NORMAL usage); what changed is line ordering, indentation depth, and the parenthesised context line. Worth one real ticket before the release PR.
  2. Windows TFM unbuilt — macOS host builds Android only. Nothing in this diff is platform-specific, but per CLAUDE.md §1 the Windows build is the pre-merge source of truth.
  3. ItemKind.Kind is deliberately not mirrored. If bundle components and true add-on sides should ever print differently, that is the field to bring across — not needed for routing.

Release notes

  • New config keys required: none
  • New secrets required: none
  • Auto-update compatibility: forward-compatible — no config-file or persisted-state change

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 SideItems, to arbitrary depth. No DTO field
changed, so the feed still deserializes — the printer app just stopped
seeing most of the order.

Kitchen routing read the top level only, in both of its steps. A "Menu
Deal" whose product is FrontKitchen containing BackKitchen fries then
produced NO back-kitchen ticket at all (hasBackKitchenItems was false),
and the fries printed on the front kitchen's ticket instead, as a nested
"+ 2x Fries" line. A live order line, silently sent to the wrong kitchen.

Routing now walks the whole tree, in KitchenTicketFilter — a pure
function, like FeedWatchdogDecision, so the tree walk is testable without
a printer. A line goes to the kitchen it names; a line that names none (a
drink, an extra sauce) is a modifier of its parent and rides with it. A
parent whose kitchen differs from its component's still reaches that
component's ticket, but only as context: printed parenthesised at normal
size, so it doesn't read as a dish that kitchen has to make.

The "does this kitchen get a ticket" question is now answered by the very
thing that will be printed, rather than by a second scan beside it.

Two further regressions from the same contract change, found while
tracing it — both are cases of a child that used to be its own top-level
row and therefore printed in full:

- The kitchen receipt rendered children ONE level deep, so a grandchild
  in a nested bundle printed nowhere. Rendering is recursive now.
- It rendered a child's name and quantity only, dropping its "NO onion"
  and its note. Every line now prints its own detail at its own depth.

A parent's note also moved above its components: trailing them, it read
as if it belonged to the last child printed.

Order/OrderItem gain a clone helper each (used to build a ticket without
mutating the order the history list and the UI hold) and OrderItem gains
a [JsonIgnore] IsContextOnly presentation flag. No wire field added,
renamed or removed — the DTO mirror is unchanged. The clone also retires
CreateFilteredOrder's 28-line hand-copy, where a field added to Order
would have silently vanished from every kitchen ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

CreateFilteredOrder and AppendKitchenItem touch no instance state (S2325),
and the item renderer's cognitive complexity was 20 against a limit of 15
(S3776) — the ingredient-customization block is now its own method, and
the two redundant "not null and Any" guards before a foreach are gone.

No behaviour change: same lines, same ESC/POS sequences, same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit db4e4ab into develop Jul 27, 2026
6 checks passed
@mahmutkaya
mahmutkaya deleted the fix/kitchen-routing-nested-bundle-items branch July 27, 2026 10:27
mahmutkaya added a commit that referenced this pull request Jul 27, 2026
Ships #87: kitchen-ticket routing walks the nested item tree, so a bundle
component whose kitchen differs from its parent's reaches its own kitchen
instead of no kitchen at all.

Deliberately released AHEAD of backend PR #237, which is what makes
OrderDto.Items root-only and is still on backend `develop`. The fix is
backward compatible: against today's production backend the printer feed
returns SideItems: null on every order, so every line is a root with its
own KitchenType and the filter reduces to exactly the old top-level
`Where(KitchenType == kitchen)` — byte-identical receipts. The new nested
layout only appears once the backend release lands, by which time the
client workstations will already have auto-updated.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant