public-ready-init
This commit is contained in:
commit
4b80fe28fe
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# SvelteKit
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
299
README.md
Normal file
299
README.md
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
# Nexus 3
|
||||||
|
|
||||||
|
A modern business management platform built with Django GraphQL and SvelteKit. Nexus 3 represents a significant architectural evolution from its predecessors, featuring a GraphQL API, modular backend architecture, and enhanced frontend state management.
|
||||||
|
|
||||||
|
## Improvements Over Nexus 1 and Nexus 2
|
||||||
|
|
||||||
|
### Architectural Improvements Over Nexus 1
|
||||||
|
|
||||||
|
| Feature | Nexus 1 | Nexus 3 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **API Architecture** | REST API | GraphQL API |
|
||||||
|
| **Frontend Framework** | React (JSX) | SvelteKit with TypeScript |
|
||||||
|
| **Backend Structure** | Monolithic (single `models.py`, `views.py`) | Modular (separate directories per domain) |
|
||||||
|
| **State Management** | Prop drilling | Svelte stores with typed operations |
|
||||||
|
| **Authentication** | JWT with DRF | JWT with graphql-jwt |
|
||||||
|
| **Type Safety** | None | Full TypeScript support |
|
||||||
|
| **Code Organization** | Flat structure | Domain-driven design |
|
||||||
|
|
||||||
|
### Architectural Improvements Over Nexus 2
|
||||||
|
|
||||||
|
| Feature | Nexus 2 | Nexus 3 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **API Architecture** | REST API (DRF ViewSets) | GraphQL API (Graphene) |
|
||||||
|
| **Data Fetching** | Multiple REST endpoints | Single GraphQL endpoint with precise queries |
|
||||||
|
| **Backend Patterns** | MVC with serializers | Repository + Command pattern |
|
||||||
|
| **Settings Management** | Single `settings.py` | Split settings (base/dev/prod) |
|
||||||
|
| **Frontend Data Layer** | Direct API calls | Typed GraphQL operations with URQL |
|
||||||
|
| **Caching** | None | URQL exchange-based caching |
|
||||||
|
| **Error Handling** | HTTP status codes | GraphQL error extensions |
|
||||||
|
|
||||||
|
### Key Improvements in Nexus 3
|
||||||
|
|
||||||
|
1. **GraphQL API**: Clients request exactly the data they need, reducing over-fetching and under-fetching
|
||||||
|
2. **Repository Pattern**: Data access is abstracted through repositories, making testing and maintenance easier
|
||||||
|
3. **Command Pattern**: Business logic is encapsulated in commands, promoting single responsibility
|
||||||
|
4. **Factory Pattern**: Object creation is standardized through factories
|
||||||
|
5. **Split Settings**: Environment-specific configuration (development, production)
|
||||||
|
6. **Typed Frontend**: Full TypeScript support with generated types for GraphQL operations
|
||||||
|
7. **URQL Client**: Modern GraphQL client with built-in caching and auth exchange
|
||||||
|
8. **Modular Structure**: Each domain (accounts, customers, services, etc.) has its own:
|
||||||
|
- Models
|
||||||
|
- Repositories
|
||||||
|
- Commands
|
||||||
|
- GraphQL types, inputs, queries, and mutations
|
||||||
|
- Frontend stores and components
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Python 3.11+
|
||||||
|
- Django 4.2+
|
||||||
|
- Graphene-Django (GraphQL)
|
||||||
|
- graphql-jwt (Authentication)
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis (caching)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- SvelteKit
|
||||||
|
- TypeScript
|
||||||
|
- URQL (GraphQL client)
|
||||||
|
- TailwindCSS
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nexus-3/
|
||||||
|
├── backend/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── settings/
|
||||||
|
│ │ ├── base.py
|
||||||
|
│ │ ├── development.py
|
||||||
|
│ │ └── production.py
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── models/ # Domain models
|
||||||
|
│ │ ├── repositories/ # Data access layer
|
||||||
|
│ │ ├── commands/ # Business logic
|
||||||
|
│ │ ├── factories/ # Object creation
|
||||||
|
│ │ ├── services/ # Service layer
|
||||||
|
│ │ └── utils/ # Utilities
|
||||||
|
│ └── graphql_api/
|
||||||
|
│ ├── types/ # GraphQL types
|
||||||
|
│ ├── inputs/ # GraphQL input types
|
||||||
|
│ ├── queries/ # GraphQL queries
|
||||||
|
│ └── mutations/ # GraphQL mutations
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── components/ # UI components by domain
|
||||||
|
│ │ ├── graphql/ # GraphQL client & operations
|
||||||
|
│ │ └── stores/ # Svelte stores by domain
|
||||||
|
│ └── routes/ # SvelteKit routes
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 15+ (or use Docker)
|
||||||
|
- Redis (optional, for caching)
|
||||||
|
|
||||||
|
### Using Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd nexus-3
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Edit backend/.env with your settings
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker-compose exec backend python manage.py migrate
|
||||||
|
|
||||||
|
# Create a superuser
|
||||||
|
docker-compose exec backend python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# Create superuser
|
||||||
|
python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DJANGO_SETTINGS_MODULE` | Settings module to use | `config.settings.development` |
|
||||||
|
| `DJANGO_SECRET_KEY` | Django secret key (production) | - |
|
||||||
|
| `DEV_SECRET_KEY` | Django secret key (development) | - |
|
||||||
|
| `DB_NAME` | Database name | `nexus` |
|
||||||
|
| `DB_USER` | Database user | `postgres` |
|
||||||
|
| `DB_PASSWORD` | Database password | - |
|
||||||
|
| `DB_HOST` | Database host | `localhost` |
|
||||||
|
| `DB_PORT` | Database port | `5432` |
|
||||||
|
| `REDIS_URL` | Redis connection URL | `redis://localhost:6379/1` |
|
||||||
|
| `SENTRY_DSN` | Sentry DSN for error tracking | - |
|
||||||
|
|
||||||
|
### Frontend Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PUBLIC_GRAPHQL_URL` | GraphQL API endpoint | `http://localhost:8000/graphql/` |
|
||||||
|
|
||||||
|
## GraphQL API
|
||||||
|
|
||||||
|
The GraphQL API is available at `/graphql/`. In development, you can use the GraphiQL interface to explore the schema.
|
||||||
|
|
||||||
|
### Example Query
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query GetAccounts {
|
||||||
|
accounts {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
customer {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
services {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
scheduledDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Mutation
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
mutation CreateAccount($input: AccountCreateInput!) {
|
||||||
|
createAccount(input: $input) {
|
||||||
|
account {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Entities
|
||||||
|
|
||||||
|
- **Customers**: Client organizations
|
||||||
|
- **Accounts**: Individual locations/sites belonging to customers
|
||||||
|
- **Services**: Scheduled service visits
|
||||||
|
- **Projects**: Multi-service projects
|
||||||
|
- **Invoices**: Billing records
|
||||||
|
- **Labor**: Labor tracking records
|
||||||
|
- **Revenue**: Revenue tracking
|
||||||
|
- **Schedules**: Employee schedules
|
||||||
|
- **Reports**: Generated reports
|
||||||
|
- **Punchlists**: Customizable service checklists
|
||||||
|
|
||||||
|
## Customizing the Punchlist Feature
|
||||||
|
|
||||||
|
The Punchlist model is designed to be customizable for your specific service workflow. The default fields provide a generic structure with sections for:
|
||||||
|
|
||||||
|
- Front area (customer-facing)
|
||||||
|
- Main work area
|
||||||
|
- Equipment
|
||||||
|
- Back area
|
||||||
|
- End of visit checklist
|
||||||
|
|
||||||
|
To customize:
|
||||||
|
|
||||||
|
1. Edit `backend/core/models/punchlists/punchlists.py` to add/remove/rename fields
|
||||||
|
2. Update `backend/graphql_api/types/punchlists.py` to expose the new fields
|
||||||
|
3. Update `backend/graphql_api/inputs/punchlists/punchlists.py` for create/update inputs
|
||||||
|
4. Run `python manage.py makemigrations` and `python manage.py migrate`
|
||||||
|
5. Update the frontend components accordingly
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend tests
|
||||||
|
cd backend
|
||||||
|
python manage.py test
|
||||||
|
|
||||||
|
# Frontend tests
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (using black and isort)
|
||||||
|
cd backend
|
||||||
|
black .
|
||||||
|
isort .
|
||||||
|
|
||||||
|
# Frontend (using prettier and eslint)
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Checklist
|
||||||
|
|
||||||
|
1. Set `DJANGO_SETTINGS_MODULE=config.settings.production`
|
||||||
|
2. Generate a strong `DJANGO_SECRET_KEY`
|
||||||
|
3. Configure PostgreSQL with proper credentials
|
||||||
|
4. Set up Redis for caching
|
||||||
|
5. Configure ALLOWED_HOSTS and CORS settings
|
||||||
|
6. Set up SSL/TLS
|
||||||
|
7. Configure email settings
|
||||||
|
8. (Optional) Set up Sentry for error tracking
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details.
|
||||||
31
backend/.env.example
Normal file
31
backend/.env.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Django Settings
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.development
|
||||||
|
DJANGO_SECRET_KEY=your-secret-key-here-generate-a-new-one
|
||||||
|
DEV_SECRET_KEY=your-dev-secret-key-here
|
||||||
|
|
||||||
|
# Database (Production)
|
||||||
|
DB_NAME=nexus
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your-database-password
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Allowed Hosts (Production)
|
||||||
|
DJANGO_ALLOWED_HOST=your-domain.com
|
||||||
|
|
||||||
|
# Email Settings (Production)
|
||||||
|
EMAIL_HOST=smtp.your-email-provider.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_HOST_USER=your-email@your-domain.com
|
||||||
|
EMAIL_HOST_PASSWORD=your-email-password
|
||||||
|
|
||||||
|
# Redis Cache (Production)
|
||||||
|
REDIS_URL=redis://localhost:6379/1
|
||||||
|
|
||||||
|
# Sentry Error Tracking (Optional)
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Google API Integration (Optional)
|
||||||
|
# GOOGLE_CREDENTIALS_PATH=/path/to/credentials.json
|
||||||
|
# GOOGLE_CALENDAR_ID=primary
|
||||||
|
# GOOGLE_IMPERSONATOR_EMAIL=admin@your-domain.com
|
||||||
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
db.sqlite3
|
||||||
|
data/
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
10
backend/config/asgi.py
Normal file
10
backend/config/asgi.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for the project.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
13
backend/config/settings/__init__.py
Normal file
13
backend/config/settings/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Settings initialization.
|
||||||
|
This module determines which settings file to use based on environment.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Set the environment variable to control which settings file is loaded
|
||||||
|
environment = os.environ.get('DJANGO_ENVIRONMENT', 'development')
|
||||||
|
|
||||||
|
if environment == 'production':
|
||||||
|
from .production import *
|
||||||
|
else:
|
||||||
|
from .development import *
|
||||||
166
backend/config/settings/base.py
Normal file
166
backend/config/settings/base.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Base settings for the application.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
# Django apps
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
|
||||||
|
# Third-party apps
|
||||||
|
'graphene_django',
|
||||||
|
'corsheaders',
|
||||||
|
'django_filters',
|
||||||
|
|
||||||
|
# Local apps
|
||||||
|
'backend.core',
|
||||||
|
'backend.graphql_api',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# GraphQL settings
|
||||||
|
GRAPHENE = {
|
||||||
|
'SCHEMA': 'backend.graphql_api.schema.schema',
|
||||||
|
"MIDDLEWARE": [
|
||||||
|
"graphql_jwt.middleware.JSONWebTokenMiddleware",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Authentication backends
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
'graphql_jwt.backends.JSONWebTokenBackend',
|
||||||
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
|
]
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
GRAPHQL_JWT = {
|
||||||
|
'JWT_VERIFY_EXPIRATION': True,
|
||||||
|
'JWT_EXPIRATION_DELTA': timedelta(days=7),
|
||||||
|
'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=30),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
logs_dir = os.path.join(BASE_DIR, 'logs')
|
||||||
|
if not os.path.exists(logs_dir):
|
||||||
|
os.makedirs(logs_dir)
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'verbose': {
|
||||||
|
'format': '{levelname} {asctime} {module} {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
|
'simple': {
|
||||||
|
'format': '{levelname} {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'verbose',
|
||||||
|
},
|
||||||
|
'file': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': os.path.join(logs_dir, 'django.log'),
|
||||||
|
'formatter': 'verbose',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': True,
|
||||||
|
},
|
||||||
|
'core': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': True,
|
||||||
|
},
|
||||||
|
'graphql_api': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
42
backend/config/settings/development.py
Normal file
42
backend/config/settings/development.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Development settings for the application.
|
||||||
|
"""
|
||||||
|
from .base import *
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.environ.get('DEV_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# CORS settings
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True # Only in development!
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
# GraphiQL settings
|
||||||
|
GRAPHENE['MIDDLEWARE'] += ['graphene_django.debug.DjangoDebugMiddleware']
|
||||||
|
|
||||||
|
# Show SQL queries in console
|
||||||
|
LOGGING['loggers']['django.db.backends'] = {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False,
|
||||||
|
}
|
||||||
99
backend/config/settings/production.py
Normal file
99
backend/config/settings/production.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Production settings for the application.
|
||||||
|
"""
|
||||||
|
from .base import *
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
os.environ.get('DJANGO_ALLOWED_HOST', 'example.com'),
|
||||||
|
'www.example.com', # Update with your domain
|
||||||
|
]
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': os.environ.get('DB_NAME', 'app_db'),
|
||||||
|
'USER': os.environ.get('DB_USER', 'app_user'),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
|
||||||
|
'HOST': os.environ.get('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||||
|
'CONN_MAX_AGE': 600, # 10 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
|
||||||
|
# CORS settings
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
'https://example.com',
|
||||||
|
'https://www.example.com', # Update with your frontend domain
|
||||||
|
]
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
EMAIL_HOST = os.environ.get('EMAIL_HOST')
|
||||||
|
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||||
|
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
|
||||||
|
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
|
||||||
|
EMAIL_USE_TLS = True
|
||||||
|
DEFAULT_FROM_EMAIL = 'noreply@example.com' # Update with your email
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||||
|
|
||||||
|
# Configure Sentry for error tracking (optional)
|
||||||
|
try:
|
||||||
|
dsn = os.environ.get('SENTRY_DSN')
|
||||||
|
if dsn: # Only initialize if DSN is provided
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=dsn,
|
||||||
|
integrations=[DjangoIntegration()],
|
||||||
|
traces_sample_rate=0.1,
|
||||||
|
send_default_pii=False
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||||
|
'LOCATION': os.environ.get('REDIS_URL', 'redis://localhost:6379/1'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_path = '/var/log/django/django.log'
|
||||||
|
log_dir = os.path.dirname(log_path)
|
||||||
|
if os.path.exists(log_dir) and os.access(log_dir, os.W_OK):
|
||||||
|
LOGGING['handlers']['file']['filename'] = log_path
|
||||||
|
|
||||||
|
|
||||||
|
# Turn off DRF Browsable API in production
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_RENDERER_CLASSES': (
|
||||||
|
'rest_framework.renderers.JSONRenderer',
|
||||||
|
)
|
||||||
|
}
|
||||||
19
backend/config/urls.py
Normal file
19
backend/config/urls.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
URL Configuration for the project.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=settings.DEBUG))),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Serve static and media files in development
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
10
backend/config/wsgi.py
Normal file
10
backend/config/wsgi.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for the project.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
13
backend/core/admin.py
Normal file
13
backend/core/admin.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from backend.core.models import *
|
||||||
|
|
||||||
|
admin.site.register(Account)
|
||||||
|
admin.site.register(Customer)
|
||||||
|
admin.site.register(Invoice)
|
||||||
|
admin.site.register(Labor)
|
||||||
|
admin.site.register(Profile)
|
||||||
|
admin.site.register(Project)
|
||||||
|
admin.site.register(Report)
|
||||||
|
admin.site.register(Revenue)
|
||||||
|
admin.site.register(Service)
|
||||||
|
admin.site.register(Schedule)
|
||||||
6
backend/core/apps.py
Normal file
6
backend/core/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'backend.core'
|
||||||
102
backend/core/commands/__init__.py
Normal file
102
backend/core/commands/__init__.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
from backend.core.commands.customers.customers import CreateCustomerCommand, UpdateCustomerCommand, \
|
||||||
|
DeleteCustomerCommand, MarkCustomerInactiveCommand
|
||||||
|
from backend.core.commands.accounts.accounts import CreateAccountCommand, UpdateAccountCommand, DeleteAccountCommand, \
|
||||||
|
MarkAccountInactiveCommand, GetAccountRevenueCommand
|
||||||
|
from backend.core.commands.services.services import CreateServiceCommand, UpdateServiceCommand, DeleteServiceCommand, \
|
||||||
|
CompleteServiceCommand, CancelServiceCommand, AssignTeamMembersCommand, GetServicesByDateRangeCommand
|
||||||
|
from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand
|
||||||
|
from backend.core.commands.projects.projects import CreateProjectCommand, UpdateProjectCommand, DeleteProjectCommand
|
||||||
|
from backend.core.commands.invoices.invoices import CreateInvoiceCommand, SendInvoiceCommand, MarkInvoicePaidCommand, \
|
||||||
|
CancelInvoiceCommand, FilterInvoicesCommand
|
||||||
|
from backend.core.commands.labor.labor import CreateLaborCommand, UpdateLaborCommand, DeleteLaborCommand, \
|
||||||
|
EndLaborCommand, CalculateLaborCostCommand
|
||||||
|
from backend.core.commands.profiles.profiles import CreateProfileCommand, UpdateProfileCommand, DeleteProfileCommand, \
|
||||||
|
SearchProfilesCommand
|
||||||
|
from backend.core.commands.reports.reports import (
|
||||||
|
CreateReportCommand,
|
||||||
|
UpdateReportCommand,
|
||||||
|
DeleteReportCommand,
|
||||||
|
GetTeamMemberReportsCommand,
|
||||||
|
GetTeamMemberActivityCommand,
|
||||||
|
GetTeamSummaryCommand
|
||||||
|
)
|
||||||
|
from backend.core.commands.revenues.revenues import (
|
||||||
|
CreateRevenueCommand,
|
||||||
|
UpdateRevenueCommand,
|
||||||
|
DeleteRevenueCommand,
|
||||||
|
EndRevenueCommand,
|
||||||
|
GetRevenueByDateRangeCommand,
|
||||||
|
CalculateTotalRevenueCommand,
|
||||||
|
GetActiveRevenuesCommand
|
||||||
|
)
|
||||||
|
from backend.core.commands.schedules.schedules import (
|
||||||
|
CreateScheduleCommand,
|
||||||
|
UpdateScheduleCommand,
|
||||||
|
DeleteScheduleCommand,
|
||||||
|
EndScheduleCommand,
|
||||||
|
GetActiveSchedulesCommand,
|
||||||
|
GenerateServicesCommand,
|
||||||
|
GetScheduleByAccountCommand,
|
||||||
|
SearchSchedulesCommand
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Command',
|
||||||
|
'CommandResult',
|
||||||
|
'CreateServiceCommand',
|
||||||
|
'UpdateServiceCommand',
|
||||||
|
'DeleteServiceCommand',
|
||||||
|
'CreateProjectCommand',
|
||||||
|
'UpdateProjectCommand',
|
||||||
|
'DeleteProjectCommand',
|
||||||
|
'CreateCustomerCommand',
|
||||||
|
'UpdateCustomerCommand',
|
||||||
|
'DeleteCustomerCommand',
|
||||||
|
'MarkCustomerInactiveCommand',
|
||||||
|
'CreateAccountCommand',
|
||||||
|
'UpdateAccountCommand',
|
||||||
|
'DeleteAccountCommand',
|
||||||
|
'MarkAccountInactiveCommand',
|
||||||
|
'GetAccountRevenueCommand',
|
||||||
|
'CreateInvoiceCommand',
|
||||||
|
'SendInvoiceCommand',
|
||||||
|
'MarkInvoicePaidCommand',
|
||||||
|
'CancelInvoiceCommand',
|
||||||
|
'FilterInvoicesCommand',
|
||||||
|
'CreateLaborCommand',
|
||||||
|
'UpdateLaborCommand',
|
||||||
|
'DeleteLaborCommand',
|
||||||
|
'EndLaborCommand',
|
||||||
|
'CalculateLaborCostCommand',
|
||||||
|
'CreateProfileCommand',
|
||||||
|
'UpdateProfileCommand',
|
||||||
|
'DeleteProfileCommand',
|
||||||
|
'SearchProfilesCommand',
|
||||||
|
'CreateReportCommand',
|
||||||
|
'UpdateReportCommand',
|
||||||
|
'DeleteReportCommand',
|
||||||
|
'GetTeamMemberReportsCommand',
|
||||||
|
'GetTeamMemberActivityCommand',
|
||||||
|
'GetTeamSummaryCommand',
|
||||||
|
'CreateRevenueCommand',
|
||||||
|
'UpdateRevenueCommand',
|
||||||
|
'DeleteRevenueCommand',
|
||||||
|
'EndRevenueCommand',
|
||||||
|
'GetRevenueByDateRangeCommand',
|
||||||
|
'CalculateTotalRevenueCommand',
|
||||||
|
'GetActiveRevenuesCommand',
|
||||||
|
'CreateScheduleCommand',
|
||||||
|
'UpdateScheduleCommand',
|
||||||
|
'DeleteScheduleCommand',
|
||||||
|
'EndScheduleCommand',
|
||||||
|
'GetActiveSchedulesCommand',
|
||||||
|
'GenerateServicesCommand',
|
||||||
|
'CompleteServiceCommand',
|
||||||
|
'CancelServiceCommand',
|
||||||
|
'AssignTeamMembersCommand',
|
||||||
|
'GetServicesByDateRangeCommand',
|
||||||
|
'GetScheduleByAccountCommand',
|
||||||
|
'SearchSchedulesCommand',
|
||||||
|
'BulkScheduleServicesCommand',
|
||||||
|
]
|
||||||
0
backend/core/commands/accounts/__init__.py
Normal file
0
backend/core/commands/accounts/__init__.py
Normal file
677
backend/core/commands/accounts/accounts.py
Normal file
677
backend/core/commands/accounts/accounts.py
Normal file
@ -0,0 +1,677 @@
|
|||||||
|
"""
|
||||||
|
Commands for account-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.core.models.accounts.accounts import Account
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date,
|
||||||
|
validate_required_fields, validate_model_exists
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateAccountCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
customer_id: str,
|
||||||
|
name: str,
|
||||||
|
street_address: str,
|
||||||
|
city: str,
|
||||||
|
state: str,
|
||||||
|
zip_code: str,
|
||||||
|
start_date: str,
|
||||||
|
primary_contact_first_name: str, # Changed from contact_first_name
|
||||||
|
primary_contact_last_name: str, # Changed from contact_last_name
|
||||||
|
primary_contact_phone: str, # Changed from contact_phone
|
||||||
|
primary_contact_email: str, # Changed from contact_email
|
||||||
|
secondary_contact_first_name: Optional[str] = None, # Added
|
||||||
|
secondary_contact_last_name: Optional[str] = None, # Added
|
||||||
|
secondary_contact_phone: Optional[str] = None, # Added
|
||||||
|
secondary_contact_email: Optional[str] = None, # Added
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create account command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
customer_id: ID of the customer this account belongs to.
|
||||||
|
name: Name of the account.
|
||||||
|
street_address: Street address of the account.
|
||||||
|
city: City of the account.
|
||||||
|
state: State of the account.
|
||||||
|
zip_code: ZIP code of the account.
|
||||||
|
primary_contact_first_name: First name of the primary contact.
|
||||||
|
primary_contact_last_name: Last name of the primary contact.
|
||||||
|
primary_contact_phone: Phone number of the primary contact.
|
||||||
|
primary_contact_email: Email of the primary contact.
|
||||||
|
secondary_contact_first_name: First name of the secondary contact (optional).
|
||||||
|
secondary_contact_last_name: Last name of the secondary contact (optional).
|
||||||
|
secondary_contact_phone: Phone number of the secondary contact (optional).
|
||||||
|
secondary_contact_email: Email of the secondary contact (optional).
|
||||||
|
start_date: Start date of the account (YYYY-MM-DD).
|
||||||
|
end_date: End date of the account (YYYY-MM-DD, optional).
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
self.name = name
|
||||||
|
self.street_address = street_address
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zip_code = zip_code
|
||||||
|
self.primary_contact_first_name = primary_contact_first_name
|
||||||
|
self.primary_contact_last_name = primary_contact_last_name
|
||||||
|
self.primary_contact_phone = primary_contact_phone
|
||||||
|
self.primary_contact_email = primary_contact_email
|
||||||
|
self.secondary_contact_first_name = secondary_contact_first_name
|
||||||
|
self.secondary_contact_last_name = secondary_contact_last_name
|
||||||
|
self.secondary_contact_phone = secondary_contact_phone
|
||||||
|
self.secondary_contact_email = secondary_contact_email
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the account creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
required_fields = [
|
||||||
|
'customer_id', 'name', 'street_address', 'city', 'state', 'zip_code',
|
||||||
|
'primary_contact_first_name', 'primary_contact_last_name',
|
||||||
|
'primary_contact_phone', 'primary_contact_email', 'start_date'
|
||||||
|
]
|
||||||
|
|
||||||
|
field_values = {
|
||||||
|
'customer_id': self.customer_id,
|
||||||
|
'name': self.name,
|
||||||
|
'street_address': self.street_address,
|
||||||
|
'city': self.city,
|
||||||
|
'state': self.state,
|
||||||
|
'zip_code': self.zip_code,
|
||||||
|
'primary_contact_first_name': self.primary_contact_first_name,
|
||||||
|
'primary_contact_last_name': self.primary_contact_last_name,
|
||||||
|
'primary_contact_phone': self.primary_contact_phone,
|
||||||
|
'primary_contact_email': self.primary_contact_email,
|
||||||
|
'start_date': self.start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_fields = validate_required_fields(field_values, required_fields)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not errors and self.customer_id:
|
||||||
|
customer_validation = validate_model_exists(
|
||||||
|
self.customer_id, 'customer', self.customer_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not customer_validation['valid']:
|
||||||
|
errors.append(customer_validation['error'])
|
||||||
|
|
||||||
|
if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email):
|
||||||
|
errors.append("Invalid primary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone):
|
||||||
|
errors.append("Invalid primary contact phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email):
|
||||||
|
errors.append("Invalid secondary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone):
|
||||||
|
errors.append("Invalid secondary contact phone format.")
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# Check if customer is active
|
||||||
|
if not errors:
|
||||||
|
customer = self.customer_repo.get_by_id(self.customer_id)
|
||||||
|
if customer and customer.end_date:
|
||||||
|
customer_end_date = parse_date(customer.end_date)
|
||||||
|
today = datetime.now().date()
|
||||||
|
if customer_end_date and customer_end_date < today:
|
||||||
|
errors.append(f"Cannot create account for inactive customer")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Account]:
|
||||||
|
"""
|
||||||
|
Execute the account creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Account]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create account data
|
||||||
|
account_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create account data dictionary
|
||||||
|
account_data = {
|
||||||
|
'id': account_id,
|
||||||
|
'customer_id': self.customer_id,
|
||||||
|
'name': self.name,
|
||||||
|
'street_address': self.street_address,
|
||||||
|
'city': self.city,
|
||||||
|
'state': self.state,
|
||||||
|
'zip_code': self.zip_code,
|
||||||
|
'primary_contact_first_name': self.primary_contact_first_name,
|
||||||
|
'primary_contact_last_name': self.primary_contact_last_name,
|
||||||
|
'primary_contact_phone': self.primary_contact_phone,
|
||||||
|
'primary_contact_email': self.primary_contact_email,
|
||||||
|
'secondary_contact_first_name': self.secondary_contact_first_name,
|
||||||
|
'secondary_contact_last_name': self.secondary_contact_last_name,
|
||||||
|
'secondary_contact_phone': self.secondary_contact_phone,
|
||||||
|
'secondary_contact_email': self.secondary_contact_email,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_account = self.account_repo.create(account_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_account,
|
||||||
|
f"Account {self.name} created successfully for customer {self.customer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create account"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAccountCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
street_address: Optional[str] = None,
|
||||||
|
city: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
zip_code: Optional[str] = None,
|
||||||
|
primary_contact_first_name: Optional[str] = None,
|
||||||
|
primary_contact_last_name: Optional[str] = None,
|
||||||
|
primary_contact_phone: Optional[str] = None,
|
||||||
|
primary_contact_email: Optional[str] = None,
|
||||||
|
secondary_contact_first_name: Optional[str] = None,
|
||||||
|
secondary_contact_last_name: Optional[str] = None,
|
||||||
|
secondary_contact_phone: Optional[str] = None,
|
||||||
|
secondary_contact_email: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update account command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account to update.
|
||||||
|
name: New name for the account.
|
||||||
|
street_address: New street address for the account.
|
||||||
|
city: New city for the account.
|
||||||
|
state: New state for the account.
|
||||||
|
zip_code: New ZIP code for the account.
|
||||||
|
primary_contact_first_name: New first name for the primary contact.
|
||||||
|
primary_contact_last_name: New last name for the primary contact.
|
||||||
|
primary_contact_phone: New phone number for the primary contact.
|
||||||
|
primary_contact_email: New email for the primary contact.
|
||||||
|
secondary_contact_first_name: New first name for the secondary contact.
|
||||||
|
secondary_contact_last_name: New last name for the secondary contact.
|
||||||
|
secondary_contact_phone: New phone number for the secondary contact.
|
||||||
|
secondary_contact_email: New email for the secondary contact.
|
||||||
|
start_date: New start date for the account.
|
||||||
|
end_date: New end date for the account.
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.name = name
|
||||||
|
self.street_address = street_address
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zip_code = zip_code
|
||||||
|
self.primary_contact_first_name = primary_contact_first_name
|
||||||
|
self.primary_contact_last_name = primary_contact_last_name
|
||||||
|
self.primary_contact_phone = primary_contact_phone
|
||||||
|
self.primary_contact_email = primary_contact_email
|
||||||
|
self.secondary_contact_first_name = secondary_contact_first_name
|
||||||
|
self.secondary_contact_last_name = secondary_contact_last_name
|
||||||
|
self.secondary_contact_phone = secondary_contact_phone
|
||||||
|
self.secondary_contact_email = secondary_contact_email
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the account update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
else:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if not account:
|
||||||
|
errors.append(f"Account with ID {self.account_id} not found")
|
||||||
|
|
||||||
|
if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email):
|
||||||
|
errors.append("Invalid primary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone):
|
||||||
|
errors.append("Invalid primary contact phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email):
|
||||||
|
errors.append("Invalid secondary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone):
|
||||||
|
errors.append("Invalid secondary contact phone format.")
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# If only updating end_date, validate it's after the existing start_date
|
||||||
|
if not errors and self.end_date and not self.start_date:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if account:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(account.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the existing start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Account]:
|
||||||
|
"""
|
||||||
|
Execute the account update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Account]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.name is not None:
|
||||||
|
update_data['name'] = self.name
|
||||||
|
|
||||||
|
if self.street_address is not None:
|
||||||
|
update_data['street_address'] = self.street_address
|
||||||
|
|
||||||
|
if self.city is not None:
|
||||||
|
update_data['city'] = self.city
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
update_data['state'] = self.state
|
||||||
|
|
||||||
|
if self.zip_code is not None:
|
||||||
|
update_data['zip_code'] = self.zip_code
|
||||||
|
|
||||||
|
if self.primary_contact_first_name is not None:
|
||||||
|
update_data['primary_contact_first_name'] = self.primary_contact_first_name
|
||||||
|
|
||||||
|
if self.primary_contact_last_name is not None:
|
||||||
|
update_data['primary_contact_last_name'] = self.primary_contact_last_name
|
||||||
|
|
||||||
|
if self.primary_contact_phone is not None:
|
||||||
|
update_data['primary_contact_phone'] = self.primary_contact_phone
|
||||||
|
|
||||||
|
if self.primary_contact_email is not None:
|
||||||
|
update_data['primary_contact_email'] = self.primary_contact_email
|
||||||
|
|
||||||
|
if self.secondary_contact_first_name is not None:
|
||||||
|
update_data['secondary_contact_first_name'] = self.secondary_contact_first_name
|
||||||
|
|
||||||
|
if self.secondary_contact_last_name is not None:
|
||||||
|
update_data['secondary_contact_last_name'] = self.secondary_contact_last_name
|
||||||
|
|
||||||
|
if self.secondary_contact_phone is not None:
|
||||||
|
update_data['secondary_contact_phone'] = self.secondary_contact_phone
|
||||||
|
|
||||||
|
if self.secondary_contact_email is not None:
|
||||||
|
update_data['secondary_contact_email'] = self.secondary_contact_email
|
||||||
|
|
||||||
|
if self.start_date is not None:
|
||||||
|
update_data['start_date'] = self.start_date
|
||||||
|
|
||||||
|
if self.end_date is not None:
|
||||||
|
update_data['end_date'] = self.end_date
|
||||||
|
|
||||||
|
# Update the account with the data dictionary
|
||||||
|
updated_account = self.account_repo.update(self.account_id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_account,
|
||||||
|
f"Account {self.account_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update account"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete an account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the delete account command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account to delete.
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the account deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
else:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if not account:
|
||||||
|
errors.append(f"Account with ID {self.account_id} not found")
|
||||||
|
|
||||||
|
# Check if account has associated services or projects
|
||||||
|
if not errors:
|
||||||
|
account_with_relations = self.account_repo.get_with_all_related(self.account_id)
|
||||||
|
if account_with_relations:
|
||||||
|
if hasattr(account_with_relations, 'services') and account_with_relations.services.exists():
|
||||||
|
errors.append(f"Cannot delete account with associated services")
|
||||||
|
if hasattr(account_with_relations, 'projects') and account_with_relations.projects.exists():
|
||||||
|
errors.append(f"Cannot delete account with associated projects")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the account deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the account
|
||||||
|
success = self.account_repo.delete(self.account_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Account {self.account_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete account",
|
||||||
|
f"Account {self.account_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete account"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkAccountInactiveCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to mark an account as inactive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the mark account inactive command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account to mark as inactive.
|
||||||
|
end_date: End date for the account (defaults to today if not provided).
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the mark account inactive request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
else:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if not account:
|
||||||
|
errors.append(f"Account with ID {self.account_id} not found")
|
||||||
|
elif account.end_date is not None:
|
||||||
|
errors.append(f"Account is already marked as inactive")
|
||||||
|
|
||||||
|
# Validate end date format if provided
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate end date is after start date if provided
|
||||||
|
if not errors and self.end_date:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if account:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(account.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Account]:
|
||||||
|
"""
|
||||||
|
Execute the mark account inactive command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Account]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Mark the account as inactive
|
||||||
|
end_date = self.end_date or datetime.now().strftime('%Y-%m-%d')
|
||||||
|
updated_account = self.account_repo.update(
|
||||||
|
self.account_id,
|
||||||
|
{'end_date': end_date}
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_account:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_account,
|
||||||
|
f"Account {self.account_id} marked as inactive successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to mark account as inactive",
|
||||||
|
f"Account {self.account_id} could not be marked as inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to mark account as inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetAccountRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get the revenue information for an account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the get account revenue command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account to get revenue for.
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the get account revenue request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
else:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if not account:
|
||||||
|
errors.append(f"Account with ID {self.account_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Any]]:
|
||||||
|
"""
|
||||||
|
Execute the get account revenue command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Any]]: Result of the command execution with revenue data.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve the account's revenue information
|
||||||
|
account_with_revenues = self.account_repo.get_with_revenues(self.account_id)
|
||||||
|
|
||||||
|
if account_with_revenues and hasattr(account_with_revenues, 'revenues'):
|
||||||
|
revenues = list(account_with_revenues.revenues.all())
|
||||||
|
return CommandResult.success_result(
|
||||||
|
revenues,
|
||||||
|
f"Retrieved revenue data for account {self.account_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"No revenue data found",
|
||||||
|
f"No revenue data found for account {self.account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve account revenue data"
|
||||||
|
)
|
||||||
113
backend/core/commands/base.py
Normal file
113
backend/core/commands/base.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Base classes for command pattern implementation.
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, List, Optional, TypeVar, Generic, Union
|
||||||
|
|
||||||
|
# Type variable for the result of a command
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
class Command(ABC):
|
||||||
|
"""
|
||||||
|
Base abstract class for all commands.
|
||||||
|
Commands encapsulate business logic and ensure data integrity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute(self) -> Any:
|
||||||
|
"""
|
||||||
|
Execute the command and return the result.
|
||||||
|
Must be implemented by all command subclasses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The result of the command execution.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate command data before execution.
|
||||||
|
Should be overridden by command subclasses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
return {'is_valid': True, 'errors': None}
|
||||||
|
|
||||||
|
|
||||||
|
class CommandResult(Generic[T]):
|
||||||
|
"""
|
||||||
|
Represents the result of a command execution.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success (bool): Whether the command was successful.
|
||||||
|
data (Optional[T]): The result data, if successful.
|
||||||
|
errors (Optional[List[str]]): List of errors, if unsuccessful.
|
||||||
|
message (Optional[str]): An optional message about the operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
success: bool = True,
|
||||||
|
data: Optional[T] = None,
|
||||||
|
errors: Optional[List[str]] = None,
|
||||||
|
message: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize a new CommandResult.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
success: Whether the command was successful.
|
||||||
|
data: The result data, if successful.
|
||||||
|
errors: List of errors, if unsuccessful.
|
||||||
|
message: An optional message about the operation.
|
||||||
|
"""
|
||||||
|
self.success = success
|
||||||
|
self.data = data
|
||||||
|
self.errors = errors or []
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert the command result to a dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: The command result as a dictionary.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'success': self.success,
|
||||||
|
'data': self.data,
|
||||||
|
'errors': self.errors,
|
||||||
|
'message': self.message
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success_result(cls, data: T = None, message: Optional[str] = None) -> 'CommandResult[T]':
|
||||||
|
"""
|
||||||
|
Create a successful command result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The result data.
|
||||||
|
message: An optional success message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[T]: A successful command result.
|
||||||
|
"""
|
||||||
|
return cls(True, data, None, message)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failure_result(cls, errors: Union[str, List[str]], message: Optional[str] = None) -> 'CommandResult[T]':
|
||||||
|
"""
|
||||||
|
Create a failed command result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
errors: Error or list of errors.
|
||||||
|
message: An optional failure message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[T]: A failed command result.
|
||||||
|
"""
|
||||||
|
if isinstance(errors, str):
|
||||||
|
errors = [errors]
|
||||||
|
return cls(False, None, errors, message)
|
||||||
0
backend/core/commands/customers/__init__.py
Normal file
0
backend/core/commands/customers/__init__.py
Normal file
629
backend/core/commands/customers/customers.py
Normal file
629
backend/core/commands/customers/customers.py
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
"""
|
||||||
|
Commands for customer-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional, Union, List
|
||||||
|
|
||||||
|
from graphene import UUID
|
||||||
|
|
||||||
|
from backend.core.models.customers.customers import Customer
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date,
|
||||||
|
validate_required_fields
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCustomerCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new customer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
name: str,
|
||||||
|
primary_contact_first_name: str,
|
||||||
|
primary_contact_last_name: str,
|
||||||
|
primary_contact_phone: str,
|
||||||
|
primary_contact_email: str,
|
||||||
|
billing_contact_first_name: str,
|
||||||
|
billing_contact_last_name: str,
|
||||||
|
billing_street_address: str,
|
||||||
|
billing_city: str,
|
||||||
|
billing_state: str,
|
||||||
|
billing_zip_code: str,
|
||||||
|
billing_email: str,
|
||||||
|
billing_terms: str,
|
||||||
|
start_date: str,
|
||||||
|
secondary_contact_first_name: Optional[str] = None,
|
||||||
|
secondary_contact_last_name: Optional[str] = None,
|
||||||
|
secondary_contact_phone: Optional[str] = None,
|
||||||
|
secondary_contact_email: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create customer command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
name: Name of the customer.
|
||||||
|
primary_contact_first_name: First name of primary contact.
|
||||||
|
primary_contact_last_name: Last name of primary contact.
|
||||||
|
primary_contact_phone: Phone number of primary contact.
|
||||||
|
primary_contact_email: Email of primary contact.
|
||||||
|
billing_contact_first_name: First name of billing contact.
|
||||||
|
billing_contact_last_name: Last name of billing contact.
|
||||||
|
billing_street_address: Street address for billing.
|
||||||
|
billing_city: City for billing.
|
||||||
|
billing_state: State for billing.
|
||||||
|
billing_zip_code: ZIP code for billing.
|
||||||
|
billing_email: Email for billing.
|
||||||
|
billing_terms: Terms for billing.
|
||||||
|
start_date: Start date of customer relationship (YYYY-MM-DD).
|
||||||
|
secondary_contact_first_name: First name of secondary contact (optional).
|
||||||
|
secondary_contact_last_name: Last name of secondary contact (optional).
|
||||||
|
secondary_contact_phone: Phone number of secondary contact (optional).
|
||||||
|
secondary_contact_email: Email of secondary contact (optional).
|
||||||
|
end_date: End date of customer relationship (YYYY-MM-DD, optional).
|
||||||
|
"""
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.name = name
|
||||||
|
self.primary_contact_first_name = primary_contact_first_name
|
||||||
|
self.primary_contact_last_name = primary_contact_last_name
|
||||||
|
self.primary_contact_phone = primary_contact_phone
|
||||||
|
self.primary_contact_email = primary_contact_email
|
||||||
|
self.secondary_contact_first_name = secondary_contact_first_name
|
||||||
|
self.secondary_contact_last_name = secondary_contact_last_name
|
||||||
|
self.secondary_contact_phone = secondary_contact_phone
|
||||||
|
self.secondary_contact_email = secondary_contact_email
|
||||||
|
self.billing_contact_first_name = billing_contact_first_name
|
||||||
|
self.billing_contact_last_name = billing_contact_last_name
|
||||||
|
self.billing_street_address = billing_street_address
|
||||||
|
self.billing_city = billing_city
|
||||||
|
self.billing_state = billing_state
|
||||||
|
self.billing_zip_code = billing_zip_code
|
||||||
|
self.billing_email = billing_email
|
||||||
|
self.billing_terms = billing_terms
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the customer creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
required_fields = [
|
||||||
|
'name', 'primary_contact_first_name', 'primary_contact_last_name',
|
||||||
|
'primary_contact_phone', 'primary_contact_email',
|
||||||
|
'billing_contact_first_name', 'billing_contact_last_name',
|
||||||
|
'billing_street_address', 'billing_city', 'billing_state',
|
||||||
|
'billing_zip_code', 'billing_email', 'billing_terms', 'start_date'
|
||||||
|
]
|
||||||
|
|
||||||
|
field_values = {
|
||||||
|
'name': self.name,
|
||||||
|
'primary_contact_first_name': self.primary_contact_first_name,
|
||||||
|
'primary_contact_last_name': self.primary_contact_last_name,
|
||||||
|
'primary_contact_phone': self.primary_contact_phone,
|
||||||
|
'primary_contact_email': self.primary_contact_email,
|
||||||
|
'billing_contact_first_name': self.billing_contact_first_name,
|
||||||
|
'billing_contact_last_name': self.billing_contact_last_name,
|
||||||
|
'billing_street_address': self.billing_street_address,
|
||||||
|
'billing_city': self.billing_city,
|
||||||
|
'billing_state': self.billing_state,
|
||||||
|
'billing_zip_code': self.billing_zip_code,
|
||||||
|
'billing_email': self.billing_email,
|
||||||
|
'billing_terms': self.billing_terms,
|
||||||
|
'start_date': self.start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_fields = validate_required_fields(field_values, required_fields)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate email formats
|
||||||
|
if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email):
|
||||||
|
errors.append("Invalid primary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email):
|
||||||
|
errors.append("Invalid secondary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.billing_email and not is_valid_email(self.billing_email):
|
||||||
|
errors.append("Invalid billing email format.")
|
||||||
|
|
||||||
|
# Validate phone formats
|
||||||
|
if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone):
|
||||||
|
errors.append("Invalid primary contact phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone):
|
||||||
|
errors.append("Invalid secondary contact phone format.")
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Customer]:
|
||||||
|
"""
|
||||||
|
Execute the customer creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Customer]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create customer data
|
||||||
|
customer_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create customer data dictionary
|
||||||
|
customer_data = {
|
||||||
|
'id': customer_id,
|
||||||
|
'name': self.name,
|
||||||
|
'primary_contact_first_name': self.primary_contact_first_name,
|
||||||
|
'primary_contact_last_name': self.primary_contact_last_name,
|
||||||
|
'primary_contact_phone': self.primary_contact_phone,
|
||||||
|
'primary_contact_email': self.primary_contact_email,
|
||||||
|
'secondary_contact_first_name': self.secondary_contact_first_name,
|
||||||
|
'secondary_contact_last_name': self.secondary_contact_last_name,
|
||||||
|
'secondary_contact_phone': self.secondary_contact_phone,
|
||||||
|
'secondary_contact_email': self.secondary_contact_email,
|
||||||
|
'billing_contact_first_name': self.billing_contact_first_name,
|
||||||
|
'billing_contact_last_name': self.billing_contact_last_name,
|
||||||
|
'billing_street_address': self.billing_street_address,
|
||||||
|
'billing_city': self.billing_city,
|
||||||
|
'billing_state': self.billing_state,
|
||||||
|
'billing_zip_code': self.billing_zip_code,
|
||||||
|
'billing_email': self.billing_email,
|
||||||
|
'billing_terms': self.billing_terms,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_customer = self.customer_repo.create(customer_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_customer,
|
||||||
|
f"Customer {self.name} created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCustomerCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing customer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
id: UUID,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
primary_contact_first_name: Optional[str] = None,
|
||||||
|
primary_contact_last_name: Optional[str] = None,
|
||||||
|
primary_contact_phone: Optional[str] = None,
|
||||||
|
primary_contact_email: Optional[str] = None,
|
||||||
|
secondary_contact_first_name: Optional[str] = None,
|
||||||
|
secondary_contact_last_name: Optional[str] = None,
|
||||||
|
secondary_contact_phone: Optional[str] = None,
|
||||||
|
secondary_contact_email: Optional[str] = None,
|
||||||
|
billing_contact_first_name: Optional[str] = None,
|
||||||
|
billing_contact_last_name: Optional[str] = None,
|
||||||
|
billing_street_address: Optional[str] = None,
|
||||||
|
billing_city: Optional[str] = None,
|
||||||
|
billing_state: Optional[str] = None,
|
||||||
|
billing_zip_code: Optional[str] = None,
|
||||||
|
billing_email: Optional[str] = None,
|
||||||
|
billing_terms: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update customer command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
id: ID of the customer to update.
|
||||||
|
name: New name for the customer.
|
||||||
|
primary_contact_first_name: New first name of primary contact.
|
||||||
|
primary_contact_last_name: New last name of primary contact.
|
||||||
|
primary_contact_phone: New phone number of primary contact.
|
||||||
|
primary_contact_email: New email of primary contact.
|
||||||
|
secondary_contact_first_name: New first name of secondary contact.
|
||||||
|
secondary_contact_last_name: New last name of secondary contact.
|
||||||
|
secondary_contact_phone: New phone number of secondary contact.
|
||||||
|
secondary_contact_email: New email of secondary contact.
|
||||||
|
billing_contact_first_name: New first name of billing contact.
|
||||||
|
billing_contact_last_name: New last name of billing contact.
|
||||||
|
billing_street_address: New street address for billing.
|
||||||
|
billing_city: New city for billing.
|
||||||
|
billing_state: New state for billing.
|
||||||
|
billing_zip_code: New ZIP code for billing.
|
||||||
|
billing_email: New email for billing.
|
||||||
|
billing_terms: New terms for billing.
|
||||||
|
start_date: New start date of customer relationship.
|
||||||
|
end_date: New end date of customer relationship.
|
||||||
|
"""
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.id = str(id)
|
||||||
|
self.name = name
|
||||||
|
self.primary_contact_first_name = primary_contact_first_name
|
||||||
|
self.primary_contact_last_name = primary_contact_last_name
|
||||||
|
self.primary_contact_phone = primary_contact_phone
|
||||||
|
self.primary_contact_email = primary_contact_email
|
||||||
|
self.secondary_contact_first_name = secondary_contact_first_name
|
||||||
|
self.secondary_contact_last_name = secondary_contact_last_name
|
||||||
|
self.secondary_contact_phone = secondary_contact_phone
|
||||||
|
self.secondary_contact_email = secondary_contact_email
|
||||||
|
self.billing_contact_first_name = billing_contact_first_name
|
||||||
|
self.billing_contact_last_name = billing_contact_last_name
|
||||||
|
self.billing_street_address = billing_street_address
|
||||||
|
self.billing_city = billing_city
|
||||||
|
self.billing_state = billing_state
|
||||||
|
self.billing_zip_code = billing_zip_code
|
||||||
|
self.billing_email = billing_email
|
||||||
|
self.billing_terms = billing_terms
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Union[bool, List[str]]]:
|
||||||
|
"""
|
||||||
|
Validate the customer update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Union[bool, List[str]]]: Validation result with 'is_valid' and 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not is_valid_uuid(self.id):
|
||||||
|
errors.append("Invalid customer ID format")
|
||||||
|
else:
|
||||||
|
customer = self.customer_repo.get_by_id(self.id)
|
||||||
|
if not customer:
|
||||||
|
errors.append(f"Customer with ID {self.id} not found")
|
||||||
|
|
||||||
|
# Validate email formats if provided
|
||||||
|
if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email):
|
||||||
|
errors.append("Invalid primary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email):
|
||||||
|
errors.append("Invalid secondary contact email format.")
|
||||||
|
|
||||||
|
if not errors and self.billing_email is not None and not is_valid_email(self.billing_email):
|
||||||
|
errors.append("Invalid billing email format.")
|
||||||
|
|
||||||
|
# Validate phone formats if provided
|
||||||
|
if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone):
|
||||||
|
errors.append("Invalid primary contact phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone):
|
||||||
|
errors.append("Invalid secondary contact phone format.")
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# If only updating end_date, validate it's after the existing start_date
|
||||||
|
if not errors and self.end_date and not self.start_date:
|
||||||
|
customer = self.customer_repo.get_by_id(self.id)
|
||||||
|
if customer:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if end and customer.start_date > end:
|
||||||
|
errors.append("End date must be after the existing start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Customer]:
|
||||||
|
"""
|
||||||
|
Execute the customer update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Customer]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.name is not None:
|
||||||
|
update_data['name'] = self.name
|
||||||
|
|
||||||
|
if self.primary_contact_first_name is not None:
|
||||||
|
update_data['primary_contact_first_name'] = self.primary_contact_first_name
|
||||||
|
|
||||||
|
if self.primary_contact_last_name is not None:
|
||||||
|
update_data['primary_contact_last_name'] = self.primary_contact_last_name
|
||||||
|
|
||||||
|
if self.primary_contact_phone is not None:
|
||||||
|
update_data['primary_contact_phone'] = self.primary_contact_phone
|
||||||
|
|
||||||
|
if self.primary_contact_email is not None:
|
||||||
|
update_data['primary_contact_email'] = self.primary_contact_email
|
||||||
|
|
||||||
|
if self.secondary_contact_first_name is not None:
|
||||||
|
update_data['secondary_contact_first_name'] = self.secondary_contact_first_name
|
||||||
|
|
||||||
|
if self.secondary_contact_last_name is not None:
|
||||||
|
update_data['secondary_contact_last_name'] = self.secondary_contact_last_name
|
||||||
|
|
||||||
|
if self.secondary_contact_phone is not None:
|
||||||
|
update_data['secondary_contact_phone'] = self.secondary_contact_phone
|
||||||
|
|
||||||
|
if self.secondary_contact_email is not None:
|
||||||
|
update_data['secondary_contact_email'] = self.secondary_contact_email
|
||||||
|
|
||||||
|
if self.billing_contact_first_name is not None:
|
||||||
|
update_data['billing_contact_first_name'] = self.billing_contact_first_name
|
||||||
|
|
||||||
|
if self.billing_contact_last_name is not None:
|
||||||
|
update_data['billing_contact_last_name'] = self.billing_contact_last_name
|
||||||
|
|
||||||
|
if self.billing_street_address is not None:
|
||||||
|
update_data['billing_street_address'] = self.billing_street_address
|
||||||
|
|
||||||
|
if self.billing_city is not None:
|
||||||
|
update_data['billing_city'] = self.billing_city
|
||||||
|
|
||||||
|
if self.billing_state is not None:
|
||||||
|
update_data['billing_state'] = self.billing_state
|
||||||
|
|
||||||
|
if self.billing_zip_code is not None:
|
||||||
|
update_data['billing_zip_code'] = self.billing_zip_code
|
||||||
|
|
||||||
|
if self.billing_email is not None:
|
||||||
|
update_data['billing_email'] = self.billing_email
|
||||||
|
|
||||||
|
if self.billing_terms is not None:
|
||||||
|
update_data['billing_terms'] = self.billing_terms
|
||||||
|
|
||||||
|
if self.start_date is not None:
|
||||||
|
update_data['start_date'] = self.start_date
|
||||||
|
|
||||||
|
if self.end_date is not None:
|
||||||
|
update_data['end_date'] = self.end_date
|
||||||
|
|
||||||
|
# Update the customer with the data dictionary
|
||||||
|
updated_customer = self.customer_repo.update(self.id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_customer,
|
||||||
|
f"Customer {self.id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteCustomerCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a customer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
customer_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the delete customer command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
customer_id: ID of the customer to delete.
|
||||||
|
"""
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the customer deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not is_valid_uuid(self.customer_id):
|
||||||
|
errors.append("Invalid customer ID format")
|
||||||
|
else:
|
||||||
|
customer = self.customer_repo.get_by_id(self.customer_id)
|
||||||
|
if not customer:
|
||||||
|
errors.append(f"Customer with ID {self.customer_id} not found")
|
||||||
|
|
||||||
|
# Check if customer has associated accounts
|
||||||
|
if not errors:
|
||||||
|
customer_with_accounts = self.customer_repo.get_with_accounts(self.customer_id)
|
||||||
|
# First check if customer_with_accounts is not None
|
||||||
|
if customer_with_accounts is not None:
|
||||||
|
# Now we can safely access the accounts attribute
|
||||||
|
if hasattr(customer_with_accounts, 'accounts') and customer_with_accounts.accounts.exists():
|
||||||
|
errors.append(f"Cannot delete customer with associated accounts")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the customer deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the customer
|
||||||
|
success = self.customer_repo.delete(self.customer_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Customer {self.customer_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete customer",
|
||||||
|
f"Customer {self.customer_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkCustomerInactiveCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to mark a customer as inactive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
customer_id: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the mark customer inactive command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
customer_id: ID of the customer to mark as inactive.
|
||||||
|
end_date: End date for the customer relationship (defaults to today if not provided).
|
||||||
|
"""
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the mark customer inactive request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not is_valid_uuid(self.customer_id):
|
||||||
|
errors.append("Invalid customer ID format")
|
||||||
|
else:
|
||||||
|
customer = self.customer_repo.get_by_id(self.customer_id)
|
||||||
|
if not customer:
|
||||||
|
errors.append(f"Customer with ID {self.customer_id} not found")
|
||||||
|
elif customer.end_date is not None:
|
||||||
|
errors.append(f"Customer is already marked as inactive")
|
||||||
|
|
||||||
|
# Validate end date format if provided
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate end date is after start date if provided
|
||||||
|
if not errors and self.end_date:
|
||||||
|
customer = self.customer_repo.get_by_id(self.customer_id)
|
||||||
|
if customer:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if end and customer.start_date > end:
|
||||||
|
errors.append("End date must be after the start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Customer]:
|
||||||
|
"""
|
||||||
|
Execute the mark customer inactive command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Customer]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Mark the customer as inactive
|
||||||
|
updated_customer = self.customer_repo.mark_inactive(self.customer_id)
|
||||||
|
|
||||||
|
if updated_customer:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_customer,
|
||||||
|
f"Customer {self.customer_id} marked as inactive successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to mark customer as inactive",
|
||||||
|
f"Customer {self.customer_id} could not be marked as inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to mark customer as inactive"
|
||||||
|
)
|
||||||
0
backend/core/commands/invoices/__init__.py
Normal file
0
backend/core/commands/invoices/__init__.py
Normal file
445
backend/core/commands/invoices/invoices.py
Normal file
445
backend/core/commands/invoices/invoices.py
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
Commands for invoice-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from backend.core.models.invoices.invoices import Invoice
|
||||||
|
from backend.core.repositories.invoices.invoices import InvoiceRepository
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, validate_required_fields,
|
||||||
|
validate_model_exists
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvoiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new invoice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invoice_repo: InvoiceRepository,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
customer_id: str,
|
||||||
|
invoice_date: str,
|
||||||
|
account_ids: Optional[List[str]] = None,
|
||||||
|
project_ids: Optional[List[str]] = None,
|
||||||
|
total_amount: Optional[float] = None,
|
||||||
|
status: str = 'draft'
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create invoice command.
|
||||||
|
"""
|
||||||
|
self.invoice_repo = invoice_repo
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
self.date = invoice_date
|
||||||
|
self.account_ids = account_ids or []
|
||||||
|
self.project_ids = project_ids or []
|
||||||
|
self.total_amount = total_amount or 0
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the invoice creation data.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
missing_fields = validate_required_fields(
|
||||||
|
{'customer_id': self.customer_id, 'date': self.date},
|
||||||
|
['customer_id', 'date']
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not errors and self.customer_id:
|
||||||
|
customer_validation = validate_model_exists(
|
||||||
|
self.customer_id, 'customer', self.customer_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not customer_validation['valid']:
|
||||||
|
errors.append(customer_validation['error'])
|
||||||
|
|
||||||
|
# Validate date format
|
||||||
|
if not errors and self.date and not is_valid_date(self.date):
|
||||||
|
errors.append("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']
|
||||||
|
if not errors and self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# If no accounts or projects are specified, warn about empty invoice
|
||||||
|
if not errors and not self.account_ids and not self.project_ids:
|
||||||
|
errors.append("Invoice must include at least one account or project")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Invoice]:
|
||||||
|
"""
|
||||||
|
Execute the invoice creation command.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create invoice data
|
||||||
|
invoice_id = generate_uuid()
|
||||||
|
|
||||||
|
# Setup invoice data
|
||||||
|
invoice_data = {
|
||||||
|
'id': invoice_id,
|
||||||
|
'customer_id': self.customer_id,
|
||||||
|
'date': self.date,
|
||||||
|
'status': self.status,
|
||||||
|
'total_amount': self.total_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create invoice with related items
|
||||||
|
created_invoice = self.invoice_repo.create_with_items(
|
||||||
|
invoice_data,
|
||||||
|
self.account_ids,
|
||||||
|
self.project_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_invoice,
|
||||||
|
f"Invoice created successfully for customer {self.customer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SendInvoiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to send an invoice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invoice_repo: InvoiceRepository,
|
||||||
|
invoice_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the send invoice command.
|
||||||
|
"""
|
||||||
|
self.invoice_repo = invoice_repo
|
||||||
|
self.invoice_id = invoice_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the invoice sending request.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate invoice exists
|
||||||
|
if not is_valid_uuid(self.invoice_id):
|
||||||
|
errors.append("Invalid invoice ID format")
|
||||||
|
else:
|
||||||
|
invoice = self.invoice_repo.get_by_id(self.invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
errors.append(f"Invoice with ID {self.invoice_id} not found")
|
||||||
|
|
||||||
|
# Check if invoice is in draft status
|
||||||
|
if not errors and invoice.status != 'draft':
|
||||||
|
errors.append(f"Only invoices in 'draft' status can be sent. Current status: {invoice.status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Invoice]:
|
||||||
|
"""
|
||||||
|
Execute the send invoice command.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update invoice status to 'sent'
|
||||||
|
updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'sent')
|
||||||
|
|
||||||
|
if updated_invoice:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_invoice,
|
||||||
|
f"Invoice {self.invoice_id} sent successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to send invoice",
|
||||||
|
f"Invoice {self.invoice_id} could not be sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to send invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarkInvoicePaidCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to mark an invoice as paid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invoice_repo: InvoiceRepository,
|
||||||
|
invoice_id: str,
|
||||||
|
payment_type: str,
|
||||||
|
date_paid: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the mark invoice paid command.
|
||||||
|
"""
|
||||||
|
self.invoice_repo = invoice_repo
|
||||||
|
self.invoice_id = invoice_id
|
||||||
|
self.payment_type = payment_type
|
||||||
|
self.date_paid = date_paid
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the mark invoice paid request.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate invoice exists
|
||||||
|
if not is_valid_uuid(self.invoice_id):
|
||||||
|
errors.append("Invalid invoice ID format")
|
||||||
|
else:
|
||||||
|
invoice = self.invoice_repo.get_by_id(self.invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
errors.append(f"Invoice with ID {self.invoice_id} not found")
|
||||||
|
|
||||||
|
# Check if invoice can be marked as paid
|
||||||
|
if not errors and invoice.status not in ['sent', 'overdue']:
|
||||||
|
errors.append(
|
||||||
|
f"Only invoices in 'sent' or 'overdue' status can be marked as paid. Current status: {invoice.status}")
|
||||||
|
|
||||||
|
# Validate payment type
|
||||||
|
valid_payment_types = ['check', 'credit_card', 'bank_transfer', 'cash']
|
||||||
|
if not errors and self.payment_type not in valid_payment_types:
|
||||||
|
errors.append(f"Invalid payment type. Must be one of: {', '.join(valid_payment_types)}")
|
||||||
|
|
||||||
|
# Validate date_paid format if provided
|
||||||
|
if not errors and self.date_paid and not is_valid_date(self.date_paid):
|
||||||
|
errors.append("Invalid date_paid format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Invoice]:
|
||||||
|
"""
|
||||||
|
Execute the mark invoice paid command.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If date_paid is provided, update with that date
|
||||||
|
if self.date_paid:
|
||||||
|
update_data = {
|
||||||
|
'status': 'paid',
|
||||||
|
'date_paid': self.date_paid,
|
||||||
|
'payment_type': self.payment_type
|
||||||
|
}
|
||||||
|
updated_invoice = self.invoice_repo.update(self.invoice_id, update_data)
|
||||||
|
else:
|
||||||
|
# Use the repository method to mark as paid with today's date
|
||||||
|
updated_invoice = self.invoice_repo.mark_as_paid(self.invoice_id, self.payment_type)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_invoice,
|
||||||
|
f"Invoice {self.invoice_id} marked as paid successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to mark invoice as paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CancelInvoiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to cancel an invoice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invoice_repo: InvoiceRepository,
|
||||||
|
invoice_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the cancel invoice command.
|
||||||
|
"""
|
||||||
|
self.invoice_repo = invoice_repo
|
||||||
|
self.invoice_id = invoice_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the invoice cancellation request.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate invoice exists
|
||||||
|
if not is_valid_uuid(self.invoice_id):
|
||||||
|
errors.append("Invalid invoice ID format")
|
||||||
|
else:
|
||||||
|
invoice = self.invoice_repo.get_by_id(self.invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
errors.append(f"Invoice with ID {self.invoice_id} not found")
|
||||||
|
|
||||||
|
# Check if invoice can be cancelled
|
||||||
|
if not errors and invoice.status == 'paid':
|
||||||
|
errors.append("Paid invoices cannot be cancelled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Invoice]:
|
||||||
|
"""
|
||||||
|
Execute the cancel invoice command.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update invoice status to 'cancelled'
|
||||||
|
updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'cancelled')
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_invoice,
|
||||||
|
f"Invoice {self.invoice_id} cancelled successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to cancel invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterInvoicesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to filter invoices by multiple criteria.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invoice_repo: InvoiceRepository,
|
||||||
|
customer_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
date_from: Optional[Union[str, date]] = None,
|
||||||
|
date_to: Optional[Union[str, date]] = None,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the filter invoices command.
|
||||||
|
"""
|
||||||
|
self.invoice_repo = invoice_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
# Convert string dates to date objects if needed
|
||||||
|
if isinstance(date_from, str):
|
||||||
|
self.date_from = parse_date(date_from)
|
||||||
|
else:
|
||||||
|
self.date_from = date_from
|
||||||
|
|
||||||
|
if isinstance(date_to, str):
|
||||||
|
self.date_to = parse_date(date_to)
|
||||||
|
else:
|
||||||
|
self.date_to = date_to
|
||||||
|
|
||||||
|
self.account_id = account_id
|
||||||
|
self.project_id = project_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the filter invoices request.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate UUID formats
|
||||||
|
if self.customer_id and not is_valid_uuid(self.customer_id):
|
||||||
|
errors.append("Invalid customer ID format")
|
||||||
|
|
||||||
|
if self.account_id and not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
|
||||||
|
if self.project_id and not is_valid_uuid(self.project_id):
|
||||||
|
errors.append("Invalid project ID format")
|
||||||
|
|
||||||
|
# Validate status if provided
|
||||||
|
if self.status:
|
||||||
|
valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled']
|
||||||
|
if self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# Validate date range if both provided
|
||||||
|
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||||
|
errors.append("Start date must be before end date")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Invoice]]:
|
||||||
|
"""
|
||||||
|
Execute the filter invoices command.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Filter invoices
|
||||||
|
filtered_invoices = self.invoice_repo.filter_invoices(
|
||||||
|
customer_id=self.customer_id,
|
||||||
|
status=self.status,
|
||||||
|
date_from=self.date_from,
|
||||||
|
date_to=self.date_to,
|
||||||
|
account_id=self.account_id,
|
||||||
|
project_id=self.project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
list(filtered_invoices),
|
||||||
|
f"Found {filtered_invoices.count()} matching invoices"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to filter invoices"
|
||||||
|
)
|
||||||
0
backend/core/commands/labor/__init__.py
Normal file
0
backend/core/commands/labor/__init__.py
Normal file
550
backend/core/commands/labor/labor.py
Normal file
550
backend/core/commands/labor/labor.py
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
"""
|
||||||
|
Commands for labor-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional, Union
|
||||||
|
from decimal import Decimal
|
||||||
|
from backend.core.models.labor.labor import Labor
|
||||||
|
from backend.core.repositories.labor.labor import LaborRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, validate_required_fields,
|
||||||
|
validate_model_exists, validate_decimal_amount
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateLaborCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new labor record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
labor_repo: LaborRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str,
|
||||||
|
amount: Union[float, str, Decimal],
|
||||||
|
start_date: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create labor command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_repo: Repository for labor operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account the labor is for.
|
||||||
|
amount: Labor cost amount.
|
||||||
|
start_date: Start date of the labor (YYYY-MM-DD).
|
||||||
|
end_date: Optional end date of the labor (YYYY-MM-DD).
|
||||||
|
"""
|
||||||
|
self.labor_repo = labor_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.amount = amount
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the labor creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
missing_fields = validate_required_fields(
|
||||||
|
{
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'amount': self.amount,
|
||||||
|
'start_date': self.start_date
|
||||||
|
},
|
||||||
|
['account_id', 'amount', 'start_date']
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate amount
|
||||||
|
if not errors:
|
||||||
|
amount_validation = validate_decimal_amount(self.amount, 'amount')
|
||||||
|
if not amount_validation['valid']:
|
||||||
|
errors.append(amount_validation['error'])
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Labor]:
|
||||||
|
"""
|
||||||
|
Execute the labor creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Labor]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create labor data
|
||||||
|
labor_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create labor data dictionary
|
||||||
|
labor_data = {
|
||||||
|
'id': labor_id,
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'amount': self.amount,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_labor = self.labor_repo.create(labor_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_labor,
|
||||||
|
f"Labor record for account {self.account_id} created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create labor record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateLaborCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing labor record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
labor_repo: LaborRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
labor_id: str,
|
||||||
|
amount: Optional[Union[float, str, Decimal]] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update labor command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_repo: Repository for labor operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
labor_id: ID of the labor record to update.
|
||||||
|
amount: New labor cost amount.
|
||||||
|
start_date: New start date of the labor.
|
||||||
|
end_date: New end date of the labor.
|
||||||
|
"""
|
||||||
|
self.labor_repo = labor_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.labor_id = labor_id
|
||||||
|
self.amount = amount
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the labor update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate labor exists
|
||||||
|
if not is_valid_uuid(self.labor_id):
|
||||||
|
errors.append("Invalid labor ID format")
|
||||||
|
else:
|
||||||
|
labor = self.labor_repo.get_by_id(self.labor_id)
|
||||||
|
if not labor:
|
||||||
|
errors.append(f"Labor record with ID {self.labor_id} not found")
|
||||||
|
|
||||||
|
# Validate amount if provided
|
||||||
|
if not errors and self.amount is not None:
|
||||||
|
amount_validation = validate_decimal_amount(self.amount, 'amount')
|
||||||
|
if not amount_validation['valid']:
|
||||||
|
errors.append(amount_validation['error'])
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# If only updating end_date, validate it's after the existing start_date
|
||||||
|
if not errors and self.end_date and not self.start_date:
|
||||||
|
labor = self.labor_repo.get_by_id(self.labor_id)
|
||||||
|
if labor:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if end and labor.start_date > end:
|
||||||
|
errors.append("End date must be after the existing start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Labor]:
|
||||||
|
"""
|
||||||
|
Execute the labor update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Labor]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.amount is not None:
|
||||||
|
update_data['amount'] = self.amount
|
||||||
|
|
||||||
|
if self.start_date is not None:
|
||||||
|
update_data['start_date'] = self.start_date
|
||||||
|
|
||||||
|
if self.end_date is not None:
|
||||||
|
update_data['end_date'] = self.end_date
|
||||||
|
|
||||||
|
# Update the labor record with the data dictionary
|
||||||
|
updated_labor = self.labor_repo.update(self.labor_id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_labor,
|
||||||
|
f"Labor record {self.labor_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update labor record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteLaborCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a labor record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, labor_repo: LaborRepository, labor_id: str):
|
||||||
|
"""
|
||||||
|
Initialize the delete labor command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_repo: Repository for labor operations.
|
||||||
|
labor_id: ID of the labor record to delete.
|
||||||
|
"""
|
||||||
|
self.labor_repo = labor_repo
|
||||||
|
self.labor_id = labor_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the labor deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate labor exists
|
||||||
|
if not is_valid_uuid(self.labor_id):
|
||||||
|
errors.append("Invalid labor ID format")
|
||||||
|
else:
|
||||||
|
labor = self.labor_repo.get_by_id(self.labor_id)
|
||||||
|
if not labor:
|
||||||
|
errors.append(f"Labor record with ID {self.labor_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the labor deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the labor record
|
||||||
|
success = self.labor_repo.delete(self.labor_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Labor record {self.labor_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete labor record",
|
||||||
|
f"Labor record {self.labor_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete labor record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EndLaborCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to end a labor record by setting its end date to today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, labor_repo: LaborRepository, labor_id: str, end_date: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the end labor command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_repo: Repository for labor operations.
|
||||||
|
labor_id: ID of the labor record to end.
|
||||||
|
end_date: Optional specific end date (defaults to today if not provided).
|
||||||
|
"""
|
||||||
|
self.labor_repo = labor_repo
|
||||||
|
self.labor_id = labor_id
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the labor end request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate labor exists
|
||||||
|
if not is_valid_uuid(self.labor_id):
|
||||||
|
errors.append("Invalid labor ID format")
|
||||||
|
else:
|
||||||
|
labor = self.labor_repo.get_by_id(self.labor_id)
|
||||||
|
if not labor:
|
||||||
|
errors.append(f"Labor record with ID {self.labor_id} not found")
|
||||||
|
elif labor.end_date is not None:
|
||||||
|
errors.append(f"Labor record is already ended")
|
||||||
|
|
||||||
|
# Validate end date format if provided
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate end date is after start date if provided
|
||||||
|
if not errors and self.end_date:
|
||||||
|
labor = self.labor_repo.get_by_id(self.labor_id)
|
||||||
|
if labor:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if end and labor.start_date > end:
|
||||||
|
errors.append("End date must be after the start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Labor]:
|
||||||
|
"""
|
||||||
|
Execute the end labor command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Labor]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# End the labor record
|
||||||
|
if self.end_date:
|
||||||
|
# Use provided end date
|
||||||
|
updated_labor = self.labor_repo.update(self.labor_id, {'end_date': self.end_date})
|
||||||
|
else:
|
||||||
|
# Use repository method that sets end date to today
|
||||||
|
updated_labor = self.labor_repo.end_labor(self.labor_id)
|
||||||
|
|
||||||
|
if updated_labor:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_labor,
|
||||||
|
f"Labor record {self.labor_id} ended successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to end labor record",
|
||||||
|
f"Labor record {self.labor_id} could not be ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to end labor record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateLaborCostCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to calculate labor cost for an account or all accounts within a date range.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
labor_repo: LaborRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the calculate labor cost command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_repo: Repository for labor operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
start_date: Optional start date for the period.
|
||||||
|
end_date: Optional end date for the period.
|
||||||
|
account_id: Optional account ID to filter by.
|
||||||
|
"""
|
||||||
|
self.labor_repo = labor_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the labor cost calculation request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account exists if provided
|
||||||
|
# Validate account exists if provided
|
||||||
|
if self.account_id:
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
else:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[float]:
|
||||||
|
"""
|
||||||
|
Execute the labor cost calculation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[float]: Result of the command execution with the calculated cost.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse dates if provided
|
||||||
|
start_date_obj = parse_date(self.start_date) if self.start_date else None
|
||||||
|
end_date_obj = parse_date(self.end_date) if self.end_date else None
|
||||||
|
|
||||||
|
# Calculate labor cost
|
||||||
|
total_cost = self.labor_repo.get_total_labor_cost(
|
||||||
|
account_id=self.account_id,
|
||||||
|
start_date=start_date_obj,
|
||||||
|
end_date=end_date_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a descriptive message
|
||||||
|
message = "Total labor cost"
|
||||||
|
if self.account_id:
|
||||||
|
account = self.account_repo.get_by_id(self.account_id)
|
||||||
|
if account:
|
||||||
|
message += f" for account '{account.name}'"
|
||||||
|
|
||||||
|
if self.start_date and self.end_date:
|
||||||
|
message += f" from {self.start_date} to {self.end_date}"
|
||||||
|
elif self.start_date:
|
||||||
|
message += f" from {self.start_date}"
|
||||||
|
elif self.end_date:
|
||||||
|
message += f" until {self.end_date}"
|
||||||
|
|
||||||
|
message += f": ${total_cost:.2f}"
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
total_cost,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to calculate labor cost"
|
||||||
|
)
|
||||||
0
backend/core/commands/profiles/__init__.py
Normal file
0
backend/core/commands/profiles/__init__.py
Normal file
429
backend/core/commands/profiles/profiles.py
Normal file
429
backend/core/commands/profiles/profiles.py
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Commands for profile-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from backend.core.models.profiles.profiles import Profile
|
||||||
|
from backend.core.repositories.profiles.profiles import ProfileRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_email, is_valid_phone,
|
||||||
|
validate_required_fields
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProfileCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
user: User,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
primary_phone: str,
|
||||||
|
email: str,
|
||||||
|
role: str = 'team_member',
|
||||||
|
secondary_phone: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create profile command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
user: The Django User object to associate with the profile.
|
||||||
|
first_name: First name of the profile.
|
||||||
|
last_name: Last name of the profile.
|
||||||
|
primary_phone: Primary phone number.
|
||||||
|
email: Email address.
|
||||||
|
role: Role of the profile ('admin', 'team_leader', 'team_member').
|
||||||
|
secondary_phone: Optional secondary phone number.
|
||||||
|
"""
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.user = user
|
||||||
|
self.first_name = first_name
|
||||||
|
self.last_name = last_name
|
||||||
|
self.primary_phone = primary_phone
|
||||||
|
self.email = email
|
||||||
|
self.role = role
|
||||||
|
self.secondary_phone = secondary_phone
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the profile creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
missing_fields = validate_required_fields(
|
||||||
|
{
|
||||||
|
'user': self.user,
|
||||||
|
'first_name': self.first_name,
|
||||||
|
'last_name': self.last_name,
|
||||||
|
'primary_phone': self.primary_phone,
|
||||||
|
'email': self.email
|
||||||
|
},
|
||||||
|
['user', 'first_name', 'last_name', 'primary_phone', 'email']
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate email format
|
||||||
|
if not errors and self.email and not is_valid_email(self.email):
|
||||||
|
errors.append("Invalid email format.")
|
||||||
|
|
||||||
|
# Validate phone formats
|
||||||
|
if not errors and self.primary_phone and not is_valid_phone(self.primary_phone):
|
||||||
|
errors.append("Invalid primary phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_phone and not is_valid_phone(self.secondary_phone):
|
||||||
|
errors.append("Invalid secondary phone format.")
|
||||||
|
|
||||||
|
# Validate role
|
||||||
|
valid_roles = ['admin', 'team_leader', 'team_member']
|
||||||
|
if not errors and self.role not in valid_roles:
|
||||||
|
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
|
||||||
|
|
||||||
|
# Check if user already has a profile
|
||||||
|
if not errors and self.profile_repo.get_by_user(self.user):
|
||||||
|
errors.append(f"User already has a profile")
|
||||||
|
|
||||||
|
# Check if email is already in use
|
||||||
|
if not errors and self.profile_repo.get_by_email(self.email):
|
||||||
|
errors.append(f"Email is already in use")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Profile]:
|
||||||
|
"""
|
||||||
|
Execute the profile creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Profile]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create profile data
|
||||||
|
profile_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create profile data dictionary
|
||||||
|
profile_data = {
|
||||||
|
'id': profile_id,
|
||||||
|
'user': self.user,
|
||||||
|
'first_name': self.first_name,
|
||||||
|
'last_name': self.last_name,
|
||||||
|
'primary_phone': self.primary_phone,
|
||||||
|
'secondary_phone': self.secondary_phone,
|
||||||
|
'email': self.email,
|
||||||
|
'role': self.role
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_profile = self.profile_repo.create(profile_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_profile,
|
||||||
|
f"Profile for {self.first_name} {self.last_name} created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
profile_id: str,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
primary_phone: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
secondary_phone: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update profile command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
profile_id: ID of the profile to update.
|
||||||
|
first_name: New first name.
|
||||||
|
last_name: New last name.
|
||||||
|
primary_phone: New primary phone number.
|
||||||
|
email: New email address.
|
||||||
|
role: New role.
|
||||||
|
secondary_phone: New secondary phone number.
|
||||||
|
"""
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.first_name = first_name
|
||||||
|
self.last_name = last_name
|
||||||
|
self.primary_phone = primary_phone
|
||||||
|
self.email = email
|
||||||
|
self.role = role
|
||||||
|
self.secondary_phone = secondary_phone
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the profile update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate profile exists
|
||||||
|
if not is_valid_uuid(self.profile_id):
|
||||||
|
errors.append("Invalid profile ID format")
|
||||||
|
else:
|
||||||
|
profile = self.profile_repo.get_by_id(self.profile_id)
|
||||||
|
if not profile:
|
||||||
|
errors.append(f"Profile with ID {self.profile_id} not found")
|
||||||
|
|
||||||
|
# Validate email format if provided
|
||||||
|
if not errors and self.email is not None and not is_valid_email(self.email):
|
||||||
|
errors.append("Invalid email format.")
|
||||||
|
|
||||||
|
# Validate phone formats if provided
|
||||||
|
if not errors and self.primary_phone is not None and not is_valid_phone(self.primary_phone):
|
||||||
|
errors.append("Invalid primary phone format.")
|
||||||
|
|
||||||
|
if not errors and self.secondary_phone is not None and not is_valid_phone(self.secondary_phone):
|
||||||
|
errors.append("Invalid secondary phone format.")
|
||||||
|
|
||||||
|
# Validate role if provided
|
||||||
|
valid_roles = ['admin', 'team_leader', 'team_member']
|
||||||
|
if not errors and self.role is not None and self.role not in valid_roles:
|
||||||
|
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
|
||||||
|
|
||||||
|
# Check if email is already in use by another profile
|
||||||
|
if not errors and self.email is not None:
|
||||||
|
existing_profile = self.profile_repo.get_by_email(self.email)
|
||||||
|
if existing_profile and str(existing_profile.id) != self.profile_id:
|
||||||
|
errors.append(f"Email is already in use by another profile")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Profile]:
|
||||||
|
"""
|
||||||
|
Execute the profile update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Profile]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.first_name is not None:
|
||||||
|
update_data['first_name'] = self.first_name
|
||||||
|
|
||||||
|
if self.last_name is not None:
|
||||||
|
update_data['last_name'] = self.last_name
|
||||||
|
|
||||||
|
if self.primary_phone is not None:
|
||||||
|
update_data['primary_phone'] = self.primary_phone
|
||||||
|
|
||||||
|
if self.secondary_phone is not None:
|
||||||
|
update_data['secondary_phone'] = self.secondary_phone
|
||||||
|
|
||||||
|
if self.email is not None:
|
||||||
|
update_data['email'] = self.email
|
||||||
|
|
||||||
|
if self.role is not None:
|
||||||
|
update_data['role'] = self.role
|
||||||
|
|
||||||
|
# Update the profile with the data dictionary
|
||||||
|
updated_profile = self.profile_repo.update(self.profile_id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_profile,
|
||||||
|
f"Profile {self.profile_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteProfileCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a profile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, profile_repo: ProfileRepository, profile_id: str):
|
||||||
|
"""
|
||||||
|
Initialize the delete profile command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
profile_id: ID of the profile to delete.
|
||||||
|
"""
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.profile_id = profile_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the profile deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate profile exists
|
||||||
|
if not is_valid_uuid(self.profile_id):
|
||||||
|
errors.append("Invalid profile ID format")
|
||||||
|
else:
|
||||||
|
profile = self.profile_repo.get_by_id(self.profile_id)
|
||||||
|
if not profile:
|
||||||
|
errors.append(f"Profile with ID {self.profile_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the profile deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the profile
|
||||||
|
success = self.profile_repo.delete(self.profile_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Profile {self.profile_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete profile",
|
||||||
|
f"Profile {self.profile_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchProfilesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to search for profiles.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
search_term: str,
|
||||||
|
role: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the search profiles.py command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
search_term: The search term to look for.
|
||||||
|
role: Optional role to filter by.
|
||||||
|
"""
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.search_term = search_term
|
||||||
|
self.role = role
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the profile search request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate role if provided
|
||||||
|
valid_roles = ['admin', 'team_leader', 'team_member']
|
||||||
|
if self.role is not None and self.role not in valid_roles:
|
||||||
|
errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[list]:
|
||||||
|
"""
|
||||||
|
Execute the profile search command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[list]: Result of the command execution with list of matching profiles.py.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search for profiles.py
|
||||||
|
results = self.profile_repo.search(self.search_term)
|
||||||
|
|
||||||
|
# Filter by role if provided
|
||||||
|
if self.role:
|
||||||
|
results = results.filter(role=self.role)
|
||||||
|
|
||||||
|
# Convert QuerySet to list
|
||||||
|
profiles = list(results)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
profiles,
|
||||||
|
f"Found {len(profiles)} matching profiles.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to search profiles.py"
|
||||||
|
)
|
||||||
0
backend/core/commands/projects/__init__.py
Normal file
0
backend/core/commands/projects/__init__.py
Normal file
444
backend/core/commands/projects/projects.py
Normal file
444
backend/core/commands/projects/projects.py
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
"""
|
||||||
|
Commands for project-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from backend.core.models.projects.projects import Project
|
||||||
|
from backend.core.repositories.projects.projects import ProjectRepository
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.profiles.profiles import ProfileRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, validate_required_fields,
|
||||||
|
validate_model_exists, validate_decimal_amount
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_repo: ProjectRepository,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
customer_id: str,
|
||||||
|
date: str,
|
||||||
|
labor: Union[float, str, Decimal],
|
||||||
|
status: str = 'planned',
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
team_member_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
amount: Optional[Union[float, str, Decimal]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create project command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_repo: Repository for project operations.
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
customer_id: ID of the customer the project is for.
|
||||||
|
date: Date of the project (YYYY-MM-DD).
|
||||||
|
labor: Labor cost for the project.
|
||||||
|
status: Status of the project ('planned', 'in_progress', 'completed', 'cancelled').
|
||||||
|
account_id: Optional ID of the account the project is for.
|
||||||
|
team_member_ids: List of profile IDs for team members assigned to the project.
|
||||||
|
notes: Additional notes about the project.
|
||||||
|
amount: Billing amount for the project.
|
||||||
|
"""
|
||||||
|
self.project_repo = project_repo
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.customer_id = customer_id
|
||||||
|
self.date = date
|
||||||
|
self.labor = labor
|
||||||
|
self.status = status
|
||||||
|
self.account_id = account_id
|
||||||
|
self.team_member_ids = team_member_ids or []
|
||||||
|
self.notes = notes
|
||||||
|
self.amount = amount or labor # Default billing amount to labor cost if not specified
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the project creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
missing_fields = validate_required_fields(
|
||||||
|
{
|
||||||
|
'customer_id': self.customer_id,
|
||||||
|
'date': self.date,
|
||||||
|
'labor': self.labor
|
||||||
|
},
|
||||||
|
['customer_id', 'date', 'labor']
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate customer exists
|
||||||
|
if not errors and self.customer_id:
|
||||||
|
customer_validation = validate_model_exists(
|
||||||
|
self.customer_id, 'customer', self.customer_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not customer_validation['valid']:
|
||||||
|
errors.append(customer_validation['error'])
|
||||||
|
|
||||||
|
# Validate account exists if provided
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate date format
|
||||||
|
if not errors and self.date and not is_valid_date(self.date):
|
||||||
|
errors.append("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate labor amount
|
||||||
|
if not errors:
|
||||||
|
labor_validation = validate_decimal_amount(self.labor, 'labor')
|
||||||
|
if not labor_validation['valid']:
|
||||||
|
errors.append(labor_validation['error'])
|
||||||
|
|
||||||
|
# Validate billing amount if provided
|
||||||
|
if not errors and self.amount is not None:
|
||||||
|
amount_validation = validate_decimal_amount(self.amount, 'amount')
|
||||||
|
if not amount_validation['valid']:
|
||||||
|
errors.append(amount_validation['error'])
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled']
|
||||||
|
if not errors and self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# Validate team member IDs
|
||||||
|
if not errors and self.team_member_ids:
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
if not is_valid_uuid(member_id):
|
||||||
|
errors.append(f"Invalid team member ID format: {member_id}")
|
||||||
|
elif not self.profile_repo.get_by_id(member_id):
|
||||||
|
errors.append(f"Team member with ID {member_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Project]:
|
||||||
|
"""
|
||||||
|
Execute the project creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Project]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create project data
|
||||||
|
project_id = generate_uuid()
|
||||||
|
|
||||||
|
# Normalize decimal values
|
||||||
|
labor = float(self.labor)
|
||||||
|
amount = float(self.amount) if self.amount is not None else labor
|
||||||
|
|
||||||
|
# Create project data dictionary instead of Project object
|
||||||
|
project_data = {
|
||||||
|
'id': project_id,
|
||||||
|
'customer_id': self.customer_id,
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'date': self.date,
|
||||||
|
'status': self.status,
|
||||||
|
'notes': self.notes,
|
||||||
|
'labor': labor,
|
||||||
|
'amount': amount
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository and handle team members
|
||||||
|
if self.team_member_ids:
|
||||||
|
# If there's a create_with_team_members method like in ServiceRepository
|
||||||
|
created_project = self.project_repo.create_with_team_members(
|
||||||
|
project_data,
|
||||||
|
self.team_member_ids
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Otherwise create the project first, then assign team members
|
||||||
|
created_project = self.project_repo.create(project_data)
|
||||||
|
|
||||||
|
# Assign team members if any
|
||||||
|
if self.team_member_ids:
|
||||||
|
team_members = []
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
member = self.profile_repo.get_by_id(member_id)
|
||||||
|
if member:
|
||||||
|
team_members.append(member)
|
||||||
|
# Assuming there's a method to assign team members
|
||||||
|
self.project_repo.assign_team_members(project_id, self.team_member_ids)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_project,
|
||||||
|
f"Project for customer {self.customer_id} created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create project"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProjectCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_repo: ProjectRepository,
|
||||||
|
customer_repo: CustomerRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
project_id: str,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
labor: Optional[Union[float, str, Decimal]] = None,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
team_member_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
amount: Optional[Union[float, str, Decimal]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update project command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_repo: Repository for project operations.
|
||||||
|
customer_repo: Repository for customer operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
project_id: ID of the project to update.
|
||||||
|
status: New status for the project.
|
||||||
|
date: New date for the project.
|
||||||
|
labor: New labor cost for the project.
|
||||||
|
account_id: New account ID for the project.
|
||||||
|
team_member_ids: New list of team member IDs.
|
||||||
|
notes: New notes for the project.
|
||||||
|
amount: New billing amount for the project.
|
||||||
|
"""
|
||||||
|
self.project_repo = project_repo
|
||||||
|
self.customer_repo = customer_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.project_id = project_id
|
||||||
|
self.status = status
|
||||||
|
self.date = date
|
||||||
|
self.labor = labor
|
||||||
|
self.account_id = account_id
|
||||||
|
self.team_member_ids = team_member_ids
|
||||||
|
self.notes = notes
|
||||||
|
self.amount = amount
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the project update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate project exists
|
||||||
|
if not is_valid_uuid(self.project_id):
|
||||||
|
errors.append("Invalid project ID format")
|
||||||
|
else:
|
||||||
|
project = self.project_repo.get_by_id(self.project_id)
|
||||||
|
if not project:
|
||||||
|
errors.append(f"Project with ID {self.project_id} not found")
|
||||||
|
|
||||||
|
# Validate date format if provided
|
||||||
|
if not errors and self.date and not is_valid_date(self.date):
|
||||||
|
errors.append("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate labor amount if provided
|
||||||
|
if not errors and self.labor is not None:
|
||||||
|
labor_validation = validate_decimal_amount(self.labor, 'labor')
|
||||||
|
if not labor_validation['valid']:
|
||||||
|
errors.append(labor_validation['error'])
|
||||||
|
|
||||||
|
# Validate billing amount if provided
|
||||||
|
if not errors and self.amount is not None:
|
||||||
|
amount_validation = validate_decimal_amount(self.amount, 'amount')
|
||||||
|
if not amount_validation['valid']:
|
||||||
|
errors.append(amount_validation['error'])
|
||||||
|
|
||||||
|
# Validate status if provided
|
||||||
|
if not errors and self.status:
|
||||||
|
valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled']
|
||||||
|
if self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# Validate account exists if provided
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate team member IDs if provided
|
||||||
|
if not errors and self.team_member_ids is not None:
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
if not is_valid_uuid(member_id):
|
||||||
|
errors.append(f"Invalid team member ID format: {member_id}")
|
||||||
|
elif not self.profile_repo.get_by_id(member_id):
|
||||||
|
errors.append(f"Team member with ID {member_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Project]:
|
||||||
|
"""
|
||||||
|
Execute the project update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Project]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.status is not None:
|
||||||
|
update_data['status'] = self.status
|
||||||
|
|
||||||
|
if self.date is not None:
|
||||||
|
update_data['date'] = self.date
|
||||||
|
|
||||||
|
if self.labor is not None:
|
||||||
|
update_data['labor'] = float(self.labor)
|
||||||
|
|
||||||
|
if self.account_id is not None:
|
||||||
|
update_data['account_id'] = self.account_id
|
||||||
|
|
||||||
|
if self.notes is not None:
|
||||||
|
update_data['notes'] = self.notes
|
||||||
|
|
||||||
|
if self.amount is not None:
|
||||||
|
update_data['amount'] = float(self.amount)
|
||||||
|
|
||||||
|
# Update the project with the data dictionary
|
||||||
|
updated_project = self.project_repo.update(self.project_id, update_data)
|
||||||
|
|
||||||
|
# Update team members if provided
|
||||||
|
if self.team_member_ids is not None:
|
||||||
|
# Assuming there's a method to assign team members
|
||||||
|
updated_project = self.project_repo.assign_team_members(
|
||||||
|
self.project_id,
|
||||||
|
self.team_member_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_project,
|
||||||
|
f"Project {self.project_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update project"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteProjectCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, project_repo: ProjectRepository, project_id: str):
|
||||||
|
"""
|
||||||
|
Initialize the delete project command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_repo: Repository for project operations.
|
||||||
|
project_id: ID of the project to delete.
|
||||||
|
"""
|
||||||
|
self.project_repo = project_repo
|
||||||
|
self.project_id = project_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the project deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate project exists
|
||||||
|
if not is_valid_uuid(self.project_id):
|
||||||
|
errors.append("Invalid project ID format")
|
||||||
|
else:
|
||||||
|
project = self.project_repo.get_by_id(self.project_id)
|
||||||
|
if not project:
|
||||||
|
errors.append(f"Project with ID {self.project_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the project deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the project
|
||||||
|
success = self.project_repo.delete(self.project_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Project {self.project_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete project",
|
||||||
|
f"Project {self.project_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete project"
|
||||||
|
)
|
||||||
0
backend/core/commands/reports/__init__.py
Normal file
0
backend/core/commands/reports/__init__.py
Normal file
296
backend/core/commands/reports/reports.py
Normal file
296
backend/core/commands/reports/reports.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
Commands for handling reports.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import date
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from backend.core.repositories import ReportRepository
|
||||||
|
from backend.core.models import Service, Project
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReportCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_member_id: UUID of the team member
|
||||||
|
report_date: Date of the report
|
||||||
|
service_ids: Optional list of service IDs to associate
|
||||||
|
project_ids: Optional list of project IDs to associate
|
||||||
|
notes: Optional notes about the report
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
team_member_id: str,
|
||||||
|
report_date: date,
|
||||||
|
service_ids: Optional[List[str]] = None,
|
||||||
|
project_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.team_member_id = team_member_id
|
||||||
|
self.report_date = report_date
|
||||||
|
self.service_ids = service_ids or []
|
||||||
|
self.project_ids = project_ids or []
|
||||||
|
self.notes = notes
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to create a report."""
|
||||||
|
try:
|
||||||
|
report_data = {
|
||||||
|
'team_member_id': self.team_member_id,
|
||||||
|
'date': self.report_date,
|
||||||
|
'notes': self.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
report = ReportRepository.create_with_items(
|
||||||
|
report_data,
|
||||||
|
self.service_ids,
|
||||||
|
self.project_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
data=report,
|
||||||
|
message="Report created successfully."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create report"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateReportCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_id: UUID of the report to update
|
||||||
|
team_member_id: Optional UUID of the team member
|
||||||
|
report_date: Optional date of the report
|
||||||
|
service_ids: Optional list of service IDs to associate
|
||||||
|
project_ids: Optional list of project IDs to associate
|
||||||
|
notes: Optional notes about the report
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
report_id: str,
|
||||||
|
team_member_id: Optional[str] = None,
|
||||||
|
report_date: Optional[date] = None,
|
||||||
|
service_ids: Optional[List[str]] = None,
|
||||||
|
project_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.report_id = report_id
|
||||||
|
self.team_member_id = team_member_id
|
||||||
|
self.report_date = report_date
|
||||||
|
self.service_ids = service_ids
|
||||||
|
self.project_ids = project_ids
|
||||||
|
self.notes = notes
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to update a report."""
|
||||||
|
try:
|
||||||
|
# Get the report
|
||||||
|
report = ReportRepository.get_by_id(self.report_id)
|
||||||
|
if not report:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Report with ID {self.report_id} not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update fields if provided
|
||||||
|
update_data = {}
|
||||||
|
if self.team_member_id is not None:
|
||||||
|
update_data['team_member_id'] = self.team_member_id
|
||||||
|
if self.report_date is not None:
|
||||||
|
update_data['date'] = self.report_date
|
||||||
|
if self.notes is not None:
|
||||||
|
update_data['notes'] = self.notes
|
||||||
|
|
||||||
|
# Update the report
|
||||||
|
if update_data:
|
||||||
|
report = ReportRepository.update(self.report_id, update_data)
|
||||||
|
|
||||||
|
# Update related services
|
||||||
|
if self.service_ids is not None:
|
||||||
|
services = Service.objects.filter(id__in=self.service_ids)
|
||||||
|
report.services.set(services)
|
||||||
|
|
||||||
|
# Update related projects
|
||||||
|
if self.project_ids is not None:
|
||||||
|
projects = Project.objects.filter(id__in=self.project_ids)
|
||||||
|
report.projects.set(projects)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
data=report,
|
||||||
|
message="Report updated successfully."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update report"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteReportCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_id: UUID of the report to delete
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, report_id: str):
|
||||||
|
self.report_id = report_id
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to delete a report."""
|
||||||
|
try:
|
||||||
|
report = ReportRepository.get_by_id(self.report_id)
|
||||||
|
if not report:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Report with ID {self.report_id} not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
ReportRepository.delete(self.report_id)
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message="Report deleted successfully."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete report"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetTeamMemberReportsCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get reports for a team member within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_member_id: UUID of the team member
|
||||||
|
start_date: Optional start date (inclusive)
|
||||||
|
end_date: Optional end date (inclusive)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
team_member_id: str,
|
||||||
|
start_date: Optional[date] = None,
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
):
|
||||||
|
self.team_member_id = team_member_id
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date or timezone.now().date()
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to get reports for a team member."""
|
||||||
|
try:
|
||||||
|
# First get reports by team member
|
||||||
|
reports = ReportRepository.get_by_team_member(self.team_member_id)
|
||||||
|
|
||||||
|
# Then filter by date range if provided
|
||||||
|
if self.start_date:
|
||||||
|
reports = reports.filter(date__gte=self.start_date)
|
||||||
|
if self.end_date:
|
||||||
|
reports = reports.filter(date__lte=self.end_date)
|
||||||
|
|
||||||
|
# Order by date descending
|
||||||
|
reports = reports.order_by('-date')
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
data=list(reports),
|
||||||
|
message=f"Found {reports.count()} reports for the team member."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to find reports for the team member."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetTeamMemberActivityCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get activity summary for a team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_member_id: UUID of the team member
|
||||||
|
start_date: Optional start date (inclusive)
|
||||||
|
end_date: Optional end date (inclusive)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
team_member_id: str,
|
||||||
|
start_date: Optional[date] = None,
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
):
|
||||||
|
self.team_member_id = team_member_id
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to get activity summary for a team member."""
|
||||||
|
try:
|
||||||
|
activity_summary = ReportRepository.get_team_member_activity(
|
||||||
|
self.team_member_id,
|
||||||
|
self.start_date,
|
||||||
|
self.end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
data=activity_summary,
|
||||||
|
message="Retrieved team member activity summary."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve team member activity summary."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetTeamSummaryCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get activity summary for all team members.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Optional start date (inclusive)
|
||||||
|
end_date: Optional end date (inclusive)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
start_date: Optional[date] = None,
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
):
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult:
|
||||||
|
"""Execute the command to get activity summary for all team members."""
|
||||||
|
try:
|
||||||
|
team_summary = ReportRepository.get_team_summary(
|
||||||
|
self.start_date,
|
||||||
|
self.end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
data=team_summary,
|
||||||
|
message="Retrieved team summary."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve team summary."
|
||||||
|
)
|
||||||
0
backend/core/commands/revenues/__init__.py
Normal file
0
backend/core/commands/revenues/__init__.py
Normal file
693
backend/core/commands/revenues/revenues.py
Normal file
693
backend/core/commands/revenues/revenues.py
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
"""
|
||||||
|
Commands for revenue-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from backend.core.models.revenues.revenues import Revenue
|
||||||
|
from backend.core.repositories.revenues.revenues import RevenueRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new revenue record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str,
|
||||||
|
amount: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create revenue command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account this revenue belongs to.
|
||||||
|
amount: Amount of revenue.
|
||||||
|
start_date: Start date of the revenue (YYYY-MM-DD).
|
||||||
|
end_date: End date of the revenue (YYYY-MM-DD, optional).
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.amount = amount
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the revenue creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
required_fields = ['account_id', 'amount', 'start_date']
|
||||||
|
|
||||||
|
field_values = {
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'amount': self.amount,
|
||||||
|
'start_date': self.start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_fields = validate_required_fields(field_values, required_fields)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate amount is a valid decimal
|
||||||
|
if not errors and self.amount:
|
||||||
|
try:
|
||||||
|
amount = float(self.amount)
|
||||||
|
if amount <= 0:
|
||||||
|
errors.append("Amount must be greater than zero.")
|
||||||
|
except ValueError:
|
||||||
|
errors.append("Amount must be a valid number.")
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# Check if there's an active revenue record for this account
|
||||||
|
if not errors:
|
||||||
|
active_revenue = self.revenue_repo.get_active_by_account(self.account_id)
|
||||||
|
if active_revenue:
|
||||||
|
errors.append(
|
||||||
|
f"Account already has an active revenue record. End the current record before creating a new one.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Revenue]:
|
||||||
|
"""
|
||||||
|
Execute the revenue creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Revenue]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create revenue data
|
||||||
|
revenue_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create revenue data dictionary
|
||||||
|
revenue_data = {
|
||||||
|
'id': revenue_id,
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'amount': self.amount,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_revenue = self.revenue_repo.create(revenue_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_revenue,
|
||||||
|
f"Revenue record created successfully for account {self.account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create revenue record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing revenue record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
revenue_id: str,
|
||||||
|
amount: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update revenue command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
revenue_id: ID of the revenue to update.
|
||||||
|
amount: New amount for the revenue.
|
||||||
|
start_date: New start date for the revenue.
|
||||||
|
end_date: New end date for the revenue.
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.revenue_id = revenue_id
|
||||||
|
self.amount = amount
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the revenue update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate revenue exists
|
||||||
|
if not is_valid_uuid(self.revenue_id):
|
||||||
|
errors.append("Invalid revenue ID format")
|
||||||
|
else:
|
||||||
|
revenue = self.revenue_repo.get_by_id(self.revenue_id)
|
||||||
|
if not revenue:
|
||||||
|
errors.append(f"Revenue with ID {self.revenue_id} not found")
|
||||||
|
|
||||||
|
# Validate amount is a valid decimal if provided
|
||||||
|
if not errors and self.amount is not None:
|
||||||
|
try:
|
||||||
|
amount = float(self.amount)
|
||||||
|
if amount <= 0:
|
||||||
|
errors.append("Amount must be greater than zero.")
|
||||||
|
except ValueError:
|
||||||
|
errors.append("Amount must be a valid number.")
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# If only updating end_date, validate it's after the existing start_date
|
||||||
|
if not errors and self.end_date and not self.start_date:
|
||||||
|
revenue = self.revenue_repo.get_by_id(self.revenue_id)
|
||||||
|
if revenue:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(revenue.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the existing start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Revenue]:
|
||||||
|
"""
|
||||||
|
Execute the revenue update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Revenue]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.amount is not None:
|
||||||
|
update_data['amount'] = self.amount
|
||||||
|
|
||||||
|
if self.start_date is not None:
|
||||||
|
update_data['start_date'] = self.start_date
|
||||||
|
|
||||||
|
if self.end_date is not None:
|
||||||
|
update_data['end_date'] = self.end_date
|
||||||
|
|
||||||
|
# Update the revenue with the data dictionary
|
||||||
|
updated_revenue = self.revenue_repo.update(self.revenue_id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_revenue,
|
||||||
|
f"Revenue {self.revenue_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update revenue"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a revenue record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
revenue_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the delete revenue command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
revenue_id: ID of the revenue to delete.
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.revenue_id = revenue_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the revenue deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate revenue exists
|
||||||
|
if not is_valid_uuid(self.revenue_id):
|
||||||
|
errors.append("Invalid revenue ID format")
|
||||||
|
else:
|
||||||
|
revenue = self.revenue_repo.get_by_id(self.revenue_id)
|
||||||
|
if not revenue:
|
||||||
|
errors.append(f"Revenue with ID {self.revenue_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the revenue deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the revenue
|
||||||
|
success = self.revenue_repo.delete(self.revenue_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Revenue {self.revenue_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete revenue",
|
||||||
|
f"Revenue {self.revenue_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete revenue"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EndRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to end a revenue record by setting its end date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
revenue_id: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the end revenue command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
revenue_id: ID of the revenue to end.
|
||||||
|
end_date: End date for the revenue (defaults to today if not provided).
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.revenue_id = revenue_id
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the end revenue request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate revenue exists
|
||||||
|
if not is_valid_uuid(self.revenue_id):
|
||||||
|
errors.append("Invalid revenue ID format")
|
||||||
|
else:
|
||||||
|
revenue = self.revenue_repo.get_by_id(self.revenue_id)
|
||||||
|
if not revenue:
|
||||||
|
errors.append(f"Revenue with ID {self.revenue_id} not found")
|
||||||
|
elif revenue.end_date is not None:
|
||||||
|
errors.append(f"Revenue is already ended")
|
||||||
|
|
||||||
|
# Validate end date format if provided
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate end date is after start date if provided
|
||||||
|
if not errors and self.end_date:
|
||||||
|
revenue = self.revenue_repo.get_by_id(self.revenue_id)
|
||||||
|
if revenue:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(revenue.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Revenue]:
|
||||||
|
"""Execute the end revenue command."""
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# End the revenue record
|
||||||
|
if self.end_date:
|
||||||
|
# Use provided end date
|
||||||
|
updated_revenue = self.revenue_repo.update(self.revenue_id, {'end_date': self.end_date})
|
||||||
|
else:
|
||||||
|
# Use repository method that sets end date to today
|
||||||
|
updated_revenue = self.revenue_repo.end_revenue(self.revenue_id)
|
||||||
|
|
||||||
|
if updated_revenue:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_revenue,
|
||||||
|
f"Revenue {self.revenue_id} ended successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to end revenue record",
|
||||||
|
f"Revenue {self.revenue_id} could not be ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to end revenue record"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetRevenueByDateRangeCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get revenue records within a date range.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the get revenue by date range command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
start_date: Start date for the range (YYYY-MM-DD).
|
||||||
|
end_date: End date for the range (YYYY-MM-DD).
|
||||||
|
account_id: Optional account ID to filter by.
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the get revenue by date range request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# Validate account ID if provided
|
||||||
|
if not errors and self.account_id and not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Revenue]]:
|
||||||
|
"""
|
||||||
|
Execute the get revenue by date range command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Revenue]]: Result of the command execution with revenue data.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse dates if provided
|
||||||
|
start_date_obj = parse_date(self.start_date) if self.start_date else None
|
||||||
|
end_date_obj = parse_date(self.end_date) if self.end_date else None
|
||||||
|
|
||||||
|
# Get revenues by date range
|
||||||
|
revenues = self.revenue_repo.get_by_date_range(start_date_obj, end_date_obj)
|
||||||
|
|
||||||
|
# Filter by account if provided
|
||||||
|
if self.account_id:
|
||||||
|
revenues = revenues.filter(account_id=self.account_id)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
list(revenues),
|
||||||
|
f"Retrieved revenue data for specified date range"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve revenue data"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateTotalRevenueCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to calculate total revenue for an account or all accounts within a date range.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""Validate the calculation request."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate date range if both dates provided
|
||||||
|
if self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[float]:
|
||||||
|
"""Execute the revenue calculation command."""
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert string dates to date objects if provided
|
||||||
|
start_date_obj = parse_date(self.start_date) if self.start_date else None
|
||||||
|
end_date_obj = parse_date(self.end_date) if self.end_date else None
|
||||||
|
|
||||||
|
# Calculate total revenue using repository method
|
||||||
|
total_revenue = self.revenue_repo.get_total_revenue(
|
||||||
|
account_id=self.account_id,
|
||||||
|
start_date=start_date_obj,
|
||||||
|
end_date=end_date_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create success message
|
||||||
|
message = "Total revenue"
|
||||||
|
if self.account_id:
|
||||||
|
message += f" for account {self.account_id}"
|
||||||
|
if self.start_date or self.end_date:
|
||||||
|
message += " for period"
|
||||||
|
if self.start_date:
|
||||||
|
message += f" from {self.start_date}"
|
||||||
|
if self.end_date:
|
||||||
|
message += f" to {self.end_date}"
|
||||||
|
message += f": ${total_revenue:.2f}"
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
total_revenue,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to calculate total revenue"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetActiveRevenuesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get all active revenue records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
revenue_repo: RevenueRepository,
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the get active revenues command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_repo: Repository for revenue operations.
|
||||||
|
account_id: Optional account ID to filter by.
|
||||||
|
"""
|
||||||
|
self.revenue_repo = revenue_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the get active revenues request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account ID if provided
|
||||||
|
if self.account_id and not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Revenue]]:
|
||||||
|
"""
|
||||||
|
Execute the get active revenues command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Revenue]]: Result of the command execution with active revenues.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get active revenues
|
||||||
|
if self.account_id:
|
||||||
|
active_revenue = self.revenue_repo.get_active_by_account(self.account_id)
|
||||||
|
revenues = [active_revenue] if active_revenue else []
|
||||||
|
else:
|
||||||
|
revenues = list(self.revenue_repo.get_active())
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
revenues,
|
||||||
|
f"Retrieved active revenue records"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve active revenue records"
|
||||||
|
)
|
||||||
0
backend/core/commands/schedules/__init__.py
Normal file
0
backend/core/commands/schedules/__init__.py
Normal file
848
backend/core/commands/schedules/schedules.py
Normal file
848
backend/core/commands/schedules/schedules.py
Normal file
@ -0,0 +1,848 @@
|
|||||||
|
"""
|
||||||
|
Commands for schedule-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.core.models.schedules.schedules import Schedule
|
||||||
|
from backend.core.models.services.services import Service
|
||||||
|
from backend.core.repositories.schedules.schedules import ScheduleRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateScheduleCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
account_id: str,
|
||||||
|
start_date: str,
|
||||||
|
monday_service: bool = False,
|
||||||
|
tuesday_service: bool = False,
|
||||||
|
wednesday_service: bool = False,
|
||||||
|
thursday_service: bool = False,
|
||||||
|
friday_service: bool = False,
|
||||||
|
saturday_service: bool = False,
|
||||||
|
sunday_service: bool = False,
|
||||||
|
weekend_service: bool = False,
|
||||||
|
schedule_exception: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create schedule command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
account_id: ID of the account this schedule belongs to.
|
||||||
|
start_date: Start date of the schedule (YYYY-MM-DD).
|
||||||
|
monday_service: Whether service is scheduled for Monday.
|
||||||
|
tuesday_service: Whether service is scheduled for Tuesday.
|
||||||
|
wednesday_service: Whether service is scheduled for Wednesday.
|
||||||
|
thursday_service: Whether service is scheduled for Thursday.
|
||||||
|
friday_service: Whether service is scheduled for Friday.
|
||||||
|
saturday_service: Whether service is scheduled for Saturday.
|
||||||
|
sunday_service: Whether service is scheduled for Sunday.
|
||||||
|
weekend_service: Whether weekend service is enabled.
|
||||||
|
schedule_exception: Any exceptions to the schedule.
|
||||||
|
end_date: End date of the schedule (YYYY-MM-DD, optional).
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.start_date = start_date
|
||||||
|
self.monday_service = monday_service
|
||||||
|
self.tuesday_service = tuesday_service
|
||||||
|
self.wednesday_service = wednesday_service
|
||||||
|
self.thursday_service = thursday_service
|
||||||
|
self.friday_service = friday_service
|
||||||
|
self.saturday_service = saturday_service
|
||||||
|
self.sunday_service = sunday_service
|
||||||
|
self.weekend_service = weekend_service
|
||||||
|
self.schedule_exception = schedule_exception
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the schedule creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
required_fields = ['account_id', 'start_date']
|
||||||
|
|
||||||
|
field_values = {
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'start_date': self.start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_fields = validate_required_fields(field_values, required_fields)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and self.start_date and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# Check if there's an active schedule for this account
|
||||||
|
if not errors:
|
||||||
|
active_schedule = self.schedule_repo.get_active_by_account(self.account_id)
|
||||||
|
if active_schedule:
|
||||||
|
errors.append(
|
||||||
|
f"Account already has an active schedule. End the current schedule before creating a new one.")
|
||||||
|
|
||||||
|
# Validate at least one service day is selected
|
||||||
|
if not errors and not any([
|
||||||
|
self.monday_service, self.tuesday_service, self.wednesday_service,
|
||||||
|
self.thursday_service, self.friday_service, self.saturday_service,
|
||||||
|
self.sunday_service, self.weekend_service
|
||||||
|
]):
|
||||||
|
errors.append("At least one service day must be selected.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Schedule]:
|
||||||
|
"""
|
||||||
|
Execute the schedule creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Schedule]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create schedule data
|
||||||
|
schedule_id = generate_uuid()
|
||||||
|
|
||||||
|
# Create schedule data dictionary
|
||||||
|
schedule_data = {
|
||||||
|
'id': schedule_id,
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'monday_service': self.monday_service,
|
||||||
|
'tuesday_service': self.tuesday_service,
|
||||||
|
'wednesday_service': self.wednesday_service,
|
||||||
|
'thursday_service': self.thursday_service,
|
||||||
|
'friday_service': self.friday_service,
|
||||||
|
'saturday_service': self.saturday_service,
|
||||||
|
'sunday_service': self.sunday_service,
|
||||||
|
'weekend_service': self.weekend_service,
|
||||||
|
'schedule_exception': self.schedule_exception,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to repository
|
||||||
|
created_schedule = self.schedule_repo.create(schedule_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_schedule,
|
||||||
|
f"Schedule created successfully for account {self.account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateScheduleCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
schedule_id: str,
|
||||||
|
monday_service: Optional[bool] = None,
|
||||||
|
tuesday_service: Optional[bool] = None,
|
||||||
|
wednesday_service: Optional[bool] = None,
|
||||||
|
thursday_service: Optional[bool] = None,
|
||||||
|
friday_service: Optional[bool] = None,
|
||||||
|
saturday_service: Optional[bool] = None,
|
||||||
|
sunday_service: Optional[bool] = None,
|
||||||
|
weekend_service: Optional[bool] = None,
|
||||||
|
schedule_exception: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update schedule command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
schedule_id: ID of the schedule to update.
|
||||||
|
monday_service: Whether service is scheduled for Monday.
|
||||||
|
tuesday_service: Whether service is scheduled for Tuesday.
|
||||||
|
wednesday_service: Whether service is scheduled for Wednesday.
|
||||||
|
thursday_service: Whether service is scheduled for Thursday.
|
||||||
|
friday_service: Whether service is scheduled for Friday.
|
||||||
|
saturday_service: Whether service is scheduled for Saturday.
|
||||||
|
sunday_service: Whether service is scheduled for Sunday.
|
||||||
|
weekend_service: Whether weekend service is enabled.
|
||||||
|
schedule_exception: Any exceptions to the schedule.
|
||||||
|
start_date: Start date of the schedule.
|
||||||
|
end_date: End date of the schedule.
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.schedule_id = schedule_id
|
||||||
|
self.monday_service = monday_service
|
||||||
|
self.tuesday_service = tuesday_service
|
||||||
|
self.wednesday_service = wednesday_service
|
||||||
|
self.thursday_service = thursday_service
|
||||||
|
self.friday_service = friday_service
|
||||||
|
self.saturday_service = saturday_service
|
||||||
|
self.sunday_service = sunday_service
|
||||||
|
self.weekend_service = weekend_service
|
||||||
|
self.schedule_exception = schedule_exception
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the schedule update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate schedule exists
|
||||||
|
if not is_valid_uuid(self.schedule_id):
|
||||||
|
errors.append("Invalid schedule ID format")
|
||||||
|
else:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
errors.append(f"Schedule with ID {self.schedule_id} not found")
|
||||||
|
|
||||||
|
# Validate date formats if provided
|
||||||
|
if not errors and self.start_date is not None and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and self.end_date is not None and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date if both provided
|
||||||
|
if not errors and self.start_date and self.end_date:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# If only updating end_date, validate it's after the existing start_date
|
||||||
|
if not errors and self.end_date and not self.start_date:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if schedule:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(schedule.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the existing start date.")
|
||||||
|
|
||||||
|
# Check if at least one service day will be selected after update
|
||||||
|
if not errors:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if schedule:
|
||||||
|
monday = self.monday_service if self.monday_service is not None else schedule.monday_service
|
||||||
|
tuesday = self.tuesday_service if self.tuesday_service is not None else schedule.tuesday_service
|
||||||
|
wednesday = self.wednesday_service if self.wednesday_service is not None else schedule.wednesday_service
|
||||||
|
thursday = self.thursday_service if self.thursday_service is not None else schedule.thursday_service
|
||||||
|
friday = self.friday_service if self.friday_service is not None else schedule.friday_service
|
||||||
|
saturday = self.saturday_service if self.saturday_service is not None else schedule.saturday_service
|
||||||
|
sunday = self.sunday_service if self.sunday_service is not None else schedule.sunday_service
|
||||||
|
weekend = self.weekend_service if self.weekend_service is not None else schedule.weekend_service
|
||||||
|
|
||||||
|
if not any([monday, tuesday, wednesday, thursday, friday, saturday, sunday, weekend]):
|
||||||
|
errors.append("At least one service day must be selected.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Schedule]:
|
||||||
|
"""
|
||||||
|
Execute the schedule update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Schedule]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.monday_service is not None:
|
||||||
|
update_data['monday_service'] = self.monday_service
|
||||||
|
|
||||||
|
if self.tuesday_service is not None:
|
||||||
|
update_data['tuesday_service'] = self.tuesday_service
|
||||||
|
|
||||||
|
if self.wednesday_service is not None:
|
||||||
|
update_data['wednesday_service'] = self.wednesday_service
|
||||||
|
|
||||||
|
if self.thursday_service is not None:
|
||||||
|
update_data['thursday_service'] = self.thursday_service
|
||||||
|
|
||||||
|
if self.friday_service is not None:
|
||||||
|
update_data['friday_service'] = self.friday_service
|
||||||
|
|
||||||
|
if self.saturday_service is not None:
|
||||||
|
update_data['saturday_service'] = self.saturday_service
|
||||||
|
|
||||||
|
if self.sunday_service is not None:
|
||||||
|
update_data['sunday_service'] = self.sunday_service
|
||||||
|
|
||||||
|
if self.weekend_service is not None:
|
||||||
|
update_data['weekend_service'] = self.weekend_service
|
||||||
|
|
||||||
|
if self.schedule_exception is not None:
|
||||||
|
update_data['schedule_exception'] = self.schedule_exception
|
||||||
|
|
||||||
|
if self.start_date is not None:
|
||||||
|
update_data['start_date'] = self.start_date
|
||||||
|
|
||||||
|
if self.end_date is not None:
|
||||||
|
update_data['end_date'] = self.end_date
|
||||||
|
|
||||||
|
# Update the schedule with the data dictionary
|
||||||
|
updated_schedule = self.schedule_repo.update(self.schedule_id, update_data)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_schedule,
|
||||||
|
f"Schedule {self.schedule_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteScheduleCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
schedule_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the delete schedule command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
schedule_id: ID of the schedule to delete.
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.schedule_id = schedule_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the schedule deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate schedule exists
|
||||||
|
if not is_valid_uuid(self.schedule_id):
|
||||||
|
errors.append("Invalid schedule ID format")
|
||||||
|
else:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
errors.append(f"Schedule with ID {self.schedule_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the schedule deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the schedule
|
||||||
|
success = self.schedule_repo.delete(self.schedule_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Schedule {self.schedule_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete schedule",
|
||||||
|
f"Schedule {self.schedule_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EndScheduleCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to end a schedule by setting its end date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
schedule_id: str,
|
||||||
|
end_date: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the end schedule command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
schedule_id: ID of the schedule to end.
|
||||||
|
end_date: End date for the schedule (defaults to today if not provided).
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.schedule_id = schedule_id
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the end schedule request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate schedule exists
|
||||||
|
if not is_valid_uuid(self.schedule_id):
|
||||||
|
errors.append("Invalid schedule ID format")
|
||||||
|
else:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
errors.append(f"Schedule with ID {self.schedule_id} not found")
|
||||||
|
elif schedule.end_date is not None:
|
||||||
|
errors.append(f"Schedule is already ended")
|
||||||
|
|
||||||
|
# Validate end date format if provided
|
||||||
|
if not errors and self.end_date and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate end date is after start date if provided
|
||||||
|
if not errors and self.end_date:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if schedule:
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
start = parse_date(schedule.start_date)
|
||||||
|
if end and start and start > end:
|
||||||
|
errors.append("End date must be after the start date.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Schedule]:
|
||||||
|
"""
|
||||||
|
Execute the end schedule command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Schedule]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# End the schedule
|
||||||
|
end_date = self.end_date or datetime.now().strftime('%Y-%m-%d')
|
||||||
|
updated_schedule = self.schedule_repo.update(
|
||||||
|
self.schedule_id,
|
||||||
|
{'end_date': end_date}
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_schedule:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_schedule,
|
||||||
|
f"Schedule {self.schedule_id} ended successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to end schedule",
|
||||||
|
f"Schedule {self.schedule_id} could not be ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to end schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetActiveSchedulesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get all active schedules.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the get active schedules.py command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
account_id: Optional account ID to filter by.
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the get active schedules.py request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account ID if provided
|
||||||
|
if self.account_id and not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Schedule]]:
|
||||||
|
"""
|
||||||
|
Execute the get active schedules.py command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Schedule]]: Result of the command execution with active schedules.py.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get active schedules.py
|
||||||
|
if self.account_id:
|
||||||
|
active_schedule = self.schedule_repo.get_active_by_account(self.account_id)
|
||||||
|
schedules = [active_schedule] if active_schedule else []
|
||||||
|
else:
|
||||||
|
schedules = list(self.schedule_repo.get_active())
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
schedules,
|
||||||
|
f"Retrieved active schedule records"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve active schedule records"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateServicesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to generate services based on a schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
schedule_id: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the generate services command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
schedule_id: ID of the schedule to generate services from.
|
||||||
|
start_date: Start date for service generation (YYYY-MM-DD).
|
||||||
|
end_date: End date for service generation (YYYY-MM-DD).
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.schedule_id = schedule_id
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the generate services request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate schedule exists
|
||||||
|
if not is_valid_uuid(self.schedule_id):
|
||||||
|
errors.append("Invalid schedule ID format")
|
||||||
|
else:
|
||||||
|
schedule = self.schedule_repo.get_by_id(self.schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
errors.append(f"Schedule with ID {self.schedule_id} not found")
|
||||||
|
|
||||||
|
# Validate date formats
|
||||||
|
if not errors and not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
if not errors and not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate start date is before end date
|
||||||
|
if not errors:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end and start > end:
|
||||||
|
errors.append("Start date must be before end date.")
|
||||||
|
|
||||||
|
# Validate date range is not too large (e.g., limit to 3 months)
|
||||||
|
if not errors:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
if start and end:
|
||||||
|
date_diff = (end - start).days
|
||||||
|
if date_diff > 90: # 3 months
|
||||||
|
errors.append("Date range too large. Maximum range is 90 days.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Service]]:
|
||||||
|
"""
|
||||||
|
Execute the generate services command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Service]]: Result of the command execution with generated services.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse dates
|
||||||
|
start_date_obj = parse_date(self.start_date)
|
||||||
|
end_date_obj = parse_date(self.end_date)
|
||||||
|
|
||||||
|
# Generate services
|
||||||
|
services = self.schedule_repo.generate_services(
|
||||||
|
self.schedule_id,
|
||||||
|
start_date_obj,
|
||||||
|
end_date_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
services,
|
||||||
|
f"Generated {len(services)} services from schedule {self.schedule_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to generate services from schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetScheduleByAccountCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to get schedules.py for a specific account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
account_id: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the get schedule by account command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
account_id: ID of the account to get schedules.py for.
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the get schedule by account request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate account ID
|
||||||
|
if not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Schedule]]:
|
||||||
|
"""
|
||||||
|
Execute the get schedule by account command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Schedule]]: Result of the command execution with account schedules.py.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get schedules.py for the account
|
||||||
|
schedules = list(self.schedule_repo.get_by_account(self.account_id))
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
schedules,
|
||||||
|
f"Retrieved {len(schedules)} schedules.py for account {self.account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to retrieve schedules.py for account"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSchedulesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to search for schedules.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
search_term: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the search schedules.py command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
search_term: Term to search for.
|
||||||
|
"""
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.search_term = search_term
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the search schedules.py request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate search term
|
||||||
|
if not self.search_term or len(self.search_term.strip()) < 2:
|
||||||
|
errors.append("Search term must be at least 2 characters long")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Schedule]]:
|
||||||
|
"""
|
||||||
|
Execute the search schedules.py command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[List[Schedule]]: Result of the command execution with matching schedules.py.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search for schedules.py
|
||||||
|
schedules = list(self.schedule_repo.search(self.search_term))
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
schedules,
|
||||||
|
f"Found {len(schedules)} schedules.py matching '{self.search_term}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to search for schedules.py"
|
||||||
|
)
|
||||||
7
backend/core/commands/services/__init__.py
Normal file
7
backend/core/commands/services/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Service commands for managing services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand
|
||||||
|
|
||||||
|
__all__ = ['BulkScheduleServicesCommand']
|
||||||
118
backend/core/commands/services/bulk_schedule.py
Normal file
118
backend/core/commands/services/bulk_schedule.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Command for bulk scheduling services.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.schedules.schedules import ScheduleRepository
|
||||||
|
from backend.core.factories.services.services import ServiceFactory
|
||||||
|
from backend.core.utils.validators import validate_required_fields
|
||||||
|
|
||||||
|
|
||||||
|
class BulkScheduleServicesCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to bulk schedule services for multiple accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
schedule_repo: ScheduleRepository,
|
||||||
|
account_ids: List[str],
|
||||||
|
year: int,
|
||||||
|
month: int
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the bulk schedule services command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
schedule_repo: Repository for schedule operations.
|
||||||
|
account_ids: List of account IDs to schedule services for.
|
||||||
|
year: The year for scheduling.
|
||||||
|
month: The month for scheduling (1-12).
|
||||||
|
"""
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.schedule_repo = schedule_repo
|
||||||
|
self.account_ids = account_ids
|
||||||
|
self.year = year
|
||||||
|
self.month = month
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the bulk schedule services request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
field_values = {
|
||||||
|
'account_ids': self.account_ids,
|
||||||
|
'year': self.year,
|
||||||
|
'month': self.month
|
||||||
|
}
|
||||||
|
missing_fields = validate_required_fields(field_values, ['account_ids', 'year', 'month'])
|
||||||
|
if missing_fields:
|
||||||
|
return {
|
||||||
|
'is_valid': False,
|
||||||
|
'errors': [f"Required fields missing: {', '.join(missing_fields)}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate account IDs
|
||||||
|
if not self.account_ids:
|
||||||
|
errors.append("No account IDs provided")
|
||||||
|
|
||||||
|
# Validate year and month
|
||||||
|
current_year = datetime.now().year
|
||||||
|
if self.year < current_year or self.year > current_year + 5:
|
||||||
|
errors.append(f"Year must be between {current_year} and {current_year + 5}")
|
||||||
|
|
||||||
|
if self.month < 1 or self.month > 12:
|
||||||
|
errors.append("Month must be between 1 and 12")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Execute the bulk schedule services command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Dict[str, Any]]: Result of the command execution with scheduled services.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get active schedules for the specified accounts
|
||||||
|
active_schedules = []
|
||||||
|
for account_id in self.account_ids:
|
||||||
|
active_schedule = self.schedule_repo.get_active_by_account(account_id)
|
||||||
|
if active_schedule:
|
||||||
|
active_schedules.append(active_schedule)
|
||||||
|
|
||||||
|
# Generate services for each account
|
||||||
|
result = ServiceFactory.generate_services_for_accounts(
|
||||||
|
active_schedules,
|
||||||
|
self.year,
|
||||||
|
self.month
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
result,
|
||||||
|
f"Bulk scheduled services for {len(active_schedules)} accounts for {self.month}/{self.year}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to bulk schedule services"
|
||||||
|
)
|
||||||
611
backend/core/commands/services/services.py
Normal file
611
backend/core/commands/services/services.py
Normal file
@ -0,0 +1,611 @@
|
|||||||
|
"""
|
||||||
|
Commands for service-related operations.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.core.models.services.services import Service
|
||||||
|
from backend.core.repositories.services.services import ServiceRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.profiles.profiles import ProfileRepository
|
||||||
|
from backend.core.utils.validators import (
|
||||||
|
is_valid_uuid, is_valid_date, is_valid_datetime,
|
||||||
|
validate_required_fields, validate_model_exists
|
||||||
|
)
|
||||||
|
from backend.core.utils.helpers import generate_uuid, parse_date
|
||||||
|
from backend.core.commands.base import Command, CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class CreateServiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to create a new service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
account_id: str,
|
||||||
|
date: str,
|
||||||
|
status: str = 'scheduled',
|
||||||
|
team_member_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
deadline_start: Optional[str] = None,
|
||||||
|
deadline_end: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the create service command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_repo: Repository for service operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
account_id: ID of the account the service is for.
|
||||||
|
date: Date of the service (YYYY-MM-DD).
|
||||||
|
status: Status of the service ('scheduled', 'in_progress', 'completed', 'cancelled').
|
||||||
|
team_member_ids: List of profile IDs for team members assigned to the service.
|
||||||
|
notes: Additional notes about the service.
|
||||||
|
deadline_start: Start time of the service deadline (ISO format).
|
||||||
|
deadline_end: End time of the service deadline (ISO format).
|
||||||
|
"""
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.account_id = account_id
|
||||||
|
self.date = date
|
||||||
|
self.status = status
|
||||||
|
self.team_member_ids = team_member_ids or []
|
||||||
|
self.notes = notes
|
||||||
|
self.deadline_start = deadline_start
|
||||||
|
self.deadline_end = deadline_end
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the service creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
missing_fields = validate_required_fields(
|
||||||
|
{
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'date': self.date
|
||||||
|
},
|
||||||
|
['account_id', 'date']
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
errors.append(f"Required fields missing: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
# Validate account exists
|
||||||
|
if not errors and self.account_id:
|
||||||
|
account_validation = validate_model_exists(
|
||||||
|
self.account_id, 'account', self.account_repo.get_by_id
|
||||||
|
)
|
||||||
|
if not account_validation['valid']:
|
||||||
|
errors.append(account_validation['error'])
|
||||||
|
|
||||||
|
# Validate date format
|
||||||
|
if not errors and self.date and not is_valid_date(self.date):
|
||||||
|
errors.append("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate deadline formats
|
||||||
|
if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start):
|
||||||
|
errors.append("Invalid deadline_start format. Use ISO format.")
|
||||||
|
|
||||||
|
if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end):
|
||||||
|
errors.append("Invalid deadline_end format. Use ISO format.")
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled']
|
||||||
|
if not errors and self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# Validate team member IDs
|
||||||
|
if not errors and self.team_member_ids:
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
if not is_valid_uuid(member_id):
|
||||||
|
errors.append(f"Invalid team member ID format: {member_id}")
|
||||||
|
elif not self.profile_repo.get_by_id(member_id):
|
||||||
|
errors.append(f"Team member with ID {member_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Service]:
|
||||||
|
"""
|
||||||
|
Execute the service creation command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Service]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create service data
|
||||||
|
service_id = generate_uuid()
|
||||||
|
|
||||||
|
# Get date objects
|
||||||
|
service_date = parse_date(self.date)
|
||||||
|
|
||||||
|
# Create default deadline times if not provided
|
||||||
|
deadline_start = self.deadline_start
|
||||||
|
deadline_end = self.deadline_end
|
||||||
|
|
||||||
|
if not deadline_start and service_date:
|
||||||
|
# Default to 9:00 AM on the service date
|
||||||
|
deadline_start = datetime.combine(
|
||||||
|
service_date,
|
||||||
|
datetime.min.time().replace(hour=9)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
if not deadline_end and service_date:
|
||||||
|
# Default to 5:00 PM on the service date
|
||||||
|
deadline_end = datetime.combine(
|
||||||
|
service_date,
|
||||||
|
datetime.min.time().replace(hour=17)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
# Create service data dictionary instead of Service object
|
||||||
|
service_data = {
|
||||||
|
'id': service_id,
|
||||||
|
'account_id': self.account_id,
|
||||||
|
'date': self.date,
|
||||||
|
'status': self.status,
|
||||||
|
'notes': self.notes,
|
||||||
|
'deadline_start': deadline_start,
|
||||||
|
'deadline_end': deadline_end
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use create_with_team_members to handle both service creation and team member assignment
|
||||||
|
created_service = self.service_repo.create_with_team_members(
|
||||||
|
service_data,
|
||||||
|
self.team_member_ids or []
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
created_service,
|
||||||
|
f"Service for account {self.account_id} created successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to create service"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateServiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to update an existing service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
account_repo: AccountRepository,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
service_id: str,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
team_member_ids: Optional[List[str]] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
deadline_start: Optional[str] = None,
|
||||||
|
deadline_end: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the update service command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_repo: Repository for service operations.
|
||||||
|
account_repo: Repository for account operations.
|
||||||
|
profile_repo: Repository for profile operations.
|
||||||
|
service_id: ID of the service to update.
|
||||||
|
status: New status for the service.
|
||||||
|
date: New date for the service.
|
||||||
|
team_member_ids: New list of team member IDs.
|
||||||
|
notes: New notes for the service.
|
||||||
|
deadline_start: New start deadline for the service.
|
||||||
|
deadline_end: New end deadline for the service.
|
||||||
|
"""
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.account_repo = account_repo
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.service_id = service_id
|
||||||
|
self.status = status
|
||||||
|
self.date = date
|
||||||
|
self.team_member_ids = team_member_ids
|
||||||
|
self.notes = notes
|
||||||
|
self.deadline_start = deadline_start
|
||||||
|
self.deadline_end = deadline_end
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the service update data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate service exists
|
||||||
|
if not is_valid_uuid(self.service_id):
|
||||||
|
errors.append("Invalid service ID format")
|
||||||
|
else:
|
||||||
|
service = self.service_repo.get_by_id(self.service_id)
|
||||||
|
if not service:
|
||||||
|
errors.append(f"Service with ID {self.service_id} not found")
|
||||||
|
|
||||||
|
# Validate date format if provided
|
||||||
|
if not errors and self.date and not is_valid_date(self.date):
|
||||||
|
errors.append("Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
# Validate deadline formats if provided
|
||||||
|
if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start):
|
||||||
|
errors.append("Invalid deadline_start format. Use ISO format.")
|
||||||
|
|
||||||
|
if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end):
|
||||||
|
errors.append("Invalid deadline_end format. Use ISO format.")
|
||||||
|
|
||||||
|
# Validate status if provided
|
||||||
|
if not errors and self.status:
|
||||||
|
valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled']
|
||||||
|
if self.status not in valid_statuses:
|
||||||
|
errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||||
|
|
||||||
|
# Validate team member IDs if provided
|
||||||
|
if not errors and self.team_member_ids is not None:
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
if not is_valid_uuid(member_id):
|
||||||
|
errors.append(f"Invalid team member ID format: {member_id}")
|
||||||
|
elif not self.profile_repo.get_by_id(member_id):
|
||||||
|
errors.append(f"Team member with ID {member_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Service]:
|
||||||
|
"""
|
||||||
|
Execute the service update command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[Service]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a dictionary of fields to update
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Add fields to update_data if they were provided
|
||||||
|
if self.status is not None:
|
||||||
|
update_data['status'] = self.status
|
||||||
|
|
||||||
|
if self.date is not None:
|
||||||
|
update_data['date'] = self.date
|
||||||
|
|
||||||
|
if self.notes is not None:
|
||||||
|
update_data['notes'] = self.notes
|
||||||
|
|
||||||
|
if self.deadline_start is not None:
|
||||||
|
update_data['deadline_start'] = self.deadline_start
|
||||||
|
|
||||||
|
if self.deadline_end is not None:
|
||||||
|
update_data['deadline_end'] = self.deadline_end
|
||||||
|
|
||||||
|
# Update the service with the data dictionary
|
||||||
|
updated_service = self.service_repo.update(self.service_id, update_data)
|
||||||
|
|
||||||
|
# Update team members if provided
|
||||||
|
if self.team_member_ids is not None:
|
||||||
|
updated_service = self.service_repo.assign_team_members(
|
||||||
|
self.service_id,
|
||||||
|
self.team_member_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_service,
|
||||||
|
f"Service {self.service_id} updated successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to update service"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteServiceCommand(Command):
|
||||||
|
"""
|
||||||
|
Command to delete a service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, service_repo: ServiceRepository, service_id: str):
|
||||||
|
"""
|
||||||
|
Initialize the delete service command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_repo: Repository for service operations.
|
||||||
|
service_id: ID of the service to delete.
|
||||||
|
"""
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.service_id = service_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the service deletion request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Validate service exists
|
||||||
|
if not is_valid_uuid(self.service_id):
|
||||||
|
errors.append("Invalid service ID format")
|
||||||
|
else:
|
||||||
|
service = self.service_repo.get_by_id(self.service_id)
|
||||||
|
if not service:
|
||||||
|
errors.append(f"Service with ID {self.service_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[bool]:
|
||||||
|
"""
|
||||||
|
Execute the service deletion command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult[bool]: Result of the command execution.
|
||||||
|
"""
|
||||||
|
# Validate command data
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the service
|
||||||
|
success = self.service_repo.delete(self.service_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return CommandResult.success_result(
|
||||||
|
True,
|
||||||
|
f"Service {self.service_id} deleted successfully"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
"Failed to delete service",
|
||||||
|
f"Service {self.service_id} could not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(
|
||||||
|
str(e),
|
||||||
|
"Failed to delete service"
|
||||||
|
)
|
||||||
|
|
||||||
|
class CompleteServiceCommand(Command):
|
||||||
|
"""Command to mark a service as complete."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
service_id: str,
|
||||||
|
completion_notes: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.service_id = service_id
|
||||||
|
self.completion_notes = completion_notes
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
errors = []
|
||||||
|
if not is_valid_uuid(self.service_id):
|
||||||
|
errors.append("Invalid service ID format")
|
||||||
|
else:
|
||||||
|
service = self.service_repo.get_by_id(self.service_id)
|
||||||
|
if not service:
|
||||||
|
errors.append(f"Service with ID {self.service_id} not found")
|
||||||
|
elif service.status == 'completed':
|
||||||
|
errors.append("Service is already completed")
|
||||||
|
elif service.status == 'cancelled':
|
||||||
|
errors.append("Cancelled service cannot be completed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Service]:
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_data = {
|
||||||
|
'status': 'completed',
|
||||||
|
'notes': self.completion_notes
|
||||||
|
}
|
||||||
|
updated_service = self.service_repo.update(self.service_id, update_data)
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_service,
|
||||||
|
f"Service {self.service_id} marked as completed"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class CancelServiceCommand(Command):
|
||||||
|
"""Command to cancel a service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
service_id: str,
|
||||||
|
cancellation_reason: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.service_id = service_id
|
||||||
|
self.cancellation_reason = cancellation_reason
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
errors = []
|
||||||
|
if not is_valid_uuid(self.service_id):
|
||||||
|
errors.append("Invalid service ID format")
|
||||||
|
else:
|
||||||
|
service = self.service_repo.get_by_id(self.service_id)
|
||||||
|
if not service:
|
||||||
|
errors.append(f"Service with ID {self.service_id} not found")
|
||||||
|
elif service.status == 'cancelled':
|
||||||
|
errors.append("Service is already cancelled")
|
||||||
|
elif service.status == 'completed':
|
||||||
|
errors.append("Completed service cannot be cancelled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Service]:
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_data = {
|
||||||
|
'status': 'cancelled',
|
||||||
|
'notes': self.cancellation_reason
|
||||||
|
}
|
||||||
|
updated_service = self.service_repo.update(self.service_id, update_data)
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_service,
|
||||||
|
f"Service {self.service_id} cancelled successfully"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class AssignTeamMembersCommand(Command):
|
||||||
|
"""Command to assign team members to a service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
profile_repo: ProfileRepository,
|
||||||
|
service_id: str,
|
||||||
|
team_member_ids: List[str]
|
||||||
|
):
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.profile_repo = profile_repo
|
||||||
|
self.service_id = service_id
|
||||||
|
self.team_member_ids = team_member_ids
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
errors = []
|
||||||
|
if not is_valid_uuid(self.service_id):
|
||||||
|
errors.append("Invalid service ID format")
|
||||||
|
else:
|
||||||
|
service = self.service_repo.get_by_id(self.service_id)
|
||||||
|
if not service:
|
||||||
|
errors.append(f"Service with ID {self.service_id} not found")
|
||||||
|
|
||||||
|
for member_id in self.team_member_ids:
|
||||||
|
if not is_valid_uuid(member_id):
|
||||||
|
errors.append(f"Invalid team member ID format: {member_id}")
|
||||||
|
elif not self.profile_repo.get_by_id(member_id):
|
||||||
|
errors.append(f"Team member with ID {member_id} not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[Service]:
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_service = self.service_repo.assign_team_members(
|
||||||
|
self.service_id,
|
||||||
|
self.team_member_ids
|
||||||
|
)
|
||||||
|
return CommandResult.success_result(
|
||||||
|
updated_service,
|
||||||
|
f"Team members assigned to service {self.service_id} successfully"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class GetServicesByDateRangeCommand(Command):
|
||||||
|
"""Command to get services within a date range."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service_repo: ServiceRepository,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
team_member_id: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.service_repo = service_repo
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
self.account_id = account_id
|
||||||
|
self.team_member_id = team_member_id
|
||||||
|
|
||||||
|
def validate(self) -> Dict[str, Any]:
|
||||||
|
errors = []
|
||||||
|
if not is_valid_date(self.start_date):
|
||||||
|
errors.append("Invalid start date format")
|
||||||
|
if not is_valid_date(self.end_date):
|
||||||
|
errors.append("Invalid end date format")
|
||||||
|
|
||||||
|
if self.account_id and not is_valid_uuid(self.account_id):
|
||||||
|
errors.append("Invalid account ID format")
|
||||||
|
if self.team_member_id and not is_valid_uuid(self.team_member_id):
|
||||||
|
errors.append("Invalid team member ID format")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'is_valid': len(errors) == 0,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self) -> CommandResult[List[Service]]:
|
||||||
|
validation = self.validate()
|
||||||
|
if not validation['is_valid']:
|
||||||
|
return CommandResult.failure_result(validation['errors'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = parse_date(self.start_date)
|
||||||
|
end = parse_date(self.end_date)
|
||||||
|
|
||||||
|
services = self.service_repo.filter_services(
|
||||||
|
date_from=start,
|
||||||
|
date_to=end,
|
||||||
|
account_id=self.account_id,
|
||||||
|
team_member_id=self.team_member_id
|
||||||
|
)
|
||||||
|
return CommandResult.success_result(
|
||||||
|
list(services),
|
||||||
|
f"Found {services.count()} services in date range"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult.failure_result(str(e))
|
||||||
|
|
||||||
3
backend/core/factories/__init__.py
Normal file
3
backend/core/factories/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Factory module for creating domain objects.
|
||||||
|
"""
|
||||||
7
backend/core/factories/services/__init__.py
Normal file
7
backend/core/factories/services/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Service factory module for creating service objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from backend.core.factories.services.services import ServiceFactory
|
||||||
|
|
||||||
|
__all__ = ['ServiceFactory']
|
||||||
202
backend/core/factories/services/services.py
Normal file
202
backend/core/factories/services/services.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Factory for creating Service objects.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from backend.core.models import Schedule, Service, Profile
|
||||||
|
from backend.core.utils.helpers import get_month_start_end, dict_to_json
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFactory:
|
||||||
|
"""
|
||||||
|
Factory for creating Service objects based on schedules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_service(service: Service) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert a Service object to a serializable dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: The Service object to serialize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: A dictionary representation of the Service object
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': str(service.id),
|
||||||
|
'account_id': str(service.account.id),
|
||||||
|
'account_name': service.account.name,
|
||||||
|
'date': service.date.isoformat(),
|
||||||
|
'status': service.status,
|
||||||
|
'notes': service.notes,
|
||||||
|
'deadline_start': service.deadline_start.isoformat(),
|
||||||
|
'deadline_end': service.deadline_end.isoformat(),
|
||||||
|
'created_at': service.created_at.isoformat(),
|
||||||
|
'updated_at': service.updated_at.isoformat(),
|
||||||
|
'completed_at': service.completed_at.isoformat() if service.completed_at else None,
|
||||||
|
'team_member_ids': [str(member.id) for member in service.team_members.all()],
|
||||||
|
'team_member_names': service.team_member_names
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_service(cls, schedule: Schedule, service_date: date, is_weekend: bool = False) -> Service:
|
||||||
|
"""
|
||||||
|
Create a Service object for a specific date based on a schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: The schedule to base the service on
|
||||||
|
service_date: The date for the service
|
||||||
|
is_weekend: Whether this is a weekend service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service: The created service object
|
||||||
|
"""
|
||||||
|
# Set default deadline times (6:00pm the day of service to 6:00am the following day)
|
||||||
|
deadline_start = datetime.combine(service_date, datetime.min.time()).replace(hour=18) # 6:00pm
|
||||||
|
deadline_end = datetime.combine(service_date + timedelta(days=1), datetime.min.time()).replace(hour=6) # 6:00am next day
|
||||||
|
|
||||||
|
notes = None
|
||||||
|
if is_weekend:
|
||||||
|
notes = "Weekend Service"
|
||||||
|
|
||||||
|
# Create the service
|
||||||
|
service = Service.objects.create(
|
||||||
|
account=schedule.account,
|
||||||
|
date=service_date,
|
||||||
|
status='scheduled',
|
||||||
|
notes=notes,
|
||||||
|
deadline_start=deadline_start,
|
||||||
|
deadline_end=deadline_end
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add admin profile to team_members by default
|
||||||
|
admin_profile_id = "7dc00b89-72d1-4dea-a1ed-4cbef220aa0c"
|
||||||
|
try:
|
||||||
|
admin_profile = Profile.objects.get(id=admin_profile_id)
|
||||||
|
service.team_members.add(admin_profile)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
# If admin profile doesn't exist, continue without adding it
|
||||||
|
pass
|
||||||
|
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_services_for_month(cls, schedule: Schedule, year: int, month: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate services for a specific month based on a schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: The schedule to base the services on
|
||||||
|
year: The year
|
||||||
|
month: The month (1-12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Dictionary with generated services and any errors
|
||||||
|
"""
|
||||||
|
if schedule.schedule_exception:
|
||||||
|
return {
|
||||||
|
'services': [],
|
||||||
|
'success': False,
|
||||||
|
'errors': [f"Schedule exception: {schedule.schedule_exception}"],
|
||||||
|
'message': "Schedule has an exception and requires manual scheduling"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the start and end dates of the month
|
||||||
|
month_start = date(year, month, 1)
|
||||||
|
month_end = get_month_start_end(datetime(year, month, 1))[1].date()
|
||||||
|
|
||||||
|
services = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Iterate through each day in the month
|
||||||
|
current_date = month_start
|
||||||
|
while current_date <= month_end:
|
||||||
|
weekday = current_date.weekday() # Monday is 0, Sunday is 6
|
||||||
|
day_name = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'][weekday]
|
||||||
|
|
||||||
|
# Check if service is required for this day
|
||||||
|
is_service_day = schedule.has_service_on_day(day_name)
|
||||||
|
is_weekend = weekday >= 5 and schedule.weekend_service
|
||||||
|
|
||||||
|
# If it's a service day or a weekend with weekend_service enabled
|
||||||
|
if is_service_day or is_weekend:
|
||||||
|
# TODO: Check if it's a holiday (would need a holiday model/service)
|
||||||
|
# For now, we'll assume it's not a holiday
|
||||||
|
is_holiday = False
|
||||||
|
|
||||||
|
if not is_holiday:
|
||||||
|
try:
|
||||||
|
# For weekend services, use the actual date but mark as weekend service
|
||||||
|
service = cls.create_service(schedule, current_date, is_weekend=is_weekend)
|
||||||
|
# Serialize the service object to make it JSON serializable
|
||||||
|
serialized_service = cls.serialize_service(service)
|
||||||
|
services.append(serialized_service)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error creating service for {current_date}: {str(e)}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'services': services,
|
||||||
|
'success': len(services) > 0,
|
||||||
|
'errors': errors,
|
||||||
|
'message': f"Generated {len(services)} services for {month}/{year}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_services_for_accounts(cls, schedules: List[Schedule], year: int, month: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate services for multiple accounts based on their schedules.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedules: List of schedules to generate services for
|
||||||
|
year: The year
|
||||||
|
month: The month (1-12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Dictionary with results for each account
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
overall_success = True
|
||||||
|
overall_errors = []
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
account_id = str(schedule.account.id)
|
||||||
|
account_name = schedule.account.name
|
||||||
|
|
||||||
|
# Skip inactive schedules
|
||||||
|
if not schedule.is_active:
|
||||||
|
results[account_id] = {
|
||||||
|
'account_name': account_name,
|
||||||
|
'services': [],
|
||||||
|
'success': False,
|
||||||
|
'errors': ["Schedule is inactive"],
|
||||||
|
'message': "Schedule is inactive"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate services for this account
|
||||||
|
account_result = cls.generate_services_for_month(schedule, year, month)
|
||||||
|
|
||||||
|
# Add account name to the result
|
||||||
|
account_result['account_name'] = account_name
|
||||||
|
|
||||||
|
# Store the result
|
||||||
|
results[account_id] = account_result
|
||||||
|
|
||||||
|
# Update overall success and errors
|
||||||
|
if not account_result['success']:
|
||||||
|
overall_success = False
|
||||||
|
|
||||||
|
if account_result['errors']:
|
||||||
|
for error in account_result['errors']:
|
||||||
|
overall_errors.append(f"{account_name}: {error}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'account_results': results,
|
||||||
|
'success': overall_success,
|
||||||
|
'errors': overall_errors,
|
||||||
|
'message': f"Generated services for {len(results)} accounts for {month}/{year}"
|
||||||
|
}
|
||||||
32
backend/core/models/__init__.py
Normal file
32
backend/core/models/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Models package initialization.
|
||||||
|
Import all models here to make them available when importing from core.models
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from backend.core.models.profiles.profiles import Profile
|
||||||
|
from backend.core.models.customers.customers import Customer
|
||||||
|
from backend.core.models.accounts.accounts import Account
|
||||||
|
from backend.core.models.revenues.revenues import Revenue
|
||||||
|
from backend.core.models.labor.labor import Labor
|
||||||
|
from backend.core.models.schedules.schedules import Schedule
|
||||||
|
from backend.core.models.services.services import Service
|
||||||
|
from backend.core.models.projects.projects import Project
|
||||||
|
from backend.core.models.invoices.invoices import Invoice
|
||||||
|
from backend.core.models.reports.reports import Report
|
||||||
|
from backend.core.models.punchlists.punchlists import Punchlist
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'User',
|
||||||
|
'Profile',
|
||||||
|
'Customer',
|
||||||
|
'Account',
|
||||||
|
'Revenue',
|
||||||
|
'Labor',
|
||||||
|
'Schedule',
|
||||||
|
'Service',
|
||||||
|
'Project',
|
||||||
|
'Invoice',
|
||||||
|
'Report',
|
||||||
|
'Punchlist',
|
||||||
|
]
|
||||||
0
backend/core/models/accounts/__init__.py
Normal file
0
backend/core/models/accounts/__init__.py
Normal file
89
backend/core/models/accounts/accounts.py
Normal file
89
backend/core/models/accounts/accounts.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Account models for managing customer accounts.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from backend.core.models.revenues.revenues import Revenue
|
||||||
|
from backend.core.models.labor.labor import Labor
|
||||||
|
|
||||||
|
|
||||||
|
class Account(models.Model):
|
||||||
|
"""Account model belonging to a customer"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='accounts')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
|
||||||
|
# Address
|
||||||
|
street_address = models.CharField(max_length=255)
|
||||||
|
city = models.CharField(max_length=100)
|
||||||
|
state = models.CharField(max_length=100)
|
||||||
|
zip_code = models.CharField(max_length=20)
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
primary_contact_first_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_last_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_phone = models.CharField(max_length=20)
|
||||||
|
primary_contact_email = models.EmailField()
|
||||||
|
|
||||||
|
# Secondary contact (optional)
|
||||||
|
secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
secondary_contact_email = models.EmailField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Account'
|
||||||
|
verbose_name_plural = 'Accounts'
|
||||||
|
ordering = ['name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['customer']),
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.customer.name})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if account is active based on end_date"""
|
||||||
|
return self.end_date is None or self.end_date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_contact_full_name(self):
|
||||||
|
"""Get contact's full name"""
|
||||||
|
return f"{self.primary_contact_first_name} {self.primary_contact_last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secondary_contact_full_name(self):
|
||||||
|
"""Get contact's full name"""
|
||||||
|
return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def address(self):
|
||||||
|
"""Get formatted address"""
|
||||||
|
return f"{self.street_address}, {self.city}, {self.state} {self.zip_code}"
|
||||||
|
|
||||||
|
def current_revenue(self):
|
||||||
|
"""Get the current active revenue for this account"""
|
||||||
|
return Revenue.objects.filter(
|
||||||
|
account=self,
|
||||||
|
start_date__lte=timezone.now().date(),
|
||||||
|
end_date__isnull=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def current_labor(self):
|
||||||
|
"""Get the current active labor for this account"""
|
||||||
|
return Labor.objects.filter(
|
||||||
|
account=self,
|
||||||
|
start_date__lte=timezone.now().date(),
|
||||||
|
end_date__isnull=True
|
||||||
|
).first()
|
||||||
0
backend/core/models/customers/__init__.py
Normal file
0
backend/core/models/customers/__init__.py
Normal file
80
backend/core/models/customers/customers.py
Normal file
80
backend/core/models/customers/customers.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Customer models for managing customer information.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(models.Model):
|
||||||
|
"""Customer model with contact information"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
|
||||||
|
# Primary contact
|
||||||
|
primary_contact_first_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_last_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_phone = models.CharField(max_length=20)
|
||||||
|
primary_contact_email = models.EmailField()
|
||||||
|
|
||||||
|
# Secondary contact (optional)
|
||||||
|
secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
secondary_contact_email = models.EmailField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Billing information
|
||||||
|
billing_contact_first_name = models.CharField(max_length=100)
|
||||||
|
billing_contact_last_name = models.CharField(max_length=100)
|
||||||
|
billing_street_address = models.CharField(max_length=255)
|
||||||
|
billing_city = models.CharField(max_length=100)
|
||||||
|
billing_state = models.CharField(max_length=100)
|
||||||
|
billing_zip_code = models.CharField(max_length=20)
|
||||||
|
billing_email = models.EmailField()
|
||||||
|
billing_terms = models.TextField()
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Customer'
|
||||||
|
verbose_name_plural = 'Customers'
|
||||||
|
ordering = ['name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
models.Index(fields=['primary_contact_email']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if customer is active based on end_date"""
|
||||||
|
return self.end_date is None or self.end_date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_contact_full_name(self):
|
||||||
|
"""Get primary contact's full name"""
|
||||||
|
return f"{self.primary_contact_first_name} {self.primary_contact_last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secondary_contact_full_name(self):
|
||||||
|
"""Get secondary contact's full name"""
|
||||||
|
if self.secondary_contact_first_name and self.secondary_contact_last_name:
|
||||||
|
return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def billing_contact_full_name(self):
|
||||||
|
"""Get billing contact's full name"""
|
||||||
|
return f"{self.billing_contact_first_name} {self.billing_contact_last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def billing_address(self):
|
||||||
|
"""Get formatted billing address"""
|
||||||
|
return f"{self.billing_street_address}, {self.billing_city}, {self.billing_state} {self.billing_zip_code}"
|
||||||
0
backend/core/models/invoices/__init__.py
Normal file
0
backend/core/models/invoices/__init__.py
Normal file
83
backend/core/models/invoices/invoices.py
Normal file
83
backend/core/models/invoices/invoices.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Invoice models for tracking customer billing.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(models.Model):
|
||||||
|
"""Invoice records"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('paid', 'Paid'),
|
||||||
|
('overdue', 'Overdue'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
PAYMENT_CHOICES = (
|
||||||
|
('check', 'Check'),
|
||||||
|
('credit_card', 'Credit Card'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
|
('cash', 'Cash'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='invoices')
|
||||||
|
date = models.DateField()
|
||||||
|
|
||||||
|
# Related items
|
||||||
|
accounts = models.ManyToManyField('Account', related_name='invoices', blank=True)
|
||||||
|
projects = models.ManyToManyField('Project', related_name='invoices', blank=True)
|
||||||
|
|
||||||
|
# Status and payment
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||||
|
date_paid = models.DateField(blank=True, null=True)
|
||||||
|
payment_type = models.CharField(max_length=20, choices=PAYMENT_CHOICES, blank=True, null=True)
|
||||||
|
|
||||||
|
# Financial
|
||||||
|
total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
sent_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Invoice'
|
||||||
|
verbose_name_plural = 'Invoices'
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['customer']),
|
||||||
|
models.Index(fields=['date']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invoice for {self.customer.name} on {self.date}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Override save to update timestamps based on status"""
|
||||||
|
if self.status == 'sent' and not self.sent_at:
|
||||||
|
self.sent_at = timezone.now()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paid(self):
|
||||||
|
"""Check if invoice is paid"""
|
||||||
|
return self.status == 'paid'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overdue(self):
|
||||||
|
"""Check if invoice is overdue"""
|
||||||
|
# Normally you'd check against a due date field, but we'll use a simple 30 day rule
|
||||||
|
if self.status not in ['paid', 'cancelled'] and self.sent_at:
|
||||||
|
return (timezone.now().date() - self.date).days > 30
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_outstanding(self):
|
||||||
|
"""Calculate days since invoice was sent"""
|
||||||
|
if not self.sent_at:
|
||||||
|
return 0
|
||||||
|
return (timezone.now().date() - self.sent_at.date()).days
|
||||||
0
backend/core/models/labor/__init__.py
Normal file
0
backend/core/models/labor/__init__.py
Normal file
42
backend/core/models/labor/labor.py
Normal file
42
backend/core/models/labor/labor.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Labor models for tracking account labor costs.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Labor(models.Model):
|
||||||
|
"""Labor records for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='labors')
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Labor'
|
||||||
|
verbose_name_plural = 'Labor'
|
||||||
|
ordering = ['-start_date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
models.Index(fields=['start_date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.account.name} - ${self.amount}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if labor record is active based on end_date"""
|
||||||
|
return self.end_date is None or self.end_date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_days(self):
|
||||||
|
"""Calculate the duration in days"""
|
||||||
|
if not self.end_date:
|
||||||
|
return (timezone.now().date() - self.start_date).days
|
||||||
|
return (self.end_date - self.start_date).days
|
||||||
0
backend/core/models/profiles/__init__.py
Normal file
0
backend/core/models/profiles/__init__.py
Normal file
73
backend/core/models/profiles/profiles.py
Normal file
73
backend/core/models/profiles/profiles.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Profile models for user extensions.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
"""Extension of the Django User model"""
|
||||||
|
ROLE_CHOICES = (
|
||||||
|
('admin', 'Admin'),
|
||||||
|
('team_leader', 'Team Leader'),
|
||||||
|
('team_member', 'Team Member'),
|
||||||
|
)
|
||||||
|
|
||||||
|
phone_regex = RegexValidator(
|
||||||
|
regex=r'^\+?1?\d{9,15}$',
|
||||||
|
message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||||
|
first_name = models.CharField(max_length=100)
|
||||||
|
last_name = models.CharField(max_length=100)
|
||||||
|
primary_phone = models.CharField(max_length=20)
|
||||||
|
secondary_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
email = models.EmailField()
|
||||||
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='team_member')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Profile'
|
||||||
|
verbose_name_plural = 'Profiles'
|
||||||
|
ordering = ['first_name', 'last_name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
return self.role == 'admin'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_team_leader(self):
|
||||||
|
return self.role == 'team_leader'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_team_member(self):
|
||||||
|
return self.role == 'team_member'
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
|
"""Create a Profile when a new User is created"""
|
||||||
|
if created and not hasattr(instance, 'profile'):
|
||||||
|
Profile.objects.create(
|
||||||
|
user=instance,
|
||||||
|
first_name=instance.first_name or '',
|
||||||
|
last_name=instance.last_name or '',
|
||||||
|
email=instance.email or '',
|
||||||
|
primary_phone='' # This will need to be updated later
|
||||||
|
)
|
||||||
0
backend/core/models/projects/__init__.py
Normal file
0
backend/core/models/projects/__init__.py
Normal file
80
backend/core/models/projects/projects.py
Normal file
80
backend/core/models/projects/projects.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Project models for tracking customer projects.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Project(models.Model):
|
||||||
|
"""Project records for customers"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('planned', 'Planned'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='projects')
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='projects', blank=True, null=True)
|
||||||
|
date = models.DateField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned')
|
||||||
|
team_members = models.ManyToManyField('Profile', related_name='projects')
|
||||||
|
notes = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Financial
|
||||||
|
labor = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
completed_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Project'
|
||||||
|
verbose_name_plural = 'Projects'
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['customer']),
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
models.Index(fields=['date']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Project for {self.customer.name} on {self.date}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Override save to set completed_at when status changes to completed"""
|
||||||
|
if self.status == 'completed' and not self.completed_at:
|
||||||
|
self.completed_at = timezone.now()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_upcoming(self):
|
||||||
|
"""Check if project is upcoming"""
|
||||||
|
return self.date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_past_due(self):
|
||||||
|
"""Check if project is past due"""
|
||||||
|
return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def team_member_names(self):
|
||||||
|
"""Get list of team member names"""
|
||||||
|
return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profit(self):
|
||||||
|
"""Calculate profit (amount - labor)"""
|
||||||
|
return self.amount - self.labor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profit_margin(self):
|
||||||
|
"""Calculate profit margin percentage"""
|
||||||
|
if self.amount == 0:
|
||||||
|
return 0
|
||||||
|
return (self.profit / self.amount) * 100
|
||||||
3
backend/core/models/punchlists/__init__.py
Normal file
3
backend/core/models/punchlists/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Punchlist models for tracking project punchlists.
|
||||||
|
"""
|
||||||
95
backend/core/models/punchlists/punchlists.py
Normal file
95
backend/core/models/punchlists/punchlists.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Punchlist models for tracking project punchlists.
|
||||||
|
|
||||||
|
This is a customizable punchlist model. Modify the fields to match your specific
|
||||||
|
service requirements. The current structure provides generic sections that can
|
||||||
|
be adapted for various industries.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Punchlist(models.Model):
|
||||||
|
"""
|
||||||
|
Punchlist records for projects.
|
||||||
|
|
||||||
|
Customize the fields below to match your service workflow.
|
||||||
|
The current structure provides generic sections for:
|
||||||
|
- Front area (customer-facing)
|
||||||
|
- Main work area
|
||||||
|
- Equipment
|
||||||
|
- Back area
|
||||||
|
- End of visit checklist
|
||||||
|
"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
project = models.ForeignKey('Project', on_delete=models.CASCADE, related_name='punchlists')
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='punchlists')
|
||||||
|
date = models.DateField()
|
||||||
|
second_visit = models.BooleanField(default=False)
|
||||||
|
second_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Front area section
|
||||||
|
front_ceiling = models.BooleanField(default=False)
|
||||||
|
front_vents = models.BooleanField(default=False)
|
||||||
|
front_fixtures = models.BooleanField(default=False)
|
||||||
|
front_counter = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Main work area section
|
||||||
|
main_equipment = models.CharField(max_length=20, blank=True)
|
||||||
|
main_equipment_disassemble = models.BooleanField(default=False)
|
||||||
|
main_equipment_reassemble = models.BooleanField(default=False)
|
||||||
|
main_equipment_alerts = models.BooleanField(default=False)
|
||||||
|
main_equipment_exterior = models.BooleanField(default=False)
|
||||||
|
main_walls = models.BooleanField(default=False)
|
||||||
|
main_fixtures = models.BooleanField(default=False)
|
||||||
|
main_ceiling = models.BooleanField(default=False)
|
||||||
|
main_vents = models.BooleanField(default=False)
|
||||||
|
main_floors = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Equipment section
|
||||||
|
equip_primary = models.BooleanField(default=False)
|
||||||
|
equip_station_1 = models.BooleanField(default=False)
|
||||||
|
equip_station_2 = models.BooleanField(default=False)
|
||||||
|
equip_station_3 = models.BooleanField(default=False)
|
||||||
|
equip_storage = models.BooleanField(default=False)
|
||||||
|
equip_prep = models.BooleanField(default=False)
|
||||||
|
equip_delivery = models.BooleanField(default=False)
|
||||||
|
equip_office = models.BooleanField(default=False)
|
||||||
|
equip_sinks = models.BooleanField(default=False)
|
||||||
|
equip_dispensers = models.BooleanField(default=False)
|
||||||
|
equip_other = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Back area section
|
||||||
|
back_ceiling = models.BooleanField(default=False)
|
||||||
|
back_vents = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# End of visit section
|
||||||
|
end_trash = models.BooleanField(default=False)
|
||||||
|
end_clean = models.BooleanField(default=False)
|
||||||
|
end_secure = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
exported_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Google API results (optional integration)
|
||||||
|
sheet_url = models.URLField(blank=True, null=True)
|
||||||
|
pdf_url = models.URLField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Punchlists"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['project']),
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
models.Index(fields=['date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Punchlist for {self.account.name} on {self.date}"
|
||||||
0
backend/core/models/reports/__init__.py
Normal file
0
backend/core/models/reports/__init__.py
Normal file
42
backend/core/models/reports/reports.py
Normal file
42
backend/core/models/reports/reports.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Report models for tracking team member reports.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Report(models.Model):
|
||||||
|
"""Report records"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
date = models.DateField()
|
||||||
|
team_member = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='reports')
|
||||||
|
services = models.ManyToManyField('Service', related_name='reports', blank=True)
|
||||||
|
projects = models.ManyToManyField('Project', related_name='reports', blank=True)
|
||||||
|
notes = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Report'
|
||||||
|
verbose_name_plural = 'Reports'
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['team_member']),
|
||||||
|
models.Index(fields=['date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Report by {self.team_member.first_name} {self.team_member.last_name} on {self.date}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_count(self):
|
||||||
|
"""Count of services in this report"""
|
||||||
|
return self.services.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project_count(self):
|
||||||
|
"""Count of projects in this report"""
|
||||||
|
return self.projects.count()
|
||||||
0
backend/core/models/revenues/__init__.py
Normal file
0
backend/core/models/revenues/__init__.py
Normal file
42
backend/core/models/revenues/revenues.py
Normal file
42
backend/core/models/revenues/revenues.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Revenue models for tracking account revenue.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Revenue(models.Model):
|
||||||
|
"""Revenue records for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='revenues')
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Revenue'
|
||||||
|
verbose_name_plural = 'Revenues'
|
||||||
|
ordering = ['-start_date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
models.Index(fields=['start_date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.account.name} - ${self.amount}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if revenue record is active based on end_date"""
|
||||||
|
return self.end_date is None or self.end_date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_days(self):
|
||||||
|
"""Calculate the duration in days"""
|
||||||
|
if not self.end_date:
|
||||||
|
return (timezone.now().date() - self.start_date).days
|
||||||
|
return (self.end_date - self.start_date).days
|
||||||
0
backend/core/models/schedules/__init__.py
Normal file
0
backend/core/models/schedules/__init__.py
Normal file
81
backend/core/models/schedules/schedules.py
Normal file
81
backend/core/models/schedules/schedules.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Schedule models for managing service schedules.py.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(models.Model):
|
||||||
|
"""Service schedules.py for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='schedules')
|
||||||
|
|
||||||
|
# Service days
|
||||||
|
monday_service = models.BooleanField(default=False)
|
||||||
|
tuesday_service = models.BooleanField(default=False)
|
||||||
|
wednesday_service = models.BooleanField(default=False)
|
||||||
|
thursday_service = models.BooleanField(default=False)
|
||||||
|
friday_service = models.BooleanField(default=False)
|
||||||
|
saturday_service = models.BooleanField(default=False)
|
||||||
|
sunday_service = models.BooleanField(default=False)
|
||||||
|
weekend_service = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Exceptions
|
||||||
|
schedule_exception = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Schedule'
|
||||||
|
verbose_name_plural = 'Schedules'
|
||||||
|
ordering = ['-start_date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Schedule for {self.account.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
"""Check if schedule is active based on end_date"""
|
||||||
|
return self.end_date is None or self.end_date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_days(self):
|
||||||
|
"""Return list of service days"""
|
||||||
|
days = []
|
||||||
|
if self.monday_service:
|
||||||
|
days.append('Monday')
|
||||||
|
if self.tuesday_service:
|
||||||
|
days.append('Tuesday')
|
||||||
|
if self.wednesday_service:
|
||||||
|
days.append('Wednesday')
|
||||||
|
if self.thursday_service:
|
||||||
|
days.append('Thursday')
|
||||||
|
if self.friday_service:
|
||||||
|
days.append('Friday')
|
||||||
|
if self.saturday_service:
|
||||||
|
days.append('Saturday')
|
||||||
|
if self.sunday_service:
|
||||||
|
days.append('Sunday')
|
||||||
|
return days
|
||||||
|
|
||||||
|
def has_service_on_day(self, day_name):
|
||||||
|
"""Check if there is service on a specific day"""
|
||||||
|
day_map = {
|
||||||
|
'monday': self.monday_service,
|
||||||
|
'tuesday': self.tuesday_service,
|
||||||
|
'wednesday': self.wednesday_service,
|
||||||
|
'thursday': self.thursday_service,
|
||||||
|
'friday': self.friday_service,
|
||||||
|
'saturday': self.saturday_service,
|
||||||
|
'sunday': self.sunday_service,
|
||||||
|
}
|
||||||
|
return day_map.get(day_name.lower(), False)
|
||||||
0
backend/core/models/services/__init__.py
Normal file
0
backend/core/models/services/__init__.py
Normal file
71
backend/core/models/services/services.py
Normal file
71
backend/core/models/services/services.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Service models for tracking service events.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Service(models.Model):
|
||||||
|
"""Service records for accounts"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='services')
|
||||||
|
date = models.DateField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled')
|
||||||
|
team_members = models.ManyToManyField('Profile', related_name='services')
|
||||||
|
notes = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Service window
|
||||||
|
deadline_start = models.DateTimeField()
|
||||||
|
deadline_end = models.DateTimeField()
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
completed_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'core'
|
||||||
|
verbose_name = 'Service'
|
||||||
|
verbose_name_plural = 'Services'
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['account']),
|
||||||
|
models.Index(fields=['date']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Service for {self.account.name} on {self.date}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Override save to set completed_at when status changes to completed"""
|
||||||
|
if self.status == 'completed' and not self.completed_at:
|
||||||
|
self.completed_at = timezone.now()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_upcoming(self):
|
||||||
|
"""Check if service is upcoming"""
|
||||||
|
return self.date > timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_today(self):
|
||||||
|
"""Check if service is scheduled for today"""
|
||||||
|
return self.date == timezone.now().date()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_past_due(self):
|
||||||
|
"""Check if service is past due"""
|
||||||
|
return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def team_member_names(self):
|
||||||
|
"""Get list of team member names"""
|
||||||
|
return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()]
|
||||||
31
backend/core/repositories/__init__.py
Normal file
31
backend/core/repositories/__init__.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Repositories package initialization.
|
||||||
|
Import all repositories here to make them available when importing from core.repositories
|
||||||
|
"""
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
from backend.core.repositories.profiles.profiles import ProfileRepository
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.revenues.revenues import RevenueRepository
|
||||||
|
from backend.core.repositories.labor.labor import LaborRepository
|
||||||
|
from backend.core.repositories.schedules.schedules import ScheduleRepository
|
||||||
|
from backend.core.repositories.services.services import ServiceRepository
|
||||||
|
from backend.core.repositories.projects.projects import ProjectRepository
|
||||||
|
from backend.core.repositories.invoices.invoices import InvoiceRepository
|
||||||
|
from backend.core.repositories.reports.reports import ReportRepository
|
||||||
|
from backend.core.repositories.punchlists.punchlists import PunchlistRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseRepository',
|
||||||
|
'ProfileRepository',
|
||||||
|
'CustomerRepository',
|
||||||
|
'AccountRepository',
|
||||||
|
'RevenueRepository',
|
||||||
|
'LaborRepository',
|
||||||
|
'ScheduleRepository',
|
||||||
|
'ServiceRepository',
|
||||||
|
'ProjectRepository',
|
||||||
|
'InvoiceRepository',
|
||||||
|
'ReportRepository',
|
||||||
|
'PunchlistRepository',
|
||||||
|
]
|
||||||
0
backend/core/repositories/accounts/__init__.py
Normal file
0
backend/core/repositories/accounts/__init__.py
Normal file
220
backend/core/repositories/accounts/accounts.py
Normal file
220
backend/core/repositories/accounts/accounts.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
Repository for Account model operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from django.db.models import Q, Prefetch
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Account, Service
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRepository(BaseRepository[Account]):
|
||||||
|
"""
|
||||||
|
Repository for Account model operations.
|
||||||
|
"""
|
||||||
|
model = Account
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_customer(cls, customer_id: str):
|
||||||
|
"""
|
||||||
|
Get accounts by customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of accounts for the customer
|
||||||
|
"""
|
||||||
|
return Account.objects.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls):
|
||||||
|
"""
|
||||||
|
Get all active accounts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active accounts
|
||||||
|
"""
|
||||||
|
return Account.objects.filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_by_customer(cls, customer_id: str):
|
||||||
|
"""
|
||||||
|
Get active accounts by customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active accounts for the customer
|
||||||
|
"""
|
||||||
|
return Account.objects.filter(
|
||||||
|
customer_id=customer_id
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_inactive(cls):
|
||||||
|
"""
|
||||||
|
Get all inactive accounts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of inactive accounts
|
||||||
|
"""
|
||||||
|
return Account.objects.filter(
|
||||||
|
end_date__isnull=False,
|
||||||
|
end_date__lte=timezone.now().date()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_inactive(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Mark an account as inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated account or None if not found
|
||||||
|
"""
|
||||||
|
return cls.update(id, {'end_date': timezone.now().date()})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, include_inactive: bool = False):
|
||||||
|
"""
|
||||||
|
Search accounts by name or contact info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
include_inactive: Whether to include inactive accounts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching accounts
|
||||||
|
"""
|
||||||
|
queryset = super().search(
|
||||||
|
search_term,
|
||||||
|
[
|
||||||
|
'name',
|
||||||
|
'primary_contact_first_name',
|
||||||
|
'primary_contact_last_name',
|
||||||
|
'primary_contact_email',
|
||||||
|
'secondary_contact_first_name',
|
||||||
|
'secondary_contact_last_name',
|
||||||
|
'secondary_contact_email',
|
||||||
|
'customer__name'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_inactive:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_services(cls, id: str, upcoming_only: bool = False) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with prefetched services.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
upcoming_only: Whether to include only upcoming services
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The account with services or None if not found
|
||||||
|
"""
|
||||||
|
services_queryset = Service.objects.all()
|
||||||
|
|
||||||
|
if upcoming_only:
|
||||||
|
services_queryset = services_queryset.filter(date__gte=timezone.now().date())
|
||||||
|
|
||||||
|
services_prefetch = Prefetch('services', queryset=services_queryset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related(services_prefetch).get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_revenues(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with prefetched revenues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The account with revenues or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related('revenues').get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_labors(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with prefetched labors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The account with labors or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related('labors').get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_schedules(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with prefetched schedules
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The account with schedules or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related('schedules').get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_projects(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with prefetched projects.
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
Returns:
|
||||||
|
The account with projects or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related('projects').get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_all_related(cls, id: str) -> Optional[Account]:
|
||||||
|
"""
|
||||||
|
Get an account with all related data prefetched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The account with all related data or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Account.objects.prefetch_related(
|
||||||
|
'services', 'revenues', 'labors', 'schedules.py', 'projects'
|
||||||
|
).select_related('customer').get(pk=id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return None
|
||||||
209
backend/core/repositories/base.py
Normal file
209
backend/core/repositories/base.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Base repository class for data access.
|
||||||
|
"""
|
||||||
|
from typing import TypeVar, Generic, List, Dict, Any, Optional, Type
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
|
||||||
|
T = TypeVar('T', bound=models.Model)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository(Generic[T]):
|
||||||
|
"""
|
||||||
|
Base repository class with common methods for data access.
|
||||||
|
"""
|
||||||
|
model: Type[T] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_id(cls, entity_id: str) -> Optional[T]:
|
||||||
|
"""
|
||||||
|
Get an entity by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: The entity ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The entity or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return cls.model.objects.get(pk=entity_id)
|
||||||
|
except cls.model.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls, **filters) -> QuerySet[T]:
|
||||||
|
"""
|
||||||
|
Get all entities with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**filters: Filter parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching entities
|
||||||
|
"""
|
||||||
|
queryset = cls.model.objects.all()
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
for key, value in filters.items():
|
||||||
|
if value is not None:
|
||||||
|
# Handle special filter keys
|
||||||
|
if key.endswith('__in') and not value:
|
||||||
|
# Empty list for __in lookup should return empty queryset
|
||||||
|
return cls.model.objects.none()
|
||||||
|
|
||||||
|
# Apply the filter
|
||||||
|
queryset = queryset.filter(**{key: value})
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, data: Dict[str, Any]) -> T:
|
||||||
|
"""
|
||||||
|
Create a new entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Entity data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created entity
|
||||||
|
"""
|
||||||
|
return cls.model.objects.create(**data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update(cls, entity_id: str, data: Dict[str, Any]) -> Optional[T]:
|
||||||
|
"""
|
||||||
|
Update an existing entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: The entity ID
|
||||||
|
data: Updated data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated entity or None if not found
|
||||||
|
"""
|
||||||
|
obj = cls.get_by_id(entity_id)
|
||||||
|
if not obj:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(obj, key, value)
|
||||||
|
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete(cls, entity_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete an entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: The entity ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
obj = cls.get_by_id(entity_id)
|
||||||
|
if not obj:
|
||||||
|
return False
|
||||||
|
|
||||||
|
obj.delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bulk_create(cls, data_list: List[Dict[str, Any]]) -> List[T]:
|
||||||
|
"""
|
||||||
|
Create multiple entities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_list: List of entity data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of created entities
|
||||||
|
"""
|
||||||
|
objects = [cls.model(**data) for data in data_list]
|
||||||
|
return cls.model.objects.bulk_create(objects)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bulk_update(cls, objects: List[T], fields: List[str]) -> int:
|
||||||
|
"""
|
||||||
|
Update multiple entities.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
objects: List of entity objects
|
||||||
|
fields: List of fields to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of updated entities
|
||||||
|
"""
|
||||||
|
# Cast the objects to Any to bypass type checking for this call
|
||||||
|
return cls.model.objects.bulk_update(objects, fields) # type: ignore
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def count(cls, **filters) -> int:
|
||||||
|
"""
|
||||||
|
Count entities with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**filters: Filter parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count of matching entities
|
||||||
|
"""
|
||||||
|
return cls.get_all(**filters).count()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def exists(cls, **filters) -> bool:
|
||||||
|
"""
|
||||||
|
Check if any entities exist with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**filters: Filter parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if entities exist, False otherwise
|
||||||
|
"""
|
||||||
|
return cls.get_all(**filters).exists()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_by_date_range(cls, start_date=None, end_date=None, date_field='date') -> QuerySet[T]:
|
||||||
|
"""
|
||||||
|
Filter entities by date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
date_field: Name of the date field to filter on
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching entities
|
||||||
|
"""
|
||||||
|
filters = {}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
filters[f'{date_field}__gte'] = start_date
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
filters[f'{date_field}__lte'] = end_date
|
||||||
|
|
||||||
|
return cls.get_all(**filters)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str]) -> QuerySet[T]:
|
||||||
|
"""
|
||||||
|
Search entities by term across multiple fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: List of fields to search in
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching entities
|
||||||
|
"""
|
||||||
|
if not search_term:
|
||||||
|
return cls.model.objects.all()
|
||||||
|
|
||||||
|
q_objects = Q()
|
||||||
|
for field in search_fields:
|
||||||
|
q_objects |= Q(**{f'{field}__icontains': search_term})
|
||||||
|
|
||||||
|
return cls.model.objects.filter(q_objects)
|
||||||
0
backend/core/repositories/customers/__init__.py
Normal file
0
backend/core/repositories/customers/__init__.py
Normal file
177
backend/core/repositories/customers/customers.py
Normal file
177
backend/core/repositories/customers/customers.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Repository for Customer model operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
from backend.core.models import Customer
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRepository(BaseRepository[Customer]):
|
||||||
|
"""
|
||||||
|
Repository for Customer model operations.
|
||||||
|
"""
|
||||||
|
model = Customer
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls):
|
||||||
|
"""
|
||||||
|
Get all active customers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active customers
|
||||||
|
"""
|
||||||
|
return Customer.objects.filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_inactive(cls):
|
||||||
|
"""
|
||||||
|
Get all inactive customers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of inactive customers
|
||||||
|
"""
|
||||||
|
return Customer.objects.filter(
|
||||||
|
end_date__isnull=False,
|
||||||
|
end_date__lte=timezone.now().date()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_inactive(cls, customer_id: str) -> Optional[Customer]:
|
||||||
|
"""
|
||||||
|
Mark a customer as inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated customer or None if not found
|
||||||
|
"""
|
||||||
|
return cls.update(customer_id, {'end_date': timezone.now().date()})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, include_inactive: bool = False):
|
||||||
|
"""
|
||||||
|
Search customers by name or contact info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
include_inactive: Whether to include inactive customers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching customers
|
||||||
|
"""
|
||||||
|
queryset = super().search(
|
||||||
|
search_term,
|
||||||
|
[
|
||||||
|
'name',
|
||||||
|
'primary_contact_first_name',
|
||||||
|
'primary_contact_last_name',
|
||||||
|
'primary_contact_email',
|
||||||
|
'secondary_contact_first_name',
|
||||||
|
'secondary_contact_last_name',
|
||||||
|
'secondary_contact_email',
|
||||||
|
'billing_contact_first_name',
|
||||||
|
'billing_contact_last_name',
|
||||||
|
'billing_email'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not include_inactive:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date())
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_email(cls, email: str) -> Optional[Customer]:
|
||||||
|
"""
|
||||||
|
Get a customer by email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The customer or None if not found
|
||||||
|
"""
|
||||||
|
return Customer.objects.filter(
|
||||||
|
Q(primary_contact_email=email) |
|
||||||
|
Q(secondary_contact_email=email) |
|
||||||
|
Q(billing_email=email)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_accounts(cls, customer_id: str) -> Optional[Customer]:
|
||||||
|
"""
|
||||||
|
Get a customer with prefetched accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The customer with accounts or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Customer.objects.prefetch_related('accounts').get(pk=customer_id)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_projects(cls, customer_id: str) -> Optional[Customer]:
|
||||||
|
"""
|
||||||
|
Get a customer with prefetched projects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The customer with projects or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Customer.objects.prefetch_related('projects').get(pk=customer_id)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_invoices(cls, customer_id: str) -> Optional[Customer]:
|
||||||
|
"""
|
||||||
|
Get a customer with prefetched invoices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The customer with invoices or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Customer.objects.prefetch_related('invoices').get(pk=customer_id)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_customers(cls, name=None, city=None, state=None, start_date=None, end_date=None) -> QuerySet[Customer]:
|
||||||
|
"""
|
||||||
|
Filter customers by multiple criteria.
|
||||||
|
"""
|
||||||
|
queryset = Customer.objects.all()
|
||||||
|
|
||||||
|
if name:
|
||||||
|
queryset = queryset.filter(name__icontains=name)
|
||||||
|
|
||||||
|
if city:
|
||||||
|
queryset = queryset.filter(billing_city__icontains=city)
|
||||||
|
|
||||||
|
if state:
|
||||||
|
queryset = queryset.filter(billing_state__iexact=state)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
queryset = queryset.filter(start_date__gte=start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
queryset = queryset.filter(end_date__lte=end_date)
|
||||||
|
|
||||||
|
return queryset
|
||||||
0
backend/core/repositories/invoices/__init__.py
Normal file
0
backend/core/repositories/invoices/__init__.py
Normal file
302
backend/core/repositories/invoices/invoices.py
Normal file
302
backend/core/repositories/invoices/invoices.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Repository for Invoice model operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from django.db.models import QuerySet, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Invoice, Account, Project
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceRepository(BaseRepository[Invoice]):
|
||||||
|
"""
|
||||||
|
Repository for Invoice model operations.
|
||||||
|
"""
|
||||||
|
model = Invoice
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_customer(cls, customer_id: str) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Get invoices by customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of invoices for the customer
|
||||||
|
"""
|
||||||
|
return Invoice.objects.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_status(cls, status: str) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Get invoices by status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: The invoice status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of invoices with the specified status
|
||||||
|
"""
|
||||||
|
return Invoice.objects.filter(status=status)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Get invoices within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of invoices within the date range
|
||||||
|
"""
|
||||||
|
return cls.filter_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_overdue(cls) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Get overdue invoices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of overdue invoices
|
||||||
|
"""
|
||||||
|
thirty_days_ago = timezone.now().date() - timedelta(days=30)
|
||||||
|
return Invoice.objects.filter(
|
||||||
|
status__in=['sent', 'overdue'], # Include both sent and already marked overdue
|
||||||
|
sent_at__isnull=False, # Must have been sent
|
||||||
|
).exclude(
|
||||||
|
status__in=['paid', 'cancelled'] # Not paid or cancelled
|
||||||
|
).filter(
|
||||||
|
sent_at__date__lt=thirty_days_ago # Sent more than 30 days ago
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unpaid(cls) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Get unpaid invoices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of unpaid invoices (sent but not paid)
|
||||||
|
"""
|
||||||
|
return Invoice.objects.filter(status='sent')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Search invoices by customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching invoices
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
['customer__name']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_invoices(
|
||||||
|
cls,
|
||||||
|
customer_id: str = None,
|
||||||
|
status: str = None,
|
||||||
|
date_from: date = None,
|
||||||
|
date_to: date = None,
|
||||||
|
account_id: str = None,
|
||||||
|
project_id: str = None
|
||||||
|
) -> QuerySet[Invoice]:
|
||||||
|
"""
|
||||||
|
Filter invoices by multiple criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Filter by customer ID
|
||||||
|
status: Filter by status
|
||||||
|
date_from: Filter by start date (inclusive)
|
||||||
|
date_to: Filter by end date (inclusive)
|
||||||
|
account_id: Filter by account ID
|
||||||
|
project_id: Filter by project ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching invoices
|
||||||
|
"""
|
||||||
|
queryset = Invoice.objects.all()
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(date__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(date__lte=date_to)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
queryset = queryset.filter(accounts__id=account_id)
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
queryset = queryset.filter(projects__id=project_id)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_with_items(
|
||||||
|
cls,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
account_ids: List[str] = None,
|
||||||
|
project_ids: List[str] = None
|
||||||
|
) -> Invoice:
|
||||||
|
"""
|
||||||
|
Create an invoice with related items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Invoice data
|
||||||
|
account_ids: List of account IDs
|
||||||
|
project_ids: List of project IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created invoice
|
||||||
|
"""
|
||||||
|
# Create the invoice
|
||||||
|
invoice = cls.create(data)
|
||||||
|
|
||||||
|
# Add accounts
|
||||||
|
if account_ids:
|
||||||
|
accounts = Account.objects.filter(id__in=account_ids)
|
||||||
|
invoice.accounts.set(accounts)
|
||||||
|
|
||||||
|
# Add projects
|
||||||
|
if project_ids:
|
||||||
|
projects = Project.objects.filter(id__in=project_ids)
|
||||||
|
invoice.projects.set(projects)
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_status(cls, invoice_id: str, status: str) -> Optional[Invoice]:
|
||||||
|
"""
|
||||||
|
Update invoice status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoice_id: The invoice ID
|
||||||
|
status: The new status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated invoice or None if not found
|
||||||
|
"""
|
||||||
|
invoice = cls.get_by_id(invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
return None
|
||||||
|
|
||||||
|
invoice.status = status
|
||||||
|
|
||||||
|
# Set sent_at if status is 'sent'
|
||||||
|
if status == 'sent' and not invoice.sent_at:
|
||||||
|
invoice.sent_at = timezone.now()
|
||||||
|
|
||||||
|
# Set date_paid if status is 'paid'
|
||||||
|
if status == 'paid' and not invoice.date_paid:
|
||||||
|
invoice.date_paid = timezone.now().date()
|
||||||
|
|
||||||
|
invoice.save()
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_as_paid(cls, invoice_id: str, payment_type: str) -> Optional[Invoice]:
|
||||||
|
"""
|
||||||
|
Mark an invoice as paid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoice_id: The invoice ID
|
||||||
|
payment_type: The payment type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated invoice or None if not found
|
||||||
|
"""
|
||||||
|
invoice = cls.get_by_id(invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
return None
|
||||||
|
|
||||||
|
invoice.status = 'paid'
|
||||||
|
invoice.date_paid = timezone.now().date()
|
||||||
|
invoice.payment_type = payment_type
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_total_paid(cls, customer_id: str = None, date_from: date = None, date_to: date = None) -> float:
|
||||||
|
"""
|
||||||
|
Get total paid invoice amount.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Filter by customer ID
|
||||||
|
date_from: Filter by start date (inclusive)
|
||||||
|
date_to: Filter by end date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total paid amount
|
||||||
|
"""
|
||||||
|
queryset = Invoice.objects.filter(status='paid')
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(date_paid__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(date_paid__lte=date_to)
|
||||||
|
|
||||||
|
result = queryset.aggregate(total=Sum('total_amount'))
|
||||||
|
return float(result['total'] or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_total_outstanding(cls, customer_id: str = None) -> float:
|
||||||
|
"""
|
||||||
|
Get total outstanding invoice amount.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Filter by customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total outstanding amount
|
||||||
|
"""
|
||||||
|
queryset = Invoice.objects.filter(status__in=['sent', 'overdue'])
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
result = queryset.aggregate(total=Sum('total_amount'))
|
||||||
|
return float(result['total'] or 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_overdue(cls, invoice_id: str = None) -> int:
|
||||||
|
"""
|
||||||
|
Mark invoice(s) as overdue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoice_id: Optional invoice ID. If not provided, all overdue invoices will be marked.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of invoices marked as overdue
|
||||||
|
"""
|
||||||
|
thirty_days_ago = timezone.now().date() - timedelta(days=30)
|
||||||
|
query = Invoice.objects.filter(
|
||||||
|
status='sent',
|
||||||
|
sent_at__isnull=False,
|
||||||
|
sent_at__date__lt=thirty_days_ago
|
||||||
|
)
|
||||||
|
|
||||||
|
if invoice_id:
|
||||||
|
query = query.filter(id=invoice_id)
|
||||||
|
|
||||||
|
count = query.update(status='overdue')
|
||||||
|
return count
|
||||||
0
backend/core/repositories/labor/__init__.py
Normal file
0
backend/core/repositories/labor/__init__.py
Normal file
183
backend/core/repositories/labor/labor.py
Normal file
183
backend/core/repositories/labor/labor.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Repository for Labor model operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Labor
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class LaborRepository(BaseRepository[Labor]):
|
||||||
|
"""
|
||||||
|
Repository for Labor model operations.
|
||||||
|
"""
|
||||||
|
model = Labor
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet[Labor]:
|
||||||
|
"""
|
||||||
|
Get labors by account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of labors for the account
|
||||||
|
"""
|
||||||
|
return Labor.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls) -> QuerySet[Labor]:
|
||||||
|
"""
|
||||||
|
Get active labors.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active labors
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Labor.objects.filter(
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_by_account(cls, account_id: str) -> Optional[Labor]:
|
||||||
|
"""
|
||||||
|
Get active labor for an account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Active labor for the account or None if not found
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Labor.objects.filter(
|
||||||
|
account_id=account_id,
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def end_labor(cls, labor_id: str) -> Optional[Labor]:
|
||||||
|
"""
|
||||||
|
End a labor record by setting its end date to today.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labor_id: The labor ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated labor or None if not found
|
||||||
|
"""
|
||||||
|
return cls.update(labor_id, {'end_date': timezone.now().date()})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Labor]:
|
||||||
|
"""
|
||||||
|
Get labors that were active during a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of labors active during the date range
|
||||||
|
"""
|
||||||
|
queryset = Labor.objects.all()
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
# Exclude labors that ended before the start date
|
||||||
|
queryset = queryset.exclude(
|
||||||
|
end_date__isnull=False,
|
||||||
|
end_date__lt=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
# Exclude labors that started after the end date
|
||||||
|
queryset = queryset.exclude(start_date__gt=end_date)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Labor]:
|
||||||
|
"""
|
||||||
|
Search labor records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching labor records
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
['account__name']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_total_labor_cost(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float:
|
||||||
|
"""
|
||||||
|
Get total labor cost for an account or all accounts within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: Optional account ID to filter by
|
||||||
|
start_date: Optional start date for the period
|
||||||
|
end_date: Optional end date for the period
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total labor cost
|
||||||
|
"""
|
||||||
|
labors = cls.get_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
labors = labors.filter(account_id=account_id)
|
||||||
|
|
||||||
|
# Calculate proportional costs for labors that span beyond the range
|
||||||
|
total_cost = 0
|
||||||
|
|
||||||
|
for labor in labors:
|
||||||
|
# Get the effective start and end dates for the calculation
|
||||||
|
# (intersection of labor period and requested period)
|
||||||
|
effective_start = max(labor.start_date, start_date) if start_date else labor.start_date
|
||||||
|
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
if labor.end_date:
|
||||||
|
effective_end = min(labor.end_date, end_date) if end_date else labor.end_date
|
||||||
|
else:
|
||||||
|
effective_end = end_date if end_date else current_date
|
||||||
|
|
||||||
|
# Calculate days in range
|
||||||
|
days_in_range = (effective_end - effective_start).days + 1
|
||||||
|
|
||||||
|
# Calculate total days for labor period
|
||||||
|
if labor.end_date:
|
||||||
|
total_days = (labor.end_date - labor.start_date).days + 1
|
||||||
|
else:
|
||||||
|
total_days = (current_date - labor.start_date).days + 1
|
||||||
|
|
||||||
|
# Avoid division by zero
|
||||||
|
if total_days <= 0:
|
||||||
|
total_days = 1
|
||||||
|
|
||||||
|
# Calculate proportional cost
|
||||||
|
proportional_cost = labor.amount * (days_in_range / total_days)
|
||||||
|
|
||||||
|
total_cost += proportional_cost
|
||||||
|
|
||||||
|
return float(total_cost)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_inactive(cls) -> QuerySet[Labor]:
|
||||||
|
"""
|
||||||
|
Get inactive labors.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of inactive labors
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Labor.objects.filter(end_date__lt=current_date)
|
||||||
0
backend/core/repositories/profiles/__init__.py
Normal file
0
backend/core/repositories/profiles/__init__.py
Normal file
103
backend/core/repositories/profiles/profiles.py
Normal file
103
backend/core/repositories/profiles/profiles.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Repository for Profile model operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
from backend.core.models import Profile
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRepository(BaseRepository[Profile]):
|
||||||
|
"""
|
||||||
|
Repository for Profile model operations.
|
||||||
|
"""
|
||||||
|
model = Profile
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_user(cls, user: User) -> Optional[Profile]:
|
||||||
|
"""
|
||||||
|
Get a profile by user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The profile or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Profile.objects.get(user=user)
|
||||||
|
except Profile.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_email(cls, email: str) -> Optional[Profile]:
|
||||||
|
"""
|
||||||
|
Get a profile by email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The profile or None if not found
|
||||||
|
"""
|
||||||
|
return Profile.objects.filter(email=email).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_role(cls, role: str) -> QuerySet[Profile]:
|
||||||
|
"""
|
||||||
|
Get profiles by role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role: The role
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of profiles with the specified role
|
||||||
|
"""
|
||||||
|
return Profile.objects.filter(role=role)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_admins(cls) -> QuerySet[Profile]:
|
||||||
|
"""
|
||||||
|
Get all admin profiles
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of admin profiles
|
||||||
|
"""
|
||||||
|
return cls.get_by_role('admin')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_team_leaders(cls) -> QuerySet[Profile]:
|
||||||
|
"""
|
||||||
|
Get all team leader profiles
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of team leader profiles
|
||||||
|
"""
|
||||||
|
return cls.get_by_role('team_leader')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_team_members(cls) -> QuerySet[Profile]:
|
||||||
|
"""
|
||||||
|
Get all team member profiles
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of team member profiles
|
||||||
|
"""
|
||||||
|
return cls.get_by_role('team_member')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Profile]:
|
||||||
|
"""
|
||||||
|
Search profiles by name or email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: List of fields to search in (optional, default fields will be used if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching profiles
|
||||||
|
"""
|
||||||
|
fields = search_fields or ['first_name', 'last_name', 'email', 'user__username', 'user__email']
|
||||||
|
return super().search(search_term, fields)
|
||||||
0
backend/core/repositories/projects/__init__.py
Normal file
0
backend/core/repositories/projects/__init__.py
Normal file
266
backend/core/repositories/projects/projects.py
Normal file
266
backend/core/repositories/projects/projects.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Repository for Project model operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Project, Profile
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRepository(BaseRepository[Project]):
|
||||||
|
"""
|
||||||
|
Repository for Project model operations.
|
||||||
|
"""
|
||||||
|
model = Project
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_customer(cls, customer_id: str) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects by customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: The customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects for the customer
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects by account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects for the account
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_team_member(cls, profile_id: str) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects by team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects assigned to the team member
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(team_members__id=profile_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_status(cls, status: str) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects by status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: The project status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects with the specified status
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(status=status)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects within the date range
|
||||||
|
"""
|
||||||
|
return cls.filter_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_upcoming(cls) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get upcoming projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of upcoming projects
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(date__gte=timezone.now().date())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_past_due(cls) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get past due projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of past due projects
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(
|
||||||
|
date__lt=timezone.now().date(),
|
||||||
|
status__in=['planned', 'in_progress']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Search projects by customer, account, or notes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching projects
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
['customer__name', 'account__name', 'notes']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_projects(
|
||||||
|
cls,
|
||||||
|
customer_id: str = None,
|
||||||
|
account_id: str = None,
|
||||||
|
status: str = None,
|
||||||
|
date_from: date = None,
|
||||||
|
date_to: date = None,
|
||||||
|
team_member_id: str = None
|
||||||
|
) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Filter projects by multiple criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Filter by customer ID
|
||||||
|
account_id: Filter by account ID
|
||||||
|
status: Filter by status
|
||||||
|
date_from: Filter by start date (inclusive)
|
||||||
|
date_to: Filter by end date (inclusive)
|
||||||
|
team_member_id: Filter by team member ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching projects
|
||||||
|
"""
|
||||||
|
queryset = Project.objects.all()
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(date__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(date__lte=date_to)
|
||||||
|
|
||||||
|
if team_member_id:
|
||||||
|
queryset = queryset.filter(team_members__id=team_member_id)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Project:
|
||||||
|
"""
|
||||||
|
Create a project with team members.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Project data
|
||||||
|
team_member_ids: List of team member IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created project
|
||||||
|
"""
|
||||||
|
# Create the project
|
||||||
|
project = cls.create(data)
|
||||||
|
|
||||||
|
# Add team members
|
||||||
|
if team_member_ids:
|
||||||
|
team_members = Profile.objects.filter(id__in=team_member_ids)
|
||||||
|
project.team_members.set(team_members)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_status(cls, project_id: str, status: str) -> Optional[Project]:
|
||||||
|
"""
|
||||||
|
Update project status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID
|
||||||
|
status: The new status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project or None if not found
|
||||||
|
"""
|
||||||
|
project = cls.get_by_id(project_id)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
project.status = status
|
||||||
|
|
||||||
|
# Set completed_at if status is 'completed'
|
||||||
|
if status == 'completed' and not project.completed_at:
|
||||||
|
project.completed_at = timezone.now()
|
||||||
|
|
||||||
|
project.save()
|
||||||
|
return project
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def assign_team_members(cls, project_id: str, team_member_ids: List[str]) -> Optional[Project]:
|
||||||
|
"""
|
||||||
|
Assign team members to a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID
|
||||||
|
team_member_ids: List of team member IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated project or None if not found
|
||||||
|
"""
|
||||||
|
project = cls.get_by_id(project_id)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
team_members = Profile.objects.filter(id__in=team_member_ids)
|
||||||
|
project.team_members.set(team_members)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_without_invoice(cls) -> QuerySet[Project]:
|
||||||
|
"""
|
||||||
|
Get projects that have not been invoiced.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of projects without invoices
|
||||||
|
"""
|
||||||
|
return Project.objects.filter(
|
||||||
|
status='completed',
|
||||||
|
invoices__isnull=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_profit(cls, project_id: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Calculate profit for a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Project profit or None if not found
|
||||||
|
"""
|
||||||
|
project = cls.get_by_id(project_id)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return float(project.amount - project.labor)
|
||||||
3
backend/core/repositories/punchlists/__init__.py
Normal file
3
backend/core/repositories/punchlists/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Punchlist repositories for managing punchlist data.
|
||||||
|
"""
|
||||||
98
backend/core/repositories/punchlists/punchlists.py
Normal file
98
backend/core/repositories/punchlists/punchlists.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Repository for Punchlist model.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from backend.core.models import Punchlist, Project, Account
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class PunchlistRepository(BaseRepository[Punchlist]):
|
||||||
|
"""Repository for Punchlist model"""
|
||||||
|
model = Punchlist
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_project(cls, project_id: str) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get punchlists by project ID
|
||||||
|
"""
|
||||||
|
return cls.model.objects.filter(project_id=project_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get punchlists by account ID
|
||||||
|
"""
|
||||||
|
return cls.model.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get punchlists by date range
|
||||||
|
"""
|
||||||
|
queryset = cls.model.objects.all()
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
queryset = queryset.filter(date__gte=start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
queryset = queryset.filter(date__lte=end_date)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_punchlists(cls, project_id: str = None, account_id: str = None,
|
||||||
|
date_from: date = None, date_to: date = None) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Filter punchlists by multiple criteria
|
||||||
|
"""
|
||||||
|
queryset = cls.model.objects.all()
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
queryset = queryset.filter(project_id=project_id)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(date__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(date__lte=date_to)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_punchlist(cls, data: Dict[str, Any]) -> Punchlist:
|
||||||
|
"""
|
||||||
|
Create a punchlist for an account.
|
||||||
|
Add any custom validation logic here.
|
||||||
|
"""
|
||||||
|
return cls.create(data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_punchlist(cls, punchlist_id: str, data: Dict[str, Any]) -> Punchlist:
|
||||||
|
"""
|
||||||
|
Update a punchlist
|
||||||
|
"""
|
||||||
|
return cls.update(punchlist_id, data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def mark_exported(cls, punchlist_id: str, sheet_url: str = None, pdf_url: str = None) -> Punchlist:
|
||||||
|
"""
|
||||||
|
Mark a punchlist as exported
|
||||||
|
"""
|
||||||
|
punchlist = cls.get_by_id(punchlist_id)
|
||||||
|
punchlist.exported_at = timezone.now()
|
||||||
|
|
||||||
|
if sheet_url:
|
||||||
|
punchlist.sheet_url = sheet_url
|
||||||
|
|
||||||
|
if pdf_url:
|
||||||
|
punchlist.pdf_url = pdf_url
|
||||||
|
|
||||||
|
punchlist.save()
|
||||||
|
return punchlist
|
||||||
0
backend/core/repositories/reports/__init__.py
Normal file
0
backend/core/repositories/reports/__init__.py
Normal file
235
backend/core/repositories/reports/reports.py
Normal file
235
backend/core/repositories/reports/reports.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Repository for Report model operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Report, Service, Project
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRepository(BaseRepository[Report]):
|
||||||
|
"""
|
||||||
|
Repository for Report model operations.
|
||||||
|
"""
|
||||||
|
model = Report
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_team_member(cls, profile_id: str) -> QuerySet[Report]:
|
||||||
|
"""
|
||||||
|
Get reports by team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of reports by the team member
|
||||||
|
"""
|
||||||
|
return Report.objects.filter(team_member_id=profile_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Report]:
|
||||||
|
"""
|
||||||
|
Get reports within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of reports within the date range
|
||||||
|
"""
|
||||||
|
return cls.filter_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_with_items(
|
||||||
|
cls,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
service_ids: List[str] = None,
|
||||||
|
project_ids: List[str] = None
|
||||||
|
) -> Report:
|
||||||
|
"""
|
||||||
|
Create a report with related items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Report data
|
||||||
|
service_ids: List of service IDs
|
||||||
|
project_ids: List of project IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created report
|
||||||
|
"""
|
||||||
|
# Create the report
|
||||||
|
report = cls.create(data)
|
||||||
|
|
||||||
|
# Add services
|
||||||
|
if service_ids:
|
||||||
|
services = Service.objects.filter(id__in=service_ids)
|
||||||
|
report.services.set(services)
|
||||||
|
|
||||||
|
# Add projects
|
||||||
|
if project_ids:
|
||||||
|
projects = Project.objects.filter(id__in=project_ids)
|
||||||
|
report.projects.set(projects)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_team_member_activity(
|
||||||
|
cls,
|
||||||
|
profile_id: str,
|
||||||
|
start_date: date = None,
|
||||||
|
end_date: date = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Get activity summary for a team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with activity summary
|
||||||
|
"""
|
||||||
|
# Set default date range to current month if not provided
|
||||||
|
if not start_date:
|
||||||
|
today = timezone.now().date()
|
||||||
|
start_date = date(today.year, today.month, 1)
|
||||||
|
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Get services for the team member
|
||||||
|
services = Service.objects.filter(
|
||||||
|
team_members__id=profile_id,
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get projects for the team member
|
||||||
|
projects = Project.objects.filter(
|
||||||
|
team_members__id=profile_id,
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get reports for the team member
|
||||||
|
reports = Report.objects.filter(
|
||||||
|
team_member_id=profile_id,
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count by status
|
||||||
|
services_by_status = {
|
||||||
|
'scheduled': services.filter(status='scheduled').count(),
|
||||||
|
'in_progress': services.filter(status='in_progress').count(),
|
||||||
|
'completed': services.filter(status='completed').count(),
|
||||||
|
'cancelled': services.filter(status='cancelled').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
projects_by_status = {
|
||||||
|
'planned': projects.filter(status='planned').count(),
|
||||||
|
'in_progress': projects.filter(status='in_progress').count(),
|
||||||
|
'completed': projects.filter(status='completed').count(),
|
||||||
|
'cancelled': projects.filter(status='cancelled').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_services': services.count(),
|
||||||
|
'total_projects': projects.count(),
|
||||||
|
'total_reports': reports.count(),
|
||||||
|
'services_by_status': services_by_status,
|
||||||
|
'projects_by_status': projects_by_status
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Report]:
|
||||||
|
"""
|
||||||
|
Search reports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching reports
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
[
|
||||||
|
'team_member__first_name',
|
||||||
|
'team_member__last_name',
|
||||||
|
'team_member__email',
|
||||||
|
'notes'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_with_all_related(cls, report_id: str) -> Optional[Report]:
|
||||||
|
"""
|
||||||
|
Get a report with all related data prefetched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_id: The report ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The report with all related data or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Report.objects.prefetch_related(
|
||||||
|
'services', 'projects'
|
||||||
|
).select_related('team_member').get(pk=report_id)
|
||||||
|
except Report.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_team_summary(cls, start_date: date = None, end_date: date = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get activity summary for all team members.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with team activity summary
|
||||||
|
"""
|
||||||
|
# Set default date range to current month if not provided
|
||||||
|
if not start_date:
|
||||||
|
today = timezone.now().date()
|
||||||
|
start_date = date(today.year, today.month, 1)
|
||||||
|
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Get all team members with reports in the date range
|
||||||
|
reports = Report.objects.filter(
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
).select_related('team_member')
|
||||||
|
|
||||||
|
# Summarize by team member
|
||||||
|
summary_by_member = {}
|
||||||
|
for report in reports:
|
||||||
|
member_id = str(report.team_member.id)
|
||||||
|
member_name = f"{report.team_member.first_name} {report.team_member.last_name}"
|
||||||
|
|
||||||
|
if member_id not in summary_by_member:
|
||||||
|
summary_by_member[member_id] = {
|
||||||
|
'name': member_name,
|
||||||
|
'report_count': 0,
|
||||||
|
'service_count': 0,
|
||||||
|
'project_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
summary_by_member[member_id]['report_count'] += 1
|
||||||
|
summary_by_member[member_id]['service_count'] += report.service_count
|
||||||
|
summary_by_member[member_id]['project_count'] += report.project_count
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_reports': reports.count(),
|
||||||
|
'member_summaries': summary_by_member
|
||||||
|
}
|
||||||
0
backend/core/repositories/revenues/__init__.py
Normal file
0
backend/core/repositories/revenues/__init__.py
Normal file
183
backend/core/repositories/revenues/revenues.py
Normal file
183
backend/core/repositories/revenues/revenues.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Repository for Revenue model operations.
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Revenue
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class RevenueRepository(BaseRepository[Revenue]):
|
||||||
|
"""
|
||||||
|
Repository for Revenue model operations.
|
||||||
|
"""
|
||||||
|
model = Revenue
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet[Revenue]:
|
||||||
|
"""
|
||||||
|
Get revenues by account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of revenues for the account
|
||||||
|
"""
|
||||||
|
return Revenue.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls) -> QuerySet[Revenue]:
|
||||||
|
"""
|
||||||
|
Get active revenues.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active revenues
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Revenue.objects.filter(
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_by_account(cls, account_id: str) -> Optional[Revenue]:
|
||||||
|
"""
|
||||||
|
Get active revenue for an account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Active revenue for the account or None if not found
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Revenue.objects.filter(
|
||||||
|
account_id=account_id,
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def end_revenue(cls, revenue_id: str) -> Optional[Revenue]:
|
||||||
|
"""
|
||||||
|
End a revenue record by setting its end date to today.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revenue_id: The revenue ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated revenue or None if not found
|
||||||
|
"""
|
||||||
|
return cls.update(revenue_id, {'end_date': timezone.now().date()})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Revenue]:
|
||||||
|
"""
|
||||||
|
Get revenues that were active during a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of revenues active during the date range
|
||||||
|
"""
|
||||||
|
queryset = Revenue.objects.all()
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
# Exclude revenues that ended before the start date
|
||||||
|
queryset = queryset.exclude(
|
||||||
|
end_date__isnull=False,
|
||||||
|
end_date__lt=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
# Exclude revenues that started after the end date
|
||||||
|
queryset = queryset.exclude(start_date__gt=end_date)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Revenue]:
|
||||||
|
"""
|
||||||
|
Search revenue records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching revenue records
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
['account__name']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_total_revenue(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float:
|
||||||
|
"""
|
||||||
|
Get total revenue for an account or all accounts within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: Optional account ID to filter by
|
||||||
|
start_date: Optional start date for the period
|
||||||
|
end_date: Optional end date for the period
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total revenue
|
||||||
|
"""
|
||||||
|
revenues = cls.get_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
revenues = revenues.filter(account_id=account_id)
|
||||||
|
|
||||||
|
# Calculate proportional revenue for records that span beyond the range
|
||||||
|
total_revenue = 0
|
||||||
|
|
||||||
|
for revenue in revenues:
|
||||||
|
# Get the effective start and end dates for the calculation
|
||||||
|
# (intersection of revenue period and requested period)
|
||||||
|
effective_start = max(revenue.start_date, start_date) if start_date else revenue.start_date
|
||||||
|
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
if revenue.end_date:
|
||||||
|
effective_end = min(revenue.end_date, end_date) if end_date else revenue.end_date
|
||||||
|
else:
|
||||||
|
effective_end = end_date if end_date else current_date
|
||||||
|
|
||||||
|
# Calculate days in range
|
||||||
|
days_in_range = (effective_end - effective_start).days + 1
|
||||||
|
|
||||||
|
# Calculate total days for revenue period
|
||||||
|
if revenue.end_date:
|
||||||
|
total_days = (revenue.end_date - revenue.start_date).days + 1
|
||||||
|
else:
|
||||||
|
total_days = (current_date - revenue.start_date).days + 1
|
||||||
|
|
||||||
|
# Avoid division by zero
|
||||||
|
if total_days <= 0:
|
||||||
|
total_days = 1
|
||||||
|
|
||||||
|
# Calculate proportional revenue
|
||||||
|
proportional_revenue = revenue.amount * (days_in_range / total_days)
|
||||||
|
|
||||||
|
total_revenue += proportional_revenue
|
||||||
|
|
||||||
|
return float(total_revenue)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_inactive(cls) -> QuerySet[Revenue]:
|
||||||
|
"""
|
||||||
|
Get inactive revenues.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of inactive revenues
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Revenue.objects.filter(end_date__lt=current_date)
|
||||||
0
backend/core/repositories/schedules/__init__.py
Normal file
0
backend/core/repositories/schedules/__init__.py
Normal file
174
backend/core/repositories/schedules/schedules.py
Normal file
174
backend/core/repositories/schedules/schedules.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Repository for Schedule model operations.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
from django.db.models import QuerySet, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Schedule, Service
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
class ScheduleRepository(BaseRepository[Schedule]):
|
||||||
|
"""
|
||||||
|
Repository for Schedule model operations.
|
||||||
|
"""
|
||||||
|
model = Schedule
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet[Schedule]:
|
||||||
|
"""
|
||||||
|
Get schedules.py by account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of schedules.py for the account
|
||||||
|
"""
|
||||||
|
return Schedule.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active(cls) -> QuerySet[Schedule]:
|
||||||
|
"""
|
||||||
|
Get active schedules.py.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active schedules.py
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Schedule.objects.filter(
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_by_account(cls, account_id: str) -> Optional[Schedule]:
|
||||||
|
"""
|
||||||
|
Get active schedule for an account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Active schedule for the account or None if not found
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Schedule.objects.filter(
|
||||||
|
account_id=account_id,
|
||||||
|
start_date__lte=current_date
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly
|
||||||
|
).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Schedule]:
|
||||||
|
"""
|
||||||
|
Search schedules
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching schedules
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
[
|
||||||
|
'account__name',
|
||||||
|
'account__customer__name'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_services(cls, schedule_id: str, start_date: date, end_date: date) -> List[Service]:
|
||||||
|
"""
|
||||||
|
Generate services based on a schedule for a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: The schedule ID
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of generated services
|
||||||
|
"""
|
||||||
|
|
||||||
|
schedule = cls.get_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return []
|
||||||
|
|
||||||
|
days_map = {
|
||||||
|
0: schedule.monday_service, # Monday is 0
|
||||||
|
1: schedule.tuesday_service, # Tuesday is 1
|
||||||
|
2: schedule.wednesday_service, # Wednesday is 2
|
||||||
|
3: schedule.thursday_service, # Thursday is 3
|
||||||
|
4: schedule.friday_service, # Friday is 4
|
||||||
|
5: schedule.saturday_service, # Saturday is 5
|
||||||
|
6: schedule.sunday_service # Sunday is 6
|
||||||
|
}
|
||||||
|
|
||||||
|
services = []
|
||||||
|
current_date = start_date
|
||||||
|
|
||||||
|
# Iterate through each day in the date range
|
||||||
|
while current_date <= end_date:
|
||||||
|
weekday = current_date.weekday()
|
||||||
|
|
||||||
|
# Check if service is scheduled for this day
|
||||||
|
if days_map[weekday] or (schedule.weekend_service and weekday >= 5):
|
||||||
|
# Create a service
|
||||||
|
service_data = {
|
||||||
|
'account': schedule.account, # Use the account object directly
|
||||||
|
'date': current_date,
|
||||||
|
'status': 'scheduled',
|
||||||
|
'deadline_start': datetime.combine(
|
||||||
|
current_date,
|
||||||
|
datetime.min.time()
|
||||||
|
).replace(hour=9), # Default to 9:00 AM
|
||||||
|
'deadline_end': datetime.combine(
|
||||||
|
current_date,
|
||||||
|
datetime.min.time()
|
||||||
|
).replace(hour=17) # Default to 5:00 PM
|
||||||
|
}
|
||||||
|
|
||||||
|
service = Service.objects.create(**service_data)
|
||||||
|
services.append(service)
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_inactive(cls) -> QuerySet[Schedule]:
|
||||||
|
"""
|
||||||
|
Get inactive schedules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of inactive schedules
|
||||||
|
"""
|
||||||
|
current_date = timezone.now().date()
|
||||||
|
return Schedule.objects.filter(end_date__lt=current_date)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Schedule]:
|
||||||
|
"""
|
||||||
|
Get schedules within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of schedules within the date range
|
||||||
|
"""
|
||||||
|
queryset = Schedule.objects.all()
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
queryset = queryset.filter(start_date__gte=start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
queryset = queryset.filter(end_date__lte=end_date)
|
||||||
|
|
||||||
|
return queryset
|
||||||
0
backend/core/repositories/services/__init__.py
Normal file
0
backend/core/repositories/services/__init__.py
Normal file
228
backend/core/repositories/services/services.py
Normal file
228
backend/core/repositories/services/services.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
Repository for Service model operations.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from datetime import date
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Service, Profile
|
||||||
|
from backend.core.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceRepository(BaseRepository[Service]):
|
||||||
|
"""
|
||||||
|
Repository for Service model operations.
|
||||||
|
"""
|
||||||
|
model = Service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_account(cls, account_id: str) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get services by account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: The account ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of services for the account
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(account_id=account_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_team_member(cls, profile_id: str) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get services by team member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of services assigned to the team member
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(team_members__id=profile_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_status(cls, status: str) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get services by status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: The service status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of services with the specified status
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(status=status)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get services within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of services within the date range
|
||||||
|
"""
|
||||||
|
return cls.filter_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_upcoming(cls) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get upcoming services.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of upcoming services
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(date__gte=timezone.now().date())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_past_due(cls) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get past due services.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of past due services
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(
|
||||||
|
date__lt=timezone.now().date(),
|
||||||
|
status__in=['scheduled', 'in_progress']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_for_today(cls) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Get services scheduled for today.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of services scheduled for today
|
||||||
|
"""
|
||||||
|
return Service.objects.filter(date=timezone.now().date())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Search services by account or notes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: The search term
|
||||||
|
search_fields: Optional list of fields to search (ignored, using predefined fields)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching services
|
||||||
|
"""
|
||||||
|
return super().search(
|
||||||
|
search_term,
|
||||||
|
['account__name', 'notes']
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filter_services(
|
||||||
|
cls,
|
||||||
|
account_id: str = None,
|
||||||
|
status: str = None,
|
||||||
|
date_from: date = None,
|
||||||
|
date_to: date = None,
|
||||||
|
team_member_id: str = None
|
||||||
|
) -> QuerySet[Service]:
|
||||||
|
"""
|
||||||
|
Filter services by multiple criteria.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_id: Filter by account ID
|
||||||
|
status: Filter by status
|
||||||
|
date_from: Filter by start date (inclusive)
|
||||||
|
date_to: Filter by end date (inclusive)
|
||||||
|
team_member_id: Filter by team member ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching services
|
||||||
|
"""
|
||||||
|
queryset = Service.objects.all()
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(date__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(date__lte=date_to)
|
||||||
|
|
||||||
|
if team_member_id:
|
||||||
|
queryset = queryset.filter(team_members__id=team_member_id)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Service:
|
||||||
|
"""
|
||||||
|
Create a service with team members.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Service data
|
||||||
|
team_member_ids: List of team member IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created service
|
||||||
|
"""
|
||||||
|
# Create the service
|
||||||
|
service = cls.create(data)
|
||||||
|
|
||||||
|
# Add team members
|
||||||
|
if team_member_ids:
|
||||||
|
team_members = Profile.objects.filter(id__in=team_member_ids)
|
||||||
|
service.team_members.set(team_members)
|
||||||
|
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_status(cls, service_id: str, status: str) -> Optional[Service]:
|
||||||
|
"""
|
||||||
|
Update service status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_id: The service ID
|
||||||
|
status: The new status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated service or None if not found
|
||||||
|
"""
|
||||||
|
service = cls.get_by_id(service_id)
|
||||||
|
if not service:
|
||||||
|
return None
|
||||||
|
|
||||||
|
service.status = status
|
||||||
|
|
||||||
|
# Set completed_at if status is 'completed'
|
||||||
|
if status == 'completed' and not service.completed_at:
|
||||||
|
service.completed_at = timezone.now()
|
||||||
|
|
||||||
|
service.save()
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def assign_team_members(cls, service_id: str, team_member_ids: List[str]) -> Optional[Service]:
|
||||||
|
"""
|
||||||
|
Assign team members to a service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_id: The service ID
|
||||||
|
team_member_ids: List of team member IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated service or None if not found
|
||||||
|
"""
|
||||||
|
service = cls.get_by_id(service_id)
|
||||||
|
if not service:
|
||||||
|
return None
|
||||||
|
|
||||||
|
team_members = Profile.objects.filter(id__in=team_member_ids)
|
||||||
|
service.team_members.set(team_members)
|
||||||
|
|
||||||
|
return service
|
||||||
16
backend/core/services/__init__.py
Normal file
16
backend/core/services/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Service layer for business logic.
|
||||||
|
"""
|
||||||
|
from backend.core.services.auth import AuthService
|
||||||
|
from backend.core.services.billing import BillingService
|
||||||
|
from backend.core.services.scheduling import SchedulingService
|
||||||
|
from backend.core.services.reporting import ReportingService
|
||||||
|
from backend.core.services.notifications import NotificationService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'AuthService',
|
||||||
|
'BillingService',
|
||||||
|
'SchedulingService',
|
||||||
|
'ReportingService',
|
||||||
|
'NotificationService'
|
||||||
|
]
|
||||||
98
backend/core/services/auth.py
Normal file
98
backend/core/services/auth.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Authentication and authorization services.
|
||||||
|
"""
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.models import Profile
|
||||||
|
from backend.core.repositories.profiles.profiles import ProfileRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""
|
||||||
|
Service for authentication and authorization operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate_user(username: str, password: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Authenticate a user with username and password.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Username
|
||||||
|
password: Password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object if authenticated, None otherwise
|
||||||
|
"""
|
||||||
|
return authenticate(username=username, password=password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_profile(user_id: int) -> Optional[Profile]:
|
||||||
|
"""
|
||||||
|
Get a user's profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Profile object or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
return ProfileRepository.get_by_user(user)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_permission(user: User, permission: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has a specific permission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User object
|
||||||
|
permission: Permission string (e.g., 'core.add_customer')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has permission, False otherwise
|
||||||
|
"""
|
||||||
|
return user.has_perm(permission)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def log_login(user: User) -> None:
|
||||||
|
"""
|
||||||
|
Log a user login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User object
|
||||||
|
"""
|
||||||
|
user.last_login = timezone.now()
|
||||||
|
user.save(update_fields=['last_login'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_user(user_data: Dict[str, Any], profile_data: Dict[str, Any]) -> User:
|
||||||
|
"""
|
||||||
|
Create a new user with profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_data: User data (username, email, password, etc.)
|
||||||
|
profile_data: Profile data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created User object
|
||||||
|
"""
|
||||||
|
# Create the user
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=user_data['username'],
|
||||||
|
email=user_data.get('email', ''),
|
||||||
|
password=user_data['password'],
|
||||||
|
first_name=user_data.get('first_name', ''),
|
||||||
|
last_name=user_data.get('last_name', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the profile
|
||||||
|
profile_data['user'] = user
|
||||||
|
ProfileRepository.create(profile_data)
|
||||||
|
|
||||||
|
return user
|
||||||
145
backend/core/services/billing.py
Normal file
145
backend/core/services/billing.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Billing and invoice services.
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
from backend.core.models import Account, Project, Invoice
|
||||||
|
from backend.core.repositories.customers.customers import CustomerRepository
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.projects.projects import ProjectRepository
|
||||||
|
from backend.core.repositories.invoices.invoices import InvoiceRepository
|
||||||
|
from backend.core.repositories.revenues.revenues import RevenueRepository
|
||||||
|
|
||||||
|
|
||||||
|
class BillingService:
|
||||||
|
"""
|
||||||
|
Service for billing and invoice operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def generate_invoice_for_customer(
|
||||||
|
customer_id: str,
|
||||||
|
invoice_date: date = None,
|
||||||
|
include_accounts: bool = True,
|
||||||
|
include_projects: bool = True,
|
||||||
|
account_ids: List[str] = None,
|
||||||
|
project_ids: List[str] = None
|
||||||
|
) -> Optional[Invoice]:
|
||||||
|
"""
|
||||||
|
Generate an invoice for a customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Customer ID
|
||||||
|
invoice_date: Invoice date (defaults to today)
|
||||||
|
include_accounts: Whether to include all accounts
|
||||||
|
include_projects: Whether to include all completed projects
|
||||||
|
account_ids: Specific account IDs to include (if not including all)
|
||||||
|
project_ids: Specific project IDs to include (if not including all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated invoice or None if no items to invoice
|
||||||
|
"""
|
||||||
|
# Get the customer
|
||||||
|
customer = CustomerRepository.get_by_id(customer_id)
|
||||||
|
if not customer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use today's date if not specified
|
||||||
|
if not invoice_date:
|
||||||
|
invoice_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Get accounts to invoice
|
||||||
|
if include_accounts:
|
||||||
|
# Get all active accounts
|
||||||
|
accounts_to_invoice = AccountRepository.get_active_by_customer(customer_id)
|
||||||
|
elif account_ids:
|
||||||
|
# Get specific accounts
|
||||||
|
accounts_to_invoice = Account.objects.filter(id__in=account_ids)
|
||||||
|
else:
|
||||||
|
accounts_to_invoice = Account.objects.none()
|
||||||
|
|
||||||
|
# Get projects to invoice
|
||||||
|
if include_projects:
|
||||||
|
# Get all completed projects without invoices
|
||||||
|
projects_to_invoice = ProjectRepository.get_without_invoice()
|
||||||
|
elif project_ids:
|
||||||
|
# Get specific projects
|
||||||
|
projects_to_invoice = Project.objects.filter(id__in=project_ids)
|
||||||
|
else:
|
||||||
|
projects_to_invoice = Project.objects.none()
|
||||||
|
|
||||||
|
# Calculate total amount
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
# Add account revenue
|
||||||
|
for account in accounts_to_invoice:
|
||||||
|
active_revenue = RevenueRepository.get_active_by_account(account.id)
|
||||||
|
if active_revenue:
|
||||||
|
# For monthly billing, divide annual amount by 12 or use the full amount
|
||||||
|
total_amount += active_revenue.amount
|
||||||
|
|
||||||
|
# Add project amounts
|
||||||
|
for project in projects_to_invoice:
|
||||||
|
total_amount += project.amount
|
||||||
|
|
||||||
|
# Don't create an invoice if there's nothing to invoice
|
||||||
|
if total_amount <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create the invoice
|
||||||
|
invoice_data = {
|
||||||
|
'customer': customer_id,
|
||||||
|
'date': invoice_date,
|
||||||
|
'status': 'draft',
|
||||||
|
'total_amount': total_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert querysets to list of IDs
|
||||||
|
account_ids_list = [str(account.id) for account in accounts_to_invoice]
|
||||||
|
project_ids_list = [str(project.id) for project in projects_to_invoice]
|
||||||
|
|
||||||
|
# Create the invoice with items
|
||||||
|
invoice = InvoiceRepository.create_with_items(
|
||||||
|
invoice_data,
|
||||||
|
account_ids_list,
|
||||||
|
project_ids_list
|
||||||
|
)
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_overdue_invoices() -> int:
|
||||||
|
"""
|
||||||
|
Identify and mark overdue invoices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of invoices marked as overdue
|
||||||
|
"""
|
||||||
|
thirty_days_ago = timezone.now().date() - timedelta(days=30)
|
||||||
|
|
||||||
|
# Get sent invoices that are more than 30 days old
|
||||||
|
overdue_invoices = Invoice.objects.filter(
|
||||||
|
status='sent',
|
||||||
|
date__lt=thirty_days_ago
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update them to overdue status
|
||||||
|
count = overdue_invoices.update(status='overdue')
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_outstanding_balance(customer_id: str) -> float:
|
||||||
|
"""
|
||||||
|
Get outstanding balance for a customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total outstanding amount
|
||||||
|
"""
|
||||||
|
return InvoiceRepository.get_total_outstanding(customer_id)
|
||||||
157
backend/core/services/notifications.py
Normal file
157
backend/core/services/notifications.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Notification services for emails, alerts, and reminders.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from backend.core.repositories.services.services import ServiceRepository
|
||||||
|
from backend.core.repositories.invoices.invoices import InvoiceRepository
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
"""
|
||||||
|
Service for sending notifications and alerts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_email(
|
||||||
|
recipient_email: str,
|
||||||
|
subject: str,
|
||||||
|
template_name: str,
|
||||||
|
context: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send an email using a template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipient_email: Recipient email address
|
||||||
|
subject: Email subject
|
||||||
|
template_name: Template name
|
||||||
|
context: Template context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
html_content = render_to_string(f"emails/{template_name}.html", context)
|
||||||
|
text_content = render_to_string(f"emails/{template_name}.txt", context)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=text_content,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[recipient_email],
|
||||||
|
html_message=html_content,
|
||||||
|
fail_silently=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error
|
||||||
|
print(f"Error sending email: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_service_reminders() -> int:
|
||||||
|
"""
|
||||||
|
Send service reminders for tomorrow's services.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of reminders sent
|
||||||
|
"""
|
||||||
|
tomorrow = timezone.now().date() + timedelta(days=1)
|
||||||
|
|
||||||
|
# Get services scheduled for tomorrow
|
||||||
|
services = ServiceRepository.get_all(date=tomorrow)
|
||||||
|
|
||||||
|
reminder_count = 0
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
# Get the account
|
||||||
|
account = service.account
|
||||||
|
|
||||||
|
# Skip if no account (shouldn't happen)
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get team members
|
||||||
|
team_members = service.team_members.all()
|
||||||
|
|
||||||
|
# Send reminder to each team member
|
||||||
|
for member in team_members:
|
||||||
|
# Skip if no email
|
||||||
|
if not member.email:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'service': service,
|
||||||
|
'account': account,
|
||||||
|
'team_member': member,
|
||||||
|
'service_time': f"{service.deadline_start.strftime('%I:%M %p')} - {service.deadline_end.strftime('%I:%M %p')}" if service.deadline_start and service.deadline_end else "All day"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
success = NotificationService.send_email(
|
||||||
|
recipient_email=member.email,
|
||||||
|
subject=f"Service Reminder: {account.name} - {tomorrow.strftime('%m/%d/%Y')}",
|
||||||
|
template_name="service_reminder",
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
reminder_count += 1
|
||||||
|
|
||||||
|
return reminder_count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_overdue_invoice_reminders() -> int:
|
||||||
|
"""
|
||||||
|
Send reminders for overdue invoices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of reminders sent
|
||||||
|
"""
|
||||||
|
# Get overdue invoices
|
||||||
|
overdue_invoices = InvoiceRepository.get_overdue()
|
||||||
|
|
||||||
|
reminder_count = 0
|
||||||
|
|
||||||
|
for invoice in overdue_invoices:
|
||||||
|
# Get the customer
|
||||||
|
customer = invoice.customer
|
||||||
|
|
||||||
|
# Skip if no customer (shouldn't happen)
|
||||||
|
if not customer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if no billing email
|
||||||
|
if not customer.billing_email:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate days overdue
|
||||||
|
days_overdue = (timezone.now().date() - invoice.date).days - 30
|
||||||
|
|
||||||
|
# Prepare context
|
||||||
|
context = {
|
||||||
|
'invoice': invoice,
|
||||||
|
'customer': customer,
|
||||||
|
'days_overdue': days_overdue,
|
||||||
|
'total_amount': invoice.total_amount
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
success = NotificationService.send_email(
|
||||||
|
recipient_email=customer.billing_email,
|
||||||
|
subject=f"Overdue Invoice Reminder: {invoice.id}",
|
||||||
|
template_name="overdue_invoice_reminder",
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
reminder_count += 1
|
||||||
|
|
||||||
|
return reminder_count
|
||||||
160
backend/core/services/reporting.py
Normal file
160
backend/core/services/reporting.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
Reporting services for analytics and business intelligence.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from backend.core.repositories.services.services import ServiceRepository
|
||||||
|
from backend.core.repositories.projects.projects import ProjectRepository
|
||||||
|
from backend.core.repositories.invoices.invoices import InvoiceRepository
|
||||||
|
from backend.core.repositories.reports.reports import ReportRepository
|
||||||
|
from backend.core.repositories.revenues.revenues import RevenueRepository
|
||||||
|
from backend.core.repositories.labor.labor import LaborRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingService:
|
||||||
|
"""
|
||||||
|
Service for generating business reports and analytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_monthly_summary(year: int, month: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get monthly business summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
year: Year
|
||||||
|
month: Month (1-12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with monthly summary data
|
||||||
|
"""
|
||||||
|
# Calculate date range for the month
|
||||||
|
start_date = date(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
end_date = date(year + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end_date = date(year, month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
|
# Get services in the month
|
||||||
|
services = ServiceRepository.get_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Get projects in the month
|
||||||
|
projects = ProjectRepository.get_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Get invoices in the month
|
||||||
|
invoices = InvoiceRepository.get_by_date_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Get paid invoices in the month
|
||||||
|
paid_invoices = invoices.filter(status='paid')
|
||||||
|
|
||||||
|
# Calculate total revenue and labor for the month
|
||||||
|
total_revenue = RevenueRepository.get_total_revenue(None, start_date, end_date)
|
||||||
|
total_labor = LaborRepository.get_total_labor_cost(None, start_date, end_date)
|
||||||
|
|
||||||
|
# Calculate profit
|
||||||
|
profit = total_revenue - total_labor
|
||||||
|
|
||||||
|
# Service statistics
|
||||||
|
service_stats = {
|
||||||
|
'total': services.count(),
|
||||||
|
'completed': services.filter(status='completed').count(),
|
||||||
|
'cancelled': services.filter(status='cancelled').count(),
|
||||||
|
'completion_rate': services.filter(
|
||||||
|
status='completed').count() / services.count() if services.count() > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Project statistics
|
||||||
|
project_stats = {
|
||||||
|
'total': projects.count(),
|
||||||
|
'completed': projects.filter(status='completed').count(),
|
||||||
|
'cancelled': projects.filter(status='cancelled').count(),
|
||||||
|
'completion_rate': projects.filter(
|
||||||
|
status='completed').count() / projects.count() if projects.count() > 0 else 0,
|
||||||
|
'total_amount': sum(p.amount for p in projects),
|
||||||
|
'total_labor': sum(p.labor for p in projects),
|
||||||
|
'profit': sum(p.amount - p.labor for p in projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Invoice statistics
|
||||||
|
invoice_stats = {
|
||||||
|
'total': invoices.count(),
|
||||||
|
'paid': paid_invoices.count(),
|
||||||
|
'payment_rate': paid_invoices.count() / invoices.count() if invoices.count() > 0 else 0,
|
||||||
|
'total_amount': sum(i.total_amount for i in invoices),
|
||||||
|
'paid_amount': sum(i.total_amount for i in paid_invoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'period': {
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
'start_date': start_date.isoformat(),
|
||||||
|
'end_date': end_date.isoformat()
|
||||||
|
},
|
||||||
|
'financials': {
|
||||||
|
'revenue': total_revenue,
|
||||||
|
'labor': total_labor,
|
||||||
|
'profit': profit,
|
||||||
|
'profit_margin': (profit / total_revenue) * 100 if total_revenue > 0 else 0
|
||||||
|
},
|
||||||
|
'services': service_stats,
|
||||||
|
'projects': project_stats,
|
||||||
|
'invoices': invoice_stats
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_team_performance(start_date: date = None, end_date: date = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get team member performance metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of team member performance data
|
||||||
|
"""
|
||||||
|
# Set default date range to current month if not provided
|
||||||
|
if not start_date:
|
||||||
|
today = timezone.now().date()
|
||||||
|
start_date = date(today.year, today.month, 1)
|
||||||
|
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Get team members with activity
|
||||||
|
team_activity = ReportRepository.get_team_summary(start_date, end_date)
|
||||||
|
|
||||||
|
team_performance = []
|
||||||
|
for member_id, summary in team_activity.get('member_summaries', {}).items():
|
||||||
|
# Get services completed by the team member
|
||||||
|
services_completed = ServiceRepository.get_by_team_member(member_id).filter(
|
||||||
|
status='completed',
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get projects completed by the team member
|
||||||
|
projects_completed = ProjectRepository.get_by_team_member(member_id).filter(
|
||||||
|
status='completed',
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
performance = {
|
||||||
|
'member_id': member_id,
|
||||||
|
'name': summary['name'],
|
||||||
|
'reports_submitted': summary['report_count'],
|
||||||
|
'services_completed': services_completed,
|
||||||
|
'projects_completed': projects_completed,
|
||||||
|
'total_work_items': services_completed + projects_completed
|
||||||
|
}
|
||||||
|
|
||||||
|
team_performance.append(performance)
|
||||||
|
|
||||||
|
# Sort by total work items descending
|
||||||
|
team_performance.sort(key=lambda x: x['total_work_items'], reverse=True)
|
||||||
|
|
||||||
|
return team_performance
|
||||||
123
backend/core/services/scheduling.py
Normal file
123
backend/core/services/scheduling.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Scheduling services for services and projects.
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from datetime import date
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
from backend.core.models import Service
|
||||||
|
from backend.core.repositories.accounts.accounts import AccountRepository
|
||||||
|
from backend.core.repositories.schedules.schedules import ScheduleRepository
|
||||||
|
from backend.core.repositories.services.services import ServiceRepository
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingService:
|
||||||
|
"""
|
||||||
|
Service for scheduling operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def generate_services_from_schedule(
|
||||||
|
schedule_id: str,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date
|
||||||
|
) -> List[Service]:
|
||||||
|
"""
|
||||||
|
Generate services based on a schedule for a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule ID
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of generated services
|
||||||
|
"""
|
||||||
|
return ScheduleRepository.generate_services(schedule_id, start_date, end_date)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def generate_services_for_all_accounts(
|
||||||
|
start_date: date,
|
||||||
|
end_date: date
|
||||||
|
) -> Dict[str, List[Service]]:
|
||||||
|
"""
|
||||||
|
Generate services for all accounts with active schedules.py.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date (inclusive)
|
||||||
|
end_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping account IDs to lists of generated services
|
||||||
|
"""
|
||||||
|
# Get all active schedules.py
|
||||||
|
active_schedules = ScheduleRepository.get_active()
|
||||||
|
|
||||||
|
# Generate services for each schedule
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for schedule in active_schedules:
|
||||||
|
services = ScheduleRepository.generate_services(
|
||||||
|
str(schedule.id), # Convert UUID to string if needed
|
||||||
|
start_date,
|
||||||
|
end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Access account.id instead of account_id
|
||||||
|
account_id = str(schedule.account.id)
|
||||||
|
result[account_id] = services
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_daily_service_schedule(date_str: str = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get schedule of services for a specific day.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: Date string (YYYY-MM-DD) or today if None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of services with account and team member info
|
||||||
|
"""
|
||||||
|
# Parse date or use today
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
target_date = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
# Invalid date format, use today
|
||||||
|
target_date = timezone.now().date()
|
||||||
|
else:
|
||||||
|
target_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Get services for the day
|
||||||
|
services = ServiceRepository.get_all(date=target_date)
|
||||||
|
|
||||||
|
# Format the result
|
||||||
|
result = []
|
||||||
|
for service in services:
|
||||||
|
# Access account.id instead of account_id
|
||||||
|
account = AccountRepository.get_by_id(str(service.account.id))
|
||||||
|
|
||||||
|
service_data = {
|
||||||
|
'id': str(service.id),
|
||||||
|
'account_name': account.name if account else 'Unknown',
|
||||||
|
'account_address': f"{account.street_address}, {account.city}, {account.state}" if account else 'Unknown',
|
||||||
|
'status': service.status,
|
||||||
|
'deadline_start': service.deadline_start.strftime('%H:%M') if service.deadline_start else None,
|
||||||
|
'deadline_end': service.deadline_end.strftime('%H:%M') if service.deadline_end else None,
|
||||||
|
'team_members': [
|
||||||
|
{
|
||||||
|
'id': str(tm.id),
|
||||||
|
'name': f"{tm.first_name} {tm.last_name}",
|
||||||
|
'role': tm.role
|
||||||
|
}
|
||||||
|
for tm in service.team_members.all()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(service_data)
|
||||||
|
|
||||||
|
return result
|
||||||
12
backend/core/utils/__init__.py
Normal file
12
backend/core/utils/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from backend.core.utils.validators import is_valid_uuid, is_valid_email, validate_decimal_amount
|
||||||
|
from backend.core.utils.helpers import generate_uuid, format_date, format_datetime, parse_date
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'is_valid_uuid',
|
||||||
|
'is_valid_email',
|
||||||
|
'validate_decimal_amount',
|
||||||
|
'generate_uuid',
|
||||||
|
'format_date',
|
||||||
|
'format_datetime',
|
||||||
|
'parse_date',
|
||||||
|
]
|
||||||
327
backend/core/utils/helpers.py
Normal file
327
backend/core/utils/helpers.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Helper functions for the API.
|
||||||
|
Provides utility functions for data transformation, processing, and other common tasks.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def generate_uuid() -> str:
|
||||||
|
"""
|
||||||
|
Generate a new UUID string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A new UUID string.
|
||||||
|
"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(date_obj: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Format a datetime object as a date string (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_obj: The datetime object to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The formatted date string.
|
||||||
|
"""
|
||||||
|
return date_obj.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(datetime_obj: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Format a datetime object as an ISO string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datetime_obj: The datetime object to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The formatted datetime string.
|
||||||
|
"""
|
||||||
|
return datetime_obj.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Parse a date string into a datetime object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: The date string to parse (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[datetime]: The parsed datetime or None if invalid.
|
||||||
|
"""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(datetime_str: str) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Parse a datetime string into a datetime object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datetime_str: The datetime string to parse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[datetime]: The parsed datetime or None if invalid.
|
||||||
|
"""
|
||||||
|
if not datetime_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(datetime_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def to_camel_case(snake_str: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a snake_case string to camelCase.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
snake_str: The snake_case string to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The camelCase string.
|
||||||
|
"""
|
||||||
|
components = snake_str.split('_')
|
||||||
|
return components[0] + ''.join(x.title() for x in components[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def to_snake_case(camel_str: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a camelCase string to snake_case.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camel_str: The camelCase string to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The snake_case string.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
# Use regex to find all capital letters and insert underscore before them
|
||||||
|
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str)
|
||||||
|
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def convert_keys_to_camel_case(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert all dictionary keys from snake_case to camelCase.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The dictionary with snake_case keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: A new dictionary with camelCase keys.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = convert_keys_to_camel_case(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
value = [
|
||||||
|
convert_keys_to_camel_case(item) if isinstance(item, dict) else item
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
result[to_camel_case(key)] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def convert_keys_to_snake_case(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert all dictionary keys from camelCase to snake_case.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The dictionary with camelCase keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: A new dictionary with snake_case keys.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = convert_keys_to_snake_case(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
value = [
|
||||||
|
convert_keys_to_snake_case(item) if isinstance(item, dict) else item
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
result[to_snake_case(key)] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def filter_none_values(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Remove all None values from a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The dictionary to filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: A new dictionary without None values.
|
||||||
|
"""
|
||||||
|
return {k: v for k, v in data.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_start_end(date: datetime) -> tuple:
|
||||||
|
"""
|
||||||
|
Get the start and end dates of the week containing the given date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: The date to get the week for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (start_date, end_date) of the week.
|
||||||
|
"""
|
||||||
|
# Monday is 0 and Sunday is 6
|
||||||
|
start = date - timedelta(days=date.weekday())
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_start_end(date: datetime) -> tuple:
|
||||||
|
"""
|
||||||
|
Get the start and end dates of the month containing the given date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: The date to get the month for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (start_date, end_date) of the month.
|
||||||
|
"""
|
||||||
|
start = date.replace(day=1)
|
||||||
|
# Get the last day by going to next month and subtracting one day
|
||||||
|
if date.month == 12:
|
||||||
|
end = datetime(date.year + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end = datetime(date.year, date.month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_range(start_date: str, end_date: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get a list of date strings between start_date and end_date (inclusive).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: The start date string (YYYY-MM-DD).
|
||||||
|
end_date: The end date string (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: List of date strings in the range.
|
||||||
|
"""
|
||||||
|
start = parse_date(start_date)
|
||||||
|
end = parse_date(end_date)
|
||||||
|
|
||||||
|
if not start or not end:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if start > end:
|
||||||
|
return []
|
||||||
|
|
||||||
|
date_list = []
|
||||||
|
current = start
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
date_list.append(format_date(current))
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return date_list
|
||||||
|
|
||||||
|
|
||||||
|
def date_diff_in_days(start_date: str, end_date: str) -> int:
|
||||||
|
"""
|
||||||
|
Calculate the difference in days between two date strings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: The start date string (YYYY-MM-DD).
|
||||||
|
end_date: The end date string (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: The number of days between the dates. Returns 0 if dates are invalid.
|
||||||
|
"""
|
||||||
|
start = parse_date(start_date)
|
||||||
|
end = parse_date(end_date)
|
||||||
|
|
||||||
|
if not start or not end:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return (end - start).days
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_json(data: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Convert a dictionary to a JSON string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The dictionary to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The JSON string.
|
||||||
|
"""
|
||||||
|
return json.dumps(data, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def json_to_dict(json_str: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert a JSON string to a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_str: The JSON string to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: The dictionary. Returns empty dict if JSON is invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return json.loads(json_str)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def paginate_results(items: List[Any], page: int = 1, page_size: int = 10) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Paginate a list of items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: The list of items to paginate.
|
||||||
|
page: The page number (1-based).
|
||||||
|
page_size: The number of items per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: A dictionary with pagination info and results.
|
||||||
|
"""
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
if page_size < 1:
|
||||||
|
page_size = 10
|
||||||
|
|
||||||
|
total_items = len(items)
|
||||||
|
total_pages = (total_items + page_size - 1) // page_size
|
||||||
|
|
||||||
|
start_idx = (page - 1) * page_size
|
||||||
|
end_idx = min(start_idx + page_size, total_items)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'total_items': total_items,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'has_previous': page > 1,
|
||||||
|
'has_next': page < total_pages,
|
||||||
|
'items': items[start_idx:end_idx]
|
||||||
|
}
|
||||||
199
backend/core/utils/validators.py
Normal file
199
backend/core/utils/validators.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Validators for API data validation.
|
||||||
|
Provides reusable validation functions for domain models data.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_uuid(value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a value is a valid UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The value to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the value is a valid UUID, False otherwise.
|
||||||
|
"""
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
uuid.UUID(value)
|
||||||
|
return True
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email(email: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate an email address format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the email format is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
if not email or not isinstance(email, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Simple regex for email validation
|
||||||
|
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return bool(re.match(email_pattern, email))
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_phone(phone: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate a phone number format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phone: The phone number to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the phone format is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
if not phone or not isinstance(phone, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove common formatting characters
|
||||||
|
cleaned = re.sub(r'[\s\-\(\)\.]+', '', phone)
|
||||||
|
|
||||||
|
# Check if it's a valid format (allows international format)
|
||||||
|
phone_pattern = r'^(\+\d{1,3})?(\d{10,15})$'
|
||||||
|
return bool(re.match(phone_pattern, cleaned))
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_date(date_val: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Validate a date string in ISO format (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_val: The date to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the date is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
return isinstance(date_val, date)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_datetime(datetime_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate a datetime string in ISO format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datetime_str: The datetime string to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the datetime format is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
if not datetime_str or not isinstance(datetime_str, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(datetime_str)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_fields(data: Dict[str, Any], required_fields: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate that all required fields are present and not empty in the data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing the data to validate.
|
||||||
|
required_fields: List of field names that are required.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: List of missing field names. Empty if all required fields are present.
|
||||||
|
"""
|
||||||
|
missing_fields = []
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data or data[field] is None or (isinstance(data[field], str) and not data[field].strip()):
|
||||||
|
missing_fields.append(field)
|
||||||
|
|
||||||
|
return missing_fields
|
||||||
|
|
||||||
|
|
||||||
|
def validate_model_exists(model_id: str, model_type: str,
|
||||||
|
repository_method: callable, error_message: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate that a model with the given ID exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_id: The ID of the model to check.
|
||||||
|
model_type: The type of model (e.g., 'customer', 'account').
|
||||||
|
repository_method: Repository method to fetch the model.
|
||||||
|
error_message: Custom error message. If None, a default message is used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Dictionary with 'valid' and 'error' keys.
|
||||||
|
"""
|
||||||
|
if not is_valid_uuid(model_id):
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f"Invalid {model_type} ID format."
|
||||||
|
}
|
||||||
|
|
||||||
|
model = repository_method(model_id)
|
||||||
|
if not model:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': error_message or f"{model_type.capitalize()} with ID {model_id} not found."
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'model': model,
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_decimal_amount(amount: Union[float, str, int], field_name: str = 'amount') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate a decimal amount (e.g., for money).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount: The amount to validate.
|
||||||
|
field_name: The name of the field being validated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Dictionary with 'valid' and 'error' keys.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert to float if it's a string
|
||||||
|
if isinstance(amount, str):
|
||||||
|
amount = float(amount)
|
||||||
|
|
||||||
|
# Check if it's a number
|
||||||
|
if not isinstance(amount, (int, float)):
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f"{field_name} must be a number."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if it's non-negative
|
||||||
|
if amount < 0:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f"{field_name} cannot be negative."
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'amount': float(amount),
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f"Invalid {field_name} format."
|
||||||
|
}
|
||||||
0
backend/graphql_api/__init__.py
Normal file
0
backend/graphql_api/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user