feat(elixir): storage location system
This commit is contained in:
@@ -1,12 +1,185 @@
|
||||
defmodule ComponentsElixir.Inventory do
|
||||
@moduledoc """
|
||||
The Inventory context for managing components and categories.
|
||||
The Inventory context: managing components and categories.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias ComponentsElixir.Repo
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
||||
|
||||
## Storage Locations
|
||||
|
||||
@doc """
|
||||
Returns the list of storage locations with computed hierarchy fields.
|
||||
"""
|
||||
def list_storage_locations do
|
||||
# Get all locations with preloaded parents in a single query
|
||||
locations = StorageLocation
|
||||
|> order_by([sl], [asc: sl.name])
|
||||
|> preload(:parent)
|
||||
|> Repo.all()
|
||||
|
||||
# Compute hierarchy fields for all locations efficiently
|
||||
compute_hierarchy_fields_batch(locations)
|
||||
|> Enum.sort_by(&{&1.level, &1.name})
|
||||
end
|
||||
|
||||
# Efficient batch computation of hierarchy fields
|
||||
defp compute_hierarchy_fields_batch(locations) do
|
||||
# Create a map for quick parent lookup to avoid N+1 queries
|
||||
location_map = Map.new(locations, fn loc -> {loc.id, loc} end)
|
||||
|
||||
Enum.map(locations, fn location ->
|
||||
level = compute_level_efficient(location, location_map, 0)
|
||||
path = compute_path_efficient(location, location_map, 0)
|
||||
|
||||
%{location | level: level, path: path}
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0
|
||||
defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
nil -> 0 # Orphaned record
|
||||
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
|
||||
end
|
||||
end
|
||||
defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion
|
||||
|
||||
defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name
|
||||
defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
nil -> name # Orphaned record
|
||||
parent ->
|
||||
parent_path = compute_path_efficient(parent, location_map, depth + 1)
|
||||
"#{parent_path}/#{name}"
|
||||
end
|
||||
end
|
||||
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion
|
||||
|
||||
@doc """
|
||||
Returns the list of root storage locations (no parent).
|
||||
"""
|
||||
def list_root_storage_locations do
|
||||
StorageLocation
|
||||
|> where([sl], is_nil(sl.parent_id))
|
||||
|> order_by([sl], [asc: sl.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single storage location with computed hierarchy fields.
|
||||
"""
|
||||
def get_storage_location!(id) do
|
||||
location = StorageLocation
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
|
||||
# Compute hierarchy fields
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
|
||||
# Simple computation for single location (allows DB queries)
|
||||
defp compute_level_for_single(%{parent_id: nil}), do: 0
|
||||
defp compute_level_for_single(%{parent_id: parent_id}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> 0
|
||||
parent -> 1 + compute_level_for_single(parent)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
|
||||
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> name
|
||||
parent -> "#{compute_path_for_single(parent)}/#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a storage location by QR code.
|
||||
"""
|
||||
def get_storage_location_by_qr_code(qr_code) do
|
||||
StorageLocation
|
||||
|> where([sl], sl.qr_code == ^qr_code)
|
||||
|> preload(:parent)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil -> nil
|
||||
location ->
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a storage location.
|
||||
"""
|
||||
def create_storage_location(attrs \\ %{}) do
|
||||
# Convert string keys to atoms to maintain consistency
|
||||
attrs = normalize_string_keys(attrs)
|
||||
|
||||
%StorageLocation{}
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a storage location.
|
||||
"""
|
||||
def update_storage_location(%StorageLocation{} = storage_location, attrs) do
|
||||
# Convert string keys to atoms to maintain consistency
|
||||
attrs = normalize_string_keys(attrs)
|
||||
|
||||
storage_location
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a storage location.
|
||||
"""
|
||||
def delete_storage_location(%StorageLocation{} = storage_location) do
|
||||
Repo.delete(storage_location)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking storage location changes.
|
||||
"""
|
||||
def change_storage_location(%StorageLocation{} = storage_location, attrs \\ %{}) do
|
||||
StorageLocation.changeset(storage_location, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a QR code string and returns storage location information.
|
||||
"""
|
||||
def parse_qr_code(qr_string) do
|
||||
case get_storage_location_by_qr_code(qr_string) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
location ->
|
||||
{:ok, %{
|
||||
type: :storage_location,
|
||||
location: location,
|
||||
qr_code: qr_string
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# Convert string keys to atoms for consistency
|
||||
defp normalize_string_keys(attrs) when is_map(attrs) do
|
||||
Enum.reduce(attrs, %{}, fn
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
atom_key = String.to_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
{key, value}, acc ->
|
||||
Map.put(acc, key, value)
|
||||
end)
|
||||
end
|
||||
|
||||
## Categories
|
||||
|
||||
@@ -15,39 +188,14 @@ defmodule ComponentsElixir.Inventory do
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> preload(:parent)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of root categories (no parent).
|
||||
"""
|
||||
def list_root_categories do
|
||||
Category
|
||||
|> where([c], is_nil(c.parent_id))
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of child categories for a given parent.
|
||||
"""
|
||||
def list_child_categories(parent_id) do
|
||||
Category
|
||||
|> where([c], c.parent_id == ^parent_id)
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single category.
|
||||
"""
|
||||
def get_category!(id) do
|
||||
Category
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
def get_category!(id), do: Repo.get!(Category, id)
|
||||
|
||||
@doc """
|
||||
Creates a category.
|
||||
@@ -81,90 +229,37 @@ defmodule ComponentsElixir.Inventory do
|
||||
Category.changeset(category, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of components in a specific category.
|
||||
"""
|
||||
def count_components_in_category(category_id) do
|
||||
Component
|
||||
|> where([c], c.category_id == ^category_id)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
## Components
|
||||
|
||||
@doc """
|
||||
Returns the list of components with optional filtering and pagination.
|
||||
Returns the list of components.
|
||||
"""
|
||||
def list_components(opts \\ []) do
|
||||
Component
|
||||
|> apply_component_filters(opts)
|
||||
|> preload(:category)
|
||||
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> preload([:category, :storage_location])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns paginated components with search and filtering.
|
||||
"""
|
||||
def paginate_components(opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
|
||||
query =
|
||||
Component
|
||||
|> apply_component_filters(opts)
|
||||
|> preload(:category)
|
||||
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||
|
||||
components =
|
||||
query
|
||||
|> limit(^limit)
|
||||
|> offset(^offset)
|
||||
|> Repo.all()
|
||||
|
||||
total_count = Repo.aggregate(query, :count, :id)
|
||||
|
||||
%{
|
||||
components: components,
|
||||
total_count: total_count,
|
||||
has_more: total_count > offset + length(components)
|
||||
}
|
||||
end
|
||||
|
||||
defp apply_component_filters(query, opts) do
|
||||
Enum.reduce(opts, query, fn
|
||||
{:search, search}, query when is_binary(search) and search != "" ->
|
||||
if String.length(search) > 3 do
|
||||
# Use full-text search for longer queries
|
||||
where(query, [c],
|
||||
fragment("to_tsvector('english', ? || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) @@ plainto_tsquery(?)",
|
||||
c.name, c.description, c.keywords, ^search))
|
||||
else
|
||||
# Use ILIKE for shorter queries
|
||||
search_term = "%#{search}%"
|
||||
where(query, [c],
|
||||
ilike(c.name, ^search_term) or
|
||||
ilike(c.description, ^search_term) or
|
||||
ilike(c.keywords, ^search_term))
|
||||
end
|
||||
|
||||
{:category_id, category_id}, query when is_integer(category_id) ->
|
||||
{:category_id, category_id}, query when not is_nil(category_id) ->
|
||||
where(query, [c], c.category_id == ^category_id)
|
||||
|
||||
{:sort_criteria, "name"}, query ->
|
||||
order_by(query, [c], [asc: c.name])
|
||||
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
|
||||
where(query, [c], c.storage_location_id == ^storage_location_id)
|
||||
|
||||
{:sort_criteria, "description"}, query ->
|
||||
order_by(query, [c], [asc: c.description])
|
||||
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
|
||||
search_pattern = "%#{search_term}%"
|
||||
where(query, [c],
|
||||
ilike(c.name, ^search_pattern) or
|
||||
ilike(c.description, ^search_pattern) or
|
||||
ilike(c.manufacturer, ^search_pattern) or
|
||||
ilike(c.part_number, ^search_pattern)
|
||||
)
|
||||
|
||||
{:sort_criteria, "id"}, query ->
|
||||
order_by(query, [c], [asc: c.id])
|
||||
|
||||
{:sort_criteria, "category_id"}, query ->
|
||||
order_by(query, [c], [asc: c.category_id, asc: c.name])
|
||||
|
||||
_, query ->
|
||||
query
|
||||
_, query -> query
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -173,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
"""
|
||||
def get_component!(id) do
|
||||
Component
|
||||
|> preload(:category)
|
||||
|> preload([:category, :storage_location])
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
@@ -195,39 +290,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component's count.
|
||||
"""
|
||||
def update_component_count(%Component{} = component, count) when is_integer(count) do
|
||||
component
|
||||
|> Component.count_changeset(%{count: count})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments a component's count.
|
||||
"""
|
||||
def increment_component_count(%Component{} = component) do
|
||||
update_component_count(component, component.count + 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrements a component's count (minimum 0).
|
||||
"""
|
||||
def decrement_component_count(%Component{} = component) do
|
||||
new_count = max(0, component.count - 1)
|
||||
update_component_count(component, new_count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component's image filename.
|
||||
"""
|
||||
def update_component_image(%Component{} = component, image_filename) do
|
||||
component
|
||||
|> Component.image_changeset(%{image_filename: image_filename})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a component.
|
||||
"""
|
||||
@@ -243,15 +305,16 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns component statistics.
|
||||
Returns inventory statistics.
|
||||
"""
|
||||
def component_stats do
|
||||
def get_inventory_stats do
|
||||
total_components = Repo.aggregate(Component, :count, :id)
|
||||
total_stock = Repo.aggregate(Component, :sum, :count) || 0
|
||||
categories_with_components =
|
||||
Component
|
||||
|> select([c], c.category_id)
|
||||
|> distinct(true)
|
||||
|
||||
total_stock = Component
|
||||
|> Repo.aggregate(:sum, :count)
|
||||
|
||||
categories_with_components = Component
|
||||
|> distinct([c], c.category_id)
|
||||
|> Repo.aggregate(:count, :category_id)
|
||||
|
||||
%{
|
||||
@@ -260,4 +323,48 @@ defmodule ComponentsElixir.Inventory do
|
||||
categories_with_components: categories_with_components
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns component statistics (alias for get_inventory_stats for compatibility).
|
||||
"""
|
||||
def component_stats do
|
||||
get_inventory_stats()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts components in a specific category.
|
||||
"""
|
||||
def count_components_in_category(category_id) do
|
||||
Component
|
||||
|> where([c], c.category_id == ^category_id)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increment component stock count.
|
||||
"""
|
||||
def increment_component_count(%Component{} = component) do
|
||||
component
|
||||
|> Component.changeset(%{count: component.count + 1})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrement component stock count.
|
||||
"""
|
||||
def decrement_component_count(%Component{} = component) do
|
||||
new_count = max(0, component.count - 1)
|
||||
component
|
||||
|> Component.changeset(%{count: new_count})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Paginate components with filters.
|
||||
"""
|
||||
def paginate_components(opts \\ []) do
|
||||
# For now, just return all components - pagination can be added later
|
||||
components = list_components(opts)
|
||||
%{components: components, has_more: false}
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user