Skip to main content

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)

  1. 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.
  2. 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.
  3. 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: status submitted, reviewed or approved, and not fully received. Drafts, canceled and completed POs count for nothing. Read from purchase_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.

CoverageWhenEffect
completeβ‰₯ 7 observed days, no ambiguous unitsvelocity 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_stock is a quantity.
  • Fallback (insufficient coverage): target = reorder point; reorder_quantity is 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:

StatusWhen
Criticalno sellable stock, or trusted days remaining ≀ lead time. Incoming stock never clears Critical (arrival dates are not modeled).
Reorderat or below the reorder point, or a positive suggestion
Overstocka maximum is set and projected stock exceeds it
Healthyotherwise

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:

SituationWhat happens
No reorder point anywhere and < 7 observed dayscoverage is insufficient and there is no fallback target β€” the row is Healthy unless out of stock
Open POs already cover the needthe row is Reorder/Critical with coveredByIncoming and a suggestion of 0
Stock above maxthe 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​

ParameterResolution order
leadTimeDaysreplenishment_setting.lead_time_days β†’ inventory.lead_time_days β†’ 0
supplierIdreplenishment_setting.supplier_id β†’ product.supplier_id β†’ null
effectiveReorderPointfirst positive of replenishment_setting.min_qty β†’ inventory.reorder_point β†’ inventory.reorder_threshold β†’ product.reorder_threshold β†’ none
maxStockfirst positive of replenishment_setting.max_qty β†’ inventory.max_stock_level β†’ none (min/max falls back to 2 Γ— reorder point)
targetDaysOfCoverreplenishment_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​

MethodPathPermissionDescription
GET/replenishment/suggestionsReplenishment Γ— ReadEvaluated rows (query: businessId, locationId, strategy, velocityWindow, supplierId, categoryId, minUrgencyDays, statuses β€” comma list, default critical,reorder β€” sortBy, sortOrder, page, limit)
POST/replenishment/budget-planReplenishment Γ— ReadAdvisory plan under a budget (budget is a decimal string in base currency). Saves nothing, creates no PO
POST/replenishment/suggestions/create-purchase-ordersPurchase Γ— CreateOne submitted PO per supplier, in the business default currency
GET/replenishment/settingsReplenishment Γ— ReadList active settings
POST/replenishment/settingsReplenishment Γ— CreateCreate or upsert a setting
PATCH/replenishment/settings/:idReplenishment Γ— UpdateUpdate a setting
DELETE/replenishment/settings/:idReplenishment Γ— DeleteSoft-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 Replenishment permission, so nobody sees a menu entry whose page answers 403

How to Use the Replenishment Suggestions Form (PWA)​

  1. Select Business and Location β€” Use the Business and Location selectors in the top-right header. The suggestions API only runs when both are set.
  2. 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)
  3. 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)
  4. 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.
  5. 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.
  6. 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.
  7. Select lines β€” Check individual rows or "select all". Edit quantities inline if needed. Each line carries the suggestion's estimated unit cost.
  8. 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.
  9. 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.