Skip to main content

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)

MethodPathHandlerDescription
GET/stores/:store_id/inventoryInventoryController.listList inventory levels (paginated)
GET/stores/:store_id/inventory/:idInventoryController.getStockGet stock level for a product
GET/stores/:store_id/inventory/:id/historyInventoryController.getHistoryGet inventory change history

Store-Scoped — Store Manager+ (Write)

MethodPathHandlerDescription
POST/stores/:store_id/inventory/receiveInventoryController.receiveReceive inventory shipment
POST/stores/:store_id/inventory/adjustInventoryController.adjustAdjust inventory (count correction, damage, etc.)
POST/stores/:store_id/inventory/transferInventoryController.transferTransfer inventory to another store

Organization-Scoped — All Org Members (Read)

MethodPathHandlerDescription
GET/orgs/:org_id/serial-numbersOrgSerialNumberController.listList serial numbers (paginated)
GET/orgs/:org_id/serial-numbers/:serial_idOrgSerialNumberController.getGet serial number detail
GET/orgs/:org_id/serial-numbers/lookup/:serialOrgSerialNumberController.getBySerialLookup by serial string

Organization-Scoped — Org Admin (Write)

MethodPathHandlerDescription
POST/orgs/:org_id/serial-numbersOrgSerialNumberController.createRegister new serial number
POST/orgs/:org_id/serial-numbers/:serial_id/sellOrgSerialNumberController.sellMark serial as sold
POST/orgs/:org_id/serial-numbers/:serial_id/returnOrgSerialNumberController.markReturnedMark serial as returned

SQLDelight Tables & Queries

inventory_levels table (composite key: store_id + product_id)

Key queries:

  • findByStoreId(store_id, limit, offset) - Paginated inventory levels
  • findByStoreIdAndProductId(store_id, product_id) - Single product stock
  • countByStoreId(store_id) - Pagination count
  • upsert(store_id, product_id, quantity_on_hand, reorder_point, reorder_qty) - Create/update level
  • updateQuantity(quantity_on_hand, store_id, product_id) - Adjust quantity

inventory_ledger table

Key queries:

  • findByStoreIdAndProductId(store_id, product_id, limit, offset) - History for a product
  • countByStoreIdAndProductId(store_id, product_id) - Pagination count
  • insert(...) - Record ledger entry

serial_numbers table

Key queries:

  • findByOrgId(org_id, limit, offset) - Paginated list
  • findById(serial_id) - Single lookup
  • findBySerialNumber(org_id, serial_number) - Lookup by string
  • countByOrgId(org_id) - Pagination count
  • insert(...) - Register
  • updateStatus(status, sold_at, transaction_id, serial_id) - Mark sold
  • markReturned(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

  1. Inventory operations are transactional — ledger entry + level update must be atomic
  2. Receiving inventory always adds positive quantity
  3. Adjustments can be positive or negative (damage, count correction, etc.) with mandatory reason
  4. Transfers decrement source and increment destination — both stores must belong to same org
  5. Transfer quantity cannot exceed available stock (400 error)
  6. Serial numbers are unique within an org (not just within a store)
  7. Serial status transitions: available -> sold -> returned (or available -> returned)
  8. Only products with requires_serial = true can have serial numbers
  9. inventory_levels uses upsert — first receive creates the level record

Acceptance Criteria

  1. Receiving inventory creates a ledger entry and updates the level
  2. Adjusting inventory works with positive and negative quantities
  3. Transferring inventory updates both source and destination stores atomically
  4. Transfer fails if insufficient stock (400)
  5. Inventory history shows all changes for a product at a store (paginated)
  6. Serial number CRUD works with status transitions
  7. Serial lookup by string returns the correct serial across the org
  8. Serial numbers are validated against requires_serial on the product
  9. All list endpoints support pagination
  10. Bazel build passes
  11. Unit tests for InventoryService and SerialNumberService