feat(elixir): storage location system

This commit is contained in:
Schuwi
2025-09-14 15:20:25 +02:00
parent b9126c286f
commit 9d090859e8
14 changed files with 1517 additions and 160 deletions

View File

@@ -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

View File

@@ -8,18 +8,20 @@ defmodule ComponentsElixir.Inventory.Component do
use Ecto.Schema
import Ecto.Changeset
alias ComponentsElixir.Inventory.Category
alias ComponentsElixir.Inventory.{Category, StorageLocation}
schema "components" do
field :name, :string
field :description, :string
field :keywords, :string
field :position, :string
field :legacy_position, :string
field :count, :integer, default: 0
field :datasheet_url, :string
field :image_filename, :string
belongs_to :category, Category
belongs_to :storage_location, StorageLocation
timestamps()
end
@@ -27,7 +29,7 @@ defmodule ComponentsElixir.Inventory.Component do
@doc false
def changeset(component, attrs) do
component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id])
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|> validate_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000)

View File

@@ -0,0 +1,133 @@
defmodule ComponentsElixir.Inventory.StorageLocation do
@moduledoc """
Schema for storage locations with hierarchical organization.
Storage locations can be nested (shelf -> drawer -> box) and each
has a unique QR code for quick scanning and identification.
"""
use Ecto.Schema
import Ecto.Changeset
alias ComponentsElixir.Inventory.{StorageLocation, Component}
schema "storage_locations" do
field :name, :string
field :description, :string
field :qr_code, :string
field :is_active, :boolean, default: true
# Computed/virtual fields - not stored in database
field :level, :integer, virtual: true
field :path, :string, virtual: true
# Only parent relationship is stored
belongs_to :parent, StorageLocation
has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component
timestamps()
end
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :is_active])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500)
|> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_qr_code()
end
# Prevent circular references (location being its own ancestor)
defp validate_no_circular_reference(changeset) do
case get_change(changeset, :parent_id) do
nil -> changeset
parent_id ->
location_id = changeset.data.id
if location_id && would_create_cycle?(location_id, parent_id) do
add_error(changeset, :parent_id, "cannot be a descendant of this location")
else
changeset
end
end
end
defp would_create_cycle?(location_id, parent_id) do
# Check if parent_id is the same as location_id or any of its descendants
location_id == parent_id or
(parent_id && is_descendant_of?(parent_id, location_id))
end
defp is_descendant_of?(potential_descendant, ancestor_id) do
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
nil -> false
%{parent_id: nil} -> false
%{parent_id: ^ancestor_id} -> true
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
end
end
@doc """
Returns the full hierarchical path as a human-readable string.
"""
def full_path(storage_location) do
storage_location.path
|> String.split("/")
|> Enum.join("")
end
@doc """
Returns the QR code format for this storage location.
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
"""
def qr_format(storage_location, parent \\ nil) do
parent_code = if parent, do: parent.qr_code, else: "ROOT"
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
end
# Private functions for changeset processing
defp put_qr_code(changeset) do
case get_field(changeset, :qr_code) do
nil -> put_change(changeset, :qr_code, generate_qr_code())
_ -> changeset
end
end
# Compute the hierarchy level based on parent chain
def compute_level(%StorageLocation{parent_id: nil}), do: 0
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
compute_level(parent) + 1
end
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
compute_level(parent) + 1
end
# Compute the full path based on parent chain
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
"#{compute_path(parent)}/#{name}"
end
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
"#{compute_path(parent)}/#{name}"
end
defp generate_qr_code do
# Generate a unique 6-character alphanumeric code
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
1..6
|> Enum.map(fn _ ->
chars
|> String.graphemes()
|> Enum.random()
end)
|> Enum.join()
end
end

View File

@@ -0,0 +1,103 @@
defmodule ComponentsElixir.QRCode do
@moduledoc """
QR Code generation and parsing for storage locations.
Provides functionality to generate QR codes for storage locations
and parse them back to retrieve location information.
"""
@doc """
Generates a QR code data string for a storage location.
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123", parent: nil}
iex> ComponentsElixir.QRCode.generate_qr_data(location)
"SL:1:ABC123:ROOT"
iex> parent = %StorageLocation{qr_code: "SHELF1"}
iex> drawer = %StorageLocation{level: 2, qr_code: "DRAW01", parent: parent}
iex> ComponentsElixir.QRCode.generate_qr_data(drawer)
"SL:2:DRAW01:SHELF1"
"""
def generate_qr_data(storage_location) do
parent_code =
case storage_location.parent do
nil -> "ROOT"
parent -> parent.qr_code
end
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
end
@doc """
Parses a QR code string and extracts components.
## Examples
iex> ComponentsElixir.QRCode.parse_qr_data("SL:1:ABC123:ROOT")
{:ok, %{level: 1, code: "ABC123", parent: "ROOT"}}
iex> ComponentsElixir.QRCode.parse_qr_data("invalid")
{:error, :invalid_format}
"""
def parse_qr_data(qr_string) do
case String.split(qr_string, ":") do
["SL", level_str, code, parent] ->
case Integer.parse(level_str) do
{level, ""} ->
{:ok, %{level: level, code: code, parent: parent}}
_ ->
{:error, :invalid_level}
end
_ ->
{:error, :invalid_format}
end
end
@doc """
Validates if a string looks like a storage location QR code.
## Examples
iex> ComponentsElixir.QRCode.valid_storage_qr?("SL:1:ABC123:ROOT")
true
iex> ComponentsElixir.QRCode.valid_storage_qr?("COMP:12345")
false
"""
def valid_storage_qr?(qr_string) do
case parse_qr_data(qr_string) do
{:ok, _} -> true
_ -> false
end
end
@doc """
Generates a printable label data structure for a storage location.
This could be used to generate PDF labels or send to a label printer.
"""
def generate_label_data(storage_location) do
qr_data = generate_qr_data(storage_location)
%{
qr_code: qr_data,
name: storage_location.name,
path: storage_location.path,
level: storage_location.level,
description: storage_location.description
}
end
@doc """
Generates multiple QR codes for disambiguation testing.
This is useful for testing multi-QR detection scenarios.
"""
def generate_test_codes(storage_locations) when is_list(storage_locations) do
Enum.map(storage_locations, &generate_qr_data/1)
end
end