Compare commits
6 Commits
56c3c16f6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe45ad63b | |||
| 9af2fa5c7f | |||
| 3e9ad2dcb1 | |||
| df8209e86d | |||
| fa730b3b8e | |||
| 0b42a74fe9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -153,6 +153,9 @@ dist/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# import data
|
||||||
|
resources/
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
*.sql
|
*.sql
|
||||||
!init-db.sql
|
!init-db.sql
|
||||||
|
|||||||
271
SOFT_DELETE_IMPLEMENTATION.md
Normal file
271
SOFT_DELETE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Soft Delete Implementation with Historical Tracking
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This implementation extends the existing temporal tables system to support soft deletes with proper historical tracking. When a product is "deleted", it's not actually removed from the database but marked as deleted with a timestamp, while maintaining full historical data.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Soft Delete Mechanism
|
||||||
|
- Products are marked as `deleted = true` instead of being physically removed
|
||||||
|
- Deletion creates a historical record of the product's state before deletion
|
||||||
|
- Deleted products get a new `valid_from` date set to the deletion date
|
||||||
|
- All historical versions remain intact for audit purposes
|
||||||
|
|
||||||
|
### 2. UI Enhancements
|
||||||
|
- **Product List**: "Show deleted" toggle next to "Add New Product" button
|
||||||
|
- **Visual Indicators**: Deleted products shown with:
|
||||||
|
- Red background tint and reduced opacity
|
||||||
|
- Strikethrough text
|
||||||
|
- 🗑️ emoji indicator
|
||||||
|
- Disabled edit/duplicate/delete actions
|
||||||
|
- **Shopping Events**: Products deleted before/on shopping date are automatically filtered out
|
||||||
|
|
||||||
|
### 3. API Behavior
|
||||||
|
- **Default**: Deleted products are hidden from all product listings
|
||||||
|
- **Optional**: `show_deleted=true` parameter shows all products including deleted ones
|
||||||
|
- **Shopping Events**: New endpoint `/products/available-for-shopping/{date}` filters products based on deletion status at specific date
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
|
||||||
|
### Products Table
|
||||||
|
```sql
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Products History Table
|
||||||
|
```sql
|
||||||
|
-- Already included in products_history table
|
||||||
|
deleted BOOLEAN DEFAULT FALSE NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Trigger
|
||||||
|
The `products_versioning_trigger` now **consistently handles ALL historization**:
|
||||||
|
- **UPDATE operations**: Creates history records for both automatic and manual versioning
|
||||||
|
- **DELETE operations**: Creates history records when products are deleted
|
||||||
|
- **Smart versioning**: Automatically detects manual vs automatic versioning based on `valid_from` changes
|
||||||
|
- **Centralized logic**: All temporal logic is in the database trigger, not split between trigger and application
|
||||||
|
|
||||||
|
### Trigger Benefits
|
||||||
|
1. **Consistency**: All versioning operations follow the same pattern
|
||||||
|
2. **Reliability**: Database-level enforcement prevents inconsistencies
|
||||||
|
3. **Simplicity**: Application code just sets fields, trigger handles the rest
|
||||||
|
4. **Performance**: Single database operation handles both data update and history creation
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Modified Endpoints
|
||||||
|
|
||||||
|
#### `GET /products/`
|
||||||
|
- **New Parameter**: `show_deleted: bool = False`
|
||||||
|
- **Behavior**: Filters out deleted products by default
|
||||||
|
- **Usage**: `GET /products/?show_deleted=true` to include deleted products
|
||||||
|
|
||||||
|
#### `PUT /products/{id}` & `DELETE /products/{id}`
|
||||||
|
- **Enhanced**: Now properly handles soft delete with historical tracking
|
||||||
|
- **Validation**: Prevents operations on already-deleted products
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
#### `GET /products/available-for-shopping/{shopping_date}`
|
||||||
|
- **Purpose**: Get products that were available (not deleted) on a specific shopping date
|
||||||
|
- **Logic**: Returns products where:
|
||||||
|
- `deleted = false` (never deleted), OR
|
||||||
|
- `deleted = true` AND `valid_from > shopping_date` (deleted after shopping date)
|
||||||
|
- **Usage**: Used by shopping event modals to filter product lists
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### ProductList Component
|
||||||
|
```typescript
|
||||||
|
// New state for toggle
|
||||||
|
const [showDeleted, setShowDeleted] = useState(false);
|
||||||
|
|
||||||
|
// Updated API call
|
||||||
|
const response = await productApi.getAll(showDeleted);
|
||||||
|
|
||||||
|
// Visual styling for deleted products
|
||||||
|
className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AddShoppingEventModal Component
|
||||||
|
```typescript
|
||||||
|
// Dynamic product fetching based on shopping date
|
||||||
|
const response = formData.date
|
||||||
|
? await productApi.getAvailableForShopping(formData.date)
|
||||||
|
: await productApi.getAll(false);
|
||||||
|
|
||||||
|
// Refetch products when date changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && formData.date) {
|
||||||
|
fetchProducts();
|
||||||
|
}
|
||||||
|
}, [formData.date, isOpen]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deletion Process Flow
|
||||||
|
|
||||||
|
### 1. User Initiates Delete
|
||||||
|
- User clicks "Delete" button on a product
|
||||||
|
- Confirmation modal appears
|
||||||
|
|
||||||
|
### 2. Backend Processing
|
||||||
|
```python
|
||||||
|
# Simple application code - trigger handles the complexity
|
||||||
|
product.deleted = True
|
||||||
|
product.valid_from = date.today() # Manual versioning date
|
||||||
|
product.updated_at = func.now()
|
||||||
|
|
||||||
|
# Trigger automatically:
|
||||||
|
# 1. Detects the change (deleted field + valid_from change)
|
||||||
|
# 2. Creates history record with old data (deleted=False, valid_to=today)
|
||||||
|
# 3. Ensures new record has valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trigger Logic (Automatic)**:
|
||||||
|
- Detects manual versioning because `valid_from` changed
|
||||||
|
- Uses the new `valid_from` as the cutoff date for history
|
||||||
|
- Creates history record: `{...old_data, valid_to: new_valid_from, operation: 'U'}`
|
||||||
|
- No additional application logic needed
|
||||||
|
|
||||||
|
### 3. Frontend Updates
|
||||||
|
- Product list refreshes
|
||||||
|
- Deleted product appears with visual indicators (if "Show deleted" is enabled)
|
||||||
|
- Product becomes unavailable for new shopping events
|
||||||
|
|
||||||
|
## Historical Data Integrity
|
||||||
|
|
||||||
|
### Shopping Events
|
||||||
|
- **Guarantee**: Shopping events always show products exactly as they existed when purchased
|
||||||
|
- **Implementation**: Uses `/products/{id}/at/{date}` endpoint to fetch historical product state
|
||||||
|
- **Benefit**: Even if a product is deleted later, historical shopping events remain accurate
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
- **Complete History**: All product versions are preserved in `products_history`
|
||||||
|
- **Deletion Tracking**: History records show when and why products were deleted
|
||||||
|
- **Temporal Queries**: Can reconstruct product state at any point in time
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### 1. View All Products (Default)
|
||||||
|
```bash
|
||||||
|
GET /products/
|
||||||
|
# Returns only non-deleted products
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. View All Products Including Deleted
|
||||||
|
```bash
|
||||||
|
GET /products/?show_deleted=true
|
||||||
|
# Returns all products with deleted status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Get Products Available for Shopping on Specific Date
|
||||||
|
```bash
|
||||||
|
GET /products/available-for-shopping/2024-01-15
|
||||||
|
# Returns products that were not deleted on 2024-01-15
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. View Historical Shopping Event
|
||||||
|
```bash
|
||||||
|
GET /shopping-events/123/products-as-purchased
|
||||||
|
# Returns products exactly as they were when purchased, regardless of current deletion status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Data Preservation**: No data is ever lost
|
||||||
|
2. **Audit Compliance**: Complete audit trail of all changes
|
||||||
|
3. **Historical Accuracy**: Shopping events remain accurate over time
|
||||||
|
4. **User Experience**: Clean interface with optional deleted product visibility
|
||||||
|
5. **Flexibility**: Easy to "undelete" products if needed (future enhancement)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Undelete Functionality**: Add ability to restore deleted products
|
||||||
|
2. **Bulk Operations**: Delete/restore multiple products at once
|
||||||
|
3. **Deletion Reasons**: Add optional reason field for deletions
|
||||||
|
4. **Advanced Filtering**: Filter by deletion date, reason, etc.
|
||||||
|
5. **Reporting**: Generate reports on deleted products and their impact
|
||||||
|
|
||||||
|
## Migration Instructions
|
||||||
|
|
||||||
|
1. **Run Migration**: Execute `temporal_migration.sql` to add `deleted` column
|
||||||
|
2. **Deploy Backend**: Update backend with new API endpoints and logic
|
||||||
|
3. **Deploy Frontend**: Update frontend with new UI components and API calls
|
||||||
|
4. **Test**: Verify soft delete functionality and historical data integrity
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
1. **Basic Deletion**: Delete a product and verify it's hidden from default view
|
||||||
|
2. **Show Deleted Toggle**: Enable "Show deleted" and verify deleted products appear with proper styling
|
||||||
|
3. **Shopping Event Filtering**: Create shopping event and verify deleted products don't appear in product list
|
||||||
|
4. **Historical Accuracy**: Delete a product that was in a past shopping event, verify the shopping event still shows correct historical data
|
||||||
|
5. **Date-based Filtering**: Test `/products/available-for-shopping/{date}` with various dates before/after product deletions
|
||||||
|
|
||||||
|
## Database Initialization
|
||||||
|
|
||||||
|
### ⚠️ Important: Trigger Creation
|
||||||
|
|
||||||
|
The temporal triggers are essential for the soft delete functionality. They must be created in **all environments**:
|
||||||
|
|
||||||
|
### **Method 1: Automatic (Recommended)**
|
||||||
|
The triggers are now automatically created when using SQLAlchemy's `create_all()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# This now creates both tables AND triggers
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
- SQLAlchemy event listener detects when `ProductHistory` table is created
|
||||||
|
- Automatically executes trigger creation SQL
|
||||||
|
- Works for fresh dev, test, and production databases
|
||||||
|
|
||||||
|
### **Method 2: Manual Database Script**
|
||||||
|
For explicit control, use the initialization script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run this for fresh database setup
|
||||||
|
python backend/database_init.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Creates all tables
|
||||||
|
- Creates all triggers
|
||||||
|
- Checks for existing triggers (safe to run multiple times)
|
||||||
|
- Provides detailed feedback
|
||||||
|
|
||||||
|
### **Method 3: Migration File**
|
||||||
|
For existing databases, run the migration:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Execute temporal_migration.sql
|
||||||
|
\i temporal_migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Setup Guide
|
||||||
|
|
||||||
|
### **Development (Fresh DB)**
|
||||||
|
```bash
|
||||||
|
# Option A: Automatic (when starting app)
|
||||||
|
python backend/main.py
|
||||||
|
# ✅ Tables + triggers created automatically
|
||||||
|
|
||||||
|
# Option B: Explicit setup
|
||||||
|
python backend/database_init.py
|
||||||
|
python backend/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Production (Fresh DB)**
|
||||||
|
```bash
|
||||||
|
# Recommended: Explicit initialization
|
||||||
|
python backend/database_init.py
|
||||||
|
# Then start the application
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Existing Database (Migration)**
|
||||||
|
```bash
|
||||||
|
# Apply migration to add soft delete functionality
|
||||||
|
docker-compose exec db psql -U postgres -d groceries -f /tmp/temporal_migration.sql
|
||||||
|
```
|
||||||
111
TEMPORAL_FEATURES.md
Normal file
111
TEMPORAL_FEATURES.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Temporal Product Tracking Features
|
||||||
|
|
||||||
|
This document describes the new historical product tracking functionality that allows you to track product changes over time.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Historical Product Versioning
|
||||||
|
- Products now maintain a complete history of changes
|
||||||
|
- When product attributes (name, weight, category, etc.) are updated, the old version is automatically saved
|
||||||
|
- Each version has `valid_from` and `valid_to` dates indicating when it was active
|
||||||
|
|
||||||
|
### 2. Manual Effective Dates
|
||||||
|
- When creating or editing products, you can specify a custom "Effective From" date
|
||||||
|
- If not specified, the current date is used
|
||||||
|
- This allows you to retroactively record product changes or schedule future changes
|
||||||
|
|
||||||
|
### 3. Shopping Event Historical Accuracy
|
||||||
|
- Shopping events now show products exactly as they were when purchased
|
||||||
|
- Even if a product's weight or name has changed since purchase, the historical data is preserved
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
### New Tables
|
||||||
|
- `products_history` - Stores old versions of products when they're updated
|
||||||
|
|
||||||
|
### New Columns
|
||||||
|
- `products.valid_from` (DATE) - When this product version became effective
|
||||||
|
- `products.valid_to` (DATE) - When this product version was superseded (9999-12-31 for current versions)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
- `GET /current-date` - Get current date for form prefilling
|
||||||
|
- `GET /products/{id}/history` - Get all historical versions of a product
|
||||||
|
- `GET /products/{id}/at/{date}` - Get product as it existed on a specific date (YYYY-MM-DD)
|
||||||
|
- `GET /shopping-events/{id}/products-as-purchased` - Get products as they were when purchased
|
||||||
|
|
||||||
|
### Updated Endpoints
|
||||||
|
- `POST /products/` - Now accepts optional `valid_from` field
|
||||||
|
- `PUT /products/{id}` - Now accepts optional `valid_from` field for manual versioning
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### Product Forms
|
||||||
|
- Added "Effective From" date field to product create/edit forms
|
||||||
|
- Date field is pre-filled with current date
|
||||||
|
- Required field with helpful description
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
- ProductCreate interface now includes optional `valid_from` field
|
||||||
|
- New utilityApi for fetching current date
|
||||||
|
- Proper form validation and error handling
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Run `temporal_migration.sql` to:
|
||||||
|
1. Add temporal columns to existing products table
|
||||||
|
2. Create products_history table
|
||||||
|
3. Set up automatic versioning trigger
|
||||||
|
4. Initialize existing products with baseline date (2025-05-01)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Creating a Product with Custom Date
|
||||||
|
```json
|
||||||
|
POST /products/
|
||||||
|
{
|
||||||
|
"name": "Organic Milk",
|
||||||
|
"category_id": 1,
|
||||||
|
"weight": 1000,
|
||||||
|
"weight_unit": "ml",
|
||||||
|
"valid_from": "2025-03-15"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating a Product with Future Effective Date
|
||||||
|
```json
|
||||||
|
PUT /products/123
|
||||||
|
{
|
||||||
|
"weight": 1200,
|
||||||
|
"valid_from": "2025-04-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Historical Product Data
|
||||||
|
```bash
|
||||||
|
# Get product as it was on January 15, 2025
|
||||||
|
GET /products/123/at/2025-01-15
|
||||||
|
|
||||||
|
# Get all versions of a product
|
||||||
|
GET /products/123/history
|
||||||
|
|
||||||
|
# Get shopping event with historical product data
|
||||||
|
GET /shopping-events/456/products-as-purchased
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Complete Audit Trail** - Never lose track of how products have changed over time
|
||||||
|
2. **Accurate Historical Data** - Shopping events always show correct product information
|
||||||
|
3. **Flexible Dating** - Record changes retroactively or schedule future changes
|
||||||
|
4. **Automatic Versioning** - No manual effort required for basic updates
|
||||||
|
5. **Cross-Database Compatibility** - Uses standard DATE fields that work with PostgreSQL, SQLite, MySQL, etc.
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- Versioning is handled automatically by database triggers
|
||||||
|
- Manual versioning is used when custom `valid_from` dates are specified
|
||||||
|
- Date format: YYYY-MM-DD (ISO 8601)
|
||||||
|
- Far future date (9999-12-31) represents current/active versions
|
||||||
|
- Temporal fields are not displayed in regular product lists (by design)
|
||||||
88
backend/database_init.py
Normal file
88
backend/database_init.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database initialization script that creates all tables and triggers.
|
||||||
|
Use this for setting up fresh development or production databases.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python database_init.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Add parent directory to path to import models
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from models import Base
|
||||||
|
from database import get_database_url
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize database with all tables and triggers"""
|
||||||
|
|
||||||
|
database_url = get_database_url()
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
|
||||||
|
print("🚀 Initializing database...")
|
||||||
|
print(f"📍 Database URL: {database_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create all tables first
|
||||||
|
print("📊 Creating tables...")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("✅ Tables created successfully")
|
||||||
|
|
||||||
|
# Verify critical tables exist before creating triggers
|
||||||
|
print("🔍 Verifying tables exist...")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
# Check if products and products_history tables exist
|
||||||
|
products_exists = connection.execute(text("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'products'
|
||||||
|
);
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
history_exists = connection.execute(text("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'products_history'
|
||||||
|
);
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
if not products_exists:
|
||||||
|
raise Exception("Products table was not created")
|
||||||
|
if not history_exists:
|
||||||
|
raise Exception("Products history table was not created")
|
||||||
|
|
||||||
|
print("✅ Required tables verified")
|
||||||
|
|
||||||
|
# Create triggers (if not already created by event listener)
|
||||||
|
print("⚙️ Ensuring triggers are created...")
|
||||||
|
with engine.connect() as connection:
|
||||||
|
# Check if trigger exists
|
||||||
|
result = connection.execute(text("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.triggers
|
||||||
|
WHERE trigger_name = 'products_versioning_trigger'
|
||||||
|
);
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print("📝 Creating products versioning trigger...")
|
||||||
|
from models import PRODUCTS_VERSIONING_TRIGGER_SQL
|
||||||
|
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
|
||||||
|
connection.commit()
|
||||||
|
print("✅ Trigger created successfully")
|
||||||
|
else:
|
||||||
|
print("✅ Trigger already exists")
|
||||||
|
|
||||||
|
print("🎉 Database initialization completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error initializing database: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_database()
|
||||||
240
backend/main.py
240
backend/main.py
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, status
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text, func
|
||||||
from typing import List
|
from typing import List
|
||||||
import models, schemas
|
import models, schemas
|
||||||
from database import engine, get_db
|
from database import engine, get_db
|
||||||
@@ -95,6 +95,34 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
def read_root():
|
def read_root():
|
||||||
return {"message": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"}
|
return {"message": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"}
|
||||||
|
|
||||||
|
# Utility endpoints
|
||||||
|
@app.get("/current-date")
|
||||||
|
def get_current_date():
|
||||||
|
"""Get current date for use as default in valid_from fields"""
|
||||||
|
from datetime import date
|
||||||
|
return {"current_date": date.today().isoformat()}
|
||||||
|
|
||||||
|
@app.get("/products/available-for-shopping/{shopping_date}", response_model=List[schemas.Product])
|
||||||
|
def get_products_available_for_shopping(shopping_date: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get products that were available (not deleted) on a specific shopping date"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the shopping date
|
||||||
|
target_date = datetime.strptime(shopping_date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
|
||||||
|
|
||||||
|
# Get products that were either:
|
||||||
|
# 1. Never deleted (deleted=False)
|
||||||
|
# 2. Deleted after the shopping date (valid_from > shopping_date for deleted=True products)
|
||||||
|
products = db.query(models.Product).filter(
|
||||||
|
(models.Product.deleted == False) |
|
||||||
|
((models.Product.deleted == True) & (models.Product.valid_from > target_date))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return products
|
||||||
|
|
||||||
# Product endpoints
|
# Product endpoints
|
||||||
@app.post("/products/", response_model=schemas.Product)
|
@app.post("/products/", response_model=schemas.Product)
|
||||||
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||||
@@ -109,15 +137,31 @@ def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)
|
|||||||
if brand is None:
|
if brand is None:
|
||||||
raise HTTPException(status_code=404, detail="Brand not found")
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
db_product = models.Product(**product.dict())
|
# Validate valid_from date if provided
|
||||||
|
if product.valid_from is not None:
|
||||||
|
from datetime import date
|
||||||
|
if product.valid_from > date.today():
|
||||||
|
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
|
||||||
|
|
||||||
|
# Create product data
|
||||||
|
product_data = product.dict(exclude={'valid_from'})
|
||||||
|
db_product = models.Product(**product_data)
|
||||||
|
|
||||||
|
# Set valid_from if provided, otherwise let database default handle it
|
||||||
|
if product.valid_from is not None:
|
||||||
|
db_product.valid_from = product.valid_from
|
||||||
|
|
||||||
db.add(db_product)
|
db.add(db_product)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_product)
|
db.refresh(db_product)
|
||||||
return db_product
|
return db_product
|
||||||
|
|
||||||
@app.get("/products/", response_model=List[schemas.Product])
|
@app.get("/products/", response_model=List[schemas.Product])
|
||||||
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def read_products(skip: int = 0, limit: int = 100, show_deleted: bool = False, db: Session = Depends(get_db)):
|
||||||
products = db.query(models.Product).offset(skip).limit(limit).all()
|
query = db.query(models.Product)
|
||||||
|
if not show_deleted:
|
||||||
|
query = query.filter(models.Product.deleted == False)
|
||||||
|
products = query.offset(skip).limit(limit).all()
|
||||||
return products
|
return products
|
||||||
|
|
||||||
@app.get("/products/{product_id}", response_model=schemas.Product)
|
@app.get("/products/{product_id}", response_model=schemas.Product)
|
||||||
@@ -127,13 +171,21 @@ def read_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
@app.get("/products/{product_id}/valid-from")
|
||||||
|
def get_product_valid_from(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get the current valid_from date for a product (used for validation when editing)"""
|
||||||
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
return {"valid_from": product.valid_from.isoformat()}
|
||||||
|
|
||||||
@app.put("/products/{product_id}", response_model=schemas.Product)
|
@app.put("/products/{product_id}", response_model=schemas.Product)
|
||||||
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
|
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
|
||||||
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
update_data = product_update.dict(exclude_unset=True)
|
update_data = product_update.dict(exclude_unset=True, exclude={'valid_from'})
|
||||||
|
|
||||||
# Validate category exists if category_id is being updated
|
# Validate category exists if category_id is being updated
|
||||||
if 'category_id' in update_data:
|
if 'category_id' in update_data:
|
||||||
@@ -147,9 +199,32 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
|
|||||||
if brand is None:
|
if brand is None:
|
||||||
raise HTTPException(status_code=404, detail="Brand not found")
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
|
# Validate valid_from date if provided
|
||||||
|
if product_update.valid_from is not None:
|
||||||
|
from datetime import date
|
||||||
|
if product_update.valid_from > date.today():
|
||||||
|
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
|
||||||
|
if product_update.valid_from <= product.valid_from:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Valid from date must be after the current product's valid from date ({product.valid_from})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if any versioned fields are actually changing
|
||||||
|
versioned_fields = ['name', 'category_id', 'brand_id', 'organic', 'weight', 'weight_unit']
|
||||||
|
has_changes = any(
|
||||||
|
field in update_data and getattr(product, field) != update_data[field]
|
||||||
|
for field in versioned_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the updates - trigger will handle history creation automatically
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
|
||||||
|
# Set valid_from if provided for manual versioning
|
||||||
|
if product_update.valid_from is not None:
|
||||||
|
product.valid_from = product_update.valid_from
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(product)
|
db.refresh(product)
|
||||||
return product
|
return product
|
||||||
@@ -160,10 +235,163 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
db.delete(product)
|
if product.deleted:
|
||||||
|
raise HTTPException(status_code=400, detail="Product is already deleted")
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Simply mark as deleted and set valid_from to today
|
||||||
|
# The trigger will automatically create the history record
|
||||||
|
product.deleted = True
|
||||||
|
product.valid_from = date.today()
|
||||||
|
product.updated_at = func.now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Product deleted successfully"}
|
return {"message": "Product deleted successfully"}
|
||||||
|
|
||||||
|
# Historical Product endpoints
|
||||||
|
@app.get("/products/{product_id}/history", response_model=List[schemas.ProductHistory])
|
||||||
|
def get_product_history(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get all historical versions of a product"""
|
||||||
|
# Check if product exists
|
||||||
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
# Get history from history table
|
||||||
|
history = db.query(models.ProductHistory).filter(
|
||||||
|
models.ProductHistory.id == product_id
|
||||||
|
).order_by(models.ProductHistory.valid_from.desc()).all()
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
|
@app.get("/products/{product_id}/at/{date}", response_model=schemas.ProductAtDate)
|
||||||
|
def get_product_at_date(product_id: int, date: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get product as it existed at a specific date - CRUCIAL for shopping events"""
|
||||||
|
from datetime import datetime, date as date_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the date string (accept YYYY-MM-DD format)
|
||||||
|
target_date = datetime.strptime(date, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
|
||||||
|
|
||||||
|
# First try current products table
|
||||||
|
current_product = db.query(models.Product).filter(
|
||||||
|
models.Product.id == product_id,
|
||||||
|
models.Product.valid_from <= target_date,
|
||||||
|
models.Product.valid_to >= target_date
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if current_product:
|
||||||
|
# Get related data
|
||||||
|
category = db.query(models.GroceryCategory).filter(
|
||||||
|
models.GroceryCategory.id == current_product.category_id
|
||||||
|
).first()
|
||||||
|
brand = None
|
||||||
|
if current_product.brand_id:
|
||||||
|
brand = db.query(models.Brand).filter(
|
||||||
|
models.Brand.id == current_product.brand_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return schemas.ProductAtDate(
|
||||||
|
id=current_product.id,
|
||||||
|
name=current_product.name,
|
||||||
|
category_id=current_product.category_id,
|
||||||
|
category=category,
|
||||||
|
brand_id=current_product.brand_id,
|
||||||
|
brand=brand,
|
||||||
|
organic=current_product.organic,
|
||||||
|
weight=current_product.weight,
|
||||||
|
weight_unit=current_product.weight_unit,
|
||||||
|
valid_from=current_product.valid_from,
|
||||||
|
valid_to=current_product.valid_to,
|
||||||
|
deleted=current_product.deleted,
|
||||||
|
was_current=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try history table
|
||||||
|
historical_product = db.query(models.ProductHistory).filter(
|
||||||
|
models.ProductHistory.id == product_id,
|
||||||
|
models.ProductHistory.valid_from <= target_date,
|
||||||
|
models.ProductHistory.valid_to >= target_date
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if historical_product:
|
||||||
|
# Get related data (note: these might have changed too, but we'll use current versions)
|
||||||
|
category = db.query(models.GroceryCategory).filter(
|
||||||
|
models.GroceryCategory.id == historical_product.category_id
|
||||||
|
).first()
|
||||||
|
brand = None
|
||||||
|
if historical_product.brand_id:
|
||||||
|
brand = db.query(models.Brand).filter(
|
||||||
|
models.Brand.id == historical_product.brand_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return schemas.ProductAtDate(
|
||||||
|
id=historical_product.id,
|
||||||
|
name=historical_product.name,
|
||||||
|
category_id=historical_product.category_id,
|
||||||
|
category=category,
|
||||||
|
brand_id=historical_product.brand_id,
|
||||||
|
brand=brand,
|
||||||
|
organic=historical_product.organic,
|
||||||
|
weight=historical_product.weight,
|
||||||
|
weight_unit=historical_product.weight_unit,
|
||||||
|
valid_from=historical_product.valid_from,
|
||||||
|
valid_to=historical_product.valid_to,
|
||||||
|
deleted=historical_product.deleted,
|
||||||
|
was_current=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Product didn't exist at that date
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Product {product_id} did not exist on {date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/shopping-events/{event_id}/products-as-purchased", response_model=List[schemas.ProductAtPurchase])
|
||||||
|
def get_shopping_event_products_as_purchased(event_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get products as they were when purchased - shows historical product data"""
|
||||||
|
# Get the shopping event
|
||||||
|
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||||
|
|
||||||
|
# Get products from association table
|
||||||
|
products_data = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT sep.product_id, sep.amount, sep.price, sep.discount
|
||||||
|
FROM shopping_event_products sep
|
||||||
|
WHERE sep.shopping_event_id = :event_id
|
||||||
|
"""),
|
||||||
|
{"event_id": event_id}
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for product_data in products_data:
|
||||||
|
# Get product as it was at the time of purchase
|
||||||
|
try:
|
||||||
|
# Extract just the date from the shopping event datetime
|
||||||
|
purchase_date = event.date.date().strftime('%Y-%m-%d')
|
||||||
|
product_at_purchase = get_product_at_date(
|
||||||
|
product_data.product_id,
|
||||||
|
purchase_date,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(schemas.ProductAtPurchase(
|
||||||
|
product=product_at_purchase,
|
||||||
|
amount=product_data.amount,
|
||||||
|
price=product_data.price,
|
||||||
|
discount=product_data.discount
|
||||||
|
))
|
||||||
|
except HTTPException:
|
||||||
|
# Product didn't exist at purchase time (shouldn't happen, but handle gracefully)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
# Shop endpoints
|
# Shop endpoints
|
||||||
@app.post("/shops/", response_model=schemas.Shop)
|
@app.post("/shops/", response_model=schemas.Shop)
|
||||||
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -1,11 +1,89 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Table
|
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Date, ForeignKey, Table, event, DDL
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func, text
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Constants for temporal tables
|
||||||
|
FAR_FUTURE_DATE = "'9999-12-31'"
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Trigger creation SQL
|
||||||
|
PRODUCTS_VERSIONING_TRIGGER_SQL = """
|
||||||
|
CREATE OR REPLACE FUNCTION products_versioning_trigger()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Handle DELETE operations
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
-- Create history record for the deleted product
|
||||||
|
INSERT INTO products_history (
|
||||||
|
id, name, category_id, brand_id, organic, weight, weight_unit,
|
||||||
|
created_at, updated_at, valid_from, valid_to, deleted, operation
|
||||||
|
) VALUES (
|
||||||
|
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
|
||||||
|
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
|
||||||
|
OLD.valid_from, CURRENT_DATE, OLD.deleted, 'D'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Handle UPDATE operations
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
-- Check if any versioned fields have changed
|
||||||
|
IF (OLD.name IS DISTINCT FROM NEW.name OR
|
||||||
|
OLD.category_id IS DISTINCT FROM NEW.category_id OR
|
||||||
|
OLD.brand_id IS DISTINCT FROM NEW.brand_id OR
|
||||||
|
OLD.organic IS DISTINCT FROM NEW.organic OR
|
||||||
|
OLD.weight IS DISTINCT FROM NEW.weight OR
|
||||||
|
OLD.weight_unit IS DISTINCT FROM NEW.weight_unit OR
|
||||||
|
OLD.deleted IS DISTINCT FROM NEW.deleted) THEN
|
||||||
|
|
||||||
|
-- Determine the valid_to date for the history record
|
||||||
|
DECLARE
|
||||||
|
history_valid_to DATE;
|
||||||
|
BEGIN
|
||||||
|
-- If valid_from was manually changed, use that as the cutoff
|
||||||
|
-- Otherwise, use current date for automatic versioning
|
||||||
|
IF OLD.valid_from IS DISTINCT FROM NEW.valid_from THEN
|
||||||
|
history_valid_to = NEW.valid_from;
|
||||||
|
ELSE
|
||||||
|
history_valid_to = CURRENT_DATE;
|
||||||
|
-- For automatic versioning, update the valid_from to today
|
||||||
|
NEW.valid_from = CURRENT_DATE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Create history record with the old data
|
||||||
|
INSERT INTO products_history (
|
||||||
|
id, name, category_id, brand_id, organic, weight, weight_unit,
|
||||||
|
created_at, updated_at, valid_from, valid_to, deleted, operation
|
||||||
|
) VALUES (
|
||||||
|
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
|
||||||
|
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
|
||||||
|
OLD.valid_from, history_valid_to, OLD.deleted, 'U'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Always ensure valid_to is set to far future for current version
|
||||||
|
NEW.valid_to = '9999-12-31';
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS products_versioning_trigger ON products;
|
||||||
|
CREATE TRIGGER products_versioning_trigger
|
||||||
|
BEFORE UPDATE OR DELETE ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION products_versioning_trigger();
|
||||||
|
"""
|
||||||
|
|
||||||
# Association table for many-to-many relationship between shopping events and products
|
# Association table for many-to-many relationship between shopping events and products
|
||||||
shopping_event_products = Table(
|
shopping_event_products = Table(
|
||||||
'shopping_event_products',
|
'shopping_event_products',
|
||||||
@@ -78,6 +156,11 @@ class Product(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Temporal columns for versioning
|
||||||
|
valid_from = Column(Date, server_default=func.current_date(), nullable=False)
|
||||||
|
valid_to = Column(Date, server_default=text(FAR_FUTURE_DATE), nullable=False)
|
||||||
|
deleted = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
category = relationship("GroceryCategory", back_populates="products")
|
category = relationship("GroceryCategory", back_populates="products")
|
||||||
brand = relationship("Brand", back_populates="products")
|
brand = relationship("Brand", back_populates="products")
|
||||||
@@ -93,6 +176,41 @@ class Product(Base):
|
|||||||
viewonly=True
|
viewonly=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ProductHistory(Base):
|
||||||
|
__tablename__ = "products_history"
|
||||||
|
|
||||||
|
history_id = Column(Integer, primary_key=True, index=True)
|
||||||
|
id = Column(Integer, nullable=False, index=True) # Original product ID
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
category_id = Column(Integer, nullable=False)
|
||||||
|
brand_id = Column(Integer, nullable=True)
|
||||||
|
organic = Column(Boolean, default=False)
|
||||||
|
weight = Column(Float, nullable=True)
|
||||||
|
weight_unit = Column(String, default="piece")
|
||||||
|
created_at = Column(DateTime(timezone=True))
|
||||||
|
updated_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# Temporal columns
|
||||||
|
valid_from = Column(Date, nullable=False)
|
||||||
|
valid_to = Column(Date, nullable=False)
|
||||||
|
deleted = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Audit columns
|
||||||
|
operation = Column(String(1), nullable=False) # 'U' for Update, 'D' for Delete
|
||||||
|
archived_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# Create trigger after ALL tables are created
|
||||||
|
@event.listens_for(Base.metadata, 'after_create')
|
||||||
|
def create_products_versioning_trigger_after_all_tables(target, connection, **kw):
|
||||||
|
"""Create the products versioning trigger after all tables are created"""
|
||||||
|
try:
|
||||||
|
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
|
||||||
|
print("✅ Products versioning trigger created successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Warning: Could not create products versioning trigger: {e}")
|
||||||
|
# Don't fail the entire application startup if trigger creation fails
|
||||||
|
pass
|
||||||
|
|
||||||
class Shop(Base):
|
class Shop(Base):
|
||||||
__tablename__ = "shops"
|
__tablename__ = "shops"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
|
||||||
# Brand schemas
|
# Brand schemas
|
||||||
class BrandBase(BaseModel):
|
class BrandBase(BaseModel):
|
||||||
@@ -70,7 +70,7 @@ class ProductBase(BaseModel):
|
|||||||
weight_unit: str = "g"
|
weight_unit: str = "g"
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
pass
|
valid_from: Optional[date] = None # If not provided, will use current date
|
||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
class ProductUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -79,6 +79,7 @@ class ProductUpdate(BaseModel):
|
|||||||
organic: Optional[bool] = None
|
organic: Optional[bool] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
weight_unit: Optional[str] = None
|
weight_unit: Optional[str] = None
|
||||||
|
valid_from: Optional[date] = None # If not provided, will use current date
|
||||||
|
|
||||||
class Product(ProductBase):
|
class Product(ProductBase):
|
||||||
id: int
|
id: int
|
||||||
@@ -90,6 +91,54 @@ class Product(ProductBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
# Historical Product schemas
|
||||||
|
class ProductHistory(BaseModel):
|
||||||
|
history_id: int
|
||||||
|
id: int # Original product ID
|
||||||
|
name: str
|
||||||
|
category_id: int
|
||||||
|
brand_id: Optional[int] = None
|
||||||
|
organic: bool = False
|
||||||
|
weight: Optional[float] = None
|
||||||
|
weight_unit: str = "g"
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
valid_from: date
|
||||||
|
valid_to: date
|
||||||
|
deleted: bool
|
||||||
|
operation: str # 'U' for Update, 'D' for Delete
|
||||||
|
archived_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class ProductAtDate(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
category_id: int
|
||||||
|
category: GroceryCategory
|
||||||
|
brand_id: Optional[int] = None
|
||||||
|
brand: Optional[Brand] = None
|
||||||
|
organic: bool = False
|
||||||
|
weight: Optional[float] = None
|
||||||
|
weight_unit: str = "g"
|
||||||
|
valid_from: date
|
||||||
|
valid_to: date
|
||||||
|
deleted: bool
|
||||||
|
was_current: bool # True if from current table, False if from history
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class ProductAtPurchase(BaseModel):
|
||||||
|
product: ProductAtDate
|
||||||
|
amount: float
|
||||||
|
price: float
|
||||||
|
discount: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Shop schemas
|
# Shop schemas
|
||||||
class ShopBase(BaseModel):
|
class ShopBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ Version configuration for Groceries Tracker Backend
|
|||||||
Single source of truth for version information
|
Single source of truth for version information
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
__app_name__ = "Groceries Tracker API"
|
__app_name__ = "Groceries Tracker API"
|
||||||
__description__ = "API for tracking grocery shopping events, products, and expenses"
|
__description__ = "API for tracking grocery shopping events, products, and expenses with historical data support"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "groceries-tracker-frontend",
|
"name": "groceries-tracker-frontend",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
|
|||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
11
frontend/public/favicon.svg
Normal file
11
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#10B981"/>
|
||||||
|
<g fill="white">
|
||||||
|
<!-- Shopping cart -->
|
||||||
|
<path d="M7 8h2l1.68 7.39a2 2 0 0 0 2 1.61H20a2 2 0 0 0 2-1.61L24 10H11"/>
|
||||||
|
<circle cx="14" cy="23" r="1"/>
|
||||||
|
<circle cx="20" cy="23" r="1"/>
|
||||||
|
<!-- Handle -->
|
||||||
|
<path d="M7 8h-2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 394 B |
@@ -3,13 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.svg" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#10B981" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Track product prices and shopping events"
|
content="Track product prices and shopping events"
|
||||||
/>
|
/>
|
||||||
<title>Product Tracker</title>
|
<title>Groceries Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Groceries",
|
||||||
|
"name": "Groceries Tracker",
|
||||||
|
"description": "Track product prices and shopping events",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "16x16 32x32 48x48",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#10B981",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { productApi, brandApi, groceryCategoryApi } from '../services/api';
|
import { productApi, brandApi, groceryCategoryApi, utilityApi } from '../services/api';
|
||||||
import { Product, Brand, GroceryCategory } from '../types';
|
import { Product, Brand, GroceryCategory } from '../types';
|
||||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface ProductFormData {
|
|||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
|
valid_from: string; // ISO date string (YYYY-MM-DD)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
|
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
|
||||||
@@ -27,12 +28,15 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: ''
|
||||||
});
|
});
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [currentDate, setCurrentDate] = useState('');
|
||||||
|
const [minValidFromDate, setMinValidFromDate] = useState('');
|
||||||
|
|
||||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchBrands();
|
fetchBrands();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
|
fetchCurrentDate();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -65,52 +70,126 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCurrentDate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await utilityApi.getCurrentDate();
|
||||||
|
// Only update if valid_from is not already set
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
valid_from: prev.valid_from || response.data.current_date
|
||||||
|
}));
|
||||||
|
setCurrentDate(response.data.current_date);
|
||||||
|
setMinValidFromDate(response.data.current_date);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch current date:', err);
|
||||||
|
// Fallback to current date if API fails
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
valid_from: prev.valid_from || today
|
||||||
|
}));
|
||||||
|
setCurrentDate(today);
|
||||||
|
setMinValidFromDate(today);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Populate form when editing or duplicating
|
// Populate form when editing or duplicating
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editProduct) {
|
if (editProduct && isOpen) {
|
||||||
|
// For editing, fetch the current valid_from to set proper constraints
|
||||||
|
const fetchProductValidFrom = async () => {
|
||||||
|
try {
|
||||||
|
const response = await productApi.getValidFromDate(editProduct.id);
|
||||||
|
const currentValidFrom = response.data.valid_from;
|
||||||
|
setMinValidFromDate(currentValidFrom);
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: editProduct.name,
|
name: editProduct.name,
|
||||||
category_id: editProduct.category_id,
|
category_id: editProduct.category_id,
|
||||||
brand_id: editProduct.brand_id,
|
brand_id: editProduct.brand_id,
|
||||||
organic: editProduct.organic,
|
organic: editProduct.organic,
|
||||||
weight: editProduct.weight,
|
weight: editProduct.weight,
|
||||||
weight_unit: editProduct.weight_unit
|
weight_unit: editProduct.weight_unit,
|
||||||
|
valid_from: currentDate // Default to today for edits
|
||||||
});
|
});
|
||||||
} else if (duplicateProduct) {
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch product valid_from:', err);
|
||||||
|
setError('Failed to load product data for editing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
fetchProductValidFrom();
|
||||||
|
}
|
||||||
|
} else if (duplicateProduct && isOpen) {
|
||||||
|
// For duplicating, use today as default and allow any date <= today
|
||||||
|
setMinValidFromDate('1900-01-01'); // No restriction for new products
|
||||||
setFormData({
|
setFormData({
|
||||||
name: duplicateProduct.name,
|
name: `${duplicateProduct.name} (Copy)`,
|
||||||
category_id: duplicateProduct.category_id,
|
category_id: duplicateProduct.category_id,
|
||||||
brand_id: duplicateProduct.brand_id,
|
brand_id: duplicateProduct.brand_id,
|
||||||
organic: duplicateProduct.organic,
|
organic: duplicateProduct.organic,
|
||||||
weight: duplicateProduct.weight,
|
weight: duplicateProduct.weight,
|
||||||
weight_unit: duplicateProduct.weight_unit
|
weight_unit: duplicateProduct.weight_unit,
|
||||||
|
valid_from: currentDate
|
||||||
});
|
});
|
||||||
} else {
|
} else if (isOpen && currentDate) {
|
||||||
// Reset form for adding new product
|
// For new products, allow any date <= today
|
||||||
|
setMinValidFromDate('1900-01-01'); // No restriction for new products
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
category_id: undefined,
|
category_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: currentDate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
}, [editProduct, duplicateProduct, isOpen, currentDate]);
|
||||||
}, [editProduct, duplicateProduct, isOpen]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.category_id) {
|
if (!formData.name.trim() || !formData.category_id || !formData.valid_from) {
|
||||||
setError('Please fill in all required fields with valid values');
|
setError('Please fill in all required fields with valid values');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate date constraints
|
||||||
|
try {
|
||||||
|
const validFromDate = new Date(formData.valid_from);
|
||||||
|
if (isNaN(validFromDate.getTime())) {
|
||||||
|
setError('Please enter a valid date');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
const today = new Date(currentDate);
|
||||||
|
if (!isNaN(today.getTime()) && validFromDate > today) {
|
||||||
|
setError('Valid from date cannot be in the future');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editProduct && minValidFromDate) {
|
||||||
|
// Only validate if minValidFromDate is set and valid
|
||||||
|
const minDate = new Date(minValidFromDate);
|
||||||
|
if (!isNaN(minDate.getTime()) && validFromDate <= minDate) {
|
||||||
|
setError(`Valid from date must be after the current product's valid from date (${minValidFromDate})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (dateError) {
|
||||||
|
console.error('Date validation error:', dateError);
|
||||||
|
setError('Please enter a valid date');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const productData = {
|
const productData: any = {
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
category_id: formData.category_id!,
|
category_id: formData.category_id!,
|
||||||
brand_id: formData.brand_id || undefined,
|
brand_id: formData.brand_id || undefined,
|
||||||
@@ -119,6 +198,11 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
weight_unit: formData.weight_unit
|
weight_unit: formData.weight_unit
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only include valid_from if it's provided
|
||||||
|
if (formData.valid_from) {
|
||||||
|
productData.valid_from = formData.valid_from;
|
||||||
|
}
|
||||||
|
|
||||||
if (editProduct) {
|
if (editProduct) {
|
||||||
// Update existing product
|
// Update existing product
|
||||||
await productApi.update(editProduct.id, productData);
|
await productApi.update(editProduct.id, productData);
|
||||||
@@ -134,7 +218,8 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
onProductAdded();
|
onProductAdded();
|
||||||
@@ -145,7 +230,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [formData, editProduct, onProductAdded, onClose]);
|
}, [formData, editProduct, onProductAdded, onClose, currentDate, minValidFromDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -228,10 +313,44 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="e.g., Whole Foods Organic Milk"
|
placeholder="Product name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="valid_from" className="block text-sm font-medium text-gray-700">
|
||||||
|
Valid from *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="valid_from"
|
||||||
|
name="valid_from"
|
||||||
|
value={formData.valid_from}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min={editProduct && minValidFromDate ? (() => {
|
||||||
|
try {
|
||||||
|
const nextDay = new Date(minValidFromDate);
|
||||||
|
if (!isNaN(nextDay.getTime())) {
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
return nextDay.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating min date:', error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})() : undefined}
|
||||||
|
max={currentDate}
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{editProduct
|
||||||
|
? `Must be after ${minValidFromDate} and not in the future`
|
||||||
|
: 'The date when this product information becomes effective (cannot be in the future)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
|
||||||
Category *
|
Category *
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
|||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await productApi.getAll();
|
// If we have a shopping date, get products available for that date
|
||||||
|
// Otherwise, get all non-deleted products
|
||||||
|
const response = formData.date
|
||||||
|
? await productApi.getAvailableForShopping(formData.date)
|
||||||
|
: await productApi.getAll(false); // false = don't show deleted
|
||||||
setProducts(response.data);
|
setProducts(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
@@ -223,6 +227,13 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [formData.shop_id]);
|
}, [formData.shop_id]);
|
||||||
|
|
||||||
|
// Effect to refetch products when shopping date changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && formData.date) {
|
||||||
|
fetchProducts();
|
||||||
|
}
|
||||||
|
}, [formData.date, isOpen]);
|
||||||
|
|
||||||
const addProductToEvent = () => {
|
const addProductToEvent = () => {
|
||||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||||
@@ -483,7 +494,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24">
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
Amount
|
Amount
|
||||||
</label>
|
</label>
|
||||||
@@ -494,10 +505,10 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
|||||||
placeholder="1"
|
placeholder="1"
|
||||||
value={newProductItem.amount}
|
value={newProductItem.amount}
|
||||||
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
|
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
|
||||||
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24">
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
Price ($)
|
Price ($)
|
||||||
</label>
|
</label>
|
||||||
@@ -508,165 +519,133 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
|||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={newProductItem.price}
|
value={newProductItem.price}
|
||||||
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
|
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
|
||||||
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="flex items-center">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1 text-center">
|
<label className="flex items-center space-x-2">
|
||||||
Discount
|
|
||||||
</label>
|
|
||||||
<div className="h-10 flex items-center justify-center border border-gray-300 rounded-md bg-gray-50">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={newProductItem.discount}
|
checked={newProductItem.discount}
|
||||||
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
|
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="text-xs font-medium text-gray-700">Discount</span>
|
||||||
</div>
|
|
||||||
<div className="w-16">
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1 opacity-0">
|
|
||||||
Action
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addProductToEvent}
|
onClick={addProductToEvent}
|
||||||
className="w-full h-10 bg-green-500 hover:bg-green-700 text-white px-3 py-2 rounded-md font-medium"
|
className="px-6 py-2 bg-green-500 hover:bg-green-700 text-white rounded-md font-medium text-sm"
|
||||||
>
|
>
|
||||||
Add
|
Add Product
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.shop_id > 0 && (
|
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
|
||||||
{shopBrands.length === 0
|
|
||||||
? `Showing all ${products.length} products (no brand restrictions for this shop)`
|
|
||||||
: `Showing ${getFilteredProducts().length} of ${products.length} products (filtered by shop's available brands)`
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selected Products List */}
|
{/* Selected Products List */}
|
||||||
{selectedProducts.length > 0 && (
|
<div className="overflow-x-auto">
|
||||||
<div className="bg-gray-50 rounded-md p-4 max-h-40 md:max-h-48 overflow-y-auto">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
<thead className="bg-gray-50">
|
||||||
{Object.entries(
|
<tr>
|
||||||
selectedProducts.reduce((groups, item, index) => {
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
const product = products.find(p => p.id === item.product_id);
|
Product
|
||||||
const category = product?.category.name || 'Unknown';
|
</th>
|
||||||
if (!groups[category]) {
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
groups[category] = [];
|
Amount
|
||||||
}
|
</th>
|
||||||
groups[category].push({ ...item, index });
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
return groups;
|
Price ($)
|
||||||
}, {} as Record<string, (ProductInEvent & { index: number })[]>)
|
</th>
|
||||||
)
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
Discount
|
||||||
.map(([category, categoryItems]) => (
|
</th>
|
||||||
<div key={category} className="mb-3 last:mb-0">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<div className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-1 border-b border-gray-300 pb-1">
|
Total ($)
|
||||||
{category}
|
</th>
|
||||||
</div>
|
<th scope="col" className="relative px-6 py-3">
|
||||||
{categoryItems.map((item) => (
|
<span className="sr-only">Edit</span>
|
||||||
<div key={item.index} className="flex flex-col md:flex-row md:justify-between md:items-center py-2 pl-2 space-y-2 md:space-y-0">
|
</th>
|
||||||
<div className="flex-1 min-w-0">
|
</tr>
|
||||||
<div className="text-sm text-gray-900">
|
</thead>
|
||||||
{getProductName(item.product_id)}
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
</div>
|
{selectedProducts.map((product, index) => (
|
||||||
<div className="text-xs text-gray-600">
|
<tr key={index}>
|
||||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
{item.discount && <span className="ml-2 text-green-600 font-medium">🏷️</span>}
|
{getProductName(product.product_id)}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<div className="flex space-x-2 md:flex-shrink-0">
|
{product.amount}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{product.price.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{product.discount ? 'Yes' : 'No'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{(product.amount * product.price).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => editProductFromEvent(item.index)}
|
onClick={() => editProductFromEvent(index)}
|
||||||
className="flex-1 md:flex-none px-3 py-1 text-blue-500 hover:text-blue-700 border border-blue-300 hover:bg-blue-50 rounded text-sm"
|
className="text-indigo-600 hover:text-indigo-900 mr-2"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeProductFromEvent(item.index)}
|
onClick={() => removeProductFromEvent(index)}
|
||||||
className="flex-1 md:flex-none px-3 py-1 text-red-500 hover:text-red-700 border border-red-300 hover:bg-red-50 rounded text-sm"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total Amount */}
|
{/* Total Amount and Notes */}
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0">
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex-1">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Total Amount ($)
|
Total Amount ($)
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center space-x-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoCalculate}
|
|
||||||
onChange={(e) => setAutoCalculate(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-600">Auto-calculate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={formData.total_amount || ''}
|
value={formData.total_amount}
|
||||||
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
|
onChange={(e) => setFormData({...formData, total_amount: parseFloat(e.target.value)})}
|
||||||
disabled={autoCalculate}
|
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
className={`w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
autoCalculate ? 'bg-gray-100 cursor-not-allowed' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{autoCalculate && selectedProducts.length > 0 && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Automatically calculated from selected products: ${calculateTotal(selectedProducts).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
{/* Notes */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Notes (Optional)
|
Notes
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({...formData, notes: e.target.value})}
|
onChange={(e) => setFormData({...formData, notes: e.target.value})}
|
||||||
rows={3}
|
className="w-full h-24 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Add any notes about this shopping event..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Save Button */}
|
||||||
<div className="flex flex-col md:flex-row md:justify-end space-y-3 md:space-y-0 md:space-x-3 pt-4">
|
<div className="flex justify-end">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full md:w-auto px-6 py-3 md:py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 font-medium text-base md:text-sm"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
|
disabled={loading}
|
||||||
className="w-full md:w-auto px-6 py-3 md:py-2 bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-md font-medium text-base md:text-sm"
|
className="px-6 py-3 bg-blue-500 hover:bg-blue-700 text-white rounded-md font-medium text-base disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Saving...' : (isEditMode ? 'Update Event' : 'Create Event')}
|
{loading ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,7 +6,21 @@ import { shoppingEventApi } from '../services/api';
|
|||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
|
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Safe date formatting function
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRecentEvents();
|
fetchRecentEvents();
|
||||||
@@ -18,7 +32,19 @@ const Dashboard: React.FC = () => {
|
|||||||
const response = await shoppingEventApi.getAll();
|
const response = await shoppingEventApi.getAll();
|
||||||
// Get the 3 most recent events
|
// Get the 3 most recent events
|
||||||
const recent = response.data
|
const recent = response.data
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.sort((a, b) => {
|
||||||
|
try {
|
||||||
|
const dateA = new Date(b.created_at);
|
||||||
|
const dateB = new Date(a.created_at);
|
||||||
|
// Check if dates are valid
|
||||||
|
if (isNaN(dateA.getTime())) return 1;
|
||||||
|
if (isNaN(dateB.getTime())) return -1;
|
||||||
|
return dateA.getTime() - dateB.getTime();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sorting events by date:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
setRecentEvents(recent);
|
setRecentEvents(recent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -179,7 +205,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<span className="text-sm text-gray-500">{event.shop.city}</span>
|
<span className="text-sm text-gray-500">{event.shop.city}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{new Date(event.date).toLocaleDateString()}
|
{formatDate(event.date)}
|
||||||
</p>
|
</p>
|
||||||
{event.products.length > 0 && (
|
{event.products.length > 0 && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const ProductList: React.FC = () => {
|
|||||||
const [duplicatingProduct, setDuplicatingProduct] = useState<Product | null>(null);
|
const [duplicatingProduct, setDuplicatingProduct] = useState<Product | null>(null);
|
||||||
const [sortField, setSortField] = useState<string>('name');
|
const [sortField, setSortField] = useState<string>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [showDeleted, setShowDeleted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
@@ -27,12 +28,12 @@ const ProductList: React.FC = () => {
|
|||||||
// Remove the parameter from URL
|
// Remove the parameter from URL
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
}
|
}
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams, showDeleted]);
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await productApi.getAll();
|
const response = await productApi.getAll(showDeleted);
|
||||||
setProducts(response.data);
|
setProducts(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to fetch products');
|
setError('Failed to fetch products');
|
||||||
@@ -180,6 +181,16 @@ const ProductList: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Products</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Products</h1>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDeleted}
|
||||||
|
onChange={(e) => setShowDeleted(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Show deleted</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
@@ -191,6 +202,7 @@ const ProductList: React.FC = () => {
|
|||||||
Add New Product
|
Add New Product
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
@@ -257,22 +269,24 @@ const ProductList: React.FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{sortedProducts.map((product) => (
|
{sortedProducts.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-gray-50">
|
<tr key={product.id} className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className={`text-sm font-medium ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
|
||||||
{product.name} {product.organic ? '🌱' : ''}
|
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
{product.category.name}
|
{product.category.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
{product.brand ? product.brand.name : '-'}
|
{product.brand ? product.brand.name : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
{!product.deleted ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(product)}
|
onClick={() => handleEdit(product)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
@@ -291,6 +305,10 @@ const ProductList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Deleted</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -301,29 +319,30 @@ const ProductList: React.FC = () => {
|
|||||||
{/* Mobile Card Layout */}
|
{/* Mobile Card Layout */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
{sortedProducts.map((product) => (
|
{sortedProducts.map((product) => (
|
||||||
<div key={product.id} className="border-b border-gray-200 p-4 last:border-b-0">
|
<div key={product.id} className={`border-b border-gray-200 p-4 last:border-b-0 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-gray-900 truncate">
|
<h3 className={`font-medium truncate ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
|
||||||
{product.name} {product.organic ? '🌱' : ''}
|
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500">{product.category.name}</p>
|
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-500'}`}>{product.category.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{product.weight && (
|
{product.weight && (
|
||||||
<div className="text-right flex-shrink-0 ml-4">
|
<div className="text-right flex-shrink-0 ml-4">
|
||||||
<p className="text-sm text-gray-600">{product.weight}{product.weight_unit}</p>
|
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>{product.weight}{product.weight_unit}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.brand && (
|
{product.brand && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-sm text-gray-600">
|
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
<span className="font-medium">Brand:</span> {product.brand.name}
|
<span className="font-medium">Brand:</span> {product.brand.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!product.deleted ? (
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(product)}
|
onClick={() => handleEdit(product)}
|
||||||
@@ -344,6 +363,11 @@ const ProductList: React.FC = () => {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<span className="text-gray-400 text-sm">Deleted</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,8 +176,18 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
bValue = b.shop.name;
|
bValue = b.shop.name;
|
||||||
break;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
|
// Safely handle date parsing with validation
|
||||||
|
try {
|
||||||
aValue = new Date(a.date);
|
aValue = new Date(a.date);
|
||||||
bValue = new Date(b.date);
|
bValue = new Date(b.date);
|
||||||
|
// Check if dates are valid
|
||||||
|
if (isNaN(aValue.getTime())) aValue = new Date(0); // fallback to epoch
|
||||||
|
if (isNaN(bValue.getTime())) bValue = new Date(0); // fallback to epoch
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing dates for sorting:', error);
|
||||||
|
aValue = new Date(0);
|
||||||
|
bValue = new Date(0);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'items':
|
case 'items':
|
||||||
aValue = a.products.length;
|
aValue = a.products.length;
|
||||||
@@ -246,6 +256,20 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Safe date formatting function
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -346,7 +370,7 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<div className="text-xs text-gray-500">{event.shop.city}</div>
|
<div className="text-xs text-gray-500">{event.shop.city}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{new Date(event.date).toLocaleDateString()}
|
{formatDate(event.date)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`items-cell px-6 py-4 whitespace-nowrap text-sm ${
|
className={`items-cell px-6 py-4 whitespace-nowrap text-sm ${
|
||||||
@@ -414,7 +438,7 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<p className="text-sm text-gray-500">{event.shop.city}</p>
|
<p className="text-sm text-gray-500">{event.shop.city}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex-shrink-0 ml-4">
|
<div className="text-right flex-shrink-0 ml-4">
|
||||||
<p className="text-sm text-gray-600">{new Date(event.date).toLocaleDateString()}</p>
|
<p className="text-sm text-gray-600">{formatDate(event.date)}</p>
|
||||||
{event.total_amount && (
|
{event.total_amount && (
|
||||||
<p className="font-semibold text-green-600 mt-1">
|
<p className="font-semibold text-green-600 mt-1">
|
||||||
${event.total_amount.toFixed(2)}
|
${event.total_amount.toFixed(2)}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ const api = {
|
|||||||
|
|
||||||
// Product API functions
|
// Product API functions
|
||||||
export const productApi = {
|
export const productApi = {
|
||||||
getAll: () => api.get<Product[]>('/products/'),
|
getAll: (showDeleted: boolean = false) => api.get<Product[]>(`/products/?show_deleted=${showDeleted}`),
|
||||||
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
||||||
|
getValidFromDate: (id: number) => api.get<{ valid_from: string }>(`/products/${id}/valid-from`),
|
||||||
|
getAvailableForShopping: (shoppingDate: string) => api.get<Product[]>(`/products/available-for-shopping/${shoppingDate}`),
|
||||||
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
||||||
update: (id: number, product: Partial<ProductCreate>) =>
|
update: (id: number, product: Partial<ProductCreate>) =>
|
||||||
api.put<Product>(`/products/${id}`, product),
|
api.put<Product>(`/products/${id}`, product),
|
||||||
@@ -89,4 +91,9 @@ export const statsApi = {
|
|||||||
getShops: () => api.get('/stats/shops'),
|
getShops: () => api.get('/stats/shops'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Utility API functions
|
||||||
|
export const utilityApi = {
|
||||||
|
getCurrentDate: () => api.get<{ current_date: string }>('/current-date'),
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
@@ -30,6 +30,7 @@ export interface Product {
|
|||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
|
deleted?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ export interface ProductCreate {
|
|||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
|
valid_from?: string; // Optional: ISO date string (YYYY-MM-DD), defaults to current date if not provided
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Shop {
|
export interface Shop {
|
||||||
|
|||||||
102
frontend/src/utils/dateUtils.ts
Normal file
102
frontend/src/utils/dateUtils.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Date utility functions for safe date handling throughout the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely formats a date string to a localized date string
|
||||||
|
* @param dateString - The date string to format
|
||||||
|
* @param fallback - The fallback value if the date is invalid (default: 'Invalid Date')
|
||||||
|
* @returns Formatted date string or fallback value
|
||||||
|
*/
|
||||||
|
export const formatDate = (dateString: string | null | undefined, fallback: string = 'Invalid Date'): string => {
|
||||||
|
if (!dateString) return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely creates a Date object from a string
|
||||||
|
* @param dateString - The date string to parse
|
||||||
|
* @returns Date object or null if invalid
|
||||||
|
*/
|
||||||
|
export const safeParseDate = (dateString: string | null | undefined): Date | null => {
|
||||||
|
if (!dateString) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing date:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely compares two dates for sorting
|
||||||
|
* @param dateA - First date string
|
||||||
|
* @param dateB - Second date string
|
||||||
|
* @param direction - Sort direction ('asc' or 'desc')
|
||||||
|
* @returns Comparison result (-1, 0, 1)
|
||||||
|
*/
|
||||||
|
export const compareDates = (
|
||||||
|
dateA: string | null | undefined,
|
||||||
|
dateB: string | null | undefined,
|
||||||
|
direction: 'asc' | 'desc' = 'asc'
|
||||||
|
): number => {
|
||||||
|
const parsedA = safeParseDate(dateA);
|
||||||
|
const parsedB = safeParseDate(dateB);
|
||||||
|
|
||||||
|
// Handle null/invalid dates
|
||||||
|
if (!parsedA && !parsedB) return 0;
|
||||||
|
if (!parsedA) return direction === 'asc' ? 1 : -1;
|
||||||
|
if (!parsedB) return direction === 'asc' ? -1 : 1;
|
||||||
|
|
||||||
|
const result = parsedA.getTime() - parsedB.getTime();
|
||||||
|
return direction === 'asc' ? result : -result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current date in YYYY-MM-DD format
|
||||||
|
* @returns Current date string
|
||||||
|
*/
|
||||||
|
export const getCurrentDateString = (): string => {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a date string is valid and not in the future
|
||||||
|
* @param dateString - The date string to validate
|
||||||
|
* @param allowFuture - Whether to allow future dates (default: false)
|
||||||
|
* @returns Object with validation result and error message
|
||||||
|
*/
|
||||||
|
export const validateDate = (
|
||||||
|
dateString: string | null | undefined,
|
||||||
|
allowFuture: boolean = false
|
||||||
|
): { isValid: boolean; error?: string } => {
|
||||||
|
if (!dateString) {
|
||||||
|
return { isValid: false, error: 'Date is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = safeParseDate(dateString);
|
||||||
|
if (!date) {
|
||||||
|
return { isValid: false, error: 'Invalid date format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowFuture && date > new Date()) {
|
||||||
|
return { isValid: false, error: 'Date cannot be in the future' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true };
|
||||||
|
};
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
name
|
|
||||||
denree
|
|
||||||
Rewe Bio
|
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
name
|
|
||||||
Obst
|
|
||||||
Gemüse
|
|
||||||
Konserven
|
|
||||||
Tiefkühl
|
|
||||||
Molkereiprodukte
|
|
||||||
|
@@ -1,696 +0,0 @@
|
|||||||
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/27.0.9 Chrome/134.0.6998.205 Electron/35.4.0 Safari/537.36" version="27.0.9">
|
|
||||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
|
||||||
<mxGraphModel dx="2317" dy="760" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
|
||||||
<root>
|
|
||||||
<mxCell id="0" />
|
|
||||||
<mxCell id="1" parent="0" />
|
|
||||||
<mxCell id="shop-event-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="43" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="300" y="470" as="sourcePoint" />
|
|
||||||
<mxPoint x="350" y="420" as="targetPoint" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="43" target="99" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="620" y="310" as="sourcePoint" />
|
|
||||||
<mxPoint x="720" y="270" as="targetPoint" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="280" y="150" as="sourcePoint" />
|
|
||||||
<mxPoint x="720" y="290" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="110" y="10" width="320" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="420" y="470" width="180" height="300" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="129" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="130" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="128" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="9" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="10" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="11" value="categorie_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="12" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="13" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="14" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="18" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="180" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="19" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="18" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="20" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="18" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="36" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="210" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="37" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="36" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="38" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="36" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="240" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
|
||||||
<mxGeometry y="270" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="39" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_events</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="420" y="110" width="180" height="240" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="180" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
|
||||||
<mxGeometry y="210" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="120" y="90" width="180" height="210" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
|
|
||||||
<mxGeometry y="180" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="760" y="210" width="220" height="180" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
|
|
||||||
<mxGeometry y="30" width="220" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
|
||||||
<mxGeometry x="30" width="190" height="30" as="geometry">
|
|
||||||
<mxRectangle width="190" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
|
||||||
<mxGeometry y="60" width="220" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
|
||||||
<mxGeometry x="30" width="190" height="30" as="geometry">
|
|
||||||
<mxRectangle width="190" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
|
||||||
<mxGeometry y="90" width="220" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
|
||||||
<mxGeometry x="30" width="190" height="30" as="geometry">
|
|
||||||
<mxRectangle width="190" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
|
||||||
<mxGeometry y="120" width="220" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
|
||||||
<mxGeometry x="30" width="190" height="30" as="geometry">
|
|
||||||
<mxRectangle width="190" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
|
||||||
<mxGeometry y="150" width="220" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
|
|
||||||
<mxGeometry x="30" width="190" height="30" as="geometry">
|
|
||||||
<mxRectangle width="190" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="-410" y="400" width="180" height="150" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="114" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="116" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="115" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="117" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="115" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="118" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="119" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="118" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="120" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="118" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="121" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="122" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="121" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="123" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="121" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="124" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="125" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="124" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="126" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="124" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="127" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="115" target="128" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="610" y="525" as="sourcePoint" />
|
|
||||||
<mxPoint x="820" y="315" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="30" y="580" width="180" height="150" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="148" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="149" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="149" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="152" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="152" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="155" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="155" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="158" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="158" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="149" target="9" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="270" y="785" as="sourcePoint" />
|
|
||||||
<mxPoint x="90" y="805" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="199" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="187" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="280" y="755" as="sourcePoint" />
|
|
||||||
<mxPoint x="430" y="615" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="200" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="115" target="190" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="90" y="135" as="sourcePoint" />
|
|
||||||
<mxPoint x="-21" y="352" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="183" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands_in_shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="-160" y="210" width="180" height="180" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="184" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="183" vertex="1">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="185" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="186" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="187" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="188" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="189" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: nowrap;">shop_id: INTEGER</span>" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="190" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="191" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="190" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="192" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="190" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="193" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="194" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="193" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="195" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="193" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="196" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
|
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="197" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="196" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="198" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="196" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-200" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">related_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="790" y="470" width="180" height="210" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-201" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-202" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-201">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-203" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-201">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-204" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="60" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-205" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-204">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-206" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-204">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-214" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-215" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-214">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-216" value="product_id" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-214">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-217" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-218" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-217">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-219" value="product_id_ref" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-217">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-207" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-208" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-207">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-209" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-207">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-210" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
|
|
||||||
<mxGeometry y="180" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-211" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-210">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-212" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-210">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-220" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-214">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="700" y="810" as="sourcePoint" />
|
|
||||||
<mxPoint x="910" y="790" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="rvE4wdXwnSLMpUZ5b23a-221" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-217">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="710" y="580" as="sourcePoint" />
|
|
||||||
<mxPoint x="880" y="700" as="targetPoint" />
|
|
||||||
<Array as="points" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
</root>
|
|
||||||
</mxGraphModel>
|
|
||||||
</diagram>
|
|
||||||
</mxfile>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
name,category_name,organic,brand_name,weight,weight_unit
|
|
||||||
"Milch 3,5%",Molkereiprodukte,true,denree,1,l
|
|
||||||
"Milch 3,5%",Molkereiprodukte,true,Rewe Bio,1,l
|
|
||||||
"Frischkäse Natur",Molkereiprodukte,true,Rewe Bio,175,g
|
|
||||||
"Frischkäse Kräuter",Molkereiprodukte,true,Rewe Bio,175,g
|
|
||||||
|
127
test_temporal_logic.md
Normal file
127
test_temporal_logic.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Temporal Logic Test Scenarios
|
||||||
|
|
||||||
|
## Test Case 1: Add New Product
|
||||||
|
|
||||||
|
### Frontend Behavior:
|
||||||
|
- **Default valid_from**: Today's date (e.g., 2025-01-15)
|
||||||
|
- **User constraint**: Can edit to any date <= today
|
||||||
|
- **Date picker**: max="2025-01-15", no min restriction
|
||||||
|
|
||||||
|
### API Call:
|
||||||
|
```json
|
||||||
|
POST /products/
|
||||||
|
{
|
||||||
|
"name": "New Milk",
|
||||||
|
"category_id": 1,
|
||||||
|
"weight": 1000,
|
||||||
|
"valid_from": "2025-01-10" // User chose 5 days ago
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Validation:
|
||||||
|
- ✅ valid_from <= today (2025-01-10 <= 2025-01-15)
|
||||||
|
|
||||||
|
### Database Result:
|
||||||
|
```sql
|
||||||
|
products: id=1, name="New Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Case 2: Edit Existing Product
|
||||||
|
|
||||||
|
### Current State:
|
||||||
|
```sql
|
||||||
|
products: id=1, name="Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Behavior:
|
||||||
|
- **Fetch current valid_from**: API call to `/products/1/valid-from` → "2025-01-10"
|
||||||
|
- **Default valid_from**: Today's date (2025-01-15)
|
||||||
|
- **User constraint**: Can edit to any date > 2025-01-10 AND <= today
|
||||||
|
- **Date picker**: min="2025-01-10", max="2025-01-15"
|
||||||
|
|
||||||
|
### API Call:
|
||||||
|
```json
|
||||||
|
PUT /products/1
|
||||||
|
{
|
||||||
|
"weight": 1200,
|
||||||
|
"valid_from": "2025-01-12" // User chose 2 days after current valid_from
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Validation:
|
||||||
|
- ✅ valid_from <= today (2025-01-12 <= 2025-01-15)
|
||||||
|
- ✅ valid_from > current_valid_from (2025-01-12 > 2025-01-10)
|
||||||
|
|
||||||
|
### Backend Processing:
|
||||||
|
1. **Manual versioning** (since valid_from was specified):
|
||||||
|
- Create history record with old data
|
||||||
|
- Set history.valid_to = new.valid_from
|
||||||
|
|
||||||
|
### Database Result:
|
||||||
|
```sql
|
||||||
|
-- History table gets the old version
|
||||||
|
products_history:
|
||||||
|
id=1, name="Milk", weight=1000,
|
||||||
|
valid_from='2025-01-10', valid_to='2025-01-12', operation='U'
|
||||||
|
|
||||||
|
-- Current table gets updated version
|
||||||
|
products:
|
||||||
|
id=1, name="Milk", weight=1200,
|
||||||
|
valid_from='2025-01-12', valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Case 3: Edit Without Custom Date (Automatic Versioning)
|
||||||
|
|
||||||
|
### Current State:
|
||||||
|
```sql
|
||||||
|
products: id=1, name="Milk", weight=1200, valid_from='2025-01-12', valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Call (no valid_from specified):
|
||||||
|
```json
|
||||||
|
PUT /products/1
|
||||||
|
{
|
||||||
|
"name": "Organic Milk"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Processing:
|
||||||
|
1. **Automatic versioning** (trigger handles it):
|
||||||
|
- Trigger detects OLD.valid_from = NEW.valid_from
|
||||||
|
- Trigger creates history with valid_to = CURRENT_DATE
|
||||||
|
- Trigger sets NEW.valid_from = CURRENT_DATE
|
||||||
|
|
||||||
|
### Database Result:
|
||||||
|
```sql
|
||||||
|
-- History table gets the old version (trigger created)
|
||||||
|
products_history:
|
||||||
|
id=1, name="Milk", weight=1200,
|
||||||
|
valid_from='2025-01-12', valid_to='2025-01-15', operation='U'
|
||||||
|
|
||||||
|
-- Current table gets updated version (trigger set dates)
|
||||||
|
products:
|
||||||
|
id=1, name="Organic Milk", weight=1200,
|
||||||
|
valid_from='2025-01-15', valid_to='9999-12-31'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Rules Summary
|
||||||
|
|
||||||
|
### For New Products:
|
||||||
|
- ✅ Frontend: valid_from <= today
|
||||||
|
- ✅ Backend: valid_from <= today
|
||||||
|
|
||||||
|
### For Existing Products:
|
||||||
|
- ✅ Frontend: current_valid_from < valid_from <= today
|
||||||
|
- ✅ Backend: current_valid_from < valid_from <= today
|
||||||
|
- ✅ Manual versioning: history.valid_to = new.valid_from
|
||||||
|
- ✅ Automatic versioning: history.valid_to = CURRENT_DATE
|
||||||
|
|
||||||
|
### Database Trigger Logic:
|
||||||
|
- **Manual versioning**: When OLD.valid_from ≠ NEW.valid_from (app handles history)
|
||||||
|
- **Automatic versioning**: When OLD.valid_from = NEW.valid_from (trigger handles history)
|
||||||
Reference in New Issue
Block a user