feat(elixir): storage location system
This commit is contained in:
133
lib/components_elixir/inventory/storage_location.ex
Normal file
133
lib/components_elixir/inventory/storage_location.ex
Normal 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
|
||||
Reference in New Issue
Block a user