Compare commits
10 Commits
1bdfea8d02
...
e078770557
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e078770557 | ||
|
|
264adbfb98 | ||
|
|
963c9a3770 | ||
|
|
5a1775e836 | ||
|
|
6a1122c3be | ||
|
|
b6e137632a | ||
|
|
8848986953 | ||
|
|
68b0c0714e | ||
|
|
76b0a97d31 | ||
|
|
fa9bf74fd9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,6 +37,7 @@ npm-debug.log
|
|||||||
|
|
||||||
# Ignore all user-generated content (uploads, QR codes, etc.)
|
# Ignore all user-generated content (uploads, QR codes, etc.)
|
||||||
/priv/static/user_generated/
|
/priv/static/user_generated/
|
||||||
|
/uploads/
|
||||||
|
|
||||||
# Ignore customized Docker Compose file.
|
# Ignore customized Docker Compose file.
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# AprilTag Migration Summary
|
|
||||||
|
|
||||||
## Completed Changes
|
|
||||||
|
|
||||||
### 1. Database Migration ✅
|
|
||||||
- Migrated from `qr_code` string field to `apriltag_id` integer field
|
|
||||||
- Added constraint to ensure valid AprilTag IDs (0-586)
|
|
||||||
- Created unique index for apriltag_id
|
|
||||||
- Preserved old qr_code data as qr_code_old for rollback safety
|
|
||||||
|
|
||||||
### 2. Schema Updates ✅
|
|
||||||
- Updated `StorageLocation` schema to use `apriltag_id` instead of `qr_code`
|
|
||||||
- Added validation for AprilTag ID range (0-586)
|
|
||||||
- Implemented auto-assignment of next available ID
|
|
||||||
- Added unique constraint validation
|
|
||||||
|
|
||||||
### 3. Business Logic Refactoring ✅
|
|
||||||
- Replaced `ComponentsElixir.QRCode` module with `ComponentsElixir.AprilTag` module
|
|
||||||
- Updated inventory functions to use AprilTag IDs instead of QR code strings
|
|
||||||
- Implemented AprilTag ID availability checking
|
|
||||||
- Added bulk SVG generation functionality
|
|
||||||
|
|
||||||
### 4. UI/UX Improvements ✅
|
|
||||||
- Replaced dropdown with 587 options with better UX:
|
|
||||||
- Radio buttons for "Auto-assign" vs "Manual selection"
|
|
||||||
- Number input for specific ID selection when manual mode selected
|
|
||||||
- Shows available ID count and examples
|
|
||||||
- Different interface for add vs edit forms
|
|
||||||
- Updated templates to show AprilTag information instead of QR codes
|
|
||||||
- Added download functionality for AprilTag SVGs
|
|
||||||
|
|
||||||
### 5. AprilTag Generation ✅
|
|
||||||
- Created `ComponentsElixir.AprilTag` module for managing tag36h11 family
|
|
||||||
- Generated all 587 placeholder SVG files with human-readable IDs
|
|
||||||
- Added Mix task `mix apriltag.generate_all` for batch generation
|
|
||||||
- SVG files served statically at `/apriltags/tag36h11_id_XXX.svg`
|
|
||||||
|
|
||||||
### 6. Event Handling ✅
|
|
||||||
- Updated LiveView event handlers for AprilTag scanning/assignment
|
|
||||||
- Added mode switching for manual vs automatic assignment
|
|
||||||
- Implemented proper form state management for different modes
|
|
||||||
|
|
||||||
## Benefits Achieved
|
|
||||||
|
|
||||||
1. **Better UX**: No more 587-option dropdown menu
|
|
||||||
2. **Future-Ready**: AprilTags designed for multi-tag detection scenarios
|
|
||||||
3. **Robust**: 587 unique IDs provide ample space without conflicts
|
|
||||||
4. **Maintainable**: Simpler integer ID system vs complex string encoding
|
|
||||||
5. **Industry Standard**: AprilTags widely used in robotics/AR applications
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- ✅ Database schema updated
|
|
||||||
- ✅ All 587 placeholder SVG files generated
|
|
||||||
- ✅ UI forms updated with better UX
|
|
||||||
- ✅ Business logic migrated to AprilTag system
|
|
||||||
- ⏳ **Next**: Real AprilTag pattern generation (future enhancement)
|
|
||||||
- ⏳ **Next**: Camera detection integration (future enhancement)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Generate AprilTag SVGs
|
|
||||||
```bash
|
|
||||||
mix apriltag.generate_all # Generate missing files
|
|
||||||
mix apriltag.generate_all --force # Regenerate all files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Available AprilTag IDs
|
|
||||||
- Range: 0-586 (tag36h11 family)
|
|
||||||
- Auto-assignment picks next available ID
|
|
||||||
- Manual assignment allows specific ID selection
|
|
||||||
- Unique constraint prevents conflicts
|
|
||||||
|
|
||||||
### File Locations
|
|
||||||
- SVG files: `priv/static/apriltags/tag36h11_id_XXX.svg`
|
|
||||||
- URL pattern: `/apriltags/tag36h11_id_XXX.svg`
|
|
||||||
- Placeholder pattern includes human-readable ID label
|
|
||||||
|
|
||||||
The system is now ready for use with AprilTags instead of QR codes! The placeholder SVGs will work perfectly for testing and development until we implement actual AprilTag pattern generation.
|
|
||||||
@@ -88,6 +88,10 @@ ENV LC_ALL en_US.UTF-8
|
|||||||
WORKDIR "/app"
|
WORKDIR "/app"
|
||||||
RUN chown nobody /app
|
RUN chown nobody /app
|
||||||
|
|
||||||
|
# Create data directory for uploads
|
||||||
|
RUN mkdir -p /data/uploads/images && \
|
||||||
|
chown -R nobody:root /data/uploads
|
||||||
|
|
||||||
# set runner ENV
|
# set runner ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -51,6 +51,7 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
|
|||||||
|
|
||||||
2. **Set up the database:**
|
2. **Set up the database:**
|
||||||
```bash
|
```bash
|
||||||
|
docker run --name components-postgres -p 5432:5432 -e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres # password: config/dev.exs
|
||||||
mix ecto.create
|
mix ecto.create
|
||||||
mix ecto.migrate
|
mix ecto.migrate
|
||||||
mix run priv/repo/seeds.exs
|
mix run priv/repo/seeds.exs
|
||||||
@@ -269,10 +270,18 @@ The project includes these Docker files:
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Generate a secure secret key:**
|
2. **Generate a secure secret key:**
|
||||||
|
|
||||||
|
**With Elixir/Phoenix installed:**
|
||||||
```bash
|
```bash
|
||||||
# Run this locally to generate a new secret
|
|
||||||
mix phx.gen.secret
|
mix phx.gen.secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Without Elixir/Phoenix (Linux/Unix):**
|
||||||
|
```bash
|
||||||
|
dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: The SECRET_KEY_BASE must be a cryptographically random string that's at least 64 characters long. Phoenix uses it to sign session cookies, CSRF tokens, and other security-sensitive data.
|
||||||
|
|
||||||
3. **Database Configuration**: The default setup includes:
|
3. **Database Configuration**: The default setup includes:
|
||||||
- PostgreSQL 15 container
|
- PostgreSQL 15 container
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
# Configure your database
|
# Configure the database
|
||||||
config :components_elixir, ComponentsElixir.Repo,
|
config :components_elixir, ComponentsElixir.Repo,
|
||||||
username: "postgres",
|
username: "postgres",
|
||||||
password: "fCnPB8VQdPkhJAD29hq6sZEY",
|
password: "fCnPB8VQdPkhJAD29hq6sZEY",
|
||||||
@@ -10,8 +10,12 @@ config :components_elixir, ComponentsElixir.Repo,
|
|||||||
show_sensitive_data_on_connection_error: true,
|
show_sensitive_data_on_connection_error: true,
|
||||||
pool_size: 10
|
pool_size: 10
|
||||||
|
|
||||||
# For development, we disable any cache and enable
|
# For development work, log all queries
|
||||||
# debugging and code reloading.
|
# config :components_elixir, ComponentsElixir.Repo, log: false
|
||||||
|
|
||||||
|
# For development, use a local uploads directory
|
||||||
|
config :components_elixir,
|
||||||
|
uploads_dir: "./uploads"
|
||||||
#
|
#
|
||||||
# The watchers configuration can be used to run external
|
# The watchers configuration can be used to run external
|
||||||
# watchers to your application. For example, we can use it
|
# watchers to your application. For example, we can use it
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
# Runtime configuration for uploads directory
|
||||||
|
config :components_elixir,
|
||||||
|
uploads_dir: System.get_env("UPLOADS_DIR", "./uploads")
|
||||||
|
|
||||||
# config/runtime.exs is executed for all environments, including
|
# config/runtime.exs is executed for all environments, including
|
||||||
# during releases. It is executed after compilation and before the
|
# during releases. It is executed after compilation and before the
|
||||||
# system starts, so it is typically used to load production configuration
|
# system starts, so it is typically used to load production configuration
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -25,9 +25,12 @@ services:
|
|||||||
PHX_HOST: "localhost"
|
PHX_HOST: "localhost"
|
||||||
PHX_SERVER: "true"
|
PHX_SERVER: "true"
|
||||||
PORT: "4000"
|
PORT: "4000"
|
||||||
|
UPLOADS_DIR: "/data/uploads"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- uploaded_files:/data/uploads
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"/bin/sh",
|
"/bin/sh",
|
||||||
@@ -37,3 +40,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
uploaded_files:
|
||||||
|
|||||||
@@ -11,99 +11,42 @@ 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 = StorageLocation
|
locations =
|
||||||
|> order_by([sl], [asc: sl.name])
|
StorageLocation
|
||||||
|> preload(:parent)
|
|> order_by([sl], asc: sl.name)
|
||||||
|> Repo.all()
|
|> 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
|
# 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
|
|
||||||
nil -> 0 # Orphaned record
|
|
||||||
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion
|
|
||||||
|
|
||||||
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
|
|
||||||
nil -> name # Orphaned record
|
|
||||||
parent ->
|
|
||||||
parent_path = compute_path_efficient(parent, location_map, depth + 1)
|
|
||||||
"#{parent_path}/#{name}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of root storage locations (no parent).
|
Returns the list of root storage locations (no parent).
|
||||||
"""
|
"""
|
||||||
def list_root_storage_locations do
|
def list_root_storage_locations do
|
||||||
StorageLocation
|
StorageLocation
|
||||||
|> where([sl], is_nil(sl.parent_id))
|
|> where([sl], is_nil(sl.parent_id))
|
||||||
|> order_by([sl], [asc: sl.name])
|
|> order_by([sl], asc: sl.name)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
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 """
|
||||||
@@ -114,13 +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 """
|
||||||
@@ -130,13 +66,15 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
# Convert string keys to atoms to maintain consistency
|
# Convert string keys to atoms to maintain consistency
|
||||||
attrs = normalize_string_keys(attrs)
|
attrs = normalize_string_keys(attrs)
|
||||||
|
|
||||||
result = %StorageLocation{}
|
result =
|
||||||
|> StorageLocation.changeset(attrs)
|
%StorageLocation{}
|
||||||
|> Repo.insert()
|
|> StorageLocation.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
{:ok, location} ->
|
{:ok, location} ->
|
||||||
{:ok, location}
|
{:ok, location}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
error
|
error
|
||||||
end
|
end
|
||||||
@@ -149,13 +87,15 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
# Convert string keys to atoms to maintain consistency
|
# Convert string keys to atoms to maintain consistency
|
||||||
attrs = normalize_string_keys(attrs)
|
attrs = normalize_string_keys(attrs)
|
||||||
|
|
||||||
result = storage_location
|
result =
|
||||||
|> StorageLocation.changeset(attrs)
|
storage_location
|
||||||
|> Repo.update()
|
|> StorageLocation.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
case result do
|
case result do
|
||||||
{:ok, updated_location} ->
|
{:ok, updated_location} ->
|
||||||
{:ok, updated_location}
|
{:ok, updated_location}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
error
|
error
|
||||||
end
|
end
|
||||||
@@ -182,12 +122,14 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
case get_storage_location_by_apriltag_id(apriltag_id) do
|
case get_storage_location_by_apriltag_id(apriltag_id) do
|
||||||
nil ->
|
nil ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
location ->
|
location ->
|
||||||
{:ok, %{
|
{:ok,
|
||||||
type: :storage_location,
|
%{
|
||||||
location: location,
|
type: :storage_location,
|
||||||
apriltag_id: apriltag_id
|
location: location,
|
||||||
}}
|
apriltag_id: apriltag_id
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -195,8 +137,9 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
Computes the path for a storage location (for display purposes).
|
Computes the path for a storage location (for display purposes).
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -205,6 +148,7 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
{key, value}, acc when is_binary(key) ->
|
{key, value}, acc when is_binary(key) ->
|
||||||
atom_key = String.to_atom(key)
|
atom_key = String.to_atom(key)
|
||||||
Map.put(acc, atom_key, value)
|
Map.put(acc, atom_key, value)
|
||||||
|
|
||||||
{key, value}, acc ->
|
{key, value}, acc ->
|
||||||
Map.put(acc, key, value)
|
Map.put(acc, key, value)
|
||||||
end)
|
end)
|
||||||
@@ -213,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
|
||||||
|
|
||||||
@@ -266,7 +211,7 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
def list_components(opts \\ []) do
|
def list_components(opts \\ []) do
|
||||||
Component
|
Component
|
||||||
|> apply_component_filters(opts)
|
|> apply_component_filters(opts)
|
||||||
|> order_by([c], [asc: c.name])
|
|> apply_component_sorting(opts)
|
||||||
|> preload([:category, :storage_location])
|
|> preload([:category, :storage_location])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
@@ -281,17 +226,36 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
|
|
||||||
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
|
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
|
||||||
search_pattern = "%#{search_term}%"
|
search_pattern = "%#{search_term}%"
|
||||||
where(query, [c],
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[c],
|
||||||
ilike(c.name, ^search_pattern) or
|
ilike(c.name, ^search_pattern) or
|
||||||
ilike(c.description, ^search_pattern) or
|
ilike(c.description, ^search_pattern) or
|
||||||
ilike(c.keywords, ^search_pattern) or
|
ilike(c.keywords, ^search_pattern) or
|
||||||
ilike(c.position, ^search_pattern)
|
ilike(c.position, ^search_pattern)
|
||||||
)
|
)
|
||||||
|
|
||||||
_, query -> query
|
_, query ->
|
||||||
|
query
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp apply_component_sorting(query, opts) do
|
||||||
|
case Keyword.get(opts, :sort_criteria, "name_asc") do
|
||||||
|
"name_asc" -> order_by(query, [c], asc: c.name)
|
||||||
|
"name_desc" -> order_by(query, [c], desc: c.name)
|
||||||
|
"inserted_at_asc" -> order_by(query, [c], asc: c.inserted_at)
|
||||||
|
"inserted_at_desc" -> order_by(query, [c], desc: c.inserted_at)
|
||||||
|
"updated_at_asc" -> order_by(query, [c], asc: c.updated_at)
|
||||||
|
"updated_at_desc" -> order_by(query, [c], desc: c.updated_at)
|
||||||
|
"count_asc" -> order_by(query, [c], asc: c.count)
|
||||||
|
"count_desc" -> order_by(query, [c], desc: c.count)
|
||||||
|
# Default fallback
|
||||||
|
_ -> order_by(query, [c], asc: c.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single component.
|
Gets a single component.
|
||||||
"""
|
"""
|
||||||
@@ -339,10 +303,12 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
def get_inventory_stats do
|
def get_inventory_stats do
|
||||||
total_components = Repo.aggregate(Component, :count, :id)
|
total_components = Repo.aggregate(Component, :count, :id)
|
||||||
|
|
||||||
total_stock = Component
|
total_stock =
|
||||||
|
Component
|
||||||
|> Repo.aggregate(:sum, :count)
|
|> Repo.aggregate(:sum, :count)
|
||||||
|
|
||||||
categories_with_components = Component
|
categories_with_components =
|
||||||
|
Component
|
||||||
|> distinct([c], c.category_id)
|
|> distinct([c], c.category_id)
|
||||||
|> Repo.aggregate(:count, :category_id)
|
|> Repo.aggregate(:count, :category_id)
|
||||||
|
|
||||||
@@ -392,6 +358,7 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
"""
|
"""
|
||||||
def decrement_component_count(%Component{} = component) do
|
def decrement_component_count(%Component{} = component) do
|
||||||
new_count = max(0, component.count - 1)
|
new_count = max(0, component.count - 1)
|
||||||
|
|
||||||
component
|
component
|
||||||
|> Component.changeset(%{count: new_count})
|
|> Component.changeset(%{count: new_count})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -15,11 +16,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
|||||||
field :name, :string
|
field :name, :string
|
||||||
field :description, :string
|
field :description, :string
|
||||||
field :apriltag_id, :integer
|
field :apriltag_id, :integer
|
||||||
field :is_active, :boolean, default: true
|
|
||||||
|
|
||||||
# Computed/virtual fields - not stored in database
|
|
||||||
field :level, :integer, virtual: true
|
|
||||||
field :path, :string, virtual: true
|
|
||||||
|
|
||||||
# Only parent relationship is stored
|
# Only parent relationship is stored
|
||||||
belongs_to :parent, StorageLocation
|
belongs_to :parent, StorageLocation
|
||||||
@@ -32,53 +28,32 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
|||||||
@doc false
|
@doc false
|
||||||
def changeset(storage_location, attrs) do
|
def changeset(storage_location, attrs) do
|
||||||
storage_location
|
storage_location
|
||||||
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id])
|
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|
||||||
|> validate_required([:name])
|
|> validate_required([:name])
|
||||||
|> validate_length(:name, min: 1, max: 100)
|
|> validate_length(:name, min: 1, max: 100)
|
||||||
|> 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.
|
||||||
@@ -103,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(
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
defmodule ComponentsElixir.QRCode do
|
|
||||||
@moduledoc """
|
|
||||||
QR Code generation and parsing for storage locations.
|
|
||||||
|
|
||||||
Provides functionality to generate QR codes for storage locations
|
|
||||||
and parse them back to retrieve location information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates a QR code data string for a storage location.
|
|
||||||
|
|
||||||
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> location = %StorageLocation{level: 1, qr_code: "ABC123", parent: nil}
|
|
||||||
iex> ComponentsElixir.QRCode.generate_qr_data(location)
|
|
||||||
"SL:1:ABC123:ROOT"
|
|
||||||
|
|
||||||
iex> parent = %StorageLocation{qr_code: "SHELF1"}
|
|
||||||
iex> drawer = %StorageLocation{level: 2, qr_code: "DRAW01", parent: parent}
|
|
||||||
iex> ComponentsElixir.QRCode.generate_qr_data(drawer)
|
|
||||||
"SL:2:DRAW01:SHELF1"
|
|
||||||
"""
|
|
||||||
def generate_qr_data(storage_location) do
|
|
||||||
parent_code =
|
|
||||||
case storage_location.parent do
|
|
||||||
nil -> "ROOT"
|
|
||||||
parent -> parent.qr_code
|
|
||||||
end
|
|
||||||
|
|
||||||
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Parses a QR code string and extracts components.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> ComponentsElixir.QRCode.parse_qr_data("SL:1:ABC123:ROOT")
|
|
||||||
{:ok, %{level: 1, code: "ABC123", parent: "ROOT"}}
|
|
||||||
|
|
||||||
iex> ComponentsElixir.QRCode.parse_qr_data("invalid")
|
|
||||||
{:error, :invalid_format}
|
|
||||||
"""
|
|
||||||
def parse_qr_data(qr_string) do
|
|
||||||
case String.split(qr_string, ":") do
|
|
||||||
["SL", level_str, code, parent] ->
|
|
||||||
case Integer.parse(level_str) do
|
|
||||||
{level, ""} ->
|
|
||||||
{:ok, %{level: level, code: code, parent: parent}}
|
|
||||||
_ ->
|
|
||||||
{:error, :invalid_level}
|
|
||||||
end
|
|
||||||
_ ->
|
|
||||||
{:error, :invalid_format}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Validates if a string looks like a storage location QR code.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> ComponentsElixir.QRCode.valid_storage_qr?("SL:1:ABC123:ROOT")
|
|
||||||
true
|
|
||||||
|
|
||||||
iex> ComponentsElixir.QRCode.valid_storage_qr?("COMP:12345")
|
|
||||||
false
|
|
||||||
"""
|
|
||||||
def valid_storage_qr?(qr_string) do
|
|
||||||
case parse_qr_data(qr_string) do
|
|
||||||
{:ok, _} -> true
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates a printable label data structure for a storage location.
|
|
||||||
|
|
||||||
This could be used to generate PDF labels or send to a label printer.
|
|
||||||
"""
|
|
||||||
def generate_label_data(storage_location) do
|
|
||||||
qr_data = generate_qr_data(storage_location)
|
|
||||||
|
|
||||||
%{
|
|
||||||
qr_code: qr_data,
|
|
||||||
name: storage_location.name,
|
|
||||||
path: storage_location.path,
|
|
||||||
level: storage_location.level,
|
|
||||||
description: storage_location.description
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates multiple QR codes for disambiguation testing.
|
|
||||||
|
|
||||||
This is useful for testing multi-QR detection scenarios.
|
|
||||||
"""
|
|
||||||
def generate_test_codes(storage_locations) when is_list(storage_locations) do
|
|
||||||
Enum.map(storage_locations, &generate_qr_data/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates a QR code image (PNG) for a storage location.
|
|
||||||
|
|
||||||
Returns the binary PNG data that can be saved to disk or served directly.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
- `:size` - The size of the QR code image in pixels (default: 200)
|
|
||||||
- `:background` - Background color as `{r, g, b}` tuple (default: white)
|
|
||||||
- `:foreground` - Foreground color as `{r, g, b}` tuple (default: black)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
|
|
||||||
iex> {:ok, png_data} = ComponentsElixir.QRCode.generate_qr_image(location)
|
|
||||||
iex> File.write!("/tmp/qr_code.png", png_data)
|
|
||||||
|
|
||||||
"""
|
|
||||||
def generate_qr_image(storage_location, _opts \\ []) do
|
|
||||||
qr_data = generate_qr_data(storage_location)
|
|
||||||
|
|
||||||
qr_data
|
|
||||||
|> QRCode.create()
|
|
||||||
|> QRCode.render(:png)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates and saves a QR code image to the specified file path.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
|
|
||||||
iex> ComponentsElixir.QRCode.save_qr_image(location, "/tmp/qr_code.png")
|
|
||||||
:ok
|
|
||||||
|
|
||||||
"""
|
|
||||||
def save_qr_image(storage_location, file_path, opts \\ []) do
|
|
||||||
case generate_qr_image(storage_location, opts) do
|
|
||||||
{:ok, png_data} ->
|
|
||||||
# Ensure directory exists
|
|
||||||
file_path
|
|
||||||
|> Path.dirname()
|
|
||||||
|> File.mkdir_p!()
|
|
||||||
|
|
||||||
File.write!(file_path, png_data)
|
|
||||||
:ok
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates a QR code image URL for serving via Phoenix static files.
|
|
||||||
|
|
||||||
This function generates the QR code image and saves it to the static directory,
|
|
||||||
returning a URL that can be used in templates.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> location = %StorageLocation{id: 123, qr_code: "ABC123"}
|
|
||||||
iex> ComponentsElixir.QRCode.get_qr_image_url(location)
|
|
||||||
"/qr_codes/storage_location_123.png"
|
|
||||||
|
|
||||||
"""
|
|
||||||
def get_qr_image_url(storage_location, opts \\ []) do
|
|
||||||
filename = "storage_location_#{storage_location.id}.png"
|
|
||||||
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
|
|
||||||
|
|
||||||
# Generate and save the image if it doesn't exist or if regeneration is forced
|
|
||||||
force_regenerate = Keyword.get(opts, :force_regenerate, false)
|
|
||||||
|
|
||||||
if force_regenerate || !File.exists?(file_path) do
|
|
||||||
case save_qr_image(storage_location, file_path, opts) do
|
|
||||||
:ok -> "/user_generated/qr_codes/#{filename}"
|
|
||||||
{:error, _reason} -> nil
|
|
||||||
end
|
|
||||||
else
|
|
||||||
"/user_generated/qr_codes/#{filename}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates QR code images for multiple storage locations (bulk generation).
|
|
||||||
|
|
||||||
Returns a list of results indicating success or failure for each location.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> locations = [location1, location2, location3]
|
|
||||||
iex> ComponentsElixir.QRCode.bulk_generate_images(locations)
|
|
||||||
[
|
|
||||||
{:ok, "/qr_codes/storage_location_1.png"},
|
|
||||||
{:ok, "/qr_codes/storage_location_2.png"},
|
|
||||||
{:error, "Failed to generate for location 3"}
|
|
||||||
]
|
|
||||||
|
|
||||||
"""
|
|
||||||
def bulk_generate_images(storage_locations, opts \\ []) do
|
|
||||||
# Use Task.async_stream for concurrent generation with back-pressure
|
|
||||||
storage_locations
|
|
||||||
|> Task.async_stream(
|
|
||||||
fn location ->
|
|
||||||
case get_qr_image_url(location, Keyword.put(opts, :force_regenerate, true)) do
|
|
||||||
nil -> {:error, "Failed to generate QR code for location #{location.id}"}
|
|
||||||
url -> {:ok, url}
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
timeout: :infinity,
|
|
||||||
max_concurrency: System.schedulers_online() * 2
|
|
||||||
)
|
|
||||||
|> Enum.map(fn {:ok, result} -> result end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Cleans up QR code images for deleted storage locations.
|
|
||||||
|
|
||||||
Should be called when storage locations are deleted to prevent orphaned files.
|
|
||||||
"""
|
|
||||||
def cleanup_qr_image(storage_location_id) do
|
|
||||||
filename = "storage_location_#{storage_location_id}.png"
|
|
||||||
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
|
|
||||||
|
|
||||||
if File.exists?(file_path) do
|
|
||||||
File.rm(file_path)
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
40
lib/components_elixir_web/controllers/file_controller.ex
Normal file
40
lib/components_elixir_web/controllers/file_controller.ex
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
defmodule ComponentsElixirWeb.FileController do
|
||||||
|
use ComponentsElixirWeb, :controller
|
||||||
|
|
||||||
|
def show(conn, %{"filename" => filename}) do
|
||||||
|
# Security: only allow alphanumeric, dashes, underscores, and dots
|
||||||
|
if String.match?(filename, ~r/^[a-zA-Z0-9_\-\.]+$/) do
|
||||||
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
|
file_path = Path.join([uploads_dir, "images", filename])
|
||||||
|
|
||||||
|
if File.exists?(file_path) do
|
||||||
|
# Get the file's MIME type
|
||||||
|
mime_type = get_mime_type(filename)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type(mime_type)
|
||||||
|
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
|
||||||
|
|> send_file(200, file_path)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_status(:not_found)
|
||||||
|
|> text("File not found")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_status(:bad_request)
|
||||||
|
|> text("Invalid filename")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_mime_type(filename) do
|
||||||
|
case Path.extname(filename) |> String.downcase() do
|
||||||
|
".jpg" -> "image/jpeg"
|
||||||
|
".jpeg" -> "image/jpeg"
|
||||||
|
".png" -> "image/png"
|
||||||
|
".gif" -> "image/gif"
|
||||||
|
".webp" -> "image/webp"
|
||||||
|
_ -> "application/octet-stream"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ defmodule ComponentsElixirWeb.Router do
|
|||||||
live "/login", LoginLive, :index
|
live "/login", LoginLive, :index
|
||||||
get "/login/authenticate", AuthController, :authenticate
|
get "/login/authenticate", AuthController, :authenticate
|
||||||
post "/logout", AuthController, :logout
|
post "/logout", AuthController, :logout
|
||||||
|
|
||||||
|
# File serving endpoint
|
||||||
|
get "/uploads/images/:filename", FileController, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", ComponentsElixirWeb do
|
scope "/", ComponentsElixirWeb do
|
||||||
|
|||||||
1
mix.exs
1
mix.exs
@@ -60,7 +60,6 @@ defmodule ComponentsElixir.MixProject do
|
|||||||
depth: 1},
|
depth: 1},
|
||||||
{:swoosh, "~> 1.16"},
|
{:swoosh, "~> 1.16"},
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
{:qr_code, "~> 3.1"},
|
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 0.26"},
|
{:gettext, "~> 0.26"},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule ComponentsElixir.Repo.Migrations.RemoveIsActiveFromStorageLocations do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:storage_locations) do
|
||||||
|
remove :is_active, :boolean
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
# Script for populating the database. You can run it as:
|
# Script for populating the database with sample data. You can run it as:
|
||||||
#
|
#
|
||||||
# mix run priv/repo/seeds.exs
|
# mix run priv/repo/seeds.exs
|
||||||
#
|
#
|
||||||
|
# This seeds file creates:
|
||||||
|
# - Sample categories (with hierarchical subcategories)
|
||||||
|
# - Storage locations (with auto-assigned AprilTag IDs)
|
||||||
|
# - Sample electronic components with proper storage assignments
|
||||||
|
# - Generates all AprilTag SVG files for immediate use
|
||||||
|
#
|
||||||
# Inside the script, you can read and write to any of your
|
# Inside the script, you can read and write to any of your
|
||||||
# repositories directly:
|
# repositories directly:
|
||||||
#
|
#
|
||||||
@@ -10,7 +16,7 @@
|
|||||||
# We recommend using the bang functions (`insert!`, `update!`
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
# and so on) as they will fail if something goes wrong.
|
# and so on) as they will fail if something goes wrong.
|
||||||
|
|
||||||
alias ComponentsElixir.{Repo, Inventory}
|
alias ComponentsElixir.{Repo, Inventory, AprilTag}
|
||||||
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
||||||
|
|
||||||
# Clear existing data
|
# Clear existing data
|
||||||
@@ -49,9 +55,18 @@ 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"})
|
||||||
|
|
||||||
# Create drawers on Shelf A
|
# Create drawers on Shelf A
|
||||||
{:ok, drawer_a1} = Inventory.create_storage_location(%{
|
{:ok, drawer_a1} = Inventory.create_storage_location(%{
|
||||||
@@ -73,7 +88,7 @@ Repo.delete_all(StorageLocation)
|
|||||||
parent_id: drawer_a1.id
|
parent_id: drawer_a1.id
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, box_a1_2} = Inventory.create_storage_location(%{
|
{:ok, _box_a1_2} = Inventory.create_storage_location(%{
|
||||||
name: "Box 2",
|
name: "Box 2",
|
||||||
description: "SMD resistors",
|
description: "SMD resistors",
|
||||||
parent_id: drawer_a1.id
|
parent_id: drawer_a1.id
|
||||||
@@ -98,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 = [
|
||||||
%{
|
%{
|
||||||
@@ -180,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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -192,19 +233,44 @@ IO.puts("Categories: #{length(Inventory.list_categories())}")
|
|||||||
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
|
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
|
||||||
IO.puts("Components: #{length(Inventory.list_components())}")
|
IO.puts("Components: #{length(Inventory.list_components())}")
|
||||||
IO.puts("")
|
IO.puts("")
|
||||||
IO.puts("Sample QR codes for testing:")
|
IO.puts("Sample AprilTag information:")
|
||||||
|
|
||||||
# Print some sample QR codes for testing
|
# Print AprilTag information for sample storage locations
|
||||||
sample_locations = [
|
sample_locations = [
|
||||||
Inventory.get_storage_location!(shelf_a.id),
|
Inventory.get_storage_location!(shelf_a.id),
|
||||||
Inventory.get_storage_location!(drawer_a1.id),
|
Inventory.get_storage_location!(drawer_a1.id),
|
||||||
Inventory.get_storage_location!(box_a1_1.id),
|
Inventory.get_storage_location!(box_a1_1.id),
|
||||||
Inventory.get_storage_location!(box_a2_1.id)
|
Inventory.get_storage_location!(box_a2_1.id)
|
||||||
]
|
]
|
||||||
|
|
||||||
Enum.each(sample_locations, fn location ->
|
Enum.each(sample_locations, fn location ->
|
||||||
qr_data = ComponentsElixir.QRCode.generate_qr_data(location)
|
if location.apriltag_id do
|
||||||
IO.puts("#{location.path}: #{qr_data}")
|
apriltag_url = AprilTag.get_apriltag_url(location)
|
||||||
|
location_path = StorageLocation.full_path(location)
|
||||||
|
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
|
||||||
|
IO.puts(" Download URL: #{apriltag_url}")
|
||||||
|
else
|
||||||
|
location_path = StorageLocation.full_path(location)
|
||||||
|
IO.puts("#{location_path}: No AprilTag assigned")
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
# Generate all AprilTag SVGs for immediate use
|
||||||
|
IO.puts("Generating AprilTag SVG files...")
|
||||||
|
result = AprilTag.generate_all_apriltag_svgs()
|
||||||
|
IO.puts("Generated #{result.success}/#{result.total} AprilTag SVG files")
|
||||||
|
|
||||||
IO.puts("")
|
IO.puts("")
|
||||||
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
IO.puts("🎉 Database seeded successfully!")
|
||||||
|
IO.puts("📊 Summary:")
|
||||||
|
IO.puts(" Categories: #{length(Inventory.list_categories())}")
|
||||||
|
IO.puts(" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)")
|
||||||
|
IO.puts(" Components: #{length(Inventory.list_components())}")
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("🏷️ AprilTag System:")
|
||||||
|
IO.puts(" - Each storage location has an auto-assigned AprilTag ID (0-586)")
|
||||||
|
IO.puts(" - SVG files available at /apriltags/tag36h11_id_XXX.svg")
|
||||||
|
IO.puts(" - Download AprilTags from storage location management page")
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("🔐 Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
||||||
|
IO.puts("🌐 Visit http://localhost:4000 to start using the system!")
|
||||||
|
|||||||
Reference in New Issue
Block a user