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_.
Propose a change 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.
Propose a change 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
bookablehold - 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).
Propose a change docs/room-and-reservations.md
The menu and ordering at the table
The menu
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).
Ordering at the table
/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
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.
Paying from the phone
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.
Propose a change 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_warningseconds before its deadline.?poste=hot|cold|barkeeps 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(), aTicketMovedEventeach). 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
takeawaychannel - "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.
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.
Propose a change 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 referenceOmnifood\MenuExportsends -, modifiers as options, the acceptance deadline); a reservation joins the book with the platform as its source. An event already seen (Notification::$id, kept inPlatformEvent) 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).
Propose a change 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.
Propose a change 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%...).
Propose a change 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) |
Propose a change 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 |
Propose a change 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(). |