Inventory Reports
Inventory reports provide analytical views of stock data: movement summaries over date ranges, slow-moving/dead stock identification, per-warehouse utilization breakdowns, and batch/serial expiry alerts. All report endpoints are read-only and require the inventory.reports.view permission.
Purpose
- Track per-product stock movements (receipts, issues, transfers, adjustments) over any date range
- Identify slow-moving and dead stock that has not moved for a configurable number of days
- Summarize warehouse utilization with product counts, quantities, and values
- Alert on upcoming batch/serial expiry dates before products become unusable
- Support filtering by warehouse, product, and category across all reports
Report Types Overview
| Report | Endpoint | Description |
|---|---|---|
| Movement Summary | GET /reports/movement-summary | Opening/closing quantities with all movement types for a date range |
| Slow-Moving Stock | GET /reports/slow-moving | Products with no movement for N days |
| Warehouse Summary | GET /reports/warehouse-summary | Per-warehouse utilization stats |
| Expiry Report | GET /reports/expiry | Batches/serials expiring within N days |
API Endpoints
| Method | Path | Description | Permission |
|---|---|---|---|
GET | /api/inventory/reports/movement-summary | Per-product movement summary for a date range | inventory.reports.view |
GET | /api/inventory/reports/slow-moving | Products with no movement for N days | inventory.reports.view |
GET | /api/inventory/reports/warehouse-summary | Per-warehouse utilization summary | inventory.reports.view |
GET | /api/inventory/reports/expiry | Items expiring within N days | inventory.reports.view |
Movement Summary
Returns per-product movement totals for a date range, including opening quantity (calculated from all movements before the start date), each movement type during the period, and the resulting closing quantity.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
date_from | date | Yes | Start date (Y-m-d) |
date_to | date | Yes | End date (Y-m-d), must be >= date_from |
warehouse_id | integer | No | Filter by warehouse |
product_id | integer | No | Filter by single product |
category_id | integer | No | Filter by product category |
Request/Response Example
Request GET /api/inventory/reports/movement-summary?date_from=2026-01-01&date_to=2026-01-31&warehouse_id=1
curl -X GET "https://moon-erp.test/api/inventory/reports/movement-summary?date_from=2026-01-01&date_to=2026-01-31&warehouse_id=1" \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"final response = await http.get(
Uri.parse('https://moon-erp.test/api/inventory/reports/movement-summary?date_from=2026-01-01&date_to=2026-01-31&warehouse_id=1'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);Response 200 OK
{
"data": {
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"items": [
{
"product_id": 1,
"product_name": "Widget A",
"product_code": "PROD-001",
"opening_qty": 500.000,
"receipts": 200.000,
"issues": 150.000,
"transfers_in": 50.000,
"transfers_out": 30.000,
"adjustments_in": 10.000,
"adjustments_out": 5.000,
"closing_qty": 575.000
},
{
"product_id": 2,
"product_name": "Widget B",
"product_code": "PROD-002",
"opening_qty": 100.000,
"receipts": 0.000,
"issues": 25.000,
"transfers_in": 0.000,
"transfers_out": 0.000,
"adjustments_in": 0.000,
"adjustments_out": 0.000,
"closing_qty": 75.000
}
],
"summary": {
"total_products": 2,
"total_receipts": 200.000,
"total_issues": 175.000
}
}
}Movement Types Tracked
| Movement Type | In/Out | Source |
|---|---|---|
receipt | In | Stock receipts (GRN) |
issue | Out | Stock issues (GDN) |
transfer_in | In | Incoming transfers |
transfer_out | Out | Outgoing transfers |
adjustment | In or Out | Stock adjustments (can be positive or negative) |
opening | In | Opening balance entries |
Closing Quantity Formula
closing_qty = opening_qty + receipts + transfers_in + adjustments_in + opening_in
- issues - transfers_out - adjustments_outSlow-Moving Stock
Identifies products that have stock on hand but no movement for a configurable number of days. Useful for identifying dead stock and reducing carrying costs.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
days_threshold | integer | 90 | Days without movement to consider slow-moving (minimum: 1) |
warehouse_id | integer | — | Filter by warehouse |
category_id | integer | — | Filter by product category |
Request/Response Example
Request GET /api/inventory/reports/slow-moving?days_threshold=60&warehouse_id=1
curl -X GET "https://moon-erp.test/api/inventory/reports/slow-moving?days_threshold=60&warehouse_id=1" \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"final response = await http.get(
Uri.parse('https://moon-erp.test/api/inventory/reports/slow-moving?days_threshold=60&warehouse_id=1'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);Response 200 OK
{
"data": {
"days_threshold": 60,
"items": [
{
"product_id": 8,
"product_name": "Legacy Connector",
"product_code": "PROD-008",
"warehouse_id": 1,
"warehouse_name": "Main Warehouse",
"quantity": 45.000,
"total_value": 562.500,
"last_movement_date": "2025-10-15",
"days_since_movement": 131
},
{
"product_id": 15,
"product_name": "Discontinued Part X",
"product_code": "PROD-015",
"warehouse_id": 1,
"warehouse_name": "Main Warehouse",
"quantity": 12.000,
"total_value": 180.000,
"last_movement_date": null,
"days_since_movement": null
}
],
"summary": {
"total_items": 2,
"total_quantity": 57.000,
"total_value": 742.500
}
}
}No Movement Date
When last_movement_date is null and days_since_movement is null, it means the product has stock but no movement records at all — typically from a data migration or manual database entry.
Warehouse Summary
Returns per-warehouse utilization statistics: total distinct products, total quantity, total value, and last activity date. Only warehouses with active stock (quantity > 0) are included.
Query Parameters
This endpoint takes no parameters. It always returns all warehouses with active stock for the authenticated user's company.
Request/Response Example
Request GET /api/inventory/reports/warehouse-summary
curl -X GET "https://moon-erp.test/api/inventory/reports/warehouse-summary" \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"final response = await http.get(
Uri.parse('https://moon-erp.test/api/inventory/reports/warehouse-summary'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);Response 200 OK
{
"data": {
"warehouses": [
{
"warehouse_id": 1,
"warehouse_name": "Main Warehouse",
"warehouse_code": "WH-001",
"total_products": 85,
"total_quantity": 12540.000,
"total_value": 458750.500,
"last_activity": "2026-02-22"
},
{
"warehouse_id": 2,
"warehouse_name": "Branch Warehouse",
"warehouse_code": "WH-002",
"total_products": 42,
"total_quantity": 3200.000,
"total_value": 96400.000,
"last_activity": "2026-02-20"
}
],
"summary": {
"total_warehouses": 2,
"grand_total_quantity": 15740.000,
"grand_total_value": 555150.500
}
}
}Last Activity
The last_activity date is derived from the most recent last_receipt_date or last_issue_date on the stock balance records for that warehouse.
Expiry Report
Returns batches and serial numbers that are expiring within a configurable number of days. Uses the product_serials table and filters by status = available to exclude already consumed serials.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
days_ahead | integer | 30 | Days ahead to check for expiring items (minimum: 1) |
warehouse_id | integer | — | Filter by warehouse |
include_expired | boolean | false | Include items that are already expired |
Request/Response Example
Request GET /api/inventory/reports/expiry?days_ahead=60&include_expired=true
curl -X GET "https://moon-erp.test/api/inventory/reports/expiry?days_ahead=60&include_expired=true" \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"final response = await http.get(
Uri.parse('https://moon-erp.test/api/inventory/reports/expiry?days_ahead=60&include_expired=true'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);Response 200 OK
{
"data": {
"days_ahead": 60,
"items": [
{
"id": 42,
"product_id": 3,
"product_name": "Antiseptic Solution",
"product_code": "PROD-003",
"serial_number": "SN-2025-042",
"batch_number": "BATCH-2025-A",
"warehouse_id": 1,
"expiry_date": "2026-01-15",
"days_until_expiry": -39,
"is_expired": true,
"cost": 8.500
},
{
"id": 78,
"product_id": 7,
"product_name": "Thermal Paste",
"product_code": "PROD-007",
"serial_number": "SN-2026-078",
"batch_number": "BATCH-2026-B",
"warehouse_id": 1,
"expiry_date": "2026-04-10",
"days_until_expiry": 46,
"is_expired": false,
"cost": 3.250
}
],
"summary": {
"total_items": 2,
"already_expired": 1,
"expiring_soon": 1,
"total_value_at_risk": 11.750
}
}
}Negative Days
When days_until_expiry is negative, the item is already expired. These are only included when include_expired=true.
Business Rules
- Permission — All four report endpoints require the
inventory.reports.viewpermission. - Company scoping — All queries are scoped to the authenticated user's
company_id. - Movement summary validation —
date_fromanddate_toare required.date_tomust be on or afterdate_from. - Opening quantity — Calculated by summing all movements (
quantity_in - quantity_out) before thedate_fromdate. - Closing quantity —
opening_qty + all inflows - all outflowsduring the period. - Slow-moving threshold — Minimum 1 day. Products with stock but no movement records at all are included (shown with
nulldates). - Slow-moving scope — Results are per product-warehouse combination, not aggregated across warehouses.
- Warehouse summary — Only includes warehouses with positive stock quantities (
quantity > 0). - Expiry source — Uses the
product_serialstable, not receipt items. Only serials withstatus = availableand a non-nullexpiry_dateare checked. - Expiry window — By default, only items expiring in the future (within
days_ahead) are shown. Setinclude_expired = trueto also see already-expired items. - Value at risk — The expiry report sums the
costfield from product serials to calculatetotal_value_at_risk. - No pagination — The movement summary, slow-moving, warehouse summary, and expiry reports return all matching results without pagination. For large datasets, use filters to narrow results.