Suggested replenishment
Suggested replenishment is one of the highest ROI features in retail apparel. It directly answers: π What should I buy now? π How much per size/color? π Which store needs it? If you implement this well, FlowPOS becomes a decision system β not just a POS. Hereβs what to add for Purchasing / Replenishment β Suggested replenishment. What βSuggested replenishmentβ means (business meaning) The system proposes purchase quantities based on: current stock sales velocity lead time min/max levels size curve (very apparel specific) seasonality (later) It produces recommended PO lines, not automatic orders (MVP). The core concept: Replenishment = math over signals Minimum signals you need: On hand stock Reserved stock Sales velocity Lead time Target stock level (min/max or days of cover) Without these, suggestions are unreliable. What you must track (data requirements) Inventory signals on_hand qty reserved qty (reservations + layaway) incoming qty (open POs) available_to_sell Demand signals sales last 7/14/30 days average daily sales sales by variant (critical apparel) Supply signals supplier lead time order frequency (weekly/monthly) MOQ (minimum order qty) pack size (very apparel) Planning signals min stock max stock target days of cover size curve rules (Phase 2) Database additions (recommended)
- Replenishment settings per variant/location This is required. replenishment_setting business_id location_id variant_id min_qty (optional) max_qty (optional) target_days_of_cover (recommended) supplier_id (default supplier) lead_time_days moq pack_size active You donβt need all fields for MVP β but schema should allow them.
- Derived metrics (not tables, but computed views/services) Youβll compute: avg_daily_sales days_of_stock_remaining suggested_qty These can be materialized later if needed.
- Optional but powerful: replenishment run snapshot If you want history of suggestions: replenishment_run id location_id executed_at executed_by parameters (JSON) replenishment_run_line run_id variant_id suggested_qty inputs snapshot (stock, velocity, lead_time) MVP can skip history. Suggestion logic (simple MVP formula) Classic retail rule β safety stock is a quantity of units, not a number of days: target_stock = avg_daily_sales * lead_time_days + safety_stock_qty
suggested_qty = target_stock
- available_to_sell
- incoming_qty (supplier POs + transfers) Then apply: MOQ rounding pack size rounding never negative Example: If you sell 2/day, lead time 10 days, 5 safety units β need 25 units. This alone is very valuable. (The exact formulas the code uses are under Implementation Details below.) Backend functionality you need Core service GET /replenishment/suggestions Inputs: location supplier (optional) timeframe for velocity strategy (min/max vs days-of-cover) Output: list of suggested variants with qty reasons (very important UX) Convert suggestions β PO POST /purchase-orders/from-suggestions This is a huge workflow win. Frontend screens (PWA) MVP screen: Replenishment suggestions Table showing: product / variant on_hand reserved incoming avg daily sales days of stock remaining suggested qty supplier Allow: select lines edit qty create PO This is a flagship screen for FlowPOS. Apparel-specific importance (huge) Replenishment must work at variant level: Not βT-shirtβ But: T-shirt / Size M / Black T-shirt / Size L / Black This is where many POS fail. Later you add: size curve logic color performance collection lifecycle But MVP variant-level suggestions already differentiate you. Reports (Metabase) Stockout risk list Items below min stock Replenishment accuracy (suggested vs actual) Supplier lead time reliability Lost sales estimate Fast movers / slow movers This feeds purchasing strategy. MVP vs Phase 2 β MVP simple suggestion formula replenishment settings (lead time + days cover) suggestion screen create PO from suggestions variant-level suggestions π Phase 2 (big differentiation) size curve replenishment seasonal logic allocation across stores AI demand forecasting supplier constraints optimization auto-replenishment runs omnichannel signals Biggest edge cases (important) New products (no sales history) β fallback rule (min stock) Highly seasonal items Promotions causing velocity spikes Negative stock distortions Returns affecting velocity Multi-location stock sharing Pack size rounding creating overstock These need explicit fallback logic. Architecture insight for FlowPOS (important) This feature requires your system to expose: inventory signals (clean) sales analytics (per variant) purchasing integration Meaning your earlier architecture decisions (ledger, cost history, variants) directly enable this. Youβre on the right path.
Implementation Detailsβ
Database: replenishment_setting tableβ
CREATE TABLE replenishment_setting (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL,
location_id UUID NOT NULL,
variant_id UUID NOT NULL,
product_id UUID NOT NULL,
supplier_id UUID,
target_days_of_cover INTEGER, -- used by days_of_cover strategy
lead_time_days INTEGER, -- supplier lead time override
min_qty NUMERIC, -- reorder point for min_max strategy
max_qty NUMERIC, -- target max for min_max strategy
moq NUMERIC, -- minimum order quantity
pack_size NUMERIC, -- round up to multiples
is_active BOOLEAN DEFAULT true,
created_by UUID NOT NULL,
updated_by UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (business_id, location_id, variant_id)
);
How a row is evaluated (059)β
Every variant with an inventory row at the location is evaluated. All the
rules live as pure functions in replenishment/domain/replenishment-policy.ts;
the service only gathers data and calls them.
Sellable stock. availableToSell = max(0, inventory.quantity). The other
buckets β reserved, damaged, quarantined, pending inspection, under repair β
are separate columns that units move into and out of, not parts of quantity,
so they are not subtracted again. (023 subtracted them and counted every
reserved or damaged unit twice.)
Incoming stock, shown separately and summed only for the projection:
incomingPurchaseQtyβ ordered minus received on open supplier POs: statussubmitted,reviewedorapproved, and not fully received. Drafts, canceled and completed POs count for nothing. Read frompurchase_order.receipt_summary, per variant.incomingTransferQtyβinventory.in_transit_incoming(transfers only).projectedAvailable = availableToSell + incomingPurchaseQty + incomingTransferQty.
Reorder point, same order everywhere (replenishment and low-stock alerts):
replenishment_setting.min_qty β inventory.reorder_point β legacy
inventory.reorder_threshold β legacy product.reorder_threshold. Only
positive values count. The row reports which one it used (reorderPointSource).
An item is "at or below its reorder point" when availableToSell <= reorderPoint
β the same comparison the low-stock alert makes.
Demand. Sales are read per exact variant from sale.sale_detail.items[].variantId.
Cancelled, voided and draft sales and return documents are not demand.
Lines with no variantId:
- are added to the variant when the product has exactly one active variant;
- are never split across several variants β they only measure how much of the product's history cannot be attributed (the ambiguous share).
observationDays = days from the later of the window start and the inventory
row's creation, to now, minus known stockout time (inventory_stockout_period).
avgDailySales = soldQty / observationDays.
| Coverage | When | Effect |
|---|---|---|
complete | β₯ 7 observed days, no ambiguous units | velocity is used |
partial | β₯ 7 observed days, ambiguous share β€ 50% | exact-variant velocity is used, flagged in the reason |
insufficient | < 7 observed days, or ambiguous share > 50% | reorder-point fallback; velocity is diagnostic only β no days remaining, no stockout date, cannot make a row Critical |
Target and order.
- Days of cover configured:
targetStock = ceil(avgDailySales Γ targetDaysOfCover)(safety stock not added again). - Otherwise:
targetStock = ceil(avgDailySales Γ leadTimeDays + safetyStockQty)βinventory.safety_stockis a quantity. - Fallback (insufficient coverage): target = reorder point;
reorder_quantityis used as the order size when set. - Min/max: orders up to
max_qtyβmax_stock_levelβ 2 Γ reorder point, only when at or below the reorder point. suggestedQty = rounding(targetStock β projectedAvailable): MOQ, then pack size, then capped at the maximum.
Status, evaluated in this order:
| Status | When |
|---|---|
| Critical | no sellable stock, or trusted days remaining β€ lead time. Incoming stock never clears Critical (arrival dates are not modeled). |
| Reorder | at or below the reorder point, or a positive suggestion |
| Overstock | a maximum is set and projected stock exceeds it |
| Healthy | otherwise |
coveredByIncoming is true when on-hand falls short of target but incoming
stock closes the gap; the suggestion is then 0. A row can be Critical and
covered at once. estimatedStockoutDate is the location-local date:
today + βdays remainingβ, or today when stock is already 0.
Cost. estimatedUnitCost comes from variant last_cost β avg_cost β
product.cost, skipping any value β€ 0 (the columns default to 0 for items
never received). With none, the cost is null and the budget plan defers the
line rather than treating it as free.
Stockout trackingβ
inventory_stockout_period holds one row per known interval with no sellable
stock at a location (at most one open per item, enforced by a partial unique
index). Every flow that changes inventory.quantity emits
inventory.stockLevelsChanged after its transaction commits, naming the
items it touched. StockoutTrackingListener re-reads their stock and opens or
closes periods idempotently. StockoutReconciliationScheduler runs nightly at
03:00 and repairs anything an event missed, using the current time β history
is never backdated, and time before tracking existed is simply unadjusted.
A new flow that changes quantity must emit the event after commit, or its
stockouts are only caught by the nightly job.
Why suggestions may be emptyβ
The screen shows Critical and Reorder rows by default
(statuses=critical,reorder); healthy and overstock rows are returned only
when requested. An empty list means nothing is at or below its reorder point
and nothing is projected to run short. Common reasons in dev/test:
| Situation | What happens |
|---|---|
| No reorder point anywhere and < 7 observed days | coverage is insufficient and there is no fallback target β the row is Healthy unless out of stock |
| Open POs already cover the need | the row is Reorder/Critical with coveredByIncoming and a suggestion of 0 |
| Stock above max | the row is Overstock and the rounding cap orders nothing |
Fix: seed replenishment_setting rows (below) or set reorder_point > 0
on inventory rows, and create some sales with variantId on their lines.
Seed Query for Dev/Testβ
Insert replenishment settings for existing inventory variants to make the suggestion engine produce results:
INSERT INTO replenishment_setting (
id, business_id, location_id, variant_id, product_id,
target_days_of_cover, lead_time_days, min_qty, max_qty,
is_active, created_by, updated_by, created_at, updated_at
)
SELECT
gen_random_uuid(),
i.business_id,
i.location_id,
i.variant_id,
i.product_id,
14, -- target 14 days of cover
3, -- 3 day lead time
5, -- min qty (reorder point for min_max)
50, -- max qty
true,
'6c0c4f32-d74a-4a84-892f-3bead447d765', -- your user id
'6c0c4f32-d74a-4a84-892f-3bead447d765',
NOW(), NOW()
FROM inventory i
WHERE i.business_id = '<YOUR_BUSINESS_ID>'
AND i.location_id = '<YOUR_LOCATION_ID>'
AND i.variant_id IS NOT NULL
LIMIT 10;
Strategy Resolutionβ
| Parameter | Resolution order |
|---|---|
leadTimeDays | replenishment_setting.lead_time_days β inventory.lead_time_days β 0 |
supplierId | replenishment_setting.supplier_id β product.supplier_id β null |
effectiveReorderPoint | first positive of replenishment_setting.min_qty β inventory.reorder_point β inventory.reorder_threshold β product.reorder_threshold β none |
maxStock | first positive of replenishment_setting.max_qty β inventory.max_stock_level β none (min/max falls back to 2 Γ reorder point) |
targetDaysOfCover | replenishment_setting.target_days_of_cover; when unset, lead-time demand + inventory.safety_stock units |
Module Architecture (Hexagonal)β
replenishment/
βββ replenishment.module.ts # NestJS module, DI wiring
βββ domain/
β βββ replenishment-policy.ts # Pure rules: reorder point, demand, target, status, cost
β βββ budget-plan.ts # Pure greedy budget allocator
β βββ replenishment-repository.domain.ts # Port: IReplenishmentRepository
β βββ stockout-period-repository.domain.ts # Port: IStockoutPeriodRepository
β βββ replenishment-suggestion.ts # Domain types
βββ application/
β βββ replenishment.service.ts # Evaluate rows, suggestions, create POs
β βββ budget-replenishment.service.ts # Budget plan over the evaluation
β βββ stockout-tracking.service.ts # Open/close stockout periods
β βββ stockout-tracking.listener.ts # Listens to inventory.stockLevelsChanged
β βββ replenishment-settings.service.ts # Per-variant settings CRUD
βββ infrastructure/
β βββ replenishment.repository.ts # Kysely: snapshot, sales, open POs, currency, timezone
β βββ stockout-period.repository.ts # Kysely: inventory_stockout_period
βββ jobs/
β βββ stockout-reconciliation.scheduler.ts # Nightly safety net
βββ interfaces/
βββ replenishment.controller.ts # HTTP adapter, guarded by the Replenishment permission
βββ query/get-suggestions.query.ts
βββ dtos/ # settings, PO conversion, budget plan
The SQL twins of the sellable-stock and reorder-point rules live in
inventories/infrastructure/inventory-availability.sql.ts, used by low-stock
detection and stockout tracking. low-stock-contract.integration.spec.ts
holds them to the same answers as the TypeScript policy.
Dependency flow: Controller β Service β Repository (via REPLENISHMENT_REPOSITORY injection token) β Database. Services depend only on the IReplenishmentRepository interface (domain port), never on the concrete Kysely implementation.
API Endpointsβ
| Method | Path | Permission | Description |
|---|---|---|---|
| GET | /replenishment/suggestions | Replenishment Γ Read | Evaluated rows (query: businessId, locationId, strategy, velocityWindow, supplierId, categoryId, minUrgencyDays, statuses β comma list, default critical,reorder β sortBy, sortOrder, page, limit) |
| POST | /replenishment/budget-plan | Replenishment Γ Read | Advisory plan under a budget (budget is a decimal string in base currency). Saves nothing, creates no PO |
| POST | /replenishment/suggestions/create-purchase-orders | Purchase Γ Create | One submitted PO per supplier, in the business default currency |
| GET | /replenishment/settings | Replenishment Γ Read | List active settings |
| POST | /replenishment/settings | Replenishment Γ Create | Create or upsert a setting |
| PATCH | /replenishment/settings/:id | Replenishment Γ Update | Update a setting |
| DELETE | /replenishment/settings/:id | Replenishment Γ Delete | Soft-delete (owner only) |
Replenishment Read, Create and Update are granted to Administrator/Admin,
StoreManager, InventoryManager and PurchasingManager; owners hold everything.
Before 059 these routes checked no permission. All endpoints are in Swagger
under the Replenishment tag. See also the Bruno collection in
api-client/flowpos/collections/replenishment/.
PWA Menu Locationβ
Replenishment is under the Purchase Module in config/firebase/remote-config/pwaMenu.json:
- Path:
/forms/ReplenishmentSuggestionsPage - Icon:
component_exchange - Roles: super, support, owner, admin, administrator, store_manager, inventory_manager, purchasing_manager β kept in step with the roles granted the
Replenishmentpermission, so nobody sees a menu entry whose page answers 403
How to Use the Replenishment Suggestions Form (PWA)β
- Select Business and Location β Use the Business and Location selectors in the top-right header. The suggestions API only runs when both are set.
- Configure strategy and velocity window
- Strategy: Days of Cover or Min / Max
- Velocity Window: 7, 14, or 30 days (used to compute average daily sales)
- Apply optional filters
- Supplier: All or a specific supplier
- Category: All or a specific category
- Urgency (days): Max days of stock remaining (e.g. show only items with β€ N days left)
- Review the suggestions table β Columns include product/variant, status (Critical / Reorder / Healthy / Overstock), available, supplier incoming, transfer incoming, projected, avg daily sales, days remaining with the estimated run-out date, suggested qty, supplier, and a plain-English reason. Low-history items show a "Fallback" badge, rows already on order show "Covered by incoming", and velocity spikes show a warning icon.
- Adjust per-variant settings β Click the gear icon on a row to open the settings modal and override target days of cover, lead time, MOQ, pack size, or preferred supplier for that variant+location.
- Plan within a budget (optional) β Enter an amount in the Budget plan panel and click "Plan purchases". Critical items are funded first, then those running out soonest, then the best sellers; lines are funded in full, partly in whole packs above the MOQ, or deferred with a reason. "Select funded lines" replaces the selection with the funded quantities and costs β it does not order anything.
- Select lines β Check individual rows or "select all". Edit quantities inline if needed. Each line carries the suggestion's estimated unit cost.
- Create Purchase Orders β Click "Create Purchase Order (N)". The system groups selected lines by supplier, creates one submitted PO per supplier, and shows a summary dialog. The new POs appear as supplier incoming on the next refresh.
- Paginate β Use Previous/Next when there are many suggestions.
Why you might see no backend calls: The suggestions query runs only when token, currentBusiness.id, and currentLocation.id are all set. If the Location selector shows a placeholder (e.g. "Select location") or the business has no locations, the query never fires. Ensure a location is selected in the header and create one if none exist.