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 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 processed_locations = compute_hierarchy_fields_batch(locations) |> Enum.sort_by(&{&1.level, &1.name}) # Ensure QR codes exist for all locations (in background) spawn(fn -> Enum.each(processed_locations, fn location -> ComponentsElixir.QRCode.get_qr_image_url(location) end) end) processed_locations 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) result = %StorageLocation{} |> StorageLocation.changeset(attrs) |> Repo.insert() case result do {:ok, location} -> # Automatically generate QR code image spawn(fn -> ComponentsElixir.QRCode.get_qr_image_url(location) end) {: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} -> # Automatically regenerate QR code image if name or hierarchy changed spawn(fn -> ComponentsElixir.QRCode.get_qr_image_url(updated_location, force_regenerate: true) end) {:ok, updated_location} error -> error end end @doc """ Deletes a storage location. """ def delete_storage_location(%StorageLocation{} = storage_location) do # Clean up QR code image before deleting ComponentsElixir.QRCode.cleanup_qr_image(storage_location.id) 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 @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 compute_path_for_single(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. """ def list_categories do Category |> preload(: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 ## Components @doc """ Returns the list of components. """ def list_components(opts \\ []) do Component |> apply_component_filters(opts) |> order_by([c], [asc: c.name]) |> 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) -> where(query, [c], c.category_id == ^category_id) {:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) -> where(query, [c], c.storage_location_id == ^storage_location_id) {: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) ) _, query -> query end) 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 """ Updates a component. """ def update_component(%Component{} = component, attrs) do component |> Component.changeset(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