Aller au contenu

omnibase/restaurant

A restaurant on omnibase: the dining room and its layouts (a floor-plan editor, a QR poster per table), meal services and table reservations (availability under a lock, a public form, a management link, ICS), the menu (dishes as omnibase/marketplace products, sections, set menus, EU allergens), ordering at the table by QR code (sessions, rounds, tickets), the pass for the staff, and the delivery and booking platforms through omnisense/omnifood.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/restaurant:dev-main

A restaurant's site on omnibase: the room and its reservations, the menu, ordering at the table by QR code, the staff's screen, the delivery and booking platforms. The site carries the identity; the bundle does the work.

Page
Installation the package, the routes, the configuration, the Stimulus controllers, the theme
The room and the reservations rooms, tables, layouts, the floor editor, QR posters, meal services, availability, the public form, the management link
The menu and ordering at the table dishes, sections, set menus, allergens, /t/{token}, sessions and tickets
The take-home shop fresh dishes to cook at home, the cold chain (pickup, local delivery, chilled parcels), ordering without an account, /suivi/{token}
The pass /service: the floor, the tickets, the book, what the staff do there
The platforms omnisense/omnifood: webhooks, answers, the menu, the keys
What guests say elsewhere omnisense/omnireview: the rating and latest reviews on the page, the dashboard's tile
Configuration reference restaurant: dumped
Reference every route and who may call it, the commands, the events, the Twig functions
Testing the suite, what it checks
Roadmap where it stands, what is left

Namespace Base\Restaurant\, translations in the restaurant domain (fr, en, de, ja), tables prefixed restaurant_.

Proposer une modification docs/index.md

Installation

composer require omnibase/restaurant          # needs omnisense/omnibase 3.x, omnibase/admin, omnibase/marketplace
composer require omnisense/omnifood omnifood/ubereats omnifood/thefork   # optional: the platforms
// config/bundles.php
Base\Restaurant\RestaurantBundle::class => ['all' => true],
Omnifood\Bridge\Symfony\OmnifoodBundle::class => ['all' => true],   // with omnisense/omnifood
# config/routes.yaml
restaurant_controller:                 # /carte, /reserver, /reservation/{token}, /t/{token}, /service, /traiteur, /suivi/{token}, the platforms' webhook
    resource: "@RestaurantBundle/src/Controller/Client"
    type: attribute
restaurant_admin_controller:           # /admin/restaurant/salle, /admin/restaurant/affiches
    resource: "@RestaurantBundle/src/Controller/Admin/FloorController.php"
    type: attribute

The CRUD screens (rooms, tables, layouts, meal services, reservations, dishes, set menus, sections, tickets) are routed by omnibase/admin (type: base_admin); link them from the site's dashboard with MenuItem::linkToCrud(...), the floor editor with MenuItem::linkToRoute('restaurant_admin_floor'), and add the restaurant_today widget.

Then the schema: bin/console make:migration, doctrine:migrations:migrate. The site needs framework.lock (symfony/lock) and a mailer.

Configuration

# config/packages/restaurant.yaml - the defaults
restaurant:
    layout: 'layout1.html.twig'      # the site's frame: blocks title, description, content, stylesheets
    staff_role: ROLE_STAFF           # who runs the pass
    manager_role: ROLE_ADMIN         # who edits the floor plan and prints the posters
    poster_color: '#432B52'          # the band across the top of the QR posters
    from_email: ~                    # sender of the reservation mails
    notify_email: ~                  # where a request to confirm is announced
    reservation:
        notice: 60                   # minutes ahead, online
        horizon: 60                  # days ahead, online
        max_covers: 8                # beyond: "call us"
        min_delay: 3                 # seconds: a form sent faster is a robot's
        cancel_until: 120            # minutes before: the guest may still change or cancel online
    table:
        ordering: true               # false: the QR code shows the menu only
        pay_online: false            # "pay from my phone": needs omnibase/marketplace's quick order
        rate_limit: 20               # requests a minute per table and address
        max_lines: 40
        max_quantity: 20
    takehome: { enabled: true, pickup: true, notice_days: 1, max_quantity: 20 }   # the take-home shop: see take-home.md
    delivery: { zip_codes: [], minimum: 0, fee_product: ~ }
    parcel: { enabled: true, shipping_method: ~, fee_product: ~, ship_days: [1, 2, 3], transit_days: 1, margin_days: 2, cutoff: '12:00' }
    pass:
        poll: 6                      # seconds between two looks at the state
        accept_warning: 180          # seconds before a platform's deadline the ticket turns red

When the restaurant is open - and closed - is omnisense/omnibase's (base.opening_hours, Base\Entity\Hours\*): a time is only offered when the place is open then. omnibase stores no ROLE_STAFF on an account: give it through a Base\Entity\User\Group (roles ['ROLE_STAFF']).

Demonstration accounts

In omnisense/omnibase's demo environment (its docs/20-architecture/demo.md) the sign-in page offers one button for each of a restaurant's people. Base\Restaurant\Demo\RestaurantDemoAccounts declares them:

Identifier Role
gerant restaurant.manager_role (ROLE_ADMIN) the back office: the floor plan, the services, the book, the menu, the take-home shop
salle group "Salle" (restaurant.staff_role) the pass: the room live, the day's reservations, tables to seat and to settle
cuisine group "Cuisine" (restaurant.staff_role) the same pass, one station at a time (/service?poste=hot): the tickets to prepare
client none the take-home shop: an order paid with the trial payment, followed, kept under its account

The password is the identifier. The fixtures take the accounts from omnibase's factory and attach what makes them worth signing in as - the store and its dishes to the manager, a day's reservations for the pass:

public function __construct(private readonly \Base\Demo\DemoAccountFactory $accounts) {}

$gerant = $this->accounts->account('gerant', $manager);   // created with its role, or the database's
$this->accounts->account('salle', $manager);              // in the group "Salle", created with restaurant.staff_role

A restaurant without one of them leaves it out (base.demo.exclude: [cuisine]); a site that renames one declares the same identifier in its own provider. The labels are demo.<identifier>.label and .description in the restaurant domain (fr, en, de, ja). In demo omnibase/marketplace offers its dev gateway alone: a take-home order and a table's bill are paid by nobody.

Front end

The public pages use Stimulus controllers the site registers, with omnisense/omnibase's poll:

// assets/bootstrap.js
import Poll from '../vendor/omnisense/omnibase/assets/controllers/poll_controller.js';
import Booking from '../vendor/omnibase/restaurant/assets/controllers/booking_controller.js';
import Filter from '../vendor/omnibase/restaurant/assets/controllers/filter_controller.js';
import Table from '../vendor/omnibase/restaurant/assets/controllers/table_controller.js';
import Pass from '../vendor/omnibase/restaurant/assets/controllers/pass_controller.js';

app.register('poll', Poll);
app.register('restaurant-booking', Booking);
app.register('restaurant-filter', Filter);
app.register('restaurant-table', Table);
app.register('restaurant-pass', Pass);

A page that polls loads whole: list /t/* and /service* among Base.boot()'s exceptions. When the bundle is a symlinked path repository, tell webpack where its imports are: config.resolve.modules = [path.resolve(__dirname, 'node_modules'), 'node_modules'].

bundles/restaurant/css/restaurant.css is painted through custom properties, on :root and again for a dark theme:

:root {
    --restaurant-ink: #241b2b;  --restaurant-soft: #6b5a78;
    --restaurant-bg: #fff;      --restaurant-paper: #f7f3f9;  --restaurant-line: rgba(67, 43, 82, .16);
    --restaurant-accent: #432B52; --restaurant-on-accent: #fff; --restaurant-hot: #E07A2F;
    --restaurant-font: "Roboto Slab", serif; --restaurant-native: "M PLUS Rounded 1c", sans-serif;
    --restaurant-sticky-top: 3.4rem;   /* under the site's fixed header */
}

The floor editor (bundles/restaurant/js/floor.js, css/floor.css) is a plain script of the back office: nothing to register.

Proposer une modification docs/installation.md

The room and the reservations

The room

Entity
Room a name, a size in centimetres, its decor (walls, counter, doors, windows, kitchen, plants, labels)
Table label, min and max covers, shape (round, square, rectangle, counter), place and rotation, token (its QR code), active, bookable (offered online), ordering (its phones may order)
Layout a named arrangement of a room: each table's place there, tables out, tables joined; the default one, the one a MealService names, or one taking over given dates

Service\FloorPlan::floor($day, $service) gives the tables in use and the joined groups: a layout taking over that date, else the service's, else the room's default, else the tables where they stand.

The floor editor

/admin/restaurant/salle (restaurant_admin_floor, the manager's role; it opens in the nest on @Admin/layout.html.twig). The room is drawn to scale in SVG. Click selects (shift-click adds), drag moves on the grid (5, 10, 25 cm), arrows nudge, R turns by 45°, D duplicates, Delete removes; the panel edits the label, the covers, the shape, the size. On the base plan the tables' own places are saved; in a layout, their places there, which are out, and the joins (select several, "Jumeler"). Saved as JSON (FloorController::save).

QR posters

/admin/restaurant/affiches: one A4 poster per table, or ?format=L7121|L7160|L7163 for label sheets (omnisense/omnibase's QrSheet). Each code is the table's /t/{token}. "Renouveler le QR" on a table gives it a new token: the poster printed before stops working.

Reservations

A MealService says which days it runs, the first and last seating, the step, how long a table is kept by party size (durations: {2: 75, 4: 90, 99: 120}), the covers the kitchen takes in all, up to how many guests a reservation is confirmed at once (autoConfirm), and its layout.

Service\Seating reckons, with nothing read nor written (it is what the unit tests exercise):

  • a time is offered when the service runs that day, the place is open then (OpeningHours::hoursOn(), days off included), it is far enough ahead, the service has covers left, and a table is free for as long as such a party keeps it;
  • the table given is the smallest that seats the party; joined tables only when no single one does; online, a table's minimum and bookable hold - the staff may seat anyone anywhere.

Service\Availability reads the day and takes the reservation under a lock on the day (LockFactory, restaurant-book-<day>): the free tables are read again inside it, so two guests taking the last table get it once.

$availability->slots($day, 4);                 // list<Model\Slot>: time, service, tables, minutes
$availability->openDays(2, null, 5);           // the next five days with a table for two
$reservations->request($booking, $locale);     // from the public form: booked, mailed (ICS attached)
$reservations->take($reservation, $day, '20:00', Reservation::SOURCE_PHONE);   // by the staff
$reservations->change($reservation, $day, $time, $covers);   // the guest, through the link (the online rules hold)
$reservations->reschedule($reservation, $day, $time, $covers); // the staff, from the pass: any free table
$reservations->cancel($reservation);
$reservations->move($reservation, ReservationStatus::SEATED);                  // the pass

Statuses: requested → confirmed → seated → finished, or no_show, cancelled, refused; the first three hold their tables. Each change dispatches Event\ReservationChangedEvent.

Times. A reservation's startsAt is the restaurant's wall clock, stored and compared as written (datetime_immutable). Moments (a ticket's arrival, a deadline, when a table was settled) are columns of omnisense/omnibase's type utc_datetime_immutable, written in UTC and read back as UTC: omnibase sets PHP's zone per visitor, and a plain datetime_immutable would be written in whatever zone the request is in. In a query, such a column is compared with Base\Database\Type\Utc::from($moment) or Utc::now().

Public pages

Route
restaurant_book /reserver the form (Form\BookingType on Model\Booking): day, covers, a time among those free (asked again from /reserver/creneaux as they change), name, e-mail, phone, allergies, a note; a trap field and a minimum delay; omnibase's PrivacyType notice
restaurant_manage /reservation/{token} the guest's page: status, calendar file, another time, cancellation - until cancel_until minutes before
restaurant_manage_ics the ICS (omnisense/omnibase's Calendar\Ics), in the restaurant's time zone

Mails (@Restaurant/email/reservation.html.twig, in the guest's language): confirmed, requested, changed, cancelled, refused; the restaurant is told of a request to confirm and of a guest's cancellation (notify_email).

Proposer une modification docs/room-and-reservations.md

Entity\Menu\Dish extends Base\Marketplace\Entity\Product: its title and text, its price before VAT and the store's VAT, its stock and availability are the shop's. A menu adds: nativeName (味噌ラーメン), an illustration, allergens (values of Base\Enum\Allergen), diets (Enum\Diet), spice (0-3), a section (MenuSection, an omnibase taxon with its order and native name), the station that makes it (Enum\Station: hot, cold, bar), tableOrder. SetMenu is a dish whose courses are listed, served at one MealService when it says so. The CRUD screens extend the shop's product screen.

Prices shown are VAT included (marketplace_price_with_vat(dish), omnibase's format_currency).

Route
restaurant_menu /carte by section; a filter hides the dishes holding the allergens ticked, or not suiting the diets ticked (on the page: nothing is sent)
restaurant_menu_print /carte/imprimer the same on paper, allergen numbers and their key

A dish run out on the pass (Dish::runOut(), ProductAvailability::OUT_OF_STOCK) stays on the menu, greyed, cannot be ordered, and is suspended on the platforms (Event\DishAvailabilityEvent).

/t/{token} is what a table's QR code opens: the menu with a stepper per dish, the round being put together (kept on the phone until sent), "Envoyer en cuisine". No account. The first round opens the table's Session (its bill); each round is a Ticket with its TicketLines (name and price copied when sent). The guests see where each round stands (polled), call the waiter, ask for the bill; the bill asked for, the phones send no more rounds.

Route
restaurant_table the page
restaurant_table_round POST /t/{token}/tournee JSON, bound with #[MapRequestPayload] to Model\Round ({lines: [{dish, quantity, note?, options?: [option ids]}], note?, covers?})
restaurant_table_call, restaurant_table_bill the waiter, the bill
restaurant_table_state the table's state, for the poll

A dish's options are omnibase/marketplace's (Product\OptionGroup, Option: a cooking, extras - one or several, a minimum, a maximum, a surcharge before VAT). The table's page shows them under the dish; a line of a round names those chosen by id (options: [12, 15]; a text stays a word for the kitchen). TableOrders::send() checks them (Service\ProductOptions: a choice missing or one too many is refused in the guest's words), a group left untouched takes its preselected option, and the surcharge - with the dish's VAT - is in the line's price; the ticket shows their labels.

With restaurant.table.pay_online: true and omnibase/marketplace's quick order, the "Notre table" tab offers "Payer depuis mon téléphone": an e-mail address for the receipt, then the shop's payment (restaurant_table_pay POST /t/{token}/payer, Service\TablePayments). The bill's rounds become the lines of an order (no account), paid through the shop's gateways; paid, the bill is settled online for the order's amount and the table is free, as at the till. A bill the shop would not price the same from its dishes - a round with paid options, a dish since removed - is settled at the till (table.error.pay_till).

Guards: the token is the only key (a renewed token locks the old poster out); posts are JSON only; symfony/rate-limiter counts per table and address (restaurant.table.rate_limit a minute); each line is checked against the menu; the pass can close a table to orders.

A ticket moves along Enum\TicketStatus:

new ─▶ accepted ─▶ preparing ─▶ ready ─▶ served
 │         └──────────┴──────────┴─▶ cancelled
 └─▶ refused | cancelled

Ticket::moveTo() refuses a step the status does not allow (TransitionException); each move dispatches Event\TicketMovedEvent. Tickets are independent of omnibase/marketplace's orders: a ticket is the kitchen's paper.

Proposer une modification docs/menu-and-table.md

The pass

/service (restaurant_pass, restaurant.staff_role): a page of the site, one screen for the floor.

  • The room, live: each table free, booked (within 45 minutes), seated, waiting on the kitchen, with something ready, calling, asking for the bill. A tap opens its card: its rounds, the total, "J'arrive", settled by card or in cash (the table is free again, its reservation finished), move the guests to another table, close the table to orders.
  • The tickets of every channel in four columns - to accept, in the kitchen, ready, served - with one button for the next step, refuse or cancel; a platform's order shows its countdown and turns red accept_warning seconds before its deadline. ?poste=hot|cold|bar keeps one station's lines. Settling a table leaves none of its rounds on the pass: what was still open - waiting, on the stove, ready - is served (Session::closeTickets(), a TicketMovedEvent each). Settling never changes what is billed: a round that was not made is cancelled before.
  • The shop's orders: a paid take-home order is a ticket of the takeaway channel - "Retrait", "Livraison" or "Colis", the customer, the time - and moving it moves the hand-over its customer follows (The take-home shop).
  • The day's book: each reservation with where it came from (the site, the phone, a walk-in, or the platform's name); confirm or refuse a request, seat (the table's bill opens, tied to the reservation), finish, absent; move a party still to come by a quick form - another day, time or size, any free table, as a phone booking (Reservations::reschedule(); the guest with an address is mailed); a form for a reservation taken on the phone or a walk-in seated now.
  • Run out: a dish out of stock, or back.
  • The platforms (with omnisense/omnifood): every platform the site declares, with the state of its connection - connected (every key its factory needs is set, in the configuration or typed in the back office), not configured (the keys missing are named; nothing is called, its webhook answers 404), in error (the last call or webhook failed: what the platform said, and when, until one succeeds - Omnifood\PlatformHealth, kept in the cache). A connected platform that takes orders is paused for 30 minutes, or resumed.

The board is drawn by the server (_pass_board.html.twig) and asked again when Service\Revision has grown - a number bumped on every write of a restaurant entity, kept in the cache. Two poll controllers read /service/etat: revision redraws, latest (the newest ticket to accept) rings once the alerts are armed by a tap. Every tap is a POST with the pass's CSRF token (restaurant-pass), answered in JSON to the page's script.

The dashboard widget restaurant_today shows the day's covers, the reservations to confirm and the tickets open.

Proposer une modification docs/pass.md

The take-home shop

Dishes to cook at home - fresh, frozen, or kept on a shelf -, sold beside the menu: /traiteur. The shop is omnibase/marketplace's (products, prices before VAT, VAT, payment, the hand-over Entity\Order\Pickup); what a restaurant adds is the dish, the cold chain, and the pass.

TakeHome: a dish, and how it is kept

Entity\Product\TakeHome extends the marketplace's Product (table restaurantProductTakeHome): nativeName, illustration, allergens (Base\Enum\Allergen), keeping (Enum\Keeping: ambient, fresh - the fridge, the default -, frozen; isChilled() is true for fresh and frozen, isFrozen() for frozen) and storageTemperature (°C at most; null: the keeping's own, 20, 4 or -18), shelfLife (days it keeps once made: useBy($madeOn)), netWeight (g), portions, cookingTime (min), cookingSteps (by language: {fr: […], en: […]}), parcel (may travel in a chilled parcel), shopPosition. Back office: TakeHomeCrudController.

The shop's pages show each dish's keeping as a pill ("Frais", "Surgelé") with how long and how cold it keeps: a frozen dish keeps for months at -18 °C, a fresh one for days at 4 °C. Both leave in an insulated bag and travel in a chilled parcel only (isParcel() says whether this one does).

Food taken home that is not eaten at once is at 5.5 % in France, the menu at 10 %: give the take-home dishes a product taxon and a VAT scoped to it (a taxon's rate wins over the store's).

Route
restaurant_takehome /traiteur the dishes, and the order form where the shop takes orders
restaurant_takehome_dish /traiteur/{slug} a dish: how it keeps, how to cook it
restaurant_takehome_order POST /traiteur/commander the order (see below)
restaurant_tracking /suivi/{token} the order followed, without an account

The cold chain

Service\ColdChain says how a basket may leave, and when - the marketplace offers every way to every basket, it does not know what keeps:

Way Offered when
collected at the restaurant (pickup) restaurant.takehome.pickup; on a slot of marketplace.pickup.slots
brought nearby (delivery) the postcode is in restaurant.delivery.zip_codes ("67300", or "674*"), the basket reaches restaurant.delivery.minimum
a chilled parcel (shipping) a carrier is configured; every dish of the basket travels (TakeHome::isParcel()); the shortest shelf life covers transit_days + margin_days; the day asked is one of ship_days (Monday to Wednesday: a parcel that left later would spend the weekend in a depot)

The dishes are made for the day asked: notice_days (1: tomorrow at the earliest) applies to every way.

$coldChain->options($lines, $postcode);                 // ['pickup' => HandOver, 'delivery' => …, 'shipping' => …]: available, days, reason
$coldChain->check(PickupMode::SHIPPING, $lines, $day);  // RestaurantException takehome.error.parcel_day, .parcel_dish, .parcel_shelf_life, .too_soon…
$coldChain->shippingDays();                             // the next days a parcel may leave
$coldChain->useBy($lines, $madeOn);                     // the basket's use-by date: its shortest
restaurant:
    takehome: { enabled: true, pickup: true, notice_days: 1, max_quantity: 20 }
    delivery:
        zip_codes: ['67000', '67300', '674*']
        minimum: 2000                    # cents before VAT
        fee_product: livraison-locale    # slug of the product charged for it; null: free
    parcel:
        shipping_method: colis-frais     # slug of the shop's ShippingMethod; its carrier is a omnisense/omnibus gateway
        fee_product: colis-frais
        ship_days: [1, 2, 3]             # ISO weekdays
        transit_days: 1
        margin_days: 2                   # days of use-by left on arrival
        cutoff: '12:00'                  # with notice_days 0
marketplace:
    pickup: { slots: { from: '11:30', to: '19:30', step: 30 }, notice: 60, horizon: 10, timezone: 'Europe/Paris' }

The chilled parcel's carrier (Service\ParcelCarrier): the shipping method named, whose gatewayName is a gateway of omnibus.gateways - omnibus/chronopost, with product: fresh (Chronofresh) in the method's parameters (omnibus.<slug>). Without the method, the package or the carrier's account, the parcel is not offered at all. The order is taken and its hand-over written; the label is not booked by the bundle: the staff books it (Shipping::book()), with the basket's use-by date as use_by. Chronofresh was not verified against a real contract.

An order without an account

Where omnibase/marketplace has its quick order (Service\QuickOrder), the shop's form takes the whole order: how many of each dish, the way, the day and slot, a name, an e-mail address, a phone. Service\TakeHomeOrders::place() checks the cold chain, makes the order for the address (QuickOrder::buy(): the member it belongs to, or a new one), takes the payment with the shop's first able gateway, writes the hand-over (Pickups::open()), and the customer lands on /suivi/{token} - or on the provider's page first, then back there (EventListener\QuickOrderListener, on QuickOrderDoneEvent).

A delivery or a parcel is charged as a line of the order (the product named by fee_product): a quick order has no shipping charge of its own. The form works without JavaScript; a refusal comes back as a flash message with what was typed. It is Form\TakeHomeOrderType on Model\TakeHomeOrder, a root without a name (its fields keep the page's names: email, lines[12], day), guarded as omnisense/omnibase guards a public form - its option guard, action takehome: a trap, the time it takes, the lists, the captcha when the site has omnisense/omnishield. The page writes its fields by hand and prints the guard's (trap, stamp, captcha - shown only after a few refused tries, as omnisense/omnibase's base.guard.captcha_after says: a field the guard has already rendered is skipped) and the token from the form's view, before the button. A test posts those hidden fields as the page prints them (tests/TakeHomeGuardTest.php). Without the quick order, the page lists the dishes and says to order at the counter.

Prices are kept before VAT and the VAT is counted on each line's total: two dishes shown at 9,90 € make 19,79 €.

On the pass, and followed

A hand-over received - its order is paid - holding dishes of the restaurant becomes a ticket of the takeaway channel (Service\TakeawayTickets, on PickupChangedEvent): the customer's name, the time, the dishes (a delivery charge is not one), to accept and prepare like any other. As the ticket moves, so does the hand-over: accepted, in preparation, ready, then handed over - or on its way, for a delivery or a parcel. /suivi/{token} shows it, with the basket's use-by date; it asks again every half minute while the order is open. This works for any paid order with a Pickup, whoever made it.

Proposer une modification docs/take-home.md

The platforms

Loaded only when Omnifood\Registry exists (config/services.php); without it the webhook answers 404 and the pass shows no platform.

# config/packages/omnifood.yaml
omnifood:
    platforms:
        ubereats: { factory: ubereats, options: { client_id: '%env(default::UBEREATS_CLIENT_ID)%', client_secret: '%env(default::UBEREATS_CLIENT_SECRET)%', store_id: '%env(default::UBEREATS_STORE_ID)%' } }
        thefork:  { factory: thefork,  options: { client_id: '%env(default::THEFORK_CLIENT_ID)%', client_secret: '%env(default::THEFORK_CLIENT_SECRET)%', restaurant_id: '%env(default::THEFORK_RESTAURANT_ID)%' } }

Keys typed in the back office. Omnifood\FoodKeysSection declares, on omnibase/admin's API keys page, the fields of each configured platform (api.food.<platform>.<option>); Omnifood\Platforms builds each platform with them over the configured options (Registry::create()).

The state of each connection is on the pass (Omnifood\PlatformHealth): connected, not configured (Platforms::missing() names the keys of Platforms::KEYS still empty - nothing is called before they are all set), or in error since a call or a webhook failed (the message kept in the cache until one succeeds). Without any credentials - the usual case before a partner agreement - every platform reads "not configured" and the site makes no call at all.

Webhooks. POST /restaurant/plateforme/{name}/webhook (restaurant_platform_webhook): the platform's package checks the signature

  • 401 and nothing done when it does not hold -; an order becomes a ticket of channel platform (Omnifood\OrderTickets: reference, short number, lines tied to the menu's dishes by their slug - the item reference Omnifood\MenuExport sends -, modifiers as options, the acceptance deadline); a reservation joins the book with the platform as its source. An event already seen (Notification::$id, kept in PlatformEvent) makes nothing; one whose order could not be read answers 502 and is tried again.

Answers. Omnifood\PlatformSubscriber tells the platform what the pass does: accepted (with a ready time), denied, ready, cancelled; a dish run out or back on every platform taking a menu; a platform's reservation seated, finished, absent, cancelled. A platform that fails is logged; the pass goes on.

bin/console restaurant:menu:push [platform] [--dry-run] [--photos=https://site/assets/images]
bin/console restaurant:reservations:sync [--days=30]      # from the cron container

Not verified against the live APIs: the platforms give no credentials without a partner agreement (see omnisense/omnifood's own notes).

Proposer une modification docs/platforms.md

What guests say elsewhere

The restaurant's rating and its latest reviews on Google, Tripadvisor or Trustpilot, on its page (/carte, the menu) and on a tile of the back office's dashboard - through omnisense/omnireview. Without the family, or without a key, nothing shows: no section, no tile, no call.

Setting it up

composer require omnisense/omnireview omnireview/google      # tripadvisor, trustpilot as wanted

Register Omnireview\Bridge\Symfony\OmnireviewBundle (its Twig functions, the cache), configure its gateways (keys in .env.local or the vault), then say where the restaurant is on each:

# config/packages/omnireview.yaml
omnireview:
    gateways:
        google: { factory: google, options: { api_key: '%env(GOOGLE_PLACES_KEY)%', language: fr } }

# config/packages/restaurant.yaml
restaurant:
    reviews:
        places:
            google: 'ChIJ...'                     # the place ID: the one thing Google lets you keep
            # tripadvisor: '1234567'              # its location ID
            # trustpilot: '<business unit id>'
        owners:
            google: 'accounts/<a>/locations/<l>'  # with the owner's access (Business Profile): every review, and whether it was answered
        limit: 3                                  # reviews per platform on the page

A platform listed in places whose omnireview gateway is not configured is left out.

On the page

The menu's page ends with @Restaurant/client/_reviews.html.twig: a section per platform, drawn by the family's own widget (omnireview_widget(), through restaurant_review_widget() so that the bundle's templates compile on a site without the family). The widget always shows the origin, the date and the link of each review, keeps them no longer than the platform allows (nothing for Google, a day for Trustpilot), and builds no JSON-LD: reviews collected by a third party are not marked up as the restaurant's own AggregateRating.

A site dresses it in templates/bundles/RestaurantBundle/client/_reviews.html.twig (the section) and templates/bundles/OmnireviewBundle/reviews.html.twig (each platform), keeping what each platform's terms ask (omnireview_terms('google'): the attribution, the authors, the links; for Tripadvisor, a page kept out of search engines).

The tile

The widget type restaurant_reviews lists the latest reviews over every platform, newest first, and how many wait for an answer - where the platform says whether one was given: Google with the owner's location, Trustpilot with its business user. A site places it on its dashboard:

yield MenuItem::block('restaurant_reviews', 'Avis', 'fa-solid fa-star')->setSize(4);

Without a platform configured, the tile renders nothing.

What it does not do

Replying and inviting a guest are the family's (ReplyInterface, InviteInterface): no screen of the bundle does it yet. Nothing is stored in the restaurant's tables.

Proposer une modification docs/reviews.md

Configuration reference

The restaurant tree

Dumped with Symfony's YamlReferenceDumper from Base\Restaurant\DependencyInjection\RestaurantConfiguration. The keys are explained where they act: Installation, the reservations in The room and the reservations, the take-home shop in The take-home shop, the platforms in The platforms, the reviews in What guests say elsewhere.

restaurant:

    # The site's page frame the public pages extend (blocks title, content, stylesheets, javascripts).
    layout:               layout1.html.twig

    # Who runs the pass (/service): seats guests, moves tickets along, settles tables.
    staff_role:           ROLE_STAFF

    # Who edits the floor plan, prints the QR posters, pauses the platforms.
    manager_role:         ROLE_ADMIN

    # The band across the top of the QR posters (a hex colour): the restaurant's own.
    poster_color:         '#432B52'

    # Sender of the reservation mails; null: the mailer's default sender.
    from_email:           null

    # Where a reservation request to confirm is announced; null: nobody is mailed.
    notify_email:         null
    reservation:

        # Minutes between now and the first time a guest may book online.
        notice:               60

        # Days ahead a guest may book online.
        horizon:              60

        # More guests than this: the form says to call.
        max_covers:           8

        # Seconds: a form sent faster than this is a robot's.
        min_delay:            3

        # Minutes before the time a guest may still change or cancel online.
        cancel_until:         120
    table:

        # Guests order from their phone after scanning their table's code; false: the code shows the menu only.
        ordering:             true

        # "Pay from my phone" at the end (needs omnibase/marketplace's quick order); false: the bill is settled at the till.
        pay_online:           false

        # Requests a table's phones may make in a minute (rounds, calls, bill).
        rate_limit:           20

        # Lines a round may hold.
        max_lines:            40

        # Of one dish in one round.
        max_quantity:         20
    takehome:

        # The shop's pages (/traiteur) answer.
        enabled:              true

        # Orders are collected at the restaurant, on a slot (omnibase/marketplace's marketplace.pickup.slots).
        pickup:               true

        # Days the kitchen needs: 1, an order is for tomorrow at the earliest; 0, for today.
        notice_days:          1

        # Of one dish in one order.
        max_quantity:         20
    delivery:

        # Where the restaurant brings orders itself: postcodes, or a prefix ("67*"). Empty: no local delivery.
        zip_codes:            []

        # The smallest basket delivered, in cents before VAT.
        minimum:              0

        # Slug of the product sold as the delivery charge (a line of the order); null: delivered free.
        fee_product:          null
    parcel:
        enabled:              true

        # Slug of the shop's shipping method that carries chilled parcels (its carrier: a glitchr/omnibus gateway, omnibus/chronopost with product "fresh"). Null, or its carrier not configured: no parcel.
        shipping_method:      null

        # Slug of the product sold as the parcel's charge (a line of the order).
        fee_product:          null

        # ISO weekdays a parcel is handed to the carrier: Monday to Wednesday.
        ship_days:

            # Defaults:
            - 1
            - 2
            - 3

        # Days on the road.
        transit_days:         1

        # Days of use-by date left on arrival, at least.
        margin_days:          2

        # With notice_days 0: past this hour, a parcel leaves tomorrow at the earliest.
        cutoff:               '12:00'
    reviews:

        # The restaurant on each platform, by its omnireview gateway's name: {google: "ChIJ...", tripadvisor: "1234567", trustpilot: "<business unit>"}.
        places:

            # Prototype
            gateway:              ~

        # The owner's side of a platform: {google: "accounts/<a>/locations/<l>"} (Business Profile: every review, and whether it was answered).
        owners:

            # Prototype
            gateway:              ~

        # Reviews shown per platform on the restaurant's page.
        limit:                3
    pass:

        # Seconds between two looks at the state by the pass and the guests' phones.
        poll:                 6

        # Seconds before a platform order's deadline the pass turns it red.
        accept_warning:       180

Each key is a container parameter (%restaurant.staff_role%, %restaurant.manager_role%...).

Proposer une modification docs/configuration.md

Reference

Routes

The staff's routes check restaurant.staff_role (ROLE_STAFF), the floor editor's restaurant.manager_role (ROLE_ADMIN), inside the action. The guests' table routes are reached by the table's token, the reservation's by its management token.

Name Path Methods Access
restaurant_menu /carte GET everyone
restaurant_menu_print /carte/imprimer GET everyone
restaurant_book /reserver GET, POST everyone
restaurant_book_slots /reserver/creneaux GET everyone; jour, couverts (2 by default); JSON
restaurant_manage /reservation/{token} GET, POST the management token (20 to 64 characters)
restaurant_manage_ics /reservation/{token}/agenda.ics GET the same; reservation.ics as an attachment
restaurant_table /t/{token} GET the table's token (16 to 32 characters)
restaurant_table_state /t/{token}/etat GET the same
restaurant_table_round /t/{token}/tournee POST the same; JSON
restaurant_table_call /t/{token}/appel POST the same
restaurant_table_bill /t/{token}/addition POST the same
restaurant_table_pay /t/{token}/payer POST the same; JSON
restaurant_takehome /traiteur GET everyone
restaurant_takehome_dish /traiteur/{slug} GET everyone
restaurant_takehome_order /traiteur/commander POST everyone
restaurant_tracking /suivi/{token} GET the order's tracking token (32 to 43 characters)
restaurant_pass /service GET staff
restaurant_pass_board /service/tableau GET staff
restaurant_pass_state /service/etat GET staff; JSON
restaurant_pass_ticket /service/ticket/{id}/{action} POST staff; forward, accept, start, ready, serve, refuse, cancel
restaurant_pass_table /service/table/{id}/{action} POST staff; settle, answer, ordering, transfer, open
restaurant_pass_reservation /service/reservation/{id}/{action} POST staff; confirm, refuse, seat, finish, no_show, cancel, move
restaurant_pass_book /service/reservation POST staff: a reservation taken at the pass
restaurant_pass_dish /service/plat/{id}/{action} POST staff; out, back
restaurant_pass_platform /service/plateforme/{name}/{action} POST staff; pause, resume
restaurant_platform_webhook /restaurant/plateforme/{name}/webhook POST the platform, by its signature (The platforms)
restaurant_admin_floor /admin/restaurant/salle GET manager
restaurant_admin_floor_room /admin/restaurant/salle/nouvelle POST manager: a new room
restaurant_admin_floor_layout /admin/restaurant/salle/{room}/disposition POST manager: a new layout of a room
restaurant_admin_floor_save /admin/restaurant/salle/{room}/enregistrer POST manager: the floor editor's save
restaurant_admin_posters /admin/restaurant/affiches GET manager: the QR posters
restaurant_admin_table_token /admin/restaurant/table/{id}/jeton POST manager: a table's new token (the old poster stops working)

The pass's taps are POSTs with the pass's token, answered with the new state when the page's script asks, with the page otherwise (The pass).

Commands

Command Options Does
restaurant:menu:push --photos=<public address of the drawings>, --dry-run sends the menu to the delivery platforms (with omnisense/omnifood)
restaurant:reservations:sync --days=30 reads the booking platforms' reservations into the book

Events

Event Carries Listened to by
Base\Restaurant\Event\TicketMovedEvent the ticket, the status it came from (null: new), a reason, whether the platform moved it PlatformSubscriber (tells a platform's order: accepted, denied, ready), TakeawayListener
Base\Restaurant\Event\DishAvailabilityEvent the dish, whether it is available PlatformSubscriber (out-of-stock pushed to the platforms)
Base\Restaurant\Event\ReservationChangedEvent the reservation, its previous status (null: new), whether the platform changed it PlatformSubscriber

omnibase/marketplace's events too: TakeawayListener turns a hand-over received (PickupChangedEvent: its order is paid) into a ticket on the pass, and moves the hand-over its customer follows when the ticket moves; QuickOrderListener settles the table's bill a paid quick order pays (OrderPaidEvent, a card payment come back from the provider) and ends a quick order on the restaurant's own page (QuickOrderDoneEvent: the table's page for a bill, the page after a take-home order). RevisionListener (Doctrine's onFlush and postFlush) grows the revision the pass watches whenever anything of the restaurant is written, so the pass redraws.

Twig functions

Function Returns
restaurant_reviews() the places of omnisense/omnireview, with their rating and latest reviews; none without it
restaurant_reviews_limit() how many reviews a place shows (3 by default)
restaurant_review_widget(gateway, ...) the family's own widget for a platform (What guests say elsewhere)

Proposer une modification docs/reference.md

Testing

Running the suite

Inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/restaurant/phpunit.xml.dist

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/restaurant:ro \
    omnisense/omnibase-harness:latest test restaurant

On this checkout, in the harness:

Tests: 69, Assertions: 462, Skipped: 11.

The skipped tests are the platforms' (OrderTicketsTest, PlatformHealthTest: omnisense/omnifood is not in the image), the reviews' (ReviewsTest: omnisense/omnireview is not either) and the captcha's.

What the tests check

Test Checks
BookingGuardTest, TakeHomeGuardTest the reservation form and the take-home order are guarded in place of their own trap: a person goes through; a filled trap, a form sent too fast, a form without the captcha's token once it is shown are refused; without a captcha, the trap and the time alone; a wrong address refused by the model
ColdChainTest a fresh dish collected tomorrow; local delivery by postcode; a chilled parcel leaves Monday to Wednesday, ordered on a Thursday it waits for Monday; the whole basket must travel and keep; no carrier, no parcel; made to order, past the cut-off; the basket's use-by date is its shortest
EntitiesTest a ticket moves one step at a time, never back nor served twice; refused or cancelled; its lines by station; a platform's order late near its deadline; moments in UTC; a bill adds its rounds but not the refused ones; a reservation holds its tables; a service's times; a layout's places, joins and dates; settling a table
EntityNamesTest every entity of the back office named singular and plural in each language
OrderTicketsTest a platform's order becomes its ticket, its lines keep their choices and prices, one already answered elsewhere arrives where it stands
PlatformHealthTest a platform without its keys says which are missing; a failed call marks a configured platform in error until one succeeds; without a cache nothing breaks
RestaurantDemoAccountsTest one account for each of the restaurant's people, the roles configured, nobody above the manager, each label and sentence in the four catalogues
ReviewsTest the platforms shown are those configured; the latest reviews and those not answered; the page's origin, date and link, no JSON-LD; the tile
SeatingTest every time of a service on an empty floor; the smallest table that fits; joined tables; the staff may use what is not offered online; held and cancelled reservations; closed days and opening hours; the notice; a service's own limit of covers
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the screens

Proposer une modification docs/testing.md

Roadmap

Where it stands

Done with omnibase/marketplace's third pass (Entity\Order\Pickup, Product\OptionGroup): the take-home dishes, the cold chain, the tracking page, the shop's orders on the pass, the dishes' options at the table (The take-home shop, The menu and ordering at the table).

On omnibase/marketplace's quick order (Service\QuickOrder), loaded only where it exists: the order without an account, the bill paid from a phone.

What is left

To come Why not yet
A bill with paid options, paid from the phone QuickOrder::buy() prices lines from the products alone: it takes neither options nor a prepared line. Such a bill is settled at the till.
A hand-over charge as a charge, not a line QuickOrder::buy() offers nothing between the order's making and its payment: the delivery and the parcel are sold as a product (fee_product).
The chilled parcel booked by itself The label is the staff's to book (Shipping::book()); booking it when the ticket is ready, with the basket's use-by date, waits for a Chronofresh contract to try it on.
Options on take-home dishes same limit of buy().

Proposer une modification docs/roadmap.md