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