refactor(elixir): hierarchical refactor
to extract common code patterns from category/storage location systems
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
# Hierarchical Implementation Analysis
|
||||
|
||||
## Overview
|
||||
This document analyzes the current hierarchical implementations in the codebase (Categories and Storage Locations) to determine the best approach for creating a shared `Hierarchical` behavior module.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Categories vs Storage Locations Feature Comparison
|
||||
|
||||
| Feature | Categories | Storage Locations | Assessment |
|
||||
|---------|------------|-------------------|------------|
|
||||
| **Path Resolution** | Simple recursive function in schema | Complex virtual fields + batch computation | **Categories wins** - cleaner approach |
|
||||
| **Cycle Prevention** | UI-level filtering (preventive) | Changeset validation (reactive) | **Categories wins** - more efficient |
|
||||
| **Hierarchical Display** | Recursive LiveView components | Recursive LiveView components | **Tie** - nearly identical |
|
||||
| **Parent Selection** | Smart dropdown filtering | Smart dropdown filtering | **Tie** - identical logic |
|
||||
| **Performance** | O(depth) recursive calls | O(n) batch computation + DB queries | **Categories wins** - simpler |
|
||||
| **Code Complexity** | ~50 lines of hierarchy logic | ~100+ lines of hierarchy logic | **Categories wins** - more maintainable |
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Vestigial `is_active` Field Removed
|
||||
- **Status**: ✅ **REMOVED**
|
||||
- **Impact**: Field was completely unused across the entire codebase
|
||||
- **Files Modified**:
|
||||
- `lib/components_elixir/inventory/storage_location.ex` (schema and changeset)
|
||||
- Migration created: `20250917210658_remove_is_active_from_storage_locations.exs`
|
||||
|
||||
### 2. Categories Implementation is Superior
|
||||
|
||||
#### **Path Resolution Approach**
|
||||
**Categories**: Elegant recursive function in schema
|
||||
```elixir
|
||||
def full_path(%Category{parent: nil} = category), do: category.name
|
||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||
"#{full_path(parent)} > #{category.name}"
|
||||
end
|
||||
```
|
||||
|
||||
**Storage Locations**: Over-engineered with virtual fields
|
||||
```elixir
|
||||
# Virtual fields in schema
|
||||
field :level, :integer, virtual: true
|
||||
field :path, :string, virtual: true
|
||||
|
||||
# Complex batch computation in context
|
||||
def compute_hierarchy_fields_batch(locations)
|
||||
def compute_level_for_single(location)
|
||||
def compute_path_for_single(location)
|
||||
```
|
||||
|
||||
#### **Cycle Prevention Strategy**
|
||||
**Categories**: **Prevention at UI level** (efficient)
|
||||
```elixir
|
||||
# Prevents invalid options from appearing in dropdown
|
||||
|> Enum.reject(fn cat ->
|
||||
cat.id == editing_category_id ||
|
||||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
|
||||
end)
|
||||
```
|
||||
|
||||
**Storage Locations**: **Validation at changeset level** (reactive)
|
||||
```elixir
|
||||
# Validates after user attempts invalid selection
|
||||
defp validate_no_circular_reference(changeset) do
|
||||
# Complex validation logic that runs on every save attempt
|
||||
end
|
||||
```
|
||||
|
||||
### 3. Shared Patterns Identified
|
||||
|
||||
Both systems implement identical patterns for:
|
||||
- **Hierarchical tree display** with recursive LiveView components
|
||||
- **Parent/child relationship filtering**
|
||||
- **Descendant detection algorithms**
|
||||
- **Root entity identification**
|
||||
- **UI depth-based styling and icons**
|
||||
|
||||
## Architecture Decision
|
||||
|
||||
### Recommended Approach: **Category-Style Hierarchical Module**
|
||||
|
||||
The category implementation should be the template for generalization because:
|
||||
|
||||
1. **Simpler**: No virtual fields or complex batch operations
|
||||
2. **More Performant**: O(depth) vs O(n) complexity
|
||||
3. **Preventive**: UI-level cycle prevention vs reactive validation
|
||||
4. **Maintainable**: Half the lines of code with same functionality
|
||||
|
||||
### AprilTag Features Remain Storage-Specific
|
||||
|
||||
The AprilTag system should **NOT** be generalized because:
|
||||
- Physical identification is meaningless for categories
|
||||
- Future scanning/detection features are location-specific
|
||||
- Keeps domain separation clean
|
||||
|
||||
## Implementation Complexity Comparison
|
||||
|
||||
### Categories (Simple & Clean)
|
||||
```
|
||||
Files: 1 schema + 1 LiveView = 2 files
|
||||
Lines: ~50 lines hierarchical logic
|
||||
Approach: Functional, recursive, preventive
|
||||
Dependencies: Standard Ecto associations
|
||||
```
|
||||
|
||||
### Storage Locations (Over-Engineered)
|
||||
```
|
||||
Files: 1 schema + 1 context helper + 1 LiveView = 3 files
|
||||
Lines: ~100+ lines hierarchical logic
|
||||
Approach: Stateful, batch processing, reactive
|
||||
Dependencies: Virtual fields + custom computation
|
||||
```
|
||||
|
||||
## Performance Analysis
|
||||
|
||||
### Path Resolution Performance
|
||||
- **Categories**: `O(depth)` - traverses only parent chain
|
||||
- **Storage Locations**: `O(n)` - processes all entities for batch computation
|
||||
|
||||
### Memory Usage
|
||||
- **Categories**: Minimal - uses existing associations
|
||||
- **Storage Locations**: Higher - virtual fields + intermediate computations
|
||||
|
||||
### Database Queries
|
||||
- **Categories**: Standard association preloading
|
||||
- **Storage Locations**: Additional queries for path/level computation
|
||||
|
||||
## Code Quality Assessment
|
||||
|
||||
### Categories Strengths
|
||||
✅ **Single Responsibility**: Each function does one thing
|
||||
✅ **Functional Style**: Pure functions, no side effects
|
||||
✅ **Standard Patterns**: Uses established Ecto association patterns
|
||||
✅ **Easy Testing**: Simple recursive functions
|
||||
✅ **Performance**: Minimal computational overhead
|
||||
|
||||
### Storage Locations Issues
|
||||
❌ **Multiple Responsibilities**: Virtual fields + validation + computation
|
||||
❌ **Complex State**: Virtual fields require careful management
|
||||
❌ **Custom Patterns**: Non-standard Ecto usage
|
||||
❌ **Hard Testing**: Complex batch operations
|
||||
❌ **Performance**: Unnecessary computational overhead
|
||||
|
||||
## Conclusion
|
||||
|
||||
The **categories implementation is objectively superior** and should guide the refactoring:
|
||||
|
||||
1. **Simpler code** (50% fewer lines)
|
||||
2. **Better performance** (O(depth) vs O(n))
|
||||
3. **More maintainable** (functional vs stateful)
|
||||
4. **Standard patterns** (Ecto associations vs virtual fields)
|
||||
5. **Preventive design** (UI filtering vs changeset validation)
|
||||
|
||||
The storage locations system should be refactored to match the categories approach, eliminating virtual fields and complex batch computations in favor of simple recursive functions.
|
||||
@@ -1,370 +0,0 @@
|
||||
# Hierarchical Refactoring Plan
|
||||
|
||||
## Overview
|
||||
This document outlines the step-by-step plan to extract common hierarchical behavior from Categories and Storage Locations into a shared `Hierarchical` module, based on the superior category implementation approach.
|
||||
|
||||
## Goals
|
||||
1. **Extract shared hierarchical patterns** into reusable modules
|
||||
2. **Simplify storage locations** to match category elegance
|
||||
3. **Maintain AprilTag-specific features** for storage locations
|
||||
4. **Preserve all existing functionality** during refactor
|
||||
5. **Improve performance and maintainability**
|
||||
|
||||
## Refactoring Strategy
|
||||
|
||||
### Phase 1: Extract Hierarchical Behavior Module ⏭️ **NEXT**
|
||||
Create shared behavior module based on category patterns
|
||||
|
||||
### Phase 2: Refactor Storage Locations 🔄 **FOLLOW-UP**
|
||||
Simplify storage locations to use new shared module
|
||||
|
||||
### Phase 3: Refactor Categories 🔄 **FOLLOW-UP**
|
||||
Update categories to use shared module
|
||||
|
||||
### Phase 4: Extract LiveView Components 🔄 **FOLLOW-UP**
|
||||
Create reusable UI components for hierarchical display
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Extract Hierarchical Behavior Module
|
||||
|
||||
### 1.1 Create Core Hierarchical Module
|
||||
|
||||
**File**: `lib/components_elixir/inventory/hierarchical.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
@moduledoc """
|
||||
Shared hierarchical behavior for entities with parent-child relationships.
|
||||
|
||||
This module provides common functionality for:
|
||||
- Path computation (e.g., "Parent > Child > Grandchild")
|
||||
- Cycle detection and prevention
|
||||
- Parent/child filtering for UI dropdowns
|
||||
- Tree traversal utilities
|
||||
|
||||
Based on the elegant category implementation approach.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Computes full hierarchical path for an entity.
|
||||
Uses recursive traversal of parent chain.
|
||||
|
||||
## Examples
|
||||
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
|
||||
iex> Hierarchical.full_path(category, &(&1.parent))
|
||||
"Electronics > Resistors"
|
||||
"""
|
||||
def full_path(entity, parent_accessor_fn, separator \\ " > ")
|
||||
|
||||
@doc """
|
||||
Filters entities to remove circular reference options for parent selection.
|
||||
Prevents an entity from being its own ancestor.
|
||||
|
||||
## Examples
|
||||
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
|
||||
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
|
||||
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
|
||||
"""
|
||||
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
|
||||
|
||||
@doc """
|
||||
Checks if an entity is a descendant of an ancestor entity.
|
||||
Used for cycle detection in parent selection.
|
||||
"""
|
||||
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn)
|
||||
|
||||
@doc """
|
||||
Gets all root entities (entities with no parent).
|
||||
"""
|
||||
def root_entities(entities, parent_id_accessor_fn)
|
||||
|
||||
@doc """
|
||||
Gets all child entities of a specific parent.
|
||||
"""
|
||||
def child_entities(entities, parent_id, parent_id_accessor_fn)
|
||||
|
||||
@doc """
|
||||
Generates display name for entity including parent context.
|
||||
For dropdown displays: "Parent > Child"
|
||||
"""
|
||||
def display_name(entity, parent_accessor_fn, separator \\ " > ")
|
||||
end
|
||||
```
|
||||
|
||||
### 1.2 Schema Behaviour Definition
|
||||
|
||||
**File**: `lib/components_elixir/inventory/hierarchical_schema.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
|
||||
@moduledoc """
|
||||
Behaviour for schemas that implement hierarchical relationships.
|
||||
|
||||
Provides a contract for entities with parent-child relationships.
|
||||
"""
|
||||
|
||||
@callback full_path(struct()) :: String.t()
|
||||
@callback parent(struct()) :: struct() | nil
|
||||
@callback children(struct()) :: [struct()]
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import ComponentsElixir.Inventory.Hierarchical
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 1.3 Update Category Schema
|
||||
|
||||
**File**: `lib/components_elixir/inventory/category.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ComponentsElixir.Inventory.Category do
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
# ... existing schema definition
|
||||
|
||||
@impl true
|
||||
def full_path(%Category{} = category) do
|
||||
ComponentsElixir.Inventory.Hierarchical.full_path(category, &(&1.parent))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def parent(%Category{parent: parent}), do: parent
|
||||
|
||||
@impl true
|
||||
def children(%Category{children: children}), do: children
|
||||
end
|
||||
```
|
||||
|
||||
## Phase 2: Refactor Storage Locations
|
||||
|
||||
### 2.1 Simplify Storage Location Schema
|
||||
|
||||
**Changes to**: `lib/components_elixir/inventory/storage_location.ex`
|
||||
|
||||
1. **Remove virtual fields**: `level` and `path`
|
||||
2. **Add category-style path function**
|
||||
3. **Remove complex cycle validation**
|
||||
4. **Keep AprilTag-specific features**
|
||||
|
||||
```elixir
|
||||
defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
# ... existing schema without virtual fields
|
||||
|
||||
@impl true
|
||||
def full_path(%StorageLocation{} = location) do
|
||||
ComponentsElixir.Inventory.Hierarchical.full_path(location, &(&1.parent), " / ")
|
||||
end
|
||||
|
||||
# Keep AprilTag-specific functionality
|
||||
def apriltag_format(storage_location), do: storage_location.apriltag_id
|
||||
end
|
||||
```
|
||||
|
||||
### 2.2 Simplify Storage Location Changeset
|
||||
|
||||
**Remove**:
|
||||
- `validate_no_circular_reference/1` function
|
||||
- Virtual field handling
|
||||
- Complex cycle detection
|
||||
|
||||
**Keep**:
|
||||
- AprilTag validation
|
||||
- Basic field validation
|
||||
|
||||
### 2.3 Update Inventory Context
|
||||
|
||||
**Changes to**: `lib/components_elixir/inventory.ex`
|
||||
|
||||
**Remove**:
|
||||
- `compute_hierarchy_fields_batch/1`
|
||||
- `compute_level_for_single/1`
|
||||
- `compute_path_for_single/1`
|
||||
- All virtual field computation
|
||||
|
||||
**Simplify**:
|
||||
- `list_storage_locations/0` - use standard preloading
|
||||
- `get_storage_location!/1` - remove virtual field computation
|
||||
|
||||
## Phase 3: Refactor Categories
|
||||
|
||||
### 3.1 Update Categories to Use Shared Module
|
||||
|
||||
**Changes to**: `lib/components_elixir/inventory/category.ex`
|
||||
|
||||
Replace existing `full_path/1` with call to shared module.
|
||||
|
||||
### 3.2 Update Categories LiveView
|
||||
|
||||
**Changes to**: `lib/components_elixir_web/live/categories_live.ex`
|
||||
|
||||
Replace custom hierarchy functions with shared module calls:
|
||||
|
||||
```elixir
|
||||
# Replace custom functions with shared module
|
||||
defp parent_category_options(categories, editing_category_id \\ nil) do
|
||||
available_categories =
|
||||
Hierarchical.filter_parent_options(
|
||||
categories,
|
||||
editing_category_id,
|
||||
&(&1.id),
|
||||
&(&1.parent_id)
|
||||
)
|
||||
|> Enum.map(fn category ->
|
||||
{Hierarchical.display_name(category, &(&1.parent)), category.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root category)", nil}] ++ available_categories
|
||||
end
|
||||
```
|
||||
|
||||
## Phase 4: Extract LiveView Components
|
||||
|
||||
### 4.1 Create Hierarchical LiveView Components
|
||||
|
||||
**File**: `lib/components_elixir_web/live/hierarchical_components.ex`
|
||||
|
||||
```elixir
|
||||
defmodule ComponentsElixirWeb.HierarchicalComponents do
|
||||
use Phoenix.Component
|
||||
alias ComponentsElixir.Inventory.Hierarchical
|
||||
|
||||
@doc """
|
||||
Renders a hierarchical tree of entities with depth-based styling.
|
||||
"""
|
||||
def hierarchy_tree(assigns)
|
||||
|
||||
@doc """
|
||||
Renders an individual item in a hierarchical tree.
|
||||
"""
|
||||
def hierarchy_item(assigns)
|
||||
|
||||
@doc """
|
||||
Renders a parent selection dropdown with cycle prevention.
|
||||
"""
|
||||
def parent_select(assigns)
|
||||
end
|
||||
```
|
||||
|
||||
### 4.2 Update LiveViews to Use Shared Components
|
||||
|
||||
Both `CategoriesLive` and `StorageLocationsLive` can use the shared components, with custom slots for entity-specific content (like AprilTag display).
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Immediate (Phase 1) - 1-2 hours
|
||||
- [ ] Create `Hierarchical` module with core functions
|
||||
- [ ] Create `HierarchicalSchema` behaviour
|
||||
- [ ] Write comprehensive tests for shared module
|
||||
|
||||
### Short-term (Phase 2) - 2-3 hours
|
||||
- [ ] Refactor storage location schema to remove virtual fields
|
||||
- [ ] Simplify storage location changeset
|
||||
- [ ] Update inventory context to remove batch computation
|
||||
- [ ] Test storage location functionality
|
||||
|
||||
### Medium-term (Phase 3) - 1-2 hours
|
||||
- [ ] Update category schema to use shared module
|
||||
- [ ] Update categories LiveView to use shared functions
|
||||
- [ ] Test category functionality
|
||||
|
||||
### Long-term (Phase 4) - 2-3 hours
|
||||
- [ ] Create shared LiveView components
|
||||
- [ ] Refactor both LiveViews to use shared components
|
||||
- [ ] Add entity-specific customization slots
|
||||
- [ ] Comprehensive integration testing
|
||||
|
||||
## Benefits After Refactoring
|
||||
|
||||
### Code Quality
|
||||
- **50% reduction** in hierarchical logic duplication
|
||||
- **Consistent patterns** across both entity types
|
||||
- **Easier testing** with isolated, pure functions
|
||||
- **Better maintainability** with single source of truth
|
||||
|
||||
### Performance
|
||||
- **Elimination of virtual fields** reduces memory usage
|
||||
- **Remove batch computation** improves response times
|
||||
- **Standard Ecto patterns** optimize database queries
|
||||
- **UI-level cycle prevention** reduces validation overhead
|
||||
|
||||
### Developer Experience
|
||||
- **Shared components** speed up new hierarchical entity development
|
||||
- **Consistent API** reduces learning curve
|
||||
- **Better documentation** with centralized behavior
|
||||
- **Easier debugging** with simplified call stacks
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Database Migration Safety
|
||||
1. **Backup database** before running migrations
|
||||
2. **Test migrations** on development environment first
|
||||
3. **Incremental approach** - one table at a time
|
||||
|
||||
### Functionality Preservation
|
||||
1. **Comprehensive test coverage** before refactoring
|
||||
2. **Feature parity testing** after each phase
|
||||
3. **AprilTag functionality isolation** to prevent interference
|
||||
|
||||
### Rollback Plan
|
||||
1. **Git branching strategy** for each phase
|
||||
2. **Database migration rollbacks** prepared
|
||||
3. **Quick revert capability** if issues discovered
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- [ ] Test all `Hierarchical` module functions
|
||||
- [ ] Test schema `full_path/1` implementations
|
||||
- [ ] Test cycle detection edge cases
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Test LiveView parent selection dropdowns
|
||||
- [ ] Test hierarchical tree rendering
|
||||
- [ ] Test AprilTag functionality preservation
|
||||
|
||||
### Performance Tests
|
||||
- [ ] Benchmark path computation performance
|
||||
- [ ] Measure memory usage before/after
|
||||
- [ ] Profile database query patterns
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Database Changes Required
|
||||
1. **Remove `is_active` column** from storage_locations (✅ **COMPLETED**)
|
||||
2. **No other database changes** needed for this refactor
|
||||
|
||||
### Deployment Considerations
|
||||
- **Zero downtime**: Refactor is code-only (except `is_active` removal)
|
||||
- **Backward compatible**: No API changes
|
||||
- **Incremental deployment**: Can deploy phase by phase
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional
|
||||
- [ ] All existing category functionality preserved
|
||||
- [ ] All existing storage location functionality preserved
|
||||
- [ ] AprilTag features remain storage-location specific
|
||||
- [ ] UI behavior identical to current implementation
|
||||
|
||||
### Non-Functional
|
||||
- [ ] Code duplication reduced by ≥50%
|
||||
- [ ] Performance maintained or improved
|
||||
- [ ] Test coverage ≥95% for shared modules
|
||||
- [ ] Documentation complete for new modules
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this plan** with team/stakeholders
|
||||
2. **Create feature branch** for refactoring work
|
||||
3. **Begin Phase 1** - Extract hierarchical behavior module
|
||||
4. **Write comprehensive tests** before any refactoring
|
||||
5. **Execute phases incrementally** with testing between each
|
||||
|
||||
This refactoring will significantly improve code quality and maintainability while preserving all existing functionality and preparing the codebase for future hierarchical entities.
|
||||
@@ -1,378 +0,0 @@
|
||||
# QR Code Storage Location System Design
|
||||
|
||||
## Overview
|
||||
Implement a hierarchical storage location system with QR code generation and scanning capabilities to enable quick component location entry and filtering.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### 1. Storage Locations Table
|
||||
```sql
|
||||
CREATE TABLE storage_locations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
qr_code VARCHAR(100) UNIQUE NOT NULL,
|
||||
parent_id INTEGER REFERENCES storage_locations(id),
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT NOT NULL, -- Materialized path: "shelf1/drawer2/box3"
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_storage_locations_parent_id ON storage_locations(parent_id);
|
||||
CREATE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
|
||||
CREATE INDEX idx_storage_locations_path ON storage_locations USING gin(path gin_trgm_ops);
|
||||
CREATE UNIQUE INDEX idx_storage_locations_name_parent ON storage_locations(name, parent_id);
|
||||
```
|
||||
|
||||
### 2. Modified Components Table
|
||||
```sql
|
||||
-- Migration to add storage_location_id to components
|
||||
ALTER TABLE components
|
||||
ADD COLUMN storage_location_id INTEGER REFERENCES storage_locations(id),
|
||||
ADD COLUMN legacy_position VARCHAR(255); -- Keep old position data for migration
|
||||
|
||||
-- Move existing position data to legacy_position
|
||||
UPDATE components SET legacy_position = position;
|
||||
```
|
||||
|
||||
## QR Code Format Design
|
||||
|
||||
### Hierarchical QR Code Strategy
|
||||
To avoid confusion with multiple QR codes in the same image, use a hierarchical encoding strategy:
|
||||
|
||||
```
|
||||
Format: SL:{level}:{unique_id}:{parent_path_hash}
|
||||
Examples:
|
||||
- Shelf: "SL:1:ABC123:ROOT"
|
||||
- Drawer: "SL:2:DEF456:ABC123"
|
||||
- Box: "SL:3:GHI789:DEF456"
|
||||
```
|
||||
|
||||
### QR Code Components:
|
||||
- **SL**: Storage Location prefix
|
||||
- **Level**: Hierarchy level (1=shelf, 2=drawer, 3=box, etc.)
|
||||
- **Unique ID**: Short alphanumeric code (6-8 chars)
|
||||
- **Parent Hash**: Reference to parent location
|
||||
|
||||
## Multi-QR Code Detection Strategy
|
||||
|
||||
### 1. Spatial Filtering
|
||||
```
|
||||
When multiple QR codes detected:
|
||||
1. Calculate distance between codes
|
||||
2. If distance < threshold:
|
||||
- Prefer higher hierarchy level (lower number)
|
||||
- Present disambiguation UI
|
||||
3. If distance > threshold:
|
||||
- Allow user to tap/select desired code
|
||||
```
|
||||
|
||||
### 2. Context-Aware Selection
|
||||
```
|
||||
Selection Priority:
|
||||
1. Exact level match (if user scanning for specific level)
|
||||
2. Deepest level in hierarchy (most specific location)
|
||||
3. Recently used locations (user preference learning)
|
||||
4. Manual disambiguation prompt
|
||||
```
|
||||
|
||||
### 3. Visual Feedback
|
||||
```
|
||||
Camera Overlay:
|
||||
- Draw bounding boxes around each detected QR code
|
||||
- Color-code by hierarchy level
|
||||
- Show location path preview on hover/tap
|
||||
- Highlight "best match" with different color
|
||||
```
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### 1. Elixir Modules
|
||||
|
||||
#### Storage Location Schema
|
||||
```elixir
|
||||
defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "storage_locations" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :qr_code, :string
|
||||
field :level, :integer, default: 0
|
||||
field :path, :string
|
||||
field :is_active, :boolean, default: true
|
||||
|
||||
belongs_to :parent, __MODULE__
|
||||
has_many :children, __MODULE__, foreign_key: :parent_id
|
||||
has_many :components, Component
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### QR Code Generation
|
||||
```elixir
|
||||
defmodule ComponentsElixir.QRCode do
|
||||
def generate_storage_qr(location) do
|
||||
qr_data = "SL:#{location.level}:#{location.qr_code}:#{parent_hash(location)}"
|
||||
|
||||
# Use :qr_code library to generate QR image
|
||||
:qr_code.encode(qr_data)
|
||||
|> :qr_code.png()
|
||||
end
|
||||
|
||||
def parse_storage_qr(qr_string) do
|
||||
case String.split(qr_string, ":") do
|
||||
["SL", level, code, parent] ->
|
||||
{:ok, %{level: level, code: code, parent: parent}}
|
||||
_ ->
|
||||
{:error, :invalid_format}
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2. Phoenix LiveView Components
|
||||
|
||||
#### QR Scanner Component
|
||||
```elixir
|
||||
defmodule ComponentsElixirWeb.QRScannerLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:scanning, false)
|
||||
|> assign(:detected_codes, [])
|
||||
|> assign(:selected_location, nil)
|
||||
|> allow_upload(:qr_scan,
|
||||
accept: ~w(.jpg .jpeg .png),
|
||||
max_entries: 1,
|
||||
auto_upload: true)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event("start_scan", _, socket) do
|
||||
{:noreply, assign(socket, :scanning, true)}
|
||||
end
|
||||
|
||||
def handle_event("qr_detected", %{"codes" => codes}, socket) do
|
||||
parsed_codes = Enum.map(codes, &parse_and_resolve_location/1)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:detected_codes, parsed_codes)
|
||||
|> maybe_auto_select_location(parsed_codes)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp maybe_auto_select_location(socket, [single_code]) do
|
||||
assign(socket, :selected_location, single_code)
|
||||
end
|
||||
|
||||
defp maybe_auto_select_location(socket, multiple_codes) do
|
||||
# Show disambiguation UI
|
||||
assign(socket, :selected_location, nil)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 3. JavaScript QR Detection
|
||||
|
||||
#### Camera Integration
|
||||
```javascript
|
||||
// assets/js/qr_scanner.js
|
||||
import jsQR from "jsqr";
|
||||
|
||||
export const QRScanner = {
|
||||
mounted() {
|
||||
this.video = this.el.querySelector('video');
|
||||
this.canvas = this.el.querySelector('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
|
||||
this.startCamera();
|
||||
this.scanLoop();
|
||||
},
|
||||
|
||||
async startCamera() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment', // Use back camera
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
});
|
||||
this.video.srcObject = stream;
|
||||
} catch (err) {
|
||||
console.error('Camera access denied:', err);
|
||||
}
|
||||
},
|
||||
|
||||
scanLoop() {
|
||||
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
|
||||
this.canvas.width = this.video.videoWidth;
|
||||
this.canvas.height = this.video.videoHeight;
|
||||
|
||||
this.context.drawImage(this.video, 0, 0);
|
||||
const imageData = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Detect multiple QR codes
|
||||
const codes = this.detectMultipleQRCodes(imageData);
|
||||
|
||||
if (codes.length > 0) {
|
||||
this.pushEvent("qr_detected", { codes: codes });
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.scanLoop());
|
||||
},
|
||||
|
||||
detectMultipleQRCodes(imageData) {
|
||||
// Implementation for detecting multiple QR codes
|
||||
// This is a simplified version - you'd need a more robust library
|
||||
const detected = [];
|
||||
|
||||
// Scan in grid pattern to find multiple codes
|
||||
const gridSize = 4;
|
||||
const width = imageData.width / gridSize;
|
||||
const height = imageData.height / gridSize;
|
||||
|
||||
for (let x = 0; x < gridSize; x++) {
|
||||
for (let y = 0; y < gridSize; y++) {
|
||||
const subImageData = this.getSubImageData(
|
||||
imageData,
|
||||
x * width,
|
||||
y * height,
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
const code = jsQR(subImageData.data, subImageData.width, subImageData.height);
|
||||
if (code && this.isStorageLocationQR(code.data)) {
|
||||
detected.push({
|
||||
data: code.data,
|
||||
location: { x: x * width, y: y * height },
|
||||
corners: code.location
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.filterDuplicates(detected);
|
||||
},
|
||||
|
||||
isStorageLocationQR(data) {
|
||||
return data.startsWith('SL:');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## User Experience Flow
|
||||
|
||||
### 1. Adding Components with QR Scan
|
||||
```
|
||||
1. User clicks "Add Component"
|
||||
2. Position field shows camera icon
|
||||
3. Click camera → QR scanner opens
|
||||
4. Scan storage location QR code
|
||||
5. If multiple codes detected:
|
||||
- Show overlay with detected locations
|
||||
- User taps to select specific location
|
||||
6. Location path auto-filled: "Shelf A → Drawer 2 → Box 5"
|
||||
7. Component saved with storage_location_id
|
||||
```
|
||||
|
||||
### 2. Filtering by Storage Location
|
||||
```
|
||||
1. Component list shows location filter dropdown
|
||||
2. Filter options show hierarchical tree:
|
||||
├── Shelf A
|
||||
│ ├── Drawer 1
|
||||
│ │ ├── Box 1
|
||||
│ │ └── Box 2
|
||||
│ └── Drawer 2
|
||||
└── Shelf B
|
||||
3. Select any level to filter components
|
||||
4. Breadcrumb shows: "Shelf A → Drawer 2" (23 components)
|
||||
```
|
||||
|
||||
### 3. Location Management
|
||||
```
|
||||
1. New "Storage Locations" section in admin
|
||||
2. Add/edit locations with auto QR generation
|
||||
3. Print QR labels with location hierarchy
|
||||
4. Bulk QR code generation for initial setup
|
||||
```
|
||||
|
||||
## Handling Multiple QR Codes in Same Image
|
||||
|
||||
### Strategy 1: Spatial Separation
|
||||
- Calculate euclidean distance between QR code centers
|
||||
- If distance < 100px → show disambiguation
|
||||
- If distance > 100px → allow selection by tap
|
||||
|
||||
### Strategy 2: Hierarchy Preference
|
||||
- Always prefer deepest level (most specific)
|
||||
- If same level → show all options
|
||||
- Color-code by hierarchy level in UI
|
||||
|
||||
### Strategy 3: Machine Learning (Future)
|
||||
- Learn user selection patterns
|
||||
- Predict most likely intended QR code
|
||||
- Still allow manual override
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Add Storage Locations
|
||||
1. Create migration for storage_locations table
|
||||
2. Add storage_location_id to components
|
||||
3. Create admin interface for location management
|
||||
|
||||
### Phase 2: QR Code Generation
|
||||
1. Add QR code generation to location creation
|
||||
2. Implement QR code printing/export functionality
|
||||
3. Generate codes for existing locations
|
||||
|
||||
### Phase 3: QR Code Scanning
|
||||
1. Add camera permissions and JavaScript QR scanner
|
||||
2. Implement single QR code detection first
|
||||
3. Add multi-QR detection and disambiguation
|
||||
|
||||
### Phase 4: Advanced Features
|
||||
1. Location-based filtering and search
|
||||
2. Bulk operations by location
|
||||
3. Location analytics and optimization
|
||||
|
||||
## Technical Dependencies
|
||||
|
||||
### Elixir Dependencies
|
||||
```elixir
|
||||
# mix.exs
|
||||
{:qr_code, "~> 3.1"}, # QR code generation
|
||||
{:image, "~> 0.37"}, # Image processing
|
||||
{:ex_image_info, "~> 0.2.4"} # Image metadata
|
||||
```
|
||||
|
||||
### JavaScript Dependencies
|
||||
???
|
||||
|
||||
## Database Indexes for Performance
|
||||
```sql
|
||||
-- Fast location lookups
|
||||
CREATE INDEX idx_components_storage_location_id ON components(storage_location_id);
|
||||
|
||||
-- Hierarchical queries
|
||||
CREATE INDEX idx_storage_locations_path_gin ON storage_locations USING gin(path gin_trgm_ops);
|
||||
|
||||
-- QR code uniqueness and fast lookup
|
||||
CREATE UNIQUE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
|
||||
```
|
||||
|
||||
This design provides a robust foundation for QR code-based storage management while handling the complexity of multiple codes in the same image through spatial analysis and user interaction patterns.
|
||||
@@ -11,73 +11,25 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Storage Locations
|
||||
|
||||
@doc """
|
||||
Returns the list of storage locations with computed hierarchy fields.
|
||||
Returns the list of storage locations with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
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)
|
||||
|> preload([parent: [parent: [parent: [parent: :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 AprilTag SVGs exist for all locations
|
||||
spawn(fn ->
|
||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
||||
end)
|
||||
|
||||
processed_locations
|
||||
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
|
||||
# Orphaned record
|
||||
nil -> 0
|
||||
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent infinite recursion
|
||||
defp compute_level_efficient(_location, _location_map, _depth), do: 0
|
||||
|
||||
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
|
||||
# Orphaned record
|
||||
nil ->
|
||||
name
|
||||
|
||||
parent ->
|
||||
parent_path = compute_path_efficient(parent, location_map, depth + 1)
|
||||
"#{parent_path}/#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
# Prevent infinite recursion
|
||||
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name
|
||||
|
||||
@doc """
|
||||
Returns the list of root storage locations (no parent).
|
||||
"""
|
||||
@@ -89,37 +41,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single storage location with computed hierarchy fields.
|
||||
Gets a single storage location with preloaded associations.
|
||||
"""
|
||||
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
|
||||
StorageLocation
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -130,15 +57,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
||||
|> 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 """
|
||||
@@ -221,7 +139,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
def compute_storage_location_path(nil), do: nil
|
||||
|
||||
def compute_storage_location_path(%StorageLocation{} = location) do
|
||||
compute_path_for_single(location)
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
# Convert string keys to atoms for consistency
|
||||
@@ -239,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Categories
|
||||
|
||||
@doc """
|
||||
Returns the list of categories.
|
||||
Returns the list of categories with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> preload(:parent)
|
||||
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
Categories can be hierarchical with parent-child relationships.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
@doc """
|
||||
Returns the full path of the category including parent names.
|
||||
"""
|
||||
def full_path(%Category{parent: nil} = category), do: category.name
|
||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||
"#{full_path(parent)} > #{category.name}"
|
||||
end
|
||||
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
||||
category.name
|
||||
@impl true
|
||||
def full_path(%Category{} = category) do
|
||||
Hierarchical.full_path(category, &(&1.parent), path_separator())
|
||||
end
|
||||
|
||||
@impl true
|
||||
def parent(%Category{parent: parent}), do: parent
|
||||
|
||||
@impl true
|
||||
def children(%Category{children: children}), do: children
|
||||
|
||||
@impl true
|
||||
def path_separator(), do: " > "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :category
|
||||
end
|
||||
|
||||
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
@@ -0,0 +1,252 @@
|
||||
defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
@moduledoc """
|
||||
Shared hierarchical behavior for entities with parent-child relationships.
|
||||
|
||||
This module provides common functionality for:
|
||||
- Path computation (e.g., "Parent > Child > Grandchild")
|
||||
- Cycle detection and prevention
|
||||
- Parent/child filtering for UI dropdowns
|
||||
- Tree traversal utilities
|
||||
|
||||
Based on the elegant category implementation approach.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Computes full hierarchical path for an entity.
|
||||
Uses recursive traversal of parent chain, loading parents from database if needed.
|
||||
Optimized to minimize database queries by trying preloaded associations first.
|
||||
|
||||
## Examples
|
||||
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
|
||||
iex> Hierarchical.full_path(category, &(&1.parent))
|
||||
"Electronics > Resistors"
|
||||
"""
|
||||
def full_path(entity, parent_accessor_fn, separator \\ " > ")
|
||||
|
||||
def full_path(nil, _parent_accessor_fn, _separator), do: ""
|
||||
|
||||
def full_path(entity, parent_accessor_fn, separator) do
|
||||
case parent_accessor_fn.(entity) do
|
||||
nil ->
|
||||
entity.name
|
||||
%Ecto.Association.NotLoaded{} ->
|
||||
# Parent not loaded - fall back to database lookup
|
||||
# This is a fallback and should be rare if preloading is done correctly
|
||||
build_path_with_db_lookup(entity, separator)
|
||||
parent ->
|
||||
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to build path when parent associations are not loaded
|
||||
# This is optimized to minimize database queries
|
||||
defp build_path_with_db_lookup(entity, separator) do
|
||||
# Build path by walking up the parent chain via database queries
|
||||
# Collect parent names from root to leaf
|
||||
path_parts = collect_path_from_root(entity, [])
|
||||
Enum.join(path_parts, separator)
|
||||
end
|
||||
|
||||
defp collect_path_from_root(entity, path_so_far) do
|
||||
case entity.parent_id do
|
||||
nil ->
|
||||
# This is a root entity, add its name and return the complete path
|
||||
[entity.name | path_so_far]
|
||||
parent_id ->
|
||||
# Load parent from database
|
||||
case load_parent_entity(entity, parent_id) do
|
||||
nil ->
|
||||
# Parent not found (orphaned record), treat this as root
|
||||
[entity.name | path_so_far]
|
||||
parent ->
|
||||
# Recursively get the path from the parent, then add current entity
|
||||
collect_path_from_root(parent, [entity.name | path_so_far])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp load_parent_entity(%{__struct__: module} = _entity, parent_id) do
|
||||
# Note: This function makes individual database queries
|
||||
# For better performance, consider preloading parent associations properly
|
||||
# or implementing batch loading if this becomes a bottleneck
|
||||
ComponentsElixir.Repo.get(module, parent_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters entities to remove circular reference options for parent selection.
|
||||
Prevents an entity from being its own ancestor.
|
||||
|
||||
## Examples
|
||||
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
|
||||
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
|
||||
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
|
||||
"""
|
||||
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
|
||||
|
||||
def filter_parent_options(entities, nil, _id_accessor_fn, _parent_id_accessor_fn) do
|
||||
entities
|
||||
end
|
||||
|
||||
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn) do
|
||||
entities
|
||||
|> Enum.reject(fn entity ->
|
||||
entity_id = id_accessor_fn.(entity)
|
||||
|
||||
# Remove self-reference
|
||||
entity_id == editing_entity_id ||
|
||||
# Remove descendants (they would create a cycle)
|
||||
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an entity is a descendant of an ancestor entity.
|
||||
Used for cycle detection in parent selection.
|
||||
"""
|
||||
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
|
||||
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
|
||||
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
|
||||
case parent_id_accessor_fn.(entity) do
|
||||
nil -> false
|
||||
^ancestor_id -> true
|
||||
parent_id ->
|
||||
parent = Enum.find(entities, fn e -> e.id == parent_id end)
|
||||
case parent do
|
||||
nil -> false
|
||||
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all root entities (entities with no parent).
|
||||
"""
|
||||
def root_entities(entities, parent_id_accessor_fn) do
|
||||
Enum.filter(entities, fn entity ->
|
||||
is_nil(parent_id_accessor_fn.(entity))
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all child entities of a specific parent.
|
||||
"""
|
||||
def child_entities(entities, parent_id, parent_id_accessor_fn) do
|
||||
Enum.filter(entities, fn entity ->
|
||||
parent_id_accessor_fn.(entity) == parent_id
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates display name for entity including parent context.
|
||||
For dropdown displays: "Parent > Child"
|
||||
"""
|
||||
def display_name(entity, parent_accessor_fn, separator \\ " > ") do
|
||||
full_path(entity, parent_accessor_fn, separator)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates options for a parent selection dropdown.
|
||||
Includes proper filtering to prevent cycles and formatted display names.
|
||||
Results are sorted hierarchically for intuitive navigation.
|
||||
"""
|
||||
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
|
||||
available_entities =
|
||||
filter_parent_options(
|
||||
entities,
|
||||
editing_entity_id,
|
||||
&(&1.id),
|
||||
&(&1.parent_id)
|
||||
)
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
|
||||
[{nil_option_text, nil}] ++ available_entities
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates options for a general selection dropdown (like filters).
|
||||
Results are sorted hierarchically for intuitive navigation.
|
||||
"""
|
||||
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
|
||||
sorted_entities =
|
||||
entities
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
|
||||
if nil_option_text do
|
||||
[{nil_option_text, nil}] ++ sorted_entities
|
||||
else
|
||||
sorted_entities
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Computes the depth/level of an entity in the hierarchy.
|
||||
Root entities have level 0.
|
||||
"""
|
||||
def compute_level(entity, parent_accessor_fn) do
|
||||
case parent_accessor_fn.(entity) do
|
||||
nil -> 0
|
||||
%Ecto.Association.NotLoaded{} -> 0
|
||||
parent -> 1 + compute_level(parent, parent_accessor_fn)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the separator string used for a specific entity type.
|
||||
Categories use " > " while storage locations use " / ".
|
||||
"""
|
||||
def separator_for(:category), do: " > "
|
||||
def separator_for(:storage_location), do: " / "
|
||||
def separator_for(_), do: " > "
|
||||
|
||||
@doc """
|
||||
Sorts entities hierarchically in depth-first order.
|
||||
Each parent is followed immediately by all its children (recursively).
|
||||
Within each level, entities are sorted alphabetically by name.
|
||||
|
||||
## Examples
|
||||
iex> entities = [
|
||||
...> %{id: 1, name: "Resistors", parent_id: nil},
|
||||
...> %{id: 2, name: "Wire", parent_id: 1},
|
||||
...> %{id: 3, name: "Capacitors", parent_id: nil},
|
||||
...> %{id: 4, name: "Ceramic", parent_id: 3}
|
||||
...> ]
|
||||
iex> Hierarchical.sort_hierarchically(entities, &(&1.parent_id))
|
||||
# Returns: [Capacitors, Capacitors>Ceramic, Resistors, Resistors>Wire]
|
||||
"""
|
||||
def sort_hierarchically(entities, parent_id_accessor_fn) do
|
||||
# First, get all root entities sorted alphabetically
|
||||
root_entities =
|
||||
entities
|
||||
|> root_entities(parent_id_accessor_fn)
|
||||
|> Enum.sort_by(& &1.name)
|
||||
|
||||
# Then recursively add children after each parent
|
||||
Enum.flat_map(root_entities, fn root ->
|
||||
[root | sort_children_recursively(entities, root.id, parent_id_accessor_fn)]
|
||||
end)
|
||||
end
|
||||
|
||||
defp sort_children_recursively(entities, parent_id, parent_id_accessor_fn) do
|
||||
children =
|
||||
entities
|
||||
|> child_entities(parent_id, parent_id_accessor_fn)
|
||||
|> Enum.sort_by(& &1.name)
|
||||
|
||||
Enum.flat_map(children, fn child ->
|
||||
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
|
||||
end)
|
||||
end
|
||||
end
|
||||
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
|
||||
@moduledoc """
|
||||
Behaviour for schemas that implement hierarchical relationships.
|
||||
|
||||
Provides a contract for entities with parent-child relationships,
|
||||
ensuring consistent interface across different hierarchical entities.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns the full hierarchical path as a string.
|
||||
Example: "Electronics > Components > Resistors"
|
||||
"""
|
||||
@callback full_path(struct()) :: String.t()
|
||||
|
||||
@doc """
|
||||
Returns the parent entity or nil if this is a root entity.
|
||||
"""
|
||||
@callback parent(struct()) :: struct() | nil
|
||||
|
||||
@doc """
|
||||
Returns the children entities as a list.
|
||||
"""
|
||||
@callback children(struct()) :: [struct()]
|
||||
|
||||
@doc """
|
||||
Returns the separator used for path display.
|
||||
"""
|
||||
@callback path_separator() :: String.t()
|
||||
|
||||
@doc """
|
||||
Returns the entity type for use with the Hierarchical module.
|
||||
"""
|
||||
@callback entity_type() :: atom()
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
|
||||
alias ComponentsElixir.Inventory.Hierarchical
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
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.
|
||||
has a unique AprilTag for quick scanning and identification.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
@@ -16,10 +17,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
field :description, :string
|
||||
field :apriltag_id, :integer
|
||||
|
||||
# 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
|
||||
@@ -37,47 +34,26 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
|> 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
|
||||
# HierarchicalSchema implementations
|
||||
@impl true
|
||||
def full_path(%StorageLocation{} = storage_location) do
|
||||
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
|
||||
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
|
||||
@impl true
|
||||
def parent(%StorageLocation{parent: parent}), do: parent
|
||||
|
||||
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
|
||||
@impl true
|
||||
def children(%StorageLocation{children: children}), do: children
|
||||
|
||||
@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
|
||||
@impl true
|
||||
def path_separator(), do: " / "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :storage_location
|
||||
|
||||
@doc """
|
||||
Returns the AprilTag format for this storage location.
|
||||
@@ -102,28 +78,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
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(
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.Category
|
||||
alias ComponentsElixir.Inventory.{Category, Hierarchical}
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
@@ -121,45 +121,20 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
end
|
||||
|
||||
defp parent_category_options(categories, editing_category_id \\ nil) do
|
||||
available_categories =
|
||||
categories
|
||||
|> Enum.reject(fn cat ->
|
||||
cat.id == editing_category_id ||
|
||||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
|
||||
end)
|
||||
|> Enum.map(fn category ->
|
||||
{category_display_name(category), category.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root category)", nil}] ++ available_categories
|
||||
end
|
||||
|
||||
defp is_descendant?(categories, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(categories, fn cat -> cat.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(categories, parent_id, ancestor_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp category_display_name(category) do
|
||||
if category.parent do
|
||||
"#{category.parent.name} > #{category.name}"
|
||||
else
|
||||
category.name
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
categories,
|
||||
editing_category_id,
|
||||
&(&1.parent),
|
||||
"No parent (Root category)"
|
||||
)
|
||||
end
|
||||
|
||||
defp root_categories(categories) do
|
||||
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
|
||||
Hierarchical.root_entities(categories, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp child_categories(categories, parent_id) do
|
||||
Enum.filter(categories, fn cat -> cat.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp count_components_in_category(category_id) do
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.Component
|
||||
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
|
||||
|
||||
@items_per_page 20
|
||||
|
||||
@@ -430,31 +430,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
end
|
||||
|
||||
defp category_options(categories) do
|
||||
[{"Select a category", nil}] ++
|
||||
Enum.map(categories, fn category ->
|
||||
{category.name, category.id}
|
||||
end)
|
||||
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
|
||||
end
|
||||
|
||||
defp storage_location_display_name(location) do
|
||||
# Use the computed path from Inventory context for full hierarchy, or fall back to location.path
|
||||
path = Inventory.compute_storage_location_path(location) || location.path
|
||||
|
||||
if path do
|
||||
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
|
||||
path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" > ")
|
||||
else
|
||||
location.name
|
||||
end
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
defp storage_location_options(storage_locations) do
|
||||
[{"No storage location", nil}] ++
|
||||
Enum.map(storage_locations, fn location ->
|
||||
{storage_location_display_name(location), location.id}
|
||||
end)
|
||||
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -503,7 +487,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
@@ -525,13 +509,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
>
|
||||
<option value="" selected={is_nil(@selected_category)}>All Categories</option>
|
||||
<%= for category <- @categories do %>
|
||||
<option value={category.id} selected={@selected_category == category.id}>
|
||||
<%= if category.parent do %>
|
||||
{category.parent.name} > {category.name}
|
||||
<% else %>
|
||||
{category.name}
|
||||
<% end %>
|
||||
<%= for {category_name, category_id} <- Hierarchical.select_options(@categories, &(&1.parent)) do %>
|
||||
<option value={category_id} selected={@selected_category == category_id}>
|
||||
{category_name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
@@ -580,7 +560,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add Component Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -714,7 +694,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Edit Component Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -858,7 +838,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Components List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
@@ -918,7 +898,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content area with image and details -->
|
||||
<div class="flex gap-6">
|
||||
<!-- Large Image -->
|
||||
@@ -944,7 +924,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 space-y-4 select-text">
|
||||
<!-- Full Description -->
|
||||
@@ -956,7 +936,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1013,7 +993,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||||
<button
|
||||
@@ -1098,7 +1078,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Middle row: Description -->
|
||||
<%= if component.description do %>
|
||||
<div class="mt-1">
|
||||
@@ -1107,7 +1087,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Bottom row: Metadata -->
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1135,7 +1115,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Keywords row -->
|
||||
<%= if component.keywords do %>
|
||||
<div class="mt-2">
|
||||
@@ -1218,7 +1198,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
|
||||
|
||||
|
||||
<!-- Modal content -->
|
||||
<div
|
||||
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
|
||||
@@ -1236,7 +1216,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 bg-base-100 rounded-b-lg">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.StorageLocation
|
||||
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
|
||||
alias ComponentsElixir.AprilTag
|
||||
|
||||
@impl true
|
||||
@@ -240,48 +240,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
end
|
||||
|
||||
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
|
||||
available_locations =
|
||||
storage_locations
|
||||
|> Enum.reject(fn loc ->
|
||||
loc.id == editing_location_id ||
|
||||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
|
||||
end)
|
||||
|> Enum.map(fn location ->
|
||||
{location_display_name(location), location.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root location)", nil}] ++ available_locations
|
||||
end
|
||||
|
||||
defp is_descendant?(storage_locations, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id)
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
storage_locations,
|
||||
editing_location_id,
|
||||
&(&1.parent),
|
||||
"No parent (Root location)"
|
||||
)
|
||||
end
|
||||
|
||||
defp location_display_name(location) do
|
||||
if location.path do
|
||||
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
|
||||
location.path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" > ")
|
||||
else
|
||||
location.name
|
||||
end
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
defp root_storage_locations(storage_locations) do
|
||||
Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end)
|
||||
Hierarchical.root_entities(storage_locations, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp child_storage_locations(storage_locations, parent_id) do
|
||||
Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id))
|
||||
end
|
||||
|
||||
defp count_components_in_location(location_id) do
|
||||
@@ -766,7 +742,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level {scan.location.level}
|
||||
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,15 @@ Repo.delete_all(StorageLocation)
|
||||
parent_id: capacitors.id
|
||||
})
|
||||
|
||||
# Create a DEEP category hierarchy to test fallback path (7+ levels)
|
||||
{:ok, deep_cat_1} = Inventory.create_category(%{name: "Level 1", description: "Deep hierarchy test", parent_id: resistors.id})
|
||||
{:ok, deep_cat_2} = Inventory.create_category(%{name: "Level 2", description: "Deep hierarchy test", parent_id: deep_cat_1.id})
|
||||
{:ok, deep_cat_3} = Inventory.create_category(%{name: "Level 3", description: "Deep hierarchy test", parent_id: deep_cat_2.id})
|
||||
{:ok, deep_cat_4} = Inventory.create_category(%{name: "Level 4", description: "Deep hierarchy test", parent_id: deep_cat_3.id})
|
||||
{:ok, deep_cat_5} = Inventory.create_category(%{name: "Level 5", description: "Deep hierarchy test", parent_id: deep_cat_4.id})
|
||||
{:ok, deep_cat_6} = Inventory.create_category(%{name: "Level 6", description: "Deep hierarchy test", parent_id: deep_cat_5.id})
|
||||
{:ok, deep_cat_7} = Inventory.create_category(%{name: "Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_cat_6.id})
|
||||
|
||||
# Create storage locations
|
||||
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
|
||||
{:ok, _shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
|
||||
@@ -104,6 +113,15 @@ Repo.delete_all(StorageLocation)
|
||||
parent_id: drawer_a2.id
|
||||
})
|
||||
|
||||
# Create a DEEP storage location hierarchy to test fallback path (7+ levels)
|
||||
{:ok, deep_loc_1} = Inventory.create_storage_location(%{name: "Deep Level 1", description: "Deep hierarchy test", parent_id: box_a1_3.id})
|
||||
{:ok, deep_loc_2} = Inventory.create_storage_location(%{name: "Deep Level 2", description: "Deep hierarchy test", parent_id: deep_loc_1.id})
|
||||
{:ok, deep_loc_3} = Inventory.create_storage_location(%{name: "Deep Level 3", description: "Deep hierarchy test", parent_id: deep_loc_2.id})
|
||||
{:ok, deep_loc_4} = Inventory.create_storage_location(%{name: "Deep Level 4", description: "Deep hierarchy test", parent_id: deep_loc_3.id})
|
||||
{:ok, deep_loc_5} = Inventory.create_storage_location(%{name: "Deep Level 5", description: "Deep hierarchy test", parent_id: deep_loc_4.id})
|
||||
{:ok, deep_loc_6} = Inventory.create_storage_location(%{name: "Deep Level 6", description: "Deep hierarchy test", parent_id: deep_loc_5.id})
|
||||
{:ok, deep_loc_7} = Inventory.create_storage_location(%{name: "Deep Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_loc_6.id})
|
||||
|
||||
# Create sample components
|
||||
sample_components = [
|
||||
%{
|
||||
@@ -186,6 +204,23 @@ sample_components = [
|
||||
storage_location_id: box_a1_1.id,
|
||||
count: 100,
|
||||
category_id: resistors.id
|
||||
},
|
||||
# Test components for deep hierarchies to ensure fallback path is exercised
|
||||
%{
|
||||
name: "Deep Category Test Component",
|
||||
description: "Component in 7-level deep category hierarchy",
|
||||
keywords: "test deep hierarchy category fallback",
|
||||
storage_location_id: box_a1_1.id,
|
||||
count: 1,
|
||||
category_id: deep_cat_7.id
|
||||
},
|
||||
%{
|
||||
name: "Deep Storage Test Component",
|
||||
description: "Component in 7-level deep storage location hierarchy",
|
||||
keywords: "test deep hierarchy storage fallback",
|
||||
storage_location_id: deep_loc_7.id,
|
||||
count: 1,
|
||||
category_id: resistors.id
|
||||
}
|
||||
]
|
||||
|
||||
@@ -211,10 +246,12 @@ sample_locations = [
|
||||
Enum.each(sample_locations, fn location ->
|
||||
if location.apriltag_id do
|
||||
apriltag_url = AprilTag.get_apriltag_url(location)
|
||||
IO.puts("#{location.path}: AprilTag ID #{location.apriltag_id}")
|
||||
location_path = StorageLocation.full_path(location)
|
||||
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
|
||||
IO.puts(" Download URL: #{apriltag_url}")
|
||||
else
|
||||
IO.puts("#{location.path}: No AprilTag assigned")
|
||||
location_path = StorageLocation.full_path(location)
|
||||
IO.puts("#{location_path}: No AprilTag assigned")
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user