@hartl-services/medusa-batch-management
v0.4.2
Published
Batch management plugin for Medusa.
Readme
Batch Management Plugin
FEFO (first-expired, first-out) batch and expiry tracking on top of Medusa's inventory module, for Medusa v2.19.0 and later 2.x releases.
Goods receipts are booked as named batches (lots) with an expiry date at a stock location. Batches carry their own quantity ledger; withdrawals (manual or fulfillment-triggered) draw down the earliest-expiring eligible batch first.
Features
- Book incoming stock as a batch; re-booking the same name/expiry/location merges into the existing batch (unique active charge per inventory item + location + name).
- FEFO withdrawal with a configurable minimum remaining shelf life for fulfillment-triggered withdrawals.
- Correct an order's active FEFO allocation in the Order detail view by replacing its batches manually. Reassignments keep the original quantity and stock location, are atomic, and retain the reversed FEFO rows for audit.
- Manual batch withdrawal with a fixed reason set (
manual,expired,damaged,correction). - Immutable purchase cost per batch plus a UTC booking date for every stock movement.
- Transfer a complete batch or a partial available quantity to another stock location while preserving its LOT and expiry date.
- Order lifecycle integration: each native fulfillment withdraws exactly its own managed line quantities; fulfillment cancellation restores only that fulfillment's still-open allocation, while a confirmed Medusa return restores only its undamaged received quantity to the originally allocated batch.
- Admin page under Inventory plus an inventory-item detail widget.
- Guards on the native inventory routes so stock can only be moved through batch management.
Installation in a shop
pnpm add @hartl-services/medusa-batch-management @hartl-services/medusa-base @tanstack/react-query@hartl-services/medusa-base is a required peer: it stores the plugin switch
(see "Feature toggle" below) and must be registered as a plugin as well;
without it every /admin/batch-management/* route fails with
UNEXPECTED_STATE.
@medusajs/admin-sdk, @medusajs/framework, @medusajs/js-sdk,
@medusajs/medusa, @medusajs/ui, react, and react-router-dom are peer
dependencies already provided by a standard Medusa 2.19 shop and admin
install. @tanstack/react-query is not part of a bare Medusa scaffold and
must be added explicitly.
// medusa-config.ts
export default defineConfig({
// ...
plugins: [
{ resolve: "@hartl-services/medusa-base", options: {} },
{
resolve: "@hartl-services/medusa-batch-management",
options: {
orderMinShelfLifeMonths: 3, // optional, non-negative integer, defaults to 3
},
},
],
});orderMinShelfLifeMonthsis the only option and is optional; it defaults to3.- No plugin-specific environment variables are required.
- Optional coupling: the Affiliate plugin
(
@hartl-services/medusa-affiliate) detects this plugin at runtime through thebatch_managementcontainer registration and uses it for lot/expiry-aware FEFO consignment transfers when both the transfer and batch-lookup capabilities are present; Batch Management itself has no dependency on Affiliate. - Run
medusa db:migrateafter installing (the module ships its own migrations), thenmedusa db:sync-linksafter changing link definitions or enabled components.
Feature toggle
The plugin declares a plugin switch without sub-features to
@hartl-services/medusa-base under the plugin key batch_management. An
Admin switches it under Settings → "Hartl Services Plugins"; the effective
value is readable at GET /store/plugin-features/batch_management.
| Key | Label | Default | Gates | Keeps running when off |
| --------------- | ----------------- | ------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| (plugin switch) | Chargenverwaltung | on | every /admin/batch-management/* route (Admin page and API answer 404 after Medusa's /admin authentication; 401 without login) | batch allocation on fulfillment (workflow hook), the fulfillment-canceled and return-received subscribers, and the native inventory route guards — they protect stock integrity and never read the toggle |
Admin surfaces of a disabled feature are hidden. The inventory-item and order
widgets stay registered but render nothing while the plugin is off
(PluginFeatureGate from @hartl-services/medusa-base/admin-components);
the Admin page's sidebar entry cannot be hidden, so it shows
PluginFeatureDisabledNotice instead of the Chargenverwaltung content.
Shop integration
Batch Management is published on npm as
@hartl-services/medusa-batch-management; any Medusa 2.19 shop can install
and configure it directly (see "Installation in a shop" above). The demo shop
apps/backend in this repository wires it in as a local workspace
dependency and lists it directly in apps/backend/medusa-config.ts's
plugins array with empty options; it is active because it is listed there,
not through a separate feature flag.
Add any future shop-specific Batch-Management option directly to its plugin
entry in apps/backend/medusa-config.ts; do not introduce an ad-hoc
environment flag or a separate plugin list.
Führe die Migrationen im Shop-Backend aus. Synchronisiere Links nach Änderungen an Link-Definitionen im Backend oder an aktivierten Shop-Komponenten:
pnpm --dir apps/backend exec medusa db:migrate
pnpm --dir apps/backend exec medusa db:sync-linksDie Admin-Erweiterung wird von Medusa über die lokale Plugin-Registrierung
geladen. Ihre öffentlichen HTTP-Verträge liegen ausschließlich im zentralen
@hartl-services/medusa-food-supplements-contracts-Paket; die Plugin-Implementierung wird
nicht von Consumer-Anwendungen importiert.
Configuration
| Option | Type | Default | Description |
| ------------------------- | ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| orderMinShelfLifeMonths | number (non-negative integer) | 3 | Minimum remaining shelf life a batch must have (from UTC "today") to be withdrawn for a fulfillment. |
The option is a plugin option; Medusa forwards it to the batch-management module, which validates it and exposes it to the fulfillment-withdrawal workflow.
Fulfillment ownership and native inventory
Placing an order or creating a native reservation does not change any batch. The Batch ledger moves only inside Medusa's native fulfillment workflow. Its compensatable fulfillment-created hook resolves managed inventory lines at the fulfillment location and withdraws their exact quantities through FEFO. Digital or otherwise unmanaged lines still commit an empty fulfillment run, so callers can distinguish a completed zero-allocation fulfillment from work that has not finished.
Every withdrawal belongs to one fulfillment_id. Partial fulfillments therefore
own independent allocations even when they share an Order line, inventory item,
or Stock Location. Cancelling fulfillment A restores only A's still-active
rows; fulfillment B remains allocated. Cancelling the Order relies on Medusa's
native fulfillment cancellation lifecycle rather than an order-wide Batch
writer.
On a confirmed return receipt (order.return_received), Batch Management reads
the return line's managed variant inventory links and expands Medusa's
cumulative sellable quantities by each link's required_quantity. It settles
each concrete order-line/inventory-item allocation independently before a later
fulfillment cancellation can restore it. Batch quantity increases only by the
received, undamaged concrete portion. Medusa's Beschädigt amount is
deliberately not sellable batch stock, and neither sellable nor damaged returned
units can be restored twice. Fulfillment packing-list snapshots retain the
original picked quantities by reconstructing returned units from the immutable
return receipts; returns do not create a new allocation revision.
Each charge has one fixed unit_cost. Re-booking the same charge with a
different price is rejected without changing the batch or native inventory
ledger. Receipt, withdrawal, and transfer booking dates default to the current
UTC day when omitted. The movement journal stores the dated, valued entries
needed to prepare a future inventory-value report; this plugin does not expose
that report yet.
Changing a charge assignment in the Order detail view only moves one fulfillment's committed batch quantity from the formerly assigned charge(s) to the selected charge(s). It does not change the order quantity, stock location, fulfillment ownership, or Medusa's native inventory movement. It is available after fulfillment creation as long as that fulfillment still has an active batch allocation.
Native inventory route guards
To keep the batch ledger authoritative, the plugin blocks manual stock changes
on the native admin inventory routes (create item with location levels, create/
update/delete a location level, and the batch location-level endpoints,
including delete/force). Stock must be moved through batch management.
Authorization
Every /admin/batch-management/* route is intentionally available to any
authenticated admin actor. Batch bookings and withdrawals are back-office
stock operations with no per-actor ownership, so they are not gated by a custom
RBAC policy — authentication is inherited from the admin API. Revisit this if
batch management ever becomes tenant- or team-scoped.
Concurrency & idempotency
- Withdrawals run inside a module transaction and take a
FOR UPDATErow lock on the candidate batches, so concurrent withdrawals for the same stock cannot oversell. - An order-allocation correction locks both the active withdrawal rows and all affected batches. It restores the old quantities and applies the replacement quantities in that one transaction; a competing cancellation or correction observes the completed state and can safely reload it.
- Fulfillment-triggered withdrawals are guarded by a durable run record unique
on
fulfillment_idplus a transaction-scoped lock on its normalized value;order_idremains indexed for document and lifecycle reads. A concurrent repeat waits for the winning transaction and returns its existing active rows without withdrawing again, while reusing a fulfillment ID for another Order is rejected. Failed fulfillment creation reverses its active rows and removes this run in one transaction; ordinary fulfillment cancellation retains it. - Affiliate consignment approval and positive corrections create native fulfillments and use this same hook. They never call a parallel Batch withdrawal path, so their retries have the same fulfillment-scoped exactly-once behavior.
Public batch transfer workflow
The plugin exports the durable transferBatchesWorkflow. Integrations provide
a stable transaction/idempotency key and explicit source batch, destination
location and quantity lines. The workflow locks the transfer run and involved
batches, preserves LOT name and expiry date, and returns source/destination
batch IDs plus the immutable LOT/MHD snapshot. A retry returns the completed
result without moving stock again, while reusing the key for a different source,
destination or quantity returns a conflict; a failed surrounding workflow can
reverse an unfinished run. If the target location does not yet have a native
inventory level for the item, the workflow creates a zero-level before booking
the movement and removes only that newly created level again when the transfer
fails.
The Affiliate plugin detects Batch support structurally at runtime. An installed Batch Management version must expose both the transfer capability and batch lookup used for pre-mutation source/item validation. Incompatible versions are rejected before inventory is changed.
Documents integration
The public module service also exposes
getOrderDocumentBatchSnapshot(orderId) for the optional Documents plugin.
It returns pending until at least one committed fulfillment run exists for the
Order. A completed zero-managed-item run returns ready with no allocations;
managed and partial fulfillments return every active withdrawal, including
reassigned rows, using only the immutable batch name and expiry snapshots
captured when each row was written. There is no live-Batch fallback or
reason-based filter. Documents can therefore print the original charge and MHD
without observing later Batch edits. Run the Batch migration before enabling
Documents on an existing installation; legacy withdrawals without these
snapshots are rejected clearly rather than producing an inaccurate invoice.
For internal packing lists the service exposes the narrower structural
getFulfillmentPackingListBatchSnapshot(fulfillmentId) capability. The durable
fulfillment run freezes ownership of the order and stock location and carries a
positive allocation revision. Its result contains active and already returned
immutable LOT snapshots for the requested fulfillment, including line item,
inventory item and the reconstructed original picked quantity. A committed
zero-managed-item run is ready with an empty allocation list; a missing run is
pending.
An allocation reassignment locks that run with the affected withdrawals,
increments its revision in the same transaction and emits
batch-management.fulfillment-allocation-revised best effort after the
transaction commits. Its name and payload schema are exported from
@hartl-services/medusa-food-supplements-contracts/events/batch-management. Event failures are logged but do not turn the committed
stock change into a failed request; periodic consumer reconciliation can repair
the projection. The optional Documents plugin can therefore render a
replacement packing-list revision without importing this plugin or reading its
tables directly.
Admin UI
The plugin adds:
/batch-management(nested under Inventory): search an inventory item, book goods receipts as batches, reduce batch stock manually, and transfer a full batch or a partial available quantity to another warehouse.- Inventory-item detail side widget: open batches for the item and a shortcut to the batch page.
- Order detail side widget: it is empty until a fulfillment commits its Batch run. Afterwards, inspect each fulfillment's automatic FEFO allocation and replace one fulfillment/line/location group's charge split manually. The selection accepts multiple charges, but its total must stay equal to that fulfillment's committed quantity.
The admin UI strings are German. See src/admin/i18n/texts.ts for the rationale
(the installed admin's plugin-i18n path is available but would require adding
react-i18next as a plugin dependency, which the workspace avoids).
Public Admin contracts
Batch Management currently exposes only custom Admin routes. The central Contracts package provides their DTOs, Zod request/query schemas and a path object, but not a second SDK client:
import type { CreateBatchPayload } from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";
const payload: CreateBatchPayload = {
name: "LOT-2026-001",
expiry_date: "2026-12-31",
quantity: 10,
unit_cost: 4.95,
booking_date: "2026-08-18",
inventory_item_id,
location_id,
};
await sdk.client.fetch(batchManagementAdminPaths.post.createBatch, {
method: "POST",
body: payload,
});The same public contract exposes the active FEFO allocations of an order and their permitted replacements. The reassignment endpoint deliberately accepts only a complete fulfillment/line-item/inventory-item/location group, so it cannot move another fulfillment's reservation or change its item or warehouse:
import type { ReassignOrderBatchAllocationsPayload } from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";
const payload: ReassignOrderBatchAllocationsPayload = {
fulfillment_id,
line_item_id,
inventory_item_id,
location_id,
allocations: [{ batch_id: replacementBatchId, quantity: 2 }],
};
await sdk.client.fetch(
batchManagementAdminPaths.post.reassignOrderAllocations(orderId),
{ method: "POST", body: payload },
);To transfer a batch, use the source batch ID in the path and keep the same idempotency key when retrying the same booking. A successful response contains the durable transfer run and the actual source/destination batch line:
import type {
BatchTransferResponse,
TransferBatchPayload,
} from "@hartl-services/medusa-food-supplements-contracts/types/batch-management-admin";
import { batchManagementAdminPaths } from "@hartl-services/medusa-food-supplements-contracts/paths/batch-management";
const payload: TransferBatchPayload = {
destination_location_id: destinationLocationId,
quantity: 3,
idempotency_key: crypto.randomUUID(),
};
const response = await sdk.client.fetch<BatchTransferResponse>(
batchManagementAdminPaths.post.transfer(sourceBatchId),
{ method: "POST", body: payload },
);Use @hartl-services/medusa-food-supplements-contracts/schemas/batch-management only when
the Admin needs an explicit runtime guard; type-only imports add no backend code
to a consumer bundle. Route middleware remains the source of Medusa-side
validation. Add future Store contracts only with a dedicated safe central DTO
and path entry.
