Locations: stores, warehouses and distribution centres¶
A location is a physical place a shop keeps stock, sells in person, or hands orders over: a store, a warehouse or a distribution centre. Locations are the foundation for multi-location fulfilment: stock held at several warehouses, store tills tied to the store they're in, click and collect from any store and (in a later release) ship-from-store.
Simple by default¶
Every shop has one location, coded default. It's where all stock lived before locations existed,
and it needs no set-up: until someone edits it, the shop behaves exactly as it always has. Stock is
read and taken there, and the admin product stock panel is the single "Quantity in stock" field it
always was.
A shop on any plan can:
- give its main location an address, phone number and opening hours;
- add stores and showrooms that don't hold stock: for example a shop with a till, or a place listed on the store pages.
Badger Commerce Enterprise
Holding stock at more than one location is part of Enterprise (the multiLocationInventory
capability; see Subscription Entitlements). Even with it, nothing
changes until the shop turns on Multiple stock locations (multi-location-enabled, under
Settings › Orders › Fulfilment). Having the entitlement never changes a shop's behaviour by
itself.
How do your orders reach customers?¶
The top of the Locations screen asks one question, so a shop never has to find the switches in Settings:
| Answer | What it sets |
|---|---|
| I post everything from one place (the default) | local-pickup-enabled and multi-location-enabled off |
| … and customers can collect from me | local-pickup-enabled on. Warns until the main location has an address |
| I have several warehouses or stores | multi-location-enabled on, click-and-collect-enabled off; the single pickup is left as it was. Shown locked, with an Enterprise badge, without multiLocationInventory, and refused if posted anyway |
| … and customers can collect from any of my stores | multi-location-enabled and click-and-collect-enabled on. Shown locked, with an Enterprise badge, without both multiLocationInventory and clickAndCollect, and refused if posted anyway |
Customers collecting from your shop¶
Enterprise shops can instead let customers collect from any of their stores, against each store's own stock: see Click and collect from your stores.
On every plan a shop can let customers collect orders from its own address, its main location:
- Give the main location an address (Shop Settings › Locations).
- Choose … and customers can collect from me on the Locations screen (or turn on Customers
can collect from you,
local-pickup-enabled, under Settings › Orders › Fulfilment).
Shoppers hear about it before checkout: every product that would be posted, and the basket, say
Delivery, or free collection from LocalPickupService.offer, put
on the model by ProductController and the BasketActions extension and shown by the shared
fragment/collectOffer fragment).
Checkout then offers Collect from LocalPickupService), so there's nothing else to set up; it is never chosen for the shopper.
After the order is placed:
| Step | Admin order page | What happens |
|---|---|---|
| Placed | The confirmation email and order page say Collect from with the address and the location's collection instructions, and that the customer will be emailed when it's ready | |
| Ready | Ready for collection | The order moves to packing and the customer is emailed orderReadyForCollection (once) |
| Collected | Collected | The order is dispatched with no tracking. No dispatch email is sent |
Both steps go through PickupService, on the usual pack and dispatch pipelines, so stock, payment
capture and fulfilment states behave as they do for an order that is sent.
The fulfiller portal does the same per fulfiller: on a collection order Pack becomes Mark ready to collect and Dispatch becomes Customer has collected (no carrier or tracking form). The side panel shows where it's collected from and the customer's name to check, not a delivery address. The ready email goes when the last item on the order is ready, so an order split between fulfillers isn't announced early.
Store pages¶
/stores/{slug} shows a location's address, phone, email, opening hours (today highlighted, in the
location's time zone), a directions link, and, where customers collect, how collection works. It
carries schema.org Store (or LocalBusiness) JSON-LD, built by StoreStructuredDataService, so
search engines can show the hours and a map. /stores lists them, or goes straight to the page when
there's only one.
A location has a page when it is active, has a street address (a first line and a postcode), and
either has Show on your store pages (STORE_LOCATOR_VISIBLE) or is where customers collect: the
main location while the shop offers the single pickup, or, with
collection from stores on, any store marked CLICK_AND_COLLECT
(LocationService.findStorePages, using StorePages.collectHere). A shop with none gets a 404.
Store locator¶
With two or more store pages, /stores is a store locator. Without JavaScript it is a plain list of
store cards (name, address, today's hours, "Collect orders here"). The page's inline script adds:
- A search box that filters the cards in the page as the shopper types: the name or town containing the text, or the postcode starting with it (case and spaces ignored). A live region announces how many stores match.
- Use my location, which asks the browser for the shopper's position (only when they press it), rounds it to two decimal places (about a kilometre) and calls the public locator API, then orders the cards nearest first with the distance on each: in miles for UK and US English, otherwise in kilometres. A refusal or a failure leaves the list as it was with a message suggesting the search. The position is sent nowhere else, without cookies, and the server neither stores nor logs it.
The storeLocator block (StoreLocatorExtension) draws the same locator on any page, footer or
stereotype: a heading (default "Find a store"), the first Stores shown stores (default 3, the
search and Use my location picking which), and a link to /stores. It renders nothing when the
shop has no store pages. Both use the stores/locator fragment (Nova, for every Nova-based theme,
and Bootstrap 4); its script is stores/locatorScript and its text comes from stores.locator.*
message keys.
Header link. On a site with store pages, every theme's header shows a store link beside
Sign in: the store's name when there is only one, or the shopper's chosen store (the bdgr_store
cookie from Make this my store or the picker), otherwise Find a store, leading to /stores.
StorefrontChromeInterceptor puts it on every page as headerStore (name, url). Each theme
draws it like its other header actions: a pin icon with the name in Nova, Brock, Depot, Pop and Spec,
a small-caps text link in Atelier, a link in Ridge's top strip, and a nav link in the Bootstrap
themes. The mobile menu or drawer carries it too, and on phones the bar drops it to keep room for
the shop's name (Nova below 576px; Brock, Depot, Pop and Spec below 1024px; Atelier below 760px;
Ridge with the rest of its strip below 641px; Bootstrap themes in the collapsed menu below lg).
A site with no menu has no drawer, so there the link stays in the bar.
API. GET /api/public/v1/locations?lat=&lng=&q=&limit= (public, no token: the shop comes from the
request's host, served same-origin on the shop's domain) returns
{"stores": [StoreLocationSummary]}: slug, name, url, addressLines, phone, latitude,
longitude, distanceKm (straight-line, with lat/lng), openToday (09:00-17:30, absent when
closed today or with no hours) and collectHere. Stores without coordinates come after the others.
limit defaults to 20 and is capped at 50; lat/lng must come together and be in range (400
otherwise). Internal details (codes, fulfillers, Stripe ids, safety stock) are never included. A
reply measured from a position is Cache-Control: private, no-store; one without is cacheable for
five minutes. The logic is StoreLocatorService (commerce-core); distances use
GeoDistance.haversineKm (common).
There is no postcode lookup yet: a typed postcode filters by prefix rather than finding the nearest store. Postcode geocoding and service areas come with ship-from-store.
Sitemap. The worker's SiteMapGenerator lists each store page (lastmod: the location's last
change) and /stores when there are two or more. A store page whose blocks, or whose stereotype's,
keep it out of search (as for pages) is left out.
Customising store pages¶
The store's details are the page's fixed core, like a product page's buy box. Around them a store
page renders content blocks like any other page, in the bannerSection (full width under the nav),
topBanner (above the details) and bottomBanner (below) slots, plus the site-wide blocks every page
has (meta tags, analytics, footer):
- For every store: set Store page stereotype (
store-page-stereotype, Settings › Orders › Fulfilment) to a stereotype, and its blocks (a banner, an FAQ, a delivery note) appear on every store page and on the/storeslist. Base it on the standard page's blocks (the header menu and search). With none set, store pages and the list use the standard page stereotype (defaultPage) so they keep the header menu; the/collect/{token}arrival page always does. - For one store: a location is an extension container (item type
location, id its code or page address). Its own blocks replace the stereotype's in the same slot, and Content blocks from on the location form picks a different stereotype for it. Open and edit blocks on the form opens the page in the inline editor; agents useextensionConfig/manageExtensionConfigwithitemType: location.
The page address (slug, Page address on the form) defaults to the location's code. Saving a
location over REST keeps its blocks, and a site import remaps the media its blocks show.
Collection from several stores
Collecting from any of several stores, against that store's own stock, is part of Enterprise
(clickAndCollect): see Click and collect from your stores. With it on,
every collection store has a page, with Make this my store.
What a location is¶
| Field | Meaning |
|---|---|
code |
Stable identifier, unique per shop: lower-case letters, digits and hyphens. It is the inventory warehouseId, so stock at a location is the stock records under its code. It can't be changed once saved. |
name, slug |
What people see, and the store page's URL segment (defaults to the code) |
type |
STORE, DISTRIBUTION_CENTRE or OTHER. Descriptive only: behaviour comes from capabilities |
active |
Off, nothing sells, ships or is collected there. A location must be deactivated before it can be removed |
capabilities |
What happens there (below) |
address, timeZone, phone, email, openingHours |
For store pages and collections. The address's latitude and longitude are used to find the nearest location. Opening hours are one or more periods per day in the location's time zone; a closing time before the opening time runs past midnight |
safetyStock |
Units of each SKU kept back from online sale, so a busy shop floor isn't sold out from under it |
sourcingPriority |
Lower is preferred when several locations could send an order equally well |
collectionSettings |
Preparation time, how many days a ready order is kept, and instructions for customers |
fulfillerId |
The fulfiller that packs this location's orders |
stripeLocationId |
The Stripe Terminal location its Badger POS card readers belong to |
Capabilities¶
| Capability | Meaning |
|---|---|
HOLDS_STOCK |
Stock is counted here. Always on for default; on any other location it needs Enterprise |
SHIP_FROM |
Online orders can be sent from here |
POS_SALES |
Badger POS tills work here |
CLICK_AND_COLLECT |
Customers can collect online orders here |
KERBSIDE |
Customers can say they've arrived and have the order brought to their car |
STORE_LOCATOR_VISIBLE |
Listed on the shop's store pages |
Where web orders ship from¶
Enterprise
Sending orders from more than one location needs Badger Commerce Enterprise
(multiLocationInventory) and Multiple stock locations (multi-location-enabled) turned
on. Until then every order is sent from your main location, exactly as before.
When a shopper pays, Badger decides which location sends each item, then takes the stock there (see Stock is taken when the card is held). The customer doesn't see the choice. The rules:
- Candidates. Active locations that hold stock and ship (
HOLDS_STOCKandSHIP_FROM): your main location first, then by sourcing priority (lower first) and name. What a location can sell is its stock less its safety stock, read live rather than from the cache. - One parcel if possible. The first candidate that can send the whole order sends all of it.
- Otherwise line by line. Each line goes to the first candidate that still has enough for it. A line's quantity is never split between locations.
- Nobody has it. The order isn't placed, the card hold is released and the shopper is told the item has just sold out, unless the site allows out-of-stock orders, in which case the lines that could be sourced are taken and the order goes ahead.
Distance to the customer isn't considered yet.
Temporarily closed locations are never candidates, and with Store capacity limits on (Enterprise), the ship-from-store capacity left on each candidate's dispatch day reorders candidates of equal priority and passes over full stores: see Store operations. Each location's courier collection times set when it dispatches, and so the dates a delivery promise gives.
Each line records where it was sent from (sourceLocationCode). If that location has a
fulfiller, the line is handed to it, so the fulfilment queue, fulfiller portal and
its notifications split the order by location; otherwise the line keeps the product's fulfiller.
Cancellations and returns put stock back at the location it came from.
Point of sale¶
A POS request names its location in the path (/v1/pos/{locationId}/...). The location must be one
the agent may work at, or the request is refused with 403:
- the agent's own Stripe Terminal location (
PosAgent.stripeLocationId, or the site'spos-stripe-location-id), as before; or - an active
POS_SALESlocation listed in the agent'slocationCodes. The path may give the location's code or its Stripe Terminal location id.
Admins without an agent record may use any of the shop's POS locations. Which stores each agent
works at is set on the POS staff screen, and the app lists them with
GET /v1/pos/locations. A shop whose only POS set-up is pos-stripe-location-id (no location
carries it) sells at its main location: the code default names that till.
Each POS sale records its store: posMetadata.locationId keeps the Stripe Terminal id (sales are
listed by it, whichever the app sends) and posMetadata.locationCode the location's code. When the
shop uses several stock locations and the store holds stock, a basket-built sale takes its stock
there; otherwise, and always on a shop with one stock location, from the main location as before.
The stockTaken record names the store, so a cancellation or return puts the stock back where it
came from. Refunds from the till return money only, not stock.
Tills can look up a SKU's stock at their store and the shop's other stock locations, and agents
with canAdjustStock can change their store's figure (a recount, a delivery, a breakage). A store
other than the main location can only be changed while the shop uses several stock locations.
System update 111 gave existing tills locations: each agent's Stripe location and the shop's
pos-stripe-location-id went on the location already carrying it, else on the main location if it
had none, else on a new store (store-1, ...) that sells in person but holds no stock. See
Mobile POS.
Availability across locations¶
Badger Commerce Enterprise
Everything in this section applies only when Multiple stock locations is on (and the shop
has multiLocationInventory). With it off, every number a shopper sees, and every basket check,
is the default location's stock read exactly as before locations existed.
With several stock locations, the storefront asks one question of every SKU: what can a shopper have? The answer counts:
- Sent to the customer (
shippable): the sum over every active location that holds stock and ships (HOLDS_STOCK+SHIP_FROM) of its stock less itssafetyStock, never below 0 at any one location. A SKU untracked at the default location is untracked (-1, always available); at any other location a SKU with no stock record has none. - Collected (
collectAt): the activeCLICK_AND_COLLECTlocations holding stock with some to sell after safety stock. These count only while collection from stores is on (click-and-collect-enabledas well): with it off, nothing can be collected, so a SKU is reported as shipping only. Click and collect checks the shopper's own store against each basket line separately.
Product pages, listings (collections, category pages, search), the variant picker and the basket
show shippable as the stock level. Adding to the basket, changing a quantity and the basket's
stock check on return use it too, read live (uncached) from every location. The search index's
In stock flag means "can be sent, or collected somewhere".
Something only a store has. With collection from stores on, a SKU no shipping location has
but a store does is still buyable, for collection only (SkuAvailability.isCollectOnly()). Its
stock level is then the most one store can hand over (a collection comes from one store), so it
shows as in stock on listings, can be added to the basket up to that many, and its product page
stock line reads Collect only: not available for delivery (stock.collectOnly). Where it can
be sent at all, the level stays shippable. Checkout then offers no delivery for the basket, only
the stores that have all of it: see click and collect.
SkuAvailability.level() is that number, and AvailabilityService.sellable returns it.
How it stays fast¶
Reading every location for every SKU on a listing would be one query per location per page, so
display reads use a materialised view instead: one SkuNetworkAvailability document per SKU
(shippable, collectAt, byLocation, keyed by site and SKU), read with one query per page.
- Kept up to date a few seconds after a change. Every stock change at any location of a multi-location shop queues the SKU (the same coalesced Redis set the search index uses; see Stock and the In stock filter). The worker recomputes the queued SKUs' documents every 5 seconds, one stock query per location for the batch, then reindexes only the products whose availability changed for shoppers (buyable at all, or where they can be collected). Selling one of 40 at one warehouse rewrites a document but not the search index.
- Rebuilt nightly, before the search reconcile, from every enabled product's SKUs; documents for SKUs no longer on sale are removed. A shop with multiple locations off has its documents removed.
- Never trusted after a location change. Each document records the locations and safety stock it
was worked out from. Adding, deactivating or changing the safety stock of a location makes those
documents stale; the storefront ignores them and works the SKU out from (cached) stock, so a
location change shows at once. Saving or removing a location publishes
LocationChangedEvent, andNetworkAvailabilityRebuilderrebuilds the shop's view in the background, so listings are back to one read within moments rather than at the nightly rebuild. The search index's in-stock flags catch up at the nightly reconcile. - Basket and checkout decisions never use the view.
| Piece | Where |
|---|---|
AvailabilityService (the storefront's read API), SkuAvailability |
commerce-core/.../inventory/availability/ |
NetworkAvailabilityService, SkuNetworkAvailability (the view) |
commerce-core/.../inventory/availability/ |
LocationStockService (which locations ship, stock less safety stock) |
commerce-core/.../inventory/ |
| Recompute and reindex, nightly rebuild | worker/.../search/StockIndexFlushJob.java, SearchReconciliationJob.java |
Managing locations¶
- Admin: Settings › Selling › Locations (
/admin/shopSettings/locations). See Locations in the admin. - REST:
GET /v1/admin/locations, andGET,PUT(create, or update the fields sent: what a request leaves out keeps its stored value, and special hours, capacity, slots, status and the service area are never touched) andDELETEon/v1/admin/locations/{code}. A second stock-holding location without Enterprise is refused with403and a reason. A SKU's stock at each location:GET /v1/admin/inventory/{skuId}/locationsandPUT /v1/admin/inventory/{skuId}/locations/{code}(400for a location that doesn't hold stock,403with a reason for any location butdefaultwithout Enterprise). See the API reference. - MCP:
locations(list, get) andmanageLocations(save, delete).inventory byLocationgives a SKU's in-stock, safety stock and sellable units at each stock location.manageInventoryonly writes stock at a location that holds it, so a mistypedwarehouseIdcan't strand stock where nothing reads it. - Product screens: with multiple stock locations on, the Stock tab has a row per stock location (see Stock at each location); otherwise it is the single field. The product list's stock badges total every stock location.
Site data transfer¶
Saved locations travel with a site export
(configuration/locations.json) without their Stripe Terminal location and fulfiller, which belong
to the source shop, or whether they are temporarily closed. Special hours and capacity limits
travel. The import merges them into the target by code, before stock. A location the
target's plan can't hold is skipped with a warning in the import report.
Code¶
| Piece | Where |
|---|---|
Location, LocationCapability, LocationService, LocationRepository (the site's list is cached; LocationCacheEvictionListener drops it on every change, see Caching) |
common/.../locations/ |
LocationServiceImpl (validation, the implicit default, the Enterprise gate) |
commerce-core/.../locations/ |
| Admin screens | web-admin/.../render/admin/LocationAdminController.java, templates/admin/locations/ |
| REST | rest/.../api/admin/LocationAdminController.java, RestShopLocationConverter, RestInboundShopLocationConverter |
| MCP | ai/.../mcp/tools/LocationTools.java, InventoryTools (byLocation) |
| Stock at each location (admin, REST, MCP) | LocationInventoryService (commerce-core/.../inventory/); ProductStockPanels / ProductStockPanel and templates/admin/productEditPanels/stockByLocation.html; REST InventoryAdminController, RestLocationStockLevelConverter |
| POS check | PosService.requireLocationAccess, called by every PosController location endpoint |
| Store pages | web-mvc/.../render/StoreController.java, templates/{nova,bootstrap}/stores/, commerce-core/.../seo/StoreStructuredDataService*; blocks via ExtensionContainerResolver and SlotNameResolver (location) |
| Collection | CollectionPointService (where customers collect from), LocalPickupService (checkout options and the collection line), PickupService (ready and collected), FulfilmentWorkServiceImpl (portal); see Click and collect |
| Status, special hours, capacity | StoreOperationsService; see Store operations |
| Sourcing and stock taking | commerce-core/.../inventory/sourcing/ (SourcingService, SourcingStrategy, RankedSourcingStrategy, CapacityRouting), StockAllocationService, StockHoldReleaseService and the worker's StockHoldReleaseJob (stock-hold-release-minutes) |