147 lines
4.7 KiB
Elixir
147 lines
4.7 KiB
Elixir
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
|
|
import Ecto.Query
|
|
|
|
alias ComponentsElixir.Inventory.{StorageLocation, Component}
|
|
|
|
schema "storage_locations" do
|
|
field :name, :string
|
|
field :description, :string
|
|
field :apriltag_id, :integer
|
|
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, :apriltag_id])
|
|
|> validate_required([:name])
|
|
|> validate_length(:name, min: 1, max: 100)
|
|
|> validate_length(:description, max: 500)
|
|
|> validate_apriltag_id()
|
|
|> foreign_key_constraint(:parent_id)
|
|
|> validate_no_circular_reference()
|
|
|> put_apriltag_id()
|
|
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 AprilTag format for this storage location.
|
|
Returns the AprilTag ID that corresponds to this location.
|
|
"""
|
|
def apriltag_format(storage_location) do
|
|
storage_location.apriltag_id
|
|
end
|
|
|
|
# Private functions for changeset processing
|
|
|
|
defp validate_apriltag_id(changeset) do
|
|
changeset
|
|
|> validate_number(:apriltag_id, greater_than_or_equal_to: 0, less_than_or_equal_to: 586)
|
|
|> unique_constraint(:apriltag_id, message: "AprilTag ID is already in use")
|
|
end
|
|
|
|
defp put_apriltag_id(changeset) do
|
|
case get_field(changeset, :apriltag_id) do
|
|
nil -> put_change(changeset, :apriltag_id, get_next_available_apriltag_id())
|
|
_ -> 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 get_next_available_apriltag_id do
|
|
# Get all used AprilTag IDs
|
|
used_ids = ComponentsElixir.Repo.all(
|
|
from sl in ComponentsElixir.Inventory.StorageLocation,
|
|
where: not is_nil(sl.apriltag_id),
|
|
select: sl.apriltag_id
|
|
)
|
|
|
|
# Find the first available ID (0-586)
|
|
0..586
|
|
|> Enum.find(&(&1 not in used_ids))
|
|
|> case do
|
|
nil ->
|
|
# All IDs are used - this should be handled at the application level
|
|
raise "All AprilTag IDs are in use"
|
|
id -> id
|
|
end
|
|
end
|
|
end
|