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
|
## Storage Locations
|
||||||
|
|
||||||
@doc """
|
@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
|
def list_storage_locations do
|
||||||
# Get all locations with preloaded parents in a single query
|
# Get all locations with preloaded parents in a single query
|
||||||
locations =
|
locations =
|
||||||
StorageLocation
|
StorageLocation
|
||||||
|> order_by([sl], asc: sl.name)
|
|> order_by([sl], asc: sl.name)
|
||||||
|> preload(:parent)
|
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||||
|> Repo.all()
|
|> 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
|
# Ensure AprilTag SVGs exist for all locations
|
||||||
spawn(fn ->
|
spawn(fn ->
|
||||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
processed_locations
|
locations
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns the list of root storage locations (no parent).
|
Returns the list of root storage locations (no parent).
|
||||||
"""
|
"""
|
||||||
@@ -89,37 +41,12 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single storage location with computed hierarchy fields.
|
Gets a single storage location with preloaded associations.
|
||||||
"""
|
"""
|
||||||
def get_storage_location!(id) do
|
def get_storage_location!(id) do
|
||||||
location =
|
StorageLocation
|
||||||
StorageLocation
|
|> preload(:parent)
|
||||||
|> preload(:parent)
|
|> Repo.get!(id)
|
||||||
|> 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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -130,15 +57,6 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
||||||
|> preload(:parent)
|
|> preload(:parent)
|
||||||
|> Repo.one()
|
|> 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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -221,7 +139,7 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
def compute_storage_location_path(nil), do: nil
|
def compute_storage_location_path(nil), do: nil
|
||||||
|
|
||||||
def compute_storage_location_path(%StorageLocation{} = location) do
|
def compute_storage_location_path(%StorageLocation{} = location) do
|
||||||
compute_path_for_single(location)
|
StorageLocation.full_path(location)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert string keys to atoms for consistency
|
# Convert string keys to atoms for consistency
|
||||||
@@ -239,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
@doc """
|
@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
|
def list_categories do
|
||||||
Category
|
Category
|
||||||
|> preload(:parent)
|
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
|
|||||||
Categories can be hierarchical with parent-child relationships.
|
Categories can be hierarchical with parent-child relationships.
|
||||||
"""
|
"""
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias ComponentsElixir.Inventory.{Category, Component}
|
alias ComponentsElixir.Inventory.{Category, Component}
|
||||||
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
|
|||||||
@doc """
|
@doc """
|
||||||
Returns the full path of the category including parent names.
|
Returns the full path of the category including parent names.
|
||||||
"""
|
"""
|
||||||
def full_path(%Category{parent: nil} = category), do: category.name
|
@impl true
|
||||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
def full_path(%Category{} = category) do
|
||||||
"#{full_path(parent)} > #{category.name}"
|
Hierarchical.full_path(category, &(&1.parent), path_separator())
|
||||||
end
|
|
||||||
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
|
||||||
category.name
|
|
||||||
end
|
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
|
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.
|
Schema for storage locations with hierarchical organization.
|
||||||
|
|
||||||
Storage locations can be nested (shelf -> drawer -> box) and each
|
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 Ecto.Schema
|
||||||
|
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
@@ -16,10 +17,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
|||||||
field :description, :string
|
field :description, :string
|
||||||
field :apriltag_id, :integer
|
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
|
# Only parent relationship is stored
|
||||||
belongs_to :parent, StorageLocation
|
belongs_to :parent, StorageLocation
|
||||||
has_many :children, StorageLocation, foreign_key: :parent_id
|
has_many :children, StorageLocation, foreign_key: :parent_id
|
||||||
@@ -37,47 +34,26 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
|||||||
|> validate_length(:description, max: 500)
|
|> validate_length(:description, max: 500)
|
||||||
|> validate_apriltag_id()
|
|> validate_apriltag_id()
|
||||||
|> foreign_key_constraint(:parent_id)
|
|> foreign_key_constraint(:parent_id)
|
||||||
|> validate_no_circular_reference()
|
|
||||||
|> put_apriltag_id()
|
|> put_apriltag_id()
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prevent circular references (location being its own ancestor)
|
# HierarchicalSchema implementations
|
||||||
defp validate_no_circular_reference(changeset) do
|
@impl true
|
||||||
case get_change(changeset, :parent_id) do
|
def full_path(%StorageLocation{} = storage_location) do
|
||||||
nil -> changeset
|
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
|
||||||
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
|
end
|
||||||
|
|
||||||
defp would_create_cycle?(location_id, parent_id) do
|
@impl true
|
||||||
# Check if parent_id is the same as location_id or any of its descendants
|
def parent(%StorageLocation{parent: parent}), do: parent
|
||||||
location_id == parent_id or
|
|
||||||
(parent_id && is_descendant_of?(parent_id, location_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp is_descendant_of?(potential_descendant, ancestor_id) do
|
@impl true
|
||||||
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
|
def children(%StorageLocation{children: children}), do: children
|
||||||
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 """
|
@impl true
|
||||||
Returns the full hierarchical path as a human-readable string.
|
def path_separator(), do: " / "
|
||||||
"""
|
|
||||||
def full_path(storage_location) do
|
@impl true
|
||||||
storage_location.path
|
def entity_type(), do: :storage_location
|
||||||
|> String.split("/")
|
|
||||||
|> Enum.join(" → ")
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the AprilTag format for this storage location.
|
Returns the AprilTag format for this storage location.
|
||||||
@@ -102,28 +78,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
|||||||
end
|
end
|
||||||
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
|
defp get_next_available_apriltag_id do
|
||||||
# Get all used AprilTag IDs
|
# Get all used AprilTag IDs
|
||||||
used_ids = ComponentsElixir.Repo.all(
|
used_ids = ComponentsElixir.Repo.all(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
|||||||
use ComponentsElixirWeb, :live_view
|
use ComponentsElixirWeb, :live_view
|
||||||
|
|
||||||
alias ComponentsElixir.{Inventory, Auth}
|
alias ComponentsElixir.{Inventory, Auth}
|
||||||
alias ComponentsElixir.Inventory.Category
|
alias ComponentsElixir.Inventory.{Category, Hierarchical}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
@@ -121,45 +121,20 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parent_category_options(categories, editing_category_id \\ nil) do
|
defp parent_category_options(categories, editing_category_id \\ nil) do
|
||||||
available_categories =
|
Hierarchical.parent_select_options(
|
||||||
categories
|
categories,
|
||||||
|> Enum.reject(fn cat ->
|
editing_category_id,
|
||||||
cat.id == editing_category_id ||
|
&(&1.parent),
|
||||||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
|
"No parent (Root category)"
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp root_categories(categories) do
|
defp root_categories(categories) do
|
||||||
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
|
Hierarchical.root_entities(categories, &(&1.parent_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp child_categories(categories, parent_id) do
|
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
|
end
|
||||||
|
|
||||||
defp count_components_in_category(category_id) do
|
defp count_components_in_category(category_id) do
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
use ComponentsElixirWeb, :live_view
|
use ComponentsElixirWeb, :live_view
|
||||||
|
|
||||||
alias ComponentsElixir.{Inventory, Auth}
|
alias ComponentsElixir.{Inventory, Auth}
|
||||||
alias ComponentsElixir.Inventory.Component
|
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
|
||||||
|
|
||||||
@items_per_page 20
|
@items_per_page 20
|
||||||
|
|
||||||
@@ -430,31 +430,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp category_options(categories) do
|
defp category_options(categories) do
|
||||||
[{"Select a category", nil}] ++
|
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
|
||||||
Enum.map(categories, fn category ->
|
|
||||||
{category.name, category.id}
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp storage_location_display_name(location) do
|
defp storage_location_display_name(location) do
|
||||||
# Use the computed path from Inventory context for full hierarchy, or fall back to location.path
|
StorageLocation.full_path(location)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp storage_location_options(storage_locations) do
|
defp storage_location_options(storage_locations) do
|
||||||
[{"No storage location", nil}] ++
|
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
|
||||||
Enum.map(storage_locations, fn location ->
|
|
||||||
{storage_location_display_name(location), location.id}
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -503,7 +487,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<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">
|
<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"
|
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>
|
<option value="" selected={is_nil(@selected_category)}>All Categories</option>
|
||||||
<%= for category <- @categories do %>
|
<%= for {category_name, category_id} <- Hierarchical.select_options(@categories, &(&1.parent)) do %>
|
||||||
<option value={category.id} selected={@selected_category == category.id}>
|
<option value={category_id} selected={@selected_category == category_id}>
|
||||||
<%= if category.parent do %>
|
{category_name}
|
||||||
{category.parent.name} > {category.name}
|
|
||||||
<% else %>
|
|
||||||
{category.name}
|
|
||||||
<% end %>
|
|
||||||
</option>
|
</option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
@@ -580,7 +560,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Component Modal -->
|
<!-- Add Component Modal -->
|
||||||
<%= if @show_add_form do %>
|
<%= if @show_add_form do %>
|
||||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Edit Component Modal -->
|
<!-- Edit Component Modal -->
|
||||||
<%= if @show_edit_form do %>
|
<%= if @show_edit_form do %>
|
||||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Components List -->
|
<!-- Components List -->
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
|
<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">
|
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||||
@@ -918,7 +898,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content area with image and details -->
|
<!-- Content area with image and details -->
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<!-- Large Image -->
|
<!-- Large Image -->
|
||||||
@@ -944,7 +924,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div class="flex-1 space-y-4 select-text">
|
<div class="flex-1 space-y-4 select-text">
|
||||||
<!-- Full Description -->
|
<!-- Full Description -->
|
||||||
@@ -956,7 +936,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Metadata Grid -->
|
<!-- Metadata Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
<%= if component.storage_location do %>
|
<%= if component.storage_location do %>
|
||||||
@@ -1013,7 +993,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||||||
<button
|
<button
|
||||||
@@ -1098,7 +1078,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Middle row: Description -->
|
<!-- Middle row: Description -->
|
||||||
<%= if component.description do %>
|
<%= if component.description do %>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
@@ -1107,7 +1087,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Bottom row: Metadata -->
|
<!-- 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">
|
<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 %>
|
<%= if component.storage_location do %>
|
||||||
@@ -1135,7 +1115,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Keywords row -->
|
<!-- Keywords row -->
|
||||||
<%= if component.keywords do %>
|
<%= if component.keywords do %>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@@ -1218,7 +1198,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
>
|
>
|
||||||
<!-- Background overlay -->
|
<!-- Background overlay -->
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
|
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
|
||||||
|
|
||||||
<!-- Modal content -->
|
<!-- Modal content -->
|
||||||
<div
|
<div
|
||||||
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-6 bg-base-100 rounded-b-lg">
|
<div class="p-6 bg-base-100 rounded-b-lg">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
|||||||
use ComponentsElixirWeb, :live_view
|
use ComponentsElixirWeb, :live_view
|
||||||
|
|
||||||
alias ComponentsElixir.{Inventory, Auth}
|
alias ComponentsElixir.{Inventory, Auth}
|
||||||
alias ComponentsElixir.Inventory.StorageLocation
|
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
|
||||||
alias ComponentsElixir.AprilTag
|
alias ComponentsElixir.AprilTag
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -240,48 +240,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
|
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
|
||||||
available_locations =
|
Hierarchical.parent_select_options(
|
||||||
storage_locations
|
storage_locations,
|
||||||
|> Enum.reject(fn loc ->
|
editing_location_id,
|
||||||
loc.id == editing_location_id ||
|
&(&1.parent),
|
||||||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
|
"No parent (Root location)"
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp location_display_name(location) do
|
defp location_display_name(location) do
|
||||||
if location.path do
|
StorageLocation.full_path(location)
|
||||||
# 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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp root_storage_locations(storage_locations) do
|
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
|
end
|
||||||
|
|
||||||
defp child_storage_locations(storage_locations, parent_id) do
|
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
|
end
|
||||||
|
|
||||||
defp count_components_in_location(location_id) do
|
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>
|
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ Repo.delete_all(StorageLocation)
|
|||||||
parent_id: capacitors.id
|
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
|
# Create storage locations
|
||||||
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
|
{: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"})
|
{: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
|
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
|
# Create sample components
|
||||||
sample_components = [
|
sample_components = [
|
||||||
%{
|
%{
|
||||||
@@ -186,6 +204,23 @@ sample_components = [
|
|||||||
storage_location_id: box_a1_1.id,
|
storage_location_id: box_a1_1.id,
|
||||||
count: 100,
|
count: 100,
|
||||||
category_id: resistors.id
|
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 ->
|
Enum.each(sample_locations, fn location ->
|
||||||
if location.apriltag_id do
|
if location.apriltag_id do
|
||||||
apriltag_url = AprilTag.get_apriltag_url(location)
|
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}")
|
IO.puts(" Download URL: #{apriltag_url}")
|
||||||
else
|
else
|
||||||
IO.puts("#{location.path}: No AprilTag assigned")
|
location_path = StorageLocation.full_path(location)
|
||||||
|
IO.puts("#{location_path}: No AprilTag assigned")
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user