Phase 4: Inventory & Serials
Depends on: Phase 3 (Product Catalog) New Endpoints: 12 New Files: 15
Goal
Implement inventory management (receive, adjust, transfer between stores) and serial number tracking for high-value products. After this phase, store managers can manage stock levels, view inventory history/ledger, and track individual serialized units.
Endpoints
Store-Scoped — All Store Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /stores/:store_id/inventory | InventoryController.list | List inventory levels (paginated) |
| GET | /stores/:store_id/inventory/:id | InventoryController.getStock | Get stock level for a product |
| GET | /stores/:store_id/inventory/:id/history | InventoryController.getHistory | Get inventory change history |
Store-Scoped — Store Manager+ (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /stores/:store_id/inventory/receive | InventoryController.receive | Receive inventory shipment |
| POST | /stores/:store_id/inventory/adjust | InventoryController.adjust | Adjust inventory (count correction, damage, etc.) |
| POST | /stores/:store_id/inventory/transfer | InventoryController.transfer | Transfer inventory to another store |
Organization-Scoped — All Org Members (Read)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /orgs/:org_id/serial-numbers | OrgSerialNumberController.list | List serial numbers (paginated) |
| GET | /orgs/:org_id/serial-numbers/:serial_id | OrgSerialNumberController.get | Get serial number detail |
| GET | /orgs/:org_id/serial-numbers/lookup/:serial | OrgSerialNumberController.getBySerial | Lookup by serial string |
Organization-Scoped — Org Admin (Write)
| Method | Path | Handler | Description |
|---|---|---|---|
| POST | /orgs/:org_id/serial-numbers | OrgSerialNumberController.create | Register new serial number |
| POST | /orgs/:org_id/serial-numbers/:serial_id/sell | OrgSerialNumberController.sell | Mark serial as sold |
| POST | /orgs/:org_id/serial-numbers/:serial_id/return | OrgSerialNumberController.markReturned | Mark serial as returned |
SQLDelight Tables & Queries
inventory_levels table (composite key: store_id + product_id)
Key queries:
findByStoreId(store_id, limit, offset)- Paginated inventory levelsfindByStoreIdAndProductId(store_id, product_id)- Single product stockcountByStoreId(store_id)- Pagination countupsert(store_id, product_id, quantity_on_hand, reorder_point, reorder_qty)- Create/update levelupdateQuantity(quantity_on_hand, store_id, product_id)- Adjust quantity
inventory_ledger table
Key queries:
findByStoreIdAndProductId(store_id, product_id, limit, offset)- History for a productcountByStoreIdAndProductId(store_id, product_id)- Pagination countinsert(...)- Record ledger entry
serial_numbers table
Key queries:
findByOrgId(org_id, limit, offset)- Paginated listfindById(serial_id)- Single lookupfindBySerialNumber(org_id, serial_number)- Lookup by stringcountByOrgId(org_id)- Pagination countinsert(...)- RegisterupdateStatus(status, sold_at, transaction_id, serial_id)- Mark soldmarkReturned(serial_id)- Mark returned
Files to Create
Controllers (2)
controller/store/InventoryController.java
@RequestMapping("/stores/{storeId}/inventory")
GET / -> inventoryService.list(storeId, page, size)
GET /{id} -> inventoryService.getStock(storeId, id)
GET /{id}/history -> inventoryService.getHistory(storeId, id, page, size)
POST /receive -> inventoryService.receive(storeId, request)
POST /adjust -> inventoryService.adjust(storeId, request)
POST /transfer -> inventoryService.transfer(storeId, request)
controller/org/OrgSerialNumberController.java
@RequestMapping("/orgs/{orgId}/serial-numbers")
GET / -> serialNumberService.list(orgId, page, size)
GET /{serialId} -> serialNumberService.get(serialId)
GET /lookup/{serial} -> serialNumberService.getBySerial(orgId, serial)
POST / -> serialNumberService.create(orgId, request)
POST /{serialId}/sell -> serialNumberService.sell(serialId)
POST /{serialId}/return -> serialNumberService.markReturned(serialId)
Services (2)
service/InventoryService.java
- list(storeId, page, size) -> Page<InventoryResponse>
- getStock(storeId, productId) -> InventoryResponse
- getHistory(storeId, productId, page, size) -> Page<InventoryHistoryResponse>
- receive(storeId, ReceiveInventoryRequest) -> InventoryResponse
Creates inventory_ledger entry (change_type: "receive")
Updates inventory_levels.quantity_on_hand += quantity
- adjust(storeId, AdjustInventoryRequest) -> InventoryResponse
Creates inventory_ledger entry (change_type: "adjustment")
Updates inventory_levels.quantity_on_hand += quantity (can be negative)
- transfer(storeId, TransferInventoryRequest) -> void
Creates inventory_ledger entries for BOTH source and destination stores
Decrements source store, increments destination store
service/SerialNumberService.java
- list, get, getBySerial, create, sell, markReturned
- Validates: product exists and has requires_serial = true
- Validates: serial_number is unique within org
- sell: changes status "available" -> "sold", records transaction_id
- markReturned: changes status "sold" -> "returned"
Repositories (3)
repository/InventoryRepository.java -- wraps InventoryLevelQueries
repository/LedgerRepository.java -- wraps InventoryLedgerQueries
repository/SerialNumberRepository.java -- wraps SerialNumberQueries
Request DTOs (3)
dto/request/ReceiveInventoryRequest.java
{ productId: @NotNull UUID, quantity: @NotNull @Min(1) int, notes: String }
dto/request/AdjustInventoryRequest.java
{ productId: @NotNull UUID, quantity: @NotNull int, reason: @NotBlank String, notes: String }
dto/request/TransferInventoryRequest.java
{ productId: @NotNull UUID, destinationStoreId: @NotNull UUID,
quantity: @NotNull @Min(1) int, notes: String }
Response DTOs (3)
dto/response/InventoryResponse.java
{ storeId: UUID, productId: UUID, productName: String, barcode: String,
quantityOnHand: int, reorderPoint: Integer, reorderQty: Integer,
lastCountedAt: Instant, updatedAt: Instant }
dto/response/InventoryHistoryResponse.java
{ ledgerId: UUID, storeId: UUID, productId: UUID,
quantityChange: int, changeType: String, referenceType: String,
referenceId: UUID, userId: UUID, notes: String, createdAt: Instant }
dto/response/SerialNumberResponse.java
{ serialId: UUID, orgId: UUID, storeId: UUID, productId: UUID,
serialNumber: String, status: String, receivedAt: Instant,
soldAt: Instant, transactionId: UUID, userId: UUID, notes: String }
Mappers (2)
mapper/InventoryMapper.java
- static toResponse(InventoryLevel, Product) -> InventoryResponse
- static toHistoryResponse(InventoryLedger) -> InventoryHistoryResponse
mapper/SerialNumberMapper.java
- static toResponse(SerialNumber) -> SerialNumberResponse
Business Rules
- Inventory operations are transactional — ledger entry + level update must be atomic
- Receiving inventory always adds positive quantity
- Adjustments can be positive or negative (damage, count correction, etc.) with mandatory reason
- Transfers decrement source and increment destination — both stores must belong to same org
- Transfer quantity cannot exceed available stock (400 error)
- Serial numbers are unique within an org (not just within a store)
- Serial status transitions:
available->sold->returned(oravailable->returned) - Only products with
requires_serial = truecan have serial numbers inventory_levelsuses upsert — first receive creates the level record
Acceptance Criteria
- Receiving inventory creates a ledger entry and updates the level
- Adjusting inventory works with positive and negative quantities
- Transferring inventory updates both source and destination stores atomically
- Transfer fails if insufficient stock (400)
- Inventory history shows all changes for a product at a store (paginated)
- Serial number CRUD works with status transitions
- Serial lookup by string returns the correct serial across the org
- Serial numbers are validated against
requires_serialon the product - All list endpoints support pagination
- Bazel build passes
- Unit tests for InventoryService and SerialNumberService