503 lines
13 KiB
Elixir
503 lines
13 KiB
Elixir
defmodule ComponentsElixir.Inventory do
|
|
@moduledoc """
|
|
The Inventory context: managing components and categories.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
alias ComponentsElixir.Repo
|
|
|
|
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
|
|
|
## Storage Locations
|
|
|
|
@doc """
|
|
Returns the list of storage locations with optimized parent preloading.
|
|
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
|
"""
|
|
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: [parent: [parent: [parent: :parent]]])
|
|
|> Repo.all()
|
|
|
|
# Ensure AprilTag SVGs exist for all locations
|
|
spawn(fn ->
|
|
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
|
end)
|
|
|
|
locations
|
|
end
|
|
|
|
@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 preloaded associations.
|
|
"""
|
|
def get_storage_location!(id) do
|
|
StorageLocation
|
|
|> preload(:parent)
|
|
|> Repo.get!(id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a storage location by AprilTag ID.
|
|
"""
|
|
def get_storage_location_by_apriltag_id(apriltag_id) do
|
|
StorageLocation
|
|
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
|
|> preload(:parent)
|
|
|> Repo.one()
|
|
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)
|
|
|
|
result =
|
|
%StorageLocation{}
|
|
|> StorageLocation.changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
case result do
|
|
{:ok, location} ->
|
|
{:ok, location}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
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)
|
|
|
|
result =
|
|
storage_location
|
|
|> StorageLocation.changeset(attrs)
|
|
|> Repo.update()
|
|
|
|
case result do
|
|
{:ok, updated_location} ->
|
|
{:ok, updated_location}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
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 an AprilTag ID and returns storage location information.
|
|
"""
|
|
def parse_apriltag_id(apriltag_id) when is_integer(apriltag_id) do
|
|
case get_storage_location_by_apriltag_id(apriltag_id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
location ->
|
|
{:ok,
|
|
%{
|
|
type: :storage_location,
|
|
location: location,
|
|
apriltag_id: apriltag_id
|
|
}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Computes the path for a storage location (for display purposes).
|
|
"""
|
|
def compute_storage_location_path(nil), do: nil
|
|
|
|
def compute_storage_location_path(%StorageLocation{} = location) do
|
|
StorageLocation.full_path(location)
|
|
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
|
|
|
|
@doc """
|
|
Returns the list of categories with optimized parent preloading.
|
|
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
|
"""
|
|
def list_categories do
|
|
Category
|
|
|> preload(parent: [parent: [parent: [parent: :parent]]])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Gets a single category.
|
|
"""
|
|
def get_category!(id), do: Repo.get!(Category, id)
|
|
|
|
@doc """
|
|
Creates a category.
|
|
"""
|
|
def create_category(attrs \\ %{}) do
|
|
%Category{}
|
|
|> Category.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a category.
|
|
"""
|
|
def update_category(%Category{} = category, attrs) do
|
|
category
|
|
|> Category.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes a category.
|
|
"""
|
|
def delete_category(%Category{} = category) do
|
|
Repo.delete(category)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking category changes.
|
|
"""
|
|
def change_category(%Category{} = category, attrs \\ %{}) do
|
|
Category.changeset(category, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Gets all category IDs that are descendants of the given category ID, including the category itself.
|
|
This is used for filtering components by category and all its subcategories.
|
|
Returns an empty list if the category doesn't exist.
|
|
|
|
Note: This implementation loads all categories into memory for traversal, which is efficient
|
|
for typical category tree sizes (hundreds of categories). For very large category trees,
|
|
a recursive CTE query could be used instead.
|
|
"""
|
|
def get_category_and_descendant_ids(category_id) when is_integer(category_id) do
|
|
categories = list_categories()
|
|
|
|
# Verify the category exists before getting descendants
|
|
case Enum.find(categories, &(&1.id == category_id)) do
|
|
nil ->
|
|
[]
|
|
|
|
_category ->
|
|
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
|
categories,
|
|
category_id,
|
|
& &1.parent_id
|
|
)
|
|
end
|
|
end
|
|
|
|
def get_category_and_descendant_ids(_), do: []
|
|
|
|
@doc """
|
|
Gets all storage location IDs that are descendants of the given storage location ID, including the location itself.
|
|
This is used for filtering components by storage location and all its sub-locations.
|
|
Returns an empty list if the storage location doesn't exist.
|
|
|
|
Note: This implementation loads all storage locations into memory for traversal, which is efficient
|
|
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
|
|
a recursive CTE query could be used instead.
|
|
"""
|
|
def get_storage_location_and_descendant_ids(storage_location_id)
|
|
when is_integer(storage_location_id) do
|
|
storage_locations = list_storage_locations()
|
|
|
|
# Verify the storage location exists before getting descendants
|
|
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
|
|
nil ->
|
|
[]
|
|
|
|
_storage_location ->
|
|
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
|
storage_locations,
|
|
storage_location_id,
|
|
& &1.parent_id
|
|
)
|
|
end
|
|
end
|
|
|
|
def get_storage_location_and_descendant_ids(_), do: []
|
|
|
|
## Components
|
|
|
|
@doc """
|
|
Returns the list of components.
|
|
"""
|
|
def list_components(opts \\ []) do
|
|
Component
|
|
|> apply_component_filters(opts)
|
|
|> apply_component_sorting(opts)
|
|
|> preload([:category, :storage_location])
|
|
|> Repo.all()
|
|
end
|
|
|
|
defp apply_component_filters(query, opts) do
|
|
Enum.reduce(opts, query, fn
|
|
{:category_id, category_id}, query when not is_nil(category_id) ->
|
|
# Get the category and all its descendant category IDs
|
|
category_ids = get_category_and_descendant_ids(category_id)
|
|
where(query, [c], c.category_id in ^category_ids)
|
|
|
|
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
|
|
# Get the storage location and all its descendant storage location IDs
|
|
storage_location_ids = get_storage_location_and_descendant_ids(storage_location_id)
|
|
where(query, [c], c.storage_location_id in ^storage_location_ids)
|
|
|
|
{: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.keywords, ^search_pattern) or
|
|
ilike(c.position, ^search_pattern)
|
|
)
|
|
|
|
_, query ->
|
|
query
|
|
end)
|
|
end
|
|
|
|
defp apply_component_sorting(query, opts) do
|
|
sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
|
|
sort_order = get_sort_order(sort_criteria)
|
|
order_by(query, [c], ^sort_order)
|
|
end
|
|
|
|
# Map of sort criteria to their corresponding sort orders
|
|
@sort_orders %{
|
|
"name_asc" => [asc: :name, asc: :id],
|
|
"name_desc" => [desc: :name, asc: :id],
|
|
"inserted_at_asc" => [asc: :inserted_at, asc: :id],
|
|
"inserted_at_desc" => [desc: :inserted_at, asc: :id],
|
|
"updated_at_asc" => [asc: :updated_at, asc: :id],
|
|
"updated_at_desc" => [desc: :updated_at, asc: :id],
|
|
"count_asc" => [asc: :count, asc: :id],
|
|
"count_desc" => [desc: :count, asc: :id]
|
|
}
|
|
|
|
defp get_sort_order(criteria) do
|
|
Map.get(@sort_orders, criteria, asc: :name, asc: :id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single component.
|
|
"""
|
|
def get_component!(id) do
|
|
Component
|
|
|> preload([:category, :storage_location])
|
|
|> Repo.get!(id)
|
|
end
|
|
|
|
@doc """
|
|
Creates a component.
|
|
"""
|
|
def create_component(attrs \\ %{}) do
|
|
%Component{}
|
|
|> Component.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Creates a component and downloads datasheet from URL if provided.
|
|
"""
|
|
def create_component_with_datasheet(attrs \\ %{}) do
|
|
# If a datasheet_url is provided, download it
|
|
updated_attrs =
|
|
case Map.get(attrs, "datasheet_url") do
|
|
url when is_binary(url) and url != "" ->
|
|
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
|
{:ok, filename} ->
|
|
Map.put(attrs, "datasheet_filename", filename)
|
|
|
|
{:error, _reason} ->
|
|
# Continue without datasheet file if download fails
|
|
attrs
|
|
end
|
|
|
|
_ ->
|
|
attrs
|
|
end
|
|
|
|
%Component{}
|
|
|> Component.changeset(updated_attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a component.
|
|
"""
|
|
def update_component(%Component{} = component, attrs) do
|
|
component
|
|
|> Component.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Updates a component and downloads datasheet from URL if provided.
|
|
"""
|
|
def update_component_with_datasheet(%Component{} = component, attrs) do
|
|
# If a datasheet_url is provided and changed, download it
|
|
updated_attrs =
|
|
case Map.get(attrs, "datasheet_url") do
|
|
url when is_binary(url) and url != "" and url != component.datasheet_url ->
|
|
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
|
{:ok, filename} ->
|
|
# Delete old datasheet file if it exists
|
|
if component.datasheet_filename do
|
|
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
|
|
component.datasheet_filename
|
|
)
|
|
end
|
|
|
|
Map.put(attrs, "datasheet_filename", filename)
|
|
|
|
{:error, _reason} ->
|
|
# Keep existing filename if download fails
|
|
attrs
|
|
end
|
|
|
|
_ ->
|
|
attrs
|
|
end
|
|
|
|
component
|
|
|> Component.changeset(updated_attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes a component.
|
|
"""
|
|
def delete_component(%Component{} = component) do
|
|
Repo.delete(component)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking component changes.
|
|
"""
|
|
def change_component(%Component{} = component, attrs \\ %{}) do
|
|
Component.changeset(component, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Returns inventory statistics.
|
|
"""
|
|
def get_inventory_stats do
|
|
total_components = Repo.aggregate(Component, :count, :id)
|
|
|
|
total_stock =
|
|
Component
|
|
|> Repo.aggregate(:sum, :count)
|
|
|
|
categories_with_components =
|
|
Component
|
|
|> distinct([c], c.category_id)
|
|
|> Repo.aggregate(:count, :category_id)
|
|
|
|
%{
|
|
total_components: total_components,
|
|
total_stock: total_stock,
|
|
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 """
|
|
Counts components in a specific storage location.
|
|
"""
|
|
def count_components_in_storage_location(storage_location_id) do
|
|
Component
|
|
|> where([c], c.storage_location_id == ^storage_location_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
|