Merge pull request 'production' (#1) from production into master

Reviewed-on: #1
This commit is contained in:
sipo 2026-01-27 18:02:43 +00:00
commit fb7704027b
553 changed files with 60382 additions and 21251 deletions

29
.dockerignore Normal file
View File

@ -0,0 +1,29 @@
.git
.gitignore
.github
.gitattributes
.env
.env.*
!.env.production.example
node_modules
npm-debug.log
vendor
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
bootstrap/cache/*
public/storage
public/hot
*.md
!README.md
tests
.phpunit.result.cache
phpunit.xml
docker-compose*.yml
.editorconfig
.styleci.yml
*.log
.DS_Store
Thumbs.db

82
.env.local.example Normal file
View File

@ -0,0 +1,82 @@
APP_NAME="Teren App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8080
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD=local_password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# Mail (Mailpit for local testing)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Vite
VITE_APP_NAME="${APP_NAME}"
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/casesNEL=null
LOG_LEVEL=debug
# Vite
VITE_DEV_SERVER_KEY=
VITE_DEV_SERVER_CERT=

88
.env.production.example Normal file
View File

@ -0,0 +1,88 @@
APP_NAME="Teren App"
APP_ENV=production
APP_KEY= # Generate with: php artisan key:generate
APP_DEBUG=false
APP_TIMEZONE=UTC
APP_URL=https://example.com # Your domain
APP_LOCALE=sl
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=sl_SI
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
BCRYPT_ROUNDS=12
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=teren_app
DB_USERNAME=teren_user
DB_PASSWORD= # Generate a strong password
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
# Cache
CACHE_STORE=redis
# pgAdmin
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD= # Generate a strong password
# WireGuard VPN (REQUIRED - app is VPN-only)
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
# Mail (configure as needed)
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PA
SCOUT_DRIVER=database
SCOUT_PREFIX=
SCOUT_QUEUE=true
# Sanctum
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
# Vite
VITE_APP_NAME="${APP_NAME}"
# LibreOffice for document previews (Docker container path)
LIBREOFFICE_BIN=/usr/bin/soffice
# Storage configuration for generated previews
FILES_PREVIEW_DISK=public
FILES_PREVIEW_BASE=previews/cases
# Logging
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error

View File

@ -22,7 +22,7 @@ ## Foundational Context
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- @inertiajs/vue3 (INERTIA) - v2
- tailwindcss (TAILWINDCSS) - v3
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
@ -359,11 +359,39 @@ ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v3 rules ===
=== tailwindcss/v4 rules ===
## Tailwind 3
## Tailwind 4
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===

20
.gitignore vendored
View File

@ -19,3 +19,23 @@ yarn-error.log
/.idea
/.vscode
/.zed
/shadcn-vue
# Development/Testing Scripts
check-*.php
test-*.php
fix-*.php
clean-*.php
mark-*.php
# Development Documentation
IMPORT_*.md
V2_*.md
REPORTS_*.md
DEDUPLICATION_*.md
# Docker Local Testing
docker-compose.local.yaml
docker-compose.override.yaml
.env.local
.env.docker

654
DEDUPLICATION_PLAN_V2.md Normal file
View File

@ -0,0 +1,654 @@
# V2 Deduplication Implementation Plan
## Problem Statement
Currently, ImportServiceV2 allows duplicate Person records and related entities when:
1. A ClientCase with the same `client_ref` already exists in the database
2. A Contract with the same `reference` already exists for the client
3. Person data is present in the import row
This causes data duplication because V2 doesn't check for existing entities before creating Person and related entities (addresses, phones, emails, activities).
## V1 Deduplication Strategy (Analysis)
### V1 Person Resolution Order (Lines 913-1015)
V1 follows this hierarchical lookup before creating a new Person:
1. **Contract Reference Lookup** (Lines 913-922)
- If contract.reference exists → Find existing Contract → Get ClientCase → Get Person
- Prevents creating new Person when Contract already exists
2. **Account Result Derivation** (Lines 924-936)
- If Account processing resolved/created a Contract → Get ClientCase → Get Person
3. **ClientCase.client_ref Lookup** (Lines 937-945)
- If client_ref exists → Find ClientCase by (client_id, client_ref) → Get Person
- Prevents creating new Person when ClientCase already exists
4. **Contact Values Lookup** (Lines 949-964)
- Check Email.value → Get Person
- Check PersonPhone.nu → Get Person
- Check PersonAddress.address → Get Person
5. **Person Identifiers Lookup** (Lines 1005-1007)
- Check tax_number, ssn, etc. via `findPersonIdByIdentifiers()`
6. **Create New Person** (Lines 1009-1011)
- Only if all above fail
### V1 Contract Deduplication (Lines 2158-2196)
**Early Contract Lookup** (Lines 2168-2180):
```php
// Try to find existing contract EARLY by (client_id, reference)
// across all cases to prevent duplicates
$existing = Contract::query()->withTrashed()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
```
**ClientCase Reuse Logic** (Lines 2214-2228):
```php
// If we have a client and client_ref, try to reuse existing case
// to avoid creating extra persons
if ($clientId && $clientRef) {
$cc = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($cc) {
// Reuse this case
$clientCaseId = $cc->id;
// If case has no person yet, set it
if (!$cc->person_id) {
// Find or create person and attach
}
}
}
```
### Key V1 Design Principles
**Resolution before Creation** - Always check for existing entities first
**Chain Derivation** - Contract → ClientCase → Person (reuse existing chain)
**Contact Deduplication** - Match by email/phone/address before creating
**Client-Scoped Lookups** - All queries scoped to import.client_id
**Minimal Person Creation** - Only create Person as last resort
## V2 Current Architecture Issues
### Problem Areas
1. **PersonHandler** (`app/Services/Import/Handlers/PersonHandler.php`)
- Currently only deduplicates by tax_number/ssn (Lines 38-58)
- Doesn't check if Person exists via Contract/ClientCase
- Processes independently without context awareness
2. **ClientCaseHandler** (`app/Services/Import/Handlers/ClientCaseHandler.php`)
- Correctly resolves by client_ref (Lines 16-27)
- But doesn't prevent PersonHandler from running afterwards
3. **ContractHandler** (`app/Services/Import/Handlers/ContractHandler.php`)
- Missing early resolution logic
- Doesn't derive Person from existing Contract chain
4. **Processing Order Issue**
- Current priority: Person(100) → ClientCase(95) → Contract(90)
- Person runs BEFORE we know if ClientCase/Contract exists
- Should be reversed: Contract → ClientCase → Person
## V2 Deduplication Plan
### Phase 1: Reverse Processing Order ✅
**Change entity priorities in database seeder:**
```php
// NEW ORDER (descending priority)
Contract: 100
ClientCase: 95
Person: 90
Email: 80
Address: 70
Phone: 60
Account: 50
Payment: 40
Activity: 30
```
**Rationale:** Process high-level entities first (Contract, ClientCase) so we can derive Person from existing chains.
### Phase 2: Early Resolution Service 🔧
**Create:** `app/Services/Import/EntityResolutionService.php`
This service will be called BEFORE handlers process entities:
```php
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*/
public function resolvePersonFromContext(
Import $import,
array $mapped,
array $context
): ?int {
// 1. Check if Contract already processed
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->getPersonFromContract($contract);
if ($personId) return $personId;
}
// 2. Check if ClientCase already processed
if ($clientCase = $context['client_case']['entity'] ?? null) {
if ($clientCase->person_id) {
return $clientCase->person_id;
}
}
// 3. Check for existing Contract by reference
if ($contractRef = $mapped['contract']['reference'] ?? null) {
$personId = $this->getPersonFromContractReference(
$import->client_id,
$contractRef
);
if ($personId) return $personId;
}
// 4. Check for existing ClientCase by client_ref
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
$personId = $this->getPersonFromClientRef(
$import->client_id,
$clientRef
);
if ($personId) return $personId;
}
// 5. Check for existing Person by contact values
$personId = $this->resolvePersonByContacts($mapped);
if ($personId) return $personId;
return null; // No existing Person found
}
/**
* Check if ClientCase exists for this client_ref.
*/
public function clientCaseExists(int $clientId, string $clientRef): bool
{
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->exists();
}
/**
* Check if Contract exists for this reference.
*/
public function contractExists(int $clientId, string $reference): bool
{
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->exists();
}
private function getPersonFromContract(Contract $contract): ?int
{
if ($contract->client_case_id) {
return ClientCase::where('id', $contract->client_case_id)
->value('person_id');
}
return null;
}
private function getPersonFromContractReference(
?int $clientId,
string $reference
): ?int {
if (!$clientId) return null;
$clientCaseId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->value('contracts.client_case_id');
if ($clientCaseId) {
return ClientCase::where('id', $clientCaseId)
->value('person_id');
}
return null;
}
private function getPersonFromClientRef(
?int $clientId,
string $clientRef
): ?int {
if (!$clientId) return null;
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->value('person_id');
}
private function resolvePersonByContacts(array $mapped): ?int
{
// Check email
if ($email = $mapped['email']['value'] ?? $mapped['emails'][0]['value'] ?? null) {
$personId = Email::where('value', trim($email))->value('person_id');
if ($personId) return $personId;
}
// Check phone
if ($phone = $mapped['phone']['nu'] ?? $mapped['person_phones'][0]['nu'] ?? null) {
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
if ($personId) return $personId;
}
// Check address
if ($address = $mapped['address']['address'] ?? $mapped['person_addresses'][0]['address'] ?? null) {
$personId = PersonAddress::where('address', trim($address))->value('person_id');
if ($personId) return $personId;
}
return null;
}
}
```
### Phase 3: Update PersonHandler 🔧
**Modify:** `app/Services/Import/Handlers/PersonHandler.php`
Add resolution service check before creating:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// FIRST: Check if Person already resolved from context
$resolutionService = app(EntityResolutionService::class);
$existingPersonId = $resolutionService->resolvePersonFromContext(
$import,
$mapped,
$context
);
if ($existingPersonId) {
$existing = Person::find($existingPersonId);
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Person already exists (found via Contract/ClientCase chain)',
];
}
// Update logic...
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// SECOND: Try existing deduplication (tax_number, ssn)
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update logic...
}
// THIRD: Check contacts deduplication
$personIdFromContacts = $resolutionService->resolvePersonByContacts($mapped);
if ($personIdFromContacts) {
$existing = Person::find($personIdFromContacts);
// Update logic...
}
// LAST: Create new Person only if all checks failed
$payload = $this->buildPayload($mapped);
$person = Person::create($payload);
return [
'action' => 'inserted',
'entity' => $person,
'count' => 1,
];
}
```
### Phase 4: Update ContractHandler 🔧
**Modify:** `app/Services/Import/Handlers/ContractHandler.php`
Add early Contract lookup and ClientCase reuse:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id;
$reference = $mapped['reference'] ?? null;
if (!$clientId || !$reference) {
return [
'action' => 'invalid',
'errors' => ['Contract requires client_id and reference'],
];
}
// EARLY LOOKUP: Check if Contract exists across all cases
$existing = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
if ($existing) {
// Contract exists - update or skip
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Contract already exists',
];
}
// Update logic...
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// Creating new Contract - resolve/create ClientCase
$clientCaseId = $this->resolveOrCreateClientCase($import, $mapped, $context);
if (!$clientCaseId) {
return [
'action' => 'invalid',
'errors' => ['Unable to resolve client_case_id'],
];
}
// Create Contract
$payload = array_merge($this->buildPayload($mapped), [
'client_case_id' => $clientCaseId,
]);
$contract = Contract::create($payload);
return [
'action' => 'inserted',
'entity' => $contract,
'count' => 1,
];
}
protected function resolveOrCreateClientCase(
Import $import,
array $mapped,
array $context
): ?int {
$clientId = $import->client_id;
$clientRef = $mapped['client_ref'] ??
$context['client_case']['entity']?->client_ref ??
null;
// If ClientCase already processed in this row
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
return $clientCaseId;
}
// Try to find existing ClientCase by client_ref
if ($clientRef) {
$existing = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($existing) {
// REUSE existing ClientCase (and its Person)
return $existing->id;
}
}
// Create new ClientCase (Person should already be processed)
$personId = $context['person']['entity']?->id ?? null;
if (!$personId) {
// Person wasn't in import, create minimal
$personId = Person::create(['type_id' => 1])->id;
}
$clientCase = ClientCase::create([
'client_id' => $clientId,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $clientCase->id;
}
```
### Phase 5: Update ClientCaseHandler 🔧
**Modify:** `app/Services/Import/Handlers/ClientCaseHandler.php`
Ensure it uses resolved Person from context:
```php
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id ?? null;
$clientRef = $mapped['client_ref'] ?? null;
// Get Person from context (should be processed first now)
$personId = $context['person']['entity']?->id ?? null;
if (!$clientId) {
return [
'action' => 'skipped',
'message' => 'ClientCase requires client_id',
];
}
$existing = $this->resolve($mapped, $context);
if ($existing) {
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'ClientCase already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
// Update person_id ONLY if provided and different
if ($personId && $existing->person_id !== $personId) {
$payload['person_id'] = $personId;
}
$appliedFields = $this->trackAppliedFields($existing, $payload);
$existing->update($payload);
return [
'action' => 'updated',
'entity' => $existing,
'count' => 1,
];
}
// Create new ClientCase
$payload = $this->buildPayload($mapped);
// Attach Person if resolved
if ($personId) {
$payload['person_id'] = $personId;
}
$payload['client_id'] = $clientId;
$clientCase = ClientCase::create($payload);
return [
'action' => 'inserted',
'entity' => $clientCase,
'count' => 1,
];
}
```
### Phase 6: Integration into ImportServiceV2 🔧
**Modify:** `app/Services/Import/ImportServiceV2.php`
Inject resolution service into processRow:
```php
protected function processRow(Import $import, array $mapped, array $raw, array $context): array
{
$entityResults = [];
$lastEntityType = null;
$lastEntityId = null;
$hasErrors = false;
// NEW: Add resolution service to context
$context['resolution_service'] = app(EntityResolutionService::class);
// Process entities in configured priority order
foreach ($this->entityConfigs as $root => $config) {
// ... existing logic ...
}
// ... rest of method ...
}
```
## Implementation Checklist
### Step 1: Update Database Priority ✅
- [ ] Modify `database/seeders/ImportEntitiesV2Seeder.php`
- [ ] Change priorities: Contract(100), ClientCase(95), Person(90)
- [ ] Run seeder: `php artisan db:seed --class=ImportEntitiesV2Seeder --force`
### Step 2: Create EntityResolutionService 🔧
- [ ] Create `app/Services/Import/EntityResolutionService.php`
- [ ] Implement all resolution methods
- [ ] Add comprehensive PHPDoc
- [ ] Add logging for debugging
### Step 3: Update PersonHandler 🔧
- [ ] Modify `process()` method to check resolution service first
- [ ] Add contact-based deduplication
- [ ] Ensure proper skip/update modes
### Step 4: Update ContractHandler 🔧
- [ ] Add early Contract lookup (client_id + reference)
- [ ] Implement ClientCase reuse logic
- [ ] Prevent duplicate Contract creation
### Step 5: Update ClientCaseHandler 🔧
- [ ] Use Person from context
- [ ] Handle person_id properly on updates
- [ ] Maintain existing deduplication
### Step 6: Integrate into ImportServiceV2 🔧
- [ ] Add resolution service to context
- [ ] Test with existing imports
### Step 7: Testing 🧪
- [ ] Test import with existing client_ref
- [ ] Test import with existing contract reference
- [ ] Test import with existing email/phone
- [ ] Test mixed scenarios
- [ ] Verify no duplicate Persons created
- [ ] Check all related entities linked correctly
## Expected Behavior After Implementation
### Scenario 1: Existing ClientCase by client_ref
```
Import Row: {client_ref: "B387055", name: "John", email: "john@test.com"}
Before V2 Fix:
❌ Creates new Person (duplicate)
❌ Creates new Email (duplicate)
✅ Reuses ClientCase
After V2 Fix:
✅ Finds existing Person via ClientCase
✅ Updates Person if needed
✅ Reuses ClientCase
✅ Reuses/updates Email
```
### Scenario 2: Existing Contract by reference
```
Import Row: {contract.reference: "REF-123", person.name: "Jane"}
Before V2 Fix:
❌ Creates new Person (duplicate)
❌ Contract might be created or updated
❌ New Person not linked to existing ClientCase
After V2 Fix:
✅ Finds existing Contract
✅ Derives Person from Contract → ClientCase chain
✅ Updates Person if needed
✅ No duplicate Person created
```
### Scenario 3: New Import (no existing entities)
```
Import Row: {client_ref: "NEW-001", name: "Bob"}
Behavior:
✅ Creates new Person
✅ Creates new ClientCase
✅ Links correctly
✅ No duplicates
```
## Success Criteria
**No duplicate Persons** when client_ref or contract reference exists
**Proper entity linking** - all entities connected to correct Person
**Backward compatibility** - existing imports still work
**Skip mode respected** - handlers honor skip/update modes
**Contact deduplication** - matches by email/phone/address
**Performance maintained** - no significant slowdown
## Rollback Plan
If issues occur:
1. Revert priority changes in database
2. Disable EntityResolutionService by commenting out context injection
3. Fall back to original handler behavior
4. Investigate and fix issues
5. Re-implement with fixes
## Notes
- This plan maintains V2's modular handler architecture
- Resolution logic is centralized in EntityResolutionService
- Handlers remain independent but context-aware
- Similar to V1 but cleaner separation of concerns
- Can be implemented incrementally (phase by phase)
- Each phase can be tested independently

1045
DEPLOYMENT_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

83
Dockerfile Normal file
View File

@ -0,0 +1,83 @@
ARG PHP_VERSION=8.4
FROM php:${PHP_VERSION}-fpm-alpine
# Set working directory
WORKDIR /var/www
# Install system dependencies
RUN apk add --no-cache \
git \
curl \
zip \
unzip \
supervisor \
nginx \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
oniguruma-dev \
libxml2-dev \
linux-headers \
${PHPIZE_DEPS}
# Configure and install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
mbstring \
exif \
pcntl \
bcmath \
gd \
opcache
# Install Redis extension via PECL
RUN pecl install redis \
&& docker-php-ext-enable redis
# Install LibreOffice from community repository
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
libreoffice-common \
libreoffice-writer \
libreoffice-calc
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create system user to run Composer and Artisan Commands
RUN addgroup -g 1000 -S www && \
adduser -u 1000 -S www -G www
# Copy application files (will be overridden by volume mount in local development)
COPY --chown=www:www . /var/www
# Copy supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
# Set permissions
RUN chown -R www:www /var/www \
&& chmod -R 755 /var/www/storage \
&& chmod -R 755 /var/www/bootstrap/cache
# PHP Configuration for production
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Copy PHP custom configuration
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
# This is needed for nginx running in a separate container to reach PHP-FPM
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Expose port 9000 for PHP-FPM
EXPOSE 9000
# Create directories for supervisor logs
RUN mkdir -p /var/log/supervisor
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

343
LOCAL_TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,343 @@
# Local Testing Guide - Windows/Mac/Linux
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
## Quick Start
### 1. Prerequisites
- Docker Desktop installed and running
- Git
- 8GB RAM recommended
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
### 2. Setup
```bash
# Clone repository (if not already)
git clone YOUR_GITEA_URL
cd Teren-app
# Copy local environment file
cp .env.local.example .env
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Wait for services to start (30 seconds)
timeout 30
# Generate application key
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
# Run migrations
docker compose -f docker-compose.local.yaml exec app php artisan migrate
# Seed database (optional)
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
# Install frontend dependencies (if needed)
npm install
npm run dev
```
### 3. Access Services
| Service | URL | Credentials |
|---------|-----|-------------|
| **Laravel App** | http://localhost:8080 | - |
| **Portainer** | http://localhost:9000 | Set on first visit |
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
| **Mailpit** | http://localhost:8025 | - |
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
| **Redis** | localhost:6379 | - |
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
## Common Commands
### Docker Compose Commands
```bash
# Start all services
docker compose -f docker-compose.local.yaml up -d
# Stop all services
docker compose -f docker-compose.local.yaml down
# View logs
docker compose -f docker-compose.local.yaml logs -f
# View specific service logs
docker compose -f docker-compose.local.yaml logs -f app
# Restart a service
docker compose -f docker-compose.local.yaml restart app
# Rebuild containers
docker compose -f docker-compose.local.yaml up -d --build
# Stop and remove everything (including volumes)
docker compose -f docker-compose.local.yaml down -v
```
### Laravel Commands
```bash
# Run artisan commands
docker compose -f docker-compose.local.yaml exec app php artisan [command]
# Examples:
docker compose -f docker-compose.local.yaml exec app php artisan migrate
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
# Run tests
docker compose -f docker-compose.local.yaml exec app php artisan test
# Access container shell
docker compose -f docker-compose.local.yaml exec app sh
# Run Composer commands
docker compose -f docker-compose.local.yaml exec app composer install
docker compose -f docker-compose.local.yaml exec app composer update
```
### Database Commands
```bash
# Connect to PostgreSQL (from inside container)
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
# Connect from Windows host
psql -h localhost -p 5433 -U teren_user -d teren_app
# Backup database
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
# Restore database
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
# Reset database
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## pgAdmin Setup
1. Open http://localhost:5050
2. Login: `admin@local.dev` / `admin`
3. Add Server:
- **General Tab:**
- Name: `Teren Local`
- **Connection Tab:**
- Host: `postgres`
- Port: `5432`
- Database: `teren_app`
- Username: `teren_user`
- Passwo
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
- Host: `localhost`
- Port: `5433` (not 5432)
- Database: `teren_app`
- Username: `teren_user`
- Password: `local_password`rd: `local_password`
4. Click Save
## Mailpit - Email Testing
All emails sent by the application are caught by Mailpit.
- Access: http://localhost:8025
- View all emails in the web interface
- Test email sending:
```bash
docker compose -f docker-compose.local.yaml exec app php artisan tinker
# In tinker:
Mail::raw('Test email', function($msg) {
$msg->to('test@example.com')->subject('Test');
});
```
## Portainer Setup
1. Open http://localhost:9000
2. On first visit, create admin account
3. Select "Docker" environment
4. Click "Connect"
Use Portainer to:
- View and manage containers
- Check logs
- Execute commands in containers
- Monitor resource usage
## Development Workflow
### Frontend Development
The local setup supports live reloading:
```bash
# Run Vite dev server (outside Docker)
npm run dev
# Or inside Docker
docker compose -f docker-compose.local.yaml exec app npm run dev
```
Access: http://localhost:8080
### Code Changes
All code changes are automatically reflected because the source code is mounted as a volume:
```yaml
volumes:
- ./:/var/www # Live code mounting
```
### Queue Workers
Queue workers are running via Supervisor inside the container. To restart:
```bash
# Restart queue workers
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
# Check status
docker compose -f docker-compose.local.yaml exec app supervisorctl status
# View worker logs
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
```
## Troubleshooting
### Port Already in Use
If you get "port is already allocated" error:
```bash
# Windows - Find process using port
netstat -ano | findstr :8080
# Kill process by PID
taskkill /PID <PID> /F
# Or change port in docker-compose.local.yaml
ports:
- "8081:80" # Change 8080 to 8081
```
### Container Won't Start
```bash
# Check logs
docker compose -f docker-compose.local.yaml logs app
# Rebuild containers
docker compose -f docker-compose.local.yaml down
docker compose -f docker-compose.local.yaml up -d --build
```
### Permission Errors (Linux/Mac)
```bash
# Fix storage permissions
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
```
### Database Connection Failed
```bash
# Check if PostgreSQL is running
docker compose -f docker-compose.local.yaml ps postgres
# Check logs
docker compose -f docker-compose.local.yaml logs postgres
# Restart PostgreSQL
docker compose -f docker-compose.local.yaml restart postgres
```
### Clear All Data and Start Fresh
```bash
# Stop and remove everything
docker compose -f docker-compose.local.yaml down -v
# Remove images
docker compose -f docker-compose.local.yaml down --rmi all
# Start fresh
docker compose -f docker-compose.local.yaml up -d --build
# Re-initialize
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
```
## Performance Tips
### Windows Performance
If using WSL2 (recommended):
1. Clone repo inside WSL2 filesystem, not Windows filesystem
2. Use WSL2 terminal for commands
3. Enable WSL2 integration in Docker Desktop settings
### Mac Performance
1. Enable VirtioFS in Docker Desktop settings
2. Disable file watching if not needed
3. Use Docker volumes for vendor directories:
```yaml
volumes:
- ./:/var/www
- /var/www/vendor # Anonymous volume for vendor
- /var/www/node_modules # Anonymous volume for node_modules
```
## Testing Production-Like Setup
To test the production VPN setup locally (advanced):
1. Enable WireGuard in `docker-compose.yaml.example`
2. Change all `10.13.13.1` bindings to `127.0.0.1`
3. Test SSL with self-signed certificates
## Differences from Production
| Feature | Local | Production |
|---------|-------|------------|
| **VPN** | No VPN | WireGuard required |
| **Port** | :8080 | :80/:443 |
| **SSL** | No SSL | Let's Encrypt |
| **Debug** | Enabled | Disabled |
| **Emails** | Mailpit | Real SMTP |
| **Logs** | Debug level | Error level |
| **Code** | Live mount | Built into image |
## Next Steps
After testing locally:
1. Review `docker-compose.yaml.example` for production
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
3. Configure WireGuard VPN
4. Deploy to production
## Useful Resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
- [Mailpit Documentation](https://github.com/axllent/mailpit)

159
QUICK_START_VPN.md Normal file
View File

@ -0,0 +1,159 @@
# Quick Start: VPN-Only Access Setup
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
## Quick Setup Steps
### 1. Install Docker (on VPS)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
### 2. Clone & Configure
```bash
git clone YOUR_GITEA_REPO/Teren-app.git
cd Teren-app
cp docker-compose.yaml.example docker-compose.yaml
cp .env.production.example .env
```
### 3. Edit Configuration
```bash
vim .env
```
**Required changes:**
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
### 4. Start WireGuard First
```bash
# Enable kernel module
sudo modprobe wireguard
# Start WireGuard
docker compose up -d wireguard
# Wait 10 seconds
sleep 10
# Check status
docker compose logs wireguard
```
### 5. Setup VPN Client (on your laptop/desktop)
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
1. Login with password from step 3
2. Click "New Client"
3. Name it (e.g., "MyLaptop")
4. Download config or scan QR code
**Install WireGuard Client:**
- Windows: https://www.wireguard.com/install/
- macOS: App Store
- Linux: `sudo apt install wireguard`
- Mobile: App Store / Play Store
**Import config and CONNECT**
### 6. Verify VPN Works
```bash
# From your local machine (while connected to VPN)
ping 10.13.13.1
```
Should get responses ✅
### 7. Secure WireGuard Dashboard
Edit `docker-compose.yaml`:
```yaml
# Find wireguard service, change:
ports:
- "51821:51821/tcp"
# To:
ports:
- "10.13.13.1:51821:51821/tcp"
```
```bash
docker compose down
docker compose up -d wireguard
```
### 8. Start All Services
```bash
# Make sure you're connected to VPN!
docker compose up -d
```
### 9. Initialize Application
```bash
# Generate app key
docker compose exec app php artisan key:generate
# Run migrations
docker compose exec app php artisan migrate --force
# Cache config
docker compose exec app php artisan config:cache
```
### 10. Access Your Services
**While connected to VPN:**
| Service | URL |
|---------|-----|
| **Laravel App** | http://10.13.13.1 |
| **Portainer** | http://10.13.13.1:9000 |
| **pgAdmin** | http://10.13.13.1:5050 |
| **WireGuard UI** | http://10.13.13.1:51821 |
## Firewall Configuration
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 51820/udp # WireGuard VPN
sudo ufw enable
```
**That's it!** ✅
## Adding More VPN Clients
1. Connect to VPN
2. Open: `http://10.13.13.1:51821`
3. Click "New Client"
4. Download config
5. Import on new device
## Troubleshooting
**Can't connect to VPN:**
```bash
docker compose logs wireguard
sudo ufw status
```
**Can't access app after VPN connection:**
```bash
ping 10.13.13.1
docker compose ps
docker compose logs nginx
```
**Check which ports are exposed:**
```bash
docker compose ps
sudo netstat -tulpn | grep 10.13.13.1
```
## Full Documentation
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.

View File

@ -0,0 +1,398 @@
# Reports Backend Rework Plan
## Overview
Transform the current hardcoded report system into a flexible, database-driven architecture that allows dynamic report configuration without code changes.
## Current Architecture Analysis
### Existing Structure
- **Report Classes**: Individual PHP classes (`ActiveContractsReport`, `ActivitiesPerPeriodReport`, etc.)
- **Registry Pattern**: `ReportRegistry` stores report instances in memory
- **Service Provider**: `ReportServiceProvider` registers reports at boot time
- **Base Class**: `BaseEloquentReport` provides common pagination logic
- **Contract Interface**: `Report` interface defines required methods (`slug`, `name`, `description`, `inputs`, `columns`, `query`)
- **Controller**: `ReportController` handles index, show, data, export routes
### Current Features
1. **Report Definition**: Each report defines:
- Slug (unique identifier)
- Name & Description
- Input parameters (filters)
- Column definitions
- Eloquent query builder
2. **Filter Types**: `date`, `string`, `select:client`, etc.
3. **Export**: PDF and CSV export functionality
4. **Pagination**: Server-side pagination support
## Proposed New Architecture
### 1. Database Schema
#### `reports` Table
```sql
CREATE TABLE reports (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
slug VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
category VARCHAR(100) NULL, -- e.g., 'contracts', 'activities', 'financial'
enabled BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_enabled_order (enabled, order)
);
```
#### `report_entities` Table
Defines which database entities (models) the report queries.
```sql
CREATE TABLE report_entities (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
model_class VARCHAR(255) NOT NULL, -- e.g., 'App\Models\Contract'
alias VARCHAR(50) NULL, -- table alias for joins
join_type ENUM('base', 'join', 'leftJoin', 'rightJoin') DEFAULT 'base',
join_first VARCHAR(100) NULL, -- first column for join
join_operator VARCHAR(10) NULL, -- =, !=, etc.
join_second VARCHAR(100) NULL, -- second column for join
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_columns` Table
Defines selectable columns and their presentation.
```sql
CREATE TABLE report_columns (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- column identifier
label VARCHAR(255) NOT NULL, -- display label
type VARCHAR(50) DEFAULT 'string', -- string, number, date, boolean, currency
expression TEXT NOT NULL, -- SQL expression or column path
sortable BOOLEAN DEFAULT TRUE,
visible BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
format_options JSON NULL, -- { "decimals": 2, "prefix": "$" }
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_filters` Table
Defines available filter parameters.
```sql
CREATE TABLE report_filters (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- filter identifier
label VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- date, string, select, multiselect, number, boolean
nullable BOOLEAN DEFAULT TRUE,
default_value TEXT NULL,
options JSON NULL, -- For select/multiselect: [{"label":"...", "value":"..."}]
data_source VARCHAR(255) NULL, -- e.g., 'clients', 'segments' for dynamic selects
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
#### `report_conditions` Table
Defines WHERE clause conditions (rules) for filtering data.
```sql
CREATE TABLE report_conditions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL, -- e.g., 'contracts.start_date'
operator VARCHAR(50) NOT NULL, -- =, !=, >, <, >=, <=, LIKE, IN, BETWEEN, IS NULL, etc.
value_type VARCHAR(50) NOT NULL, -- static, filter, expression
value TEXT NULL, -- static value or expression
filter_key VARCHAR(100) NULL, -- references report_filters.key
logical_operator ENUM('AND', 'OR') DEFAULT 'AND',
group_id INT NULL, -- for grouping conditions (AND within group, OR between groups)
order INT DEFAULT 0,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_group (report_id, group_id, order)
);
```
#### `report_orders` Table
Defines default ORDER BY clauses.
```sql
CREATE TABLE report_orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL,
direction ENUM('ASC', 'DESC') DEFAULT 'ASC',
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
```
### 2. Model Structure
#### Report Model
```php
class Report extends Model
{
protected $fillable = ['slug', 'name', 'description', 'category', 'enabled', 'order'];
protected $casts = ['enabled' => 'boolean'];
public function entities(): HasMany { return $this->hasMany(ReportEntity::class); }
public function columns(): HasMany { return $this->hasMany(ReportColumn::class); }
public function filters(): HasMany { return $this->hasMany(ReportFilter::class); }
public function conditions(): HasMany { return $this->hasMany(ReportCondition::class); }
public function orders(): HasMany { return $this->hasMany(ReportOrder::class); }
}
```
### 3. Query Builder Service
Create `ReportQueryBuilder` service to dynamically construct queries:
```php
class ReportQueryBuilder
{
public function build(Report $report, array $filters = []): Builder
{
// 1. Start with base model query
// 2. Apply joins from report_entities
// 3. Select columns from report_columns
// 4. Apply conditions from report_conditions
// 5. Apply filter values to parameterized conditions
// 6. Apply ORDER BY from report_orders
// 7. Return Builder instance
}
}
```
### 4. Backward Compatibility Layer
Keep existing Report classes but load from database:
```php
class DatabaseReport extends BaseEloquentReport implements Report
{
public function __construct(protected Report $dbReport) {}
public function slug(): string { return $this->dbReport->slug; }
public function name(): string { return $this->dbReport->name; }
public function description(): ?string { return $this->dbReport->description; }
public function inputs(): array {
return $this->dbReport->filters()
->orderBy('order')
->get()
->map(fn($f) => [
'key' => $f->key,
'type' => $f->type,
'label' => $f->label,
'nullable' => $f->nullable,
'default' => $f->default_value,
'options' => $f->options,
])
->toArray();
}
public function columns(): array {
return $this->dbReport->columns()
->where('visible', true)
->orderBy('order')
->get()
->map(fn($c) => ['key' => $c->key, 'label' => $c->label])
->toArray();
}
public function query(array $filters): Builder {
return app(ReportQueryBuilder::class)->build($this->dbReport, $filters);
}
}
```
### 5. Migration Strategy
#### Phase 1: Database Setup
1. Create migrations for all new tables
2. Create models with relationships
3. Create seeders to migrate existing hardcoded reports
#### Phase 2: Service Layer
1. Build `ReportQueryBuilder` service
2. Build `DatabaseReport` adapter class
3. Update `ReportRegistry` to load from database
4. Create report management CRUD (admin UI)
#### Phase 3: Testing & Validation
1. Unit tests for query builder
2. Integration tests comparing old vs new results
3. Performance benchmarks
4. Export functionality validation
#### Phase 4: Migration Seeder
1. Create seeder that converts each hardcoded report into database records
2. Example for `ActiveContractsReport`:
```php
$report = Report::create([
'slug' => 'active-contracts',
'name' => 'Aktivne pogodbe',
'description' => 'Pogodbe, ki so aktivne...',
'enabled' => true,
]);
// Add entities (joins)
$report->entities()->create([
'model_class' => 'App\Models\Contract',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\Models\ClientCase',
'join_type' => 'join',
'join_first' => 'contracts.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 1,
]);
// Add columns
$report->columns()->create([
'key' => 'contract_reference',
'label' => 'Pogodba',
'expression' => 'contracts.reference',
'order' => 0,
]);
// Add filters
$report->filters()->create([
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select',
'data_source' => 'clients',
'nullable' => true,
'order' => 0,
]);
// Add conditions
$report->conditions()->create([
'column' => 'contracts.start_date',
'operator' => '<=',
'value_type' => 'expression',
'value' => 'CURRENT_DATE',
'group_id' => 1,
'order' => 0,
]);
```
#### Phase 5: Remove Old Report System
Once the new database-driven system is validated and working:
1. **Delete Hardcoded Report Classes**:
- Remove `app/Reports/ActiveContractsReport.php`
- Remove `app/Reports/ActivitiesPerPeriodReport.php`
- Remove `app/Reports/ActionsDecisionsCountReport.php`
- Remove `app/Reports/DecisionsCountReport.php`
- Remove `app/Reports/FieldJobsCompletedReport.php`
- Remove `app/Reports/SegmentActivityCountsReport.php`
2. **Remove Base Classes/Interfaces** (if no longer needed):
- Remove `app/Reports/BaseEloquentReport.php`
- Remove `app/Reports/Contracts/Report.php` interface
3. **Remove/Update Service Provider**:
- Remove `app/Providers/ReportServiceProvider.php`
- Or update it to only load reports from database
4. **Update ReportRegistry**:
- Modify to load from database instead of manual registration
- Remove all hardcoded `register()` calls
5. **Clean Up Config**:
- Remove any report-specific configuration files if they exist
- Update `bootstrap/providers.php` to remove ReportServiceProvider
6. **Documentation Cleanup**:
- Update any documentation referencing old report classes
- Add migration guide for future report creation
### 6. Admin UI for Report Management
Create CRUD interface at `Settings/Reports/*`:
- **Index**: List all reports with enable/disable toggle
- **Create**: Wizard-style form for building new reports
- **Edit**: Visual query builder interface
- **Test**: Preview report results
- **Clone**: Duplicate existing report as starting point
### 7. Advanced Features (Future)
1. **Calculated Fields**: Allow expressions like `(column_a + column_b) / 2`
2. **Aggregations**: Support SUM, AVG, COUNT, MIN, MAX
3. **Subqueries**: Define subquery relationships
4. **Report Templates**: Predefined report structures
5. **Scheduled Reports**: Email reports on schedule
6. **Report Sharing**: Share reports with specific users/roles
7. **Version History**: Track report definition changes
8. **Report Permissions**: Control who can view/edit reports
## Benefits
1. **No Code Changes**: Add/modify reports through UI
2. **Flexibility**: Non-developers can create reports
3. **Consistency**: All reports follow same structure
4. **Maintainability**: Centralized report logic
5. **Reusability**: Share entities, filters, conditions
6. **Version Control**: Track changes to report definitions
7. **Performance**: Optimize query builder once
8. **Export**: Works with any report automatically
## Risks & Considerations
1. **Complexity**: Query builder must handle diverse SQL patterns
2. **Performance**: Dynamic query building overhead
3. **Security**: SQL injection risks with user input
4. **Learning Curve**: Team needs to understand new system
5. **Testing**: Comprehensive test suite required
6. **Migration**: Convert all existing reports correctly
7. **Edge Cases**: Complex queries may be difficult to represent
## Timeline Estimate
- **Phase 1 (Database)**: 2-3 days
- **Phase 2 (Services)**: 4-5 days
- **Phase 3 (Testing)**: 2-3 days
- **Phase 4 (Migration)**: 1-2 days
- **Phase 5 (Cleanup)**: 1 day
- **Admin UI**: 3-4 days
- **Total**: 13-18 days
## Success Criteria
1. ✅ All existing reports work identically
2. ✅ New reports can be created via UI
3. ✅ Export functionality preserved
4. ✅ Performance within 10% of current
5. ✅ Zero SQL injection vulnerabilities
6. ✅ Comprehensive test coverage (>80%)
7. ✅ Documentation complete

View File

@ -0,0 +1,528 @@
# Reports Frontend Rework Plan
## Overview
This plan outlines the modernization of Reports frontend pages (`Index.vue` and `Show.vue`) using shadcn-vue components and AppCard containers, following the same patterns established in the Settings pages rework.
## Current State Analysis
### Reports/Index.vue (30 lines)
**Current Implementation:**
- Simple grid layout with native divs
- Report cards: `border rounded-lg p-4 bg-white shadow-sm hover:shadow-md`
- Grid: `md:grid-cols-2 lg:grid-cols-3`
- Each card shows: name (h2), description (p), Link to report
- **No shadcn-vue components used**
**Identified Issues:**
- Native HTML/Tailwind instead of shadcn-vue Card
- Inconsistent with Settings pages styling
- No icons for visual interest
- Basic hover effects only
### Reports/Show.vue (314 lines)
**Current Implementation:**
- Complex page with filters, export buttons, and data table
- Header section: title, description, export buttons (lines 190-196)
- Buttons: `px-3 py-2 rounded bg-gray-200 hover:bg-gray-300`
- Filter section: grid layout `md:grid-cols-4` (lines 218-270)
- Native inputs: `border rounded px-2 py-1`
- Native selects: `border rounded px-2 py-1`
- DatePicker component (already working)
- Filter buttons: Apply (`bg-indigo-600`) and Reset (`bg-gray-100`)
- Data table: DataTableServer component (lines 285-300)
- Formatting functions: formatNumberEU, formatDateEU, formatDateTimeEU, formatCell
**Identified Issues:**
- No Card containers for sections
- Native buttons instead of shadcn Button
- Native input/select elements instead of shadcn Input/Select
- No visual separation between sections
- Filter section could be extracted to partial
## Target Architecture
### Pattern Reference from Settings Pages
**Settings/Index.vue Pattern:**
```vue
<Card class="hover:shadow-lg transition-shadow">
<CardHeader>
<div class="flex items-center gap-2">
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
<CardTitle>Title</CardTitle>
</div>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost">Action →</Button>
</CardContent>
</Card>
```
**Settings/Archive/Index.vue Pattern:**
- Uses AppCard for main container
- Extracted partials: ArchiveRuleCard, CreateRuleForm, EditRuleForm
- Alert components for warnings
- Badge components for status indicators
## Implementation Plan
### Phase 1: Reports/Index.vue Rework (Simple)
**Goal:** Replace native divs with shadcn-vue Card components, add icons
**Changes:**
1. **Import shadcn-vue components:**
```js
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { BarChart3, FileText, Activity, Users, TrendingUp, Calendar } from "lucide-vue-next";
```
2. **Add icon mapping for reports:**
```js
const reportIcons = {
'contracts': FileText,
'field': TrendingUp,
'activities': Activity,
// fallback icon
default: BarChart3,
};
function getReportIcon(category) {
return reportIcons[category] || reportIcons.default;
}
```
3. **Replace report card structure:**
- Remove native `<div class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md">`
- Use `<Card class="hover:shadow-lg transition-shadow cursor-pointer">`
- Structure:
```vue
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<component :is="getReportIcon(report.category)" class="h-5 w-5 text-muted-foreground" />
<CardTitle>{{ report.name }}</CardTitle>
</div>
<CardDescription>{{ report.description }}</CardDescription>
</CardHeader>
<CardContent>
<Link :href="route('reports.show', report.slug)">
<Button variant="ghost" size="sm" class="w-full justify-start">
Odpri →
</Button>
</Link>
</CardContent>
</Card>
```
4. **Update page header:**
- Wrap in proper container with consistent spacing
- Match Settings/Index.vue header style
**Estimated Changes:**
- Lines: 30 → ~65 lines (with imports and icon logic)
- Files modified: 1 (Index.vue)
- Files created: 0
**Risk Level:** Low (simple page, straightforward replacement)
---
### Phase 2: Reports/Show.vue Rework - Structure (Medium)
**Goal:** Add Card containers for sections, replace native buttons
**Changes:**
1. **Import shadcn-vue components:**
```js
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
import { Download, Filter, RotateCcw } from "lucide-vue-next";
```
2. **Wrap header + export buttons in Card:**
```vue
<Card class="mb-6">
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>{{ name }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="exportFile('csv')">
<Download class="mr-2 h-4 w-4" />
CSV
</Button>
<Button variant="outline" size="sm" @click="exportFile('pdf')">
<Download class="mr-2 h-4 w-4" />
PDF
</Button>
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
<Download class="mr-2 h-4 w-4" />
Excel
</Button>
</div>
</div>
</CardHeader>
</Card>
```
3. **Wrap filters in Card:**
```vue
<Card class="mb-6">
<CardHeader>
<div class="flex items-center gap-2">
<Filter class="h-5 w-5 text-muted-foreground" />
<CardTitle>Filtri</CardTitle>
</div>
</CardHeader>
<CardContent>
<!-- Filter grid here -->
<div class="grid gap-4 md:grid-cols-4">
<!-- Filter inputs -->
</div>
<Separator class="my-4" />
<div class="flex gap-2">
<Button @click="applyFilters">
<Filter class="mr-2 h-4 w-4" />
Prikaži
</Button>
<Button variant="outline" @click="resetFilters">
<RotateCcw class="mr-2 h-4 w-4" />
Ponastavi
</Button>
</div>
</CardContent>
</Card>
```
4. **Wrap DataTableServer in Card:**
```vue
<Card>
<CardHeader>
<CardTitle>Rezultati</CardTitle>
<CardDescription>
Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }}
</CardDescription>
</CardHeader>
<CardContent>
<DataTableServer
<!-- props -->
/>
</CardContent>
</Card>
```
5. **Replace all native buttons with shadcn Button:**
- Export buttons: `variant="outline" size="sm"`
- Apply filter button: default variant
- Reset button: `variant="outline"`
**Estimated Changes:**
- Lines: 314 → ~350 lines (with imports and Card wrappers)
- Files modified: 1 (Show.vue)
- Files created: 0
- **Keep formatting functions unchanged** (working correctly)
**Risk Level:** Low-Medium (more complex but no logic changes)
---
### Phase 3: Reports/Show.vue - Replace Native Inputs (Medium)
**Goal:** Replace native input/select elements with shadcn-vue components
**Changes:**
1. **Replace date inputs:**
```vue
<!-- Keep DatePicker as-is (already working) -->
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<DatePicker
v-model="filters[inp.key]"
format="dd.MM.yyyy"
placeholder="Izberi datum"
/>
</div>
```
2. **Replace text/number inputs:**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Input
v-model="filters[inp.key]"
:type="inp.type === 'integer' ? 'number' : 'text'"
placeholder="Vnesi vrednost"
/>
</div>
```
3. **Replace select inputs (user/client):**
```vue
<div class="space-y-2">
<Label>{{ inp.label || inp.key }}</Label>
<Select v-model="filters[inp.key]">
<SelectTrigger>
<SelectValue placeholder="— brez —" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">— brez —</SelectItem>
<SelectItem v-for="u in userOptions" :key="u.id" :value="u.id">
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="userLoading" class="text-xs text-muted-foreground">Nalagam…</div>
</div>
```
4. **Update filter grid layout:**
- Change from `md:grid-cols-4` to `md:grid-cols-2 lg:grid-cols-4`
- Use `space-y-2` for label/input spacing
- Consistent gap: `gap-4`
**Estimated Changes:**
- Lines: ~350 → ~380 lines (shadcn Input/Select have more markup)
- Files modified: 1 (Show.vue)
- Files created: 0
**Risk Level:** Medium (v-model binding changes, test thoroughly)
---
### Phase 4: Optional - Extract Filter Section Partial (Optional)
**Goal:** Reduce Show.vue complexity by extracting filter logic
**Decision Criteria:**
- If filter section exceeds ~80 lines → extract to partial
- If multiple filter types need separate handling → extract
**Potential Partial Structure:**
```
resources/js/Pages/Reports/Partials/
FilterSection.vue
```
**FilterSection.vue:**
- Props: `inputs`, `filters` (reactive object), `userOptions`, `clientOptions`, `loading states`
- Emits: `@apply`, `@reset`
- Contains: entire filter grid + buttons
**Benefits:**
- Show.vue reduced from ~380 lines to ~300 lines
- Filter logic isolated and reusable
- Easier to maintain filter types
**Risks:**
- Adds complexity with props/emits
- Might not be worth it if filter logic is simple
**Recommendation:** Evaluate after Phase 3 completion. If filter section is clean and under 80 lines, skip this phase.
---
## Component Inventory
### shadcn-vue Components Needed
**Already Installed (verify):**
- Card, CardHeader, CardTitle, CardDescription, CardContent
- Button
- Input
- Select, SelectTrigger, SelectValue, SelectContent, SelectItem
- Label
- Badge
- Separator
**Need to Check:**
- lucide-vue-next icons (Download, Filter, RotateCcw, BarChart3, FileText, Activity, TrendingUp, Calendar)
### Custom Components
- AppCard (if needed for consistency)
- DatePicker (already working, keep as-is)
- DataTableServer (keep as-is)
---
## Testing Checklist
### Reports/Index.vue Testing:
- [ ] Cards display with correct icons
- [ ] Card hover effects work
- [ ] Links navigate to correct report
- [ ] Grid layout responsive (2 cols MD, 3 cols LG)
- [ ] Icons match report categories
### Reports/Show.vue Testing:
- [ ] Header Card displays title, description, export buttons
- [ ] Export buttons work (CSV, PDF, Excel)
- [ ] Filter Card displays all filter inputs correctly
- [ ] Date filters use DatePicker component
- [ ] User/Client selects load options async
- [ ] Apply filters button triggers report refresh
- [ ] Reset button clears all filters
- [ ] DataTableServer Card displays results
- [ ] Formatting functions work (dates, numbers, currencies)
- [ ] Pagination works
- [ ] All 6 reports render correctly:
- [ ] active-contracts
- [ ] field-jobs-completed
- [ ] decisions-counts
- [ ] segment-activity-counts
- [ ] actions-decisions-counts
- [ ] activities-per-period
---
## Implementation Order
### Step 1: Reports/Index.vue (30 min)
1. Import shadcn-vue components + icons
2. Add icon mapping function
3. Replace native divs with Card structure
4. Test navigation and layout
5. Verify responsive grid
### Step 2: Reports/Show.vue - Structure (45 min)
1. Import shadcn-vue components + icons
2. Wrap header + exports in Card
3. Wrap filters in Card
4. Wrap DataTableServer in Card
5. Replace all native buttons
6. Test all 6 reports
### Step 3: Reports/Show.vue - Inputs (60 min)
1. Replace text/number inputs with shadcn Input
2. Replace select inputs with shadcn Select
3. Add Label components
4. Test v-model bindings
5. Test async user/client loading
6. Test filter apply/reset
7. Verify all filter types work
### Step 4: Optional Partial Extraction (30 min, if needed)
1. Create FilterSection.vue partial
2. Move filter logic to partial
3. Set up props/emits
4. Test with all reports
### Step 5: Final Testing (30 min)
1. Test complete workflow (Index → Show → Filters → Export)
2. Verify all 6 reports
3. Test responsive layouts (mobile, tablet, desktop)
4. Check formatting consistency
5. Verify no regressions
**Total Estimated Time:** 2.5 - 3.5 hours
---
## Risk Assessment
### Low Risk:
- Index.vue rework (simple structure, straightforward replacement)
- Adding Card containers to Show.vue
- Replacing native buttons with shadcn Button
### Medium Risk:
- Replacing native inputs with shadcn Input/Select
- v-model bindings might need adjustments
- Async select loading needs testing
- Number input behavior might differ
### Mitigation Strategies:
1. Test each phase incrementally
2. Keep formatting functions unchanged (already working)
3. Test v-model bindings immediately after input replacement
4. Verify async loading with console logs
5. Test all 6 reports after each phase
6. Keep git commits small and atomic
---
## Success Criteria
### Functional Requirements:
✅ All reports navigate from Index page
✅ All filters work correctly (date, text, number, user select, client select)
✅ Apply filters refreshes report data
✅ Reset filters clears all inputs
✅ Export buttons generate CSV/PDF/Excel files
✅ DataTableServer displays results correctly
✅ Pagination works
✅ Formatting functions work (dates, numbers)
### Visual Requirements:
✅ Consistent Card-based layout
✅ shadcn-vue components throughout
✅ Icons for visual interest
✅ Hover effects on cards
✅ Proper spacing and alignment
✅ Responsive layout (mobile, tablet, desktop)
✅ Matches Settings pages style
### Code Quality:
✅ No code duplication
✅ Clean component imports
✅ Consistent naming conventions
✅ Proper TypeScript/Vue 3 patterns
✅ Formatting functions unchanged
✅ No regressions in functionality
---
## Notes
- **DatePicker component:** Already working, imported correctly, no changes needed
- **Formatting functions:** Keep unchanged (formatNumberEU, formatDateEU, formatDateTimeEU, formatCell)
- **DataTableServer:** Keep as-is, already working well
- **Async loading:** User/client select loading works, just needs shadcn Select wrapper
- **Pattern consistency:** Follow Settings/Index.vue and Settings/Archive/Index.vue patterns
- **Icon usage:** Add icons to Index.vue for visual interest, use lucide-vue-next
- **Button variants:** Use `variant="outline"` for secondary actions, default for primary
---
## Post-Implementation
After completing all phases:
1. **Documentation:**
- Update this document with actual implementation notes
- Document any deviations from plan
- Note any unexpected issues
2. **Code Review:**
- Check for consistent component usage
- Verify no native HTML/CSS buttons/inputs remain
- Ensure proper import structure
3. **User Feedback:**
- Test with actual users
- Gather feedback on UI improvements
- Note any requested adjustments
4. **Performance:**
- Verify no performance regressions
- Check bundle size impact
- Monitor async loading times
---
## Conclusion
This plan provides a structured approach to modernizing the Reports frontend pages using shadcn-vue components. The phased approach allows for incremental testing and reduces risk. The estimated total time is 2.5-3.5 hours, with low to medium risk level.
**Recommendation:** Start with Phase 1 (Index.vue) as a proof of concept, then proceed to Phase 2 and 3 for Show.vue. Evaluate Phase 4 (partial extraction) after Phase 3 completion based on actual complexity.

View File

@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FixImportMappingEntities extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:fix-mapping-entities {--dry-run : Show changes without applying them}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix entity names in import_mappings table to use canonical roots';
/**
* Entity name mappings from incorrect to correct canonical roots
*/
protected array $entityMapping = [
'contracts' => 'contract',
'contract' => 'contract',
'client_cases' => 'client_case',
'client_case' => 'client_case',
'person_addresses' => 'address',
'addresses' => 'address',
'address' => 'address',
'person_phones' => 'phone',
'phones' => 'phone',
'phone' => 'phone',
'emails' => 'email',
'email' => 'email',
'activities' => 'activity',
'activity' => 'activity',
'persons' => 'person',
'person' => 'person',
'accounts' => 'account',
'account' => 'account',
'payments' => 'payment',
'payment' => 'payment',
'bookings' => 'booking',
'booking' => 'booking',
];
/**
* Execute the console command.
*/
public function handle()
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->info('Running in DRY-RUN mode - no changes will be made');
}
$mappings = DB::table('import_mappings')
->whereNotNull('entity')
->where('entity', '!=', '')
->get();
if ($mappings->isEmpty()) {
$this->info('No mappings found to fix.');
return 0;
}
$this->info("Found {$mappings->count()} mappings to check");
$this->newLine();
$updates = [];
$unchanged = 0;
foreach ($mappings as $mapping) {
$currentEntity = trim($mapping->entity);
if (isset($this->entityMapping[$currentEntity])) {
$correctEntity = $this->entityMapping[$currentEntity];
if ($currentEntity !== $correctEntity) {
$updates[] = [
'id' => $mapping->id,
'current' => $currentEntity,
'correct' => $correctEntity,
'source' => $mapping->source_column,
'target' => $mapping->target_field,
];
} else {
$unchanged++;
}
} else {
$this->warn("Unknown entity type: {$currentEntity} (ID: {$mapping->id})");
}
}
if (empty($updates)) {
$this->info("✓ All {$unchanged} mappings already have correct entity names!");
return 0;
}
// Display changes
$this->info("Changes to be made:");
$this->newLine();
$table = [];
foreach ($updates as $update) {
$table[] = [
$update['id'],
$update['source'],
$update['target'],
$update['current'],
$update['correct'],
];
}
$this->table(
['ID', 'Source Column', 'Target Field', 'Current Entity', 'Correct Entity'],
$table
);
$this->newLine();
$this->info("Total changes: " . count($updates));
$this->info("Unchanged: {$unchanged}");
if ($dryRun) {
$this->newLine();
$this->warn('DRY-RUN mode: No changes were made. Run without --dry-run to apply changes.');
return 0;
}
// Confirm before proceeding
if (!$this->confirm('Do you want to apply these changes?', true)) {
$this->info('Operation cancelled.');
return 0;
}
// Apply updates
$updated = 0;
foreach ($updates as $update) {
DB::table('import_mappings')
->where('id', $update['id'])
->update(['entity' => $update['correct']]);
$updated++;
}
$this->newLine();
$this->info("✓ Successfully updated {$updated} mappings!");
return 0;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PopulateImportMappingEntities extends Command
{
protected $signature = 'import:populate-mapping-entities {--dry-run : Show changes without applying them}';
protected $description = 'Populate entity column from target_field for mappings where entity is null';
protected array $entityMap = [
'contracts' => 'contract',
'client_cases' => 'client_case',
'person_addresses' => 'address',
'person_phones' => 'phone',
'emails' => 'email',
'activities' => 'activity',
'payments' => 'payment',
'accounts' => 'account',
'persons' => 'person',
'person' => 'person',
'contract' => 'contract',
'client_case' => 'client_case',
'address' => 'address',
'phone' => 'phone',
'email' => 'email',
'activity' => 'activity',
'payment' => 'payment',
'account' => 'account',
];
public function handle()
{
$dryRun = $this->option('dry-run');
$this->info('Populating entity column from target_field...');
if ($dryRun) {
$this->warn('DRY RUN MODE - No changes will be made');
}
// Get all mappings where entity is null
$mappings = DB::table('import_mappings')
->whereNull('entity')
->get();
if ($mappings->isEmpty()) {
$this->info('No mappings found with null entity.');
return 0;
}
$this->info("Found {$mappings->count()} mappings to process.");
$this->newLine();
$updated = 0;
$skipped = 0;
foreach ($mappings as $mapping) {
$targetField = $mapping->target_field;
// Parse the target_field to extract entity and field
if (str_contains($targetField, '.')) {
[$rawEntity, $field] = explode('.', $targetField, 2);
} elseif (str_contains($targetField, '->')) {
[$rawEntity, $field] = explode('->', $targetField, 2);
} else {
$this->warn("Skipping mapping ID {$mapping->id}: Cannot parse target_field '{$targetField}'");
$skipped++;
continue;
}
$rawEntity = trim($rawEntity);
$field = trim($field);
// Map to canonical entity name
$canonicalEntity = $this->entityMap[$rawEntity] ?? $rawEntity;
$this->line(sprintf(
"ID %d: '%s' -> '%s' => entity='%s', field='%s'",
$mapping->id,
$mapping->source_column,
$targetField,
$canonicalEntity,
$field
));
if (!$dryRun) {
DB::table('import_mappings')
->where('id', $mapping->id)
->update([
'entity' => $canonicalEntity,
'target_field' => $field,
]);
$updated++;
}
}
$this->newLine();
if ($dryRun) {
$this->info("Dry run complete. Would have updated {$mappings->count()} mappings.");
} else {
$this->info("Successfully updated {$updated} mappings.");
}
if ($skipped > 0) {
$this->warn("Skipped {$skipped} mappings that couldn't be parsed.");
}
return 0;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RefreshMaterializedViews extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh configured Postgres materialized views for reporting';
/**
* Execute the console command.
*/
public function handle(): int
{
$views = (array) config('reports.materialized_views', []);
if (empty($views)) {
$this->info('No materialized views configured.');
return self::SUCCESS;
}
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
foreach ($views as $view) {
$name = trim((string) $view);
if ($name === '') {
continue;
}
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
// Use a safe fallback: wrap with " if not already quoted
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
try {
DB::statement($safe);
$this->info("Refreshed: {$name}");
} catch (\Throwable $e) {
$this->error("Failed to refresh {$name}: ".$e->getMessage());
}
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\Import;
use App\Services\Import\ImportSimulationServiceV2;
use Illuminate\Console\Command;
class SimulateImportV2Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:simulate-v2 {import_id} {--limit=100 : Number of rows to simulate} {--verbose : Include detailed information}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Simulate ImportServiceV2 without persisting data';
/**
* Execute the console command.
*/
public function handle(ImportSimulationServiceV2 $service): int
{
$importId = $this->argument('import_id');
$limit = (int) $this->option('limit');
$verbose = (bool) $this->option('verbose');
$import = Import::find($importId);
if (! $import) {
$this->error("Import #{$importId} not found.");
return 1;
}
$this->info("Simulating import #{$importId} - {$import->file_name}");
$this->info("Client: ".($import->client->name ?? 'N/A'));
$this->info("Limit: {$limit} rows");
$this->line('');
$result = $service->simulate($import, $limit, $verbose);
if (! $result['success']) {
$this->error('Simulation failed: '.$result['error']);
return 1;
}
$this->info("✓ Simulated {$result['total_simulated']} rows");
$this->line('');
// Display summaries
if (! empty($result['summaries'])) {
$this->info('=== Entity Summaries ===');
$summaryRows = [];
foreach ($result['summaries'] as $entity => $stats) {
$summaryRows[] = [
'entity' => $entity,
'create' => $stats['create'],
'update' => $stats['update'],
'skip' => $stats['skip'],
'invalid' => $stats['invalid'],
'total' => array_sum($stats),
];
}
$this->table(
['Entity', 'Create', 'Update', 'Skip', 'Invalid', 'Total'],
$summaryRows
);
$this->line('');
}
// Display row previews (first 5)
if (! empty($result['rows'])) {
$this->info('=== Row Previews (first 5) ===');
foreach (array_slice($result['rows'], 0, 5) as $row) {
$this->line("Row #{$row['row_number']}:");
if (! empty($row['entities'])) {
foreach ($row['entities'] as $entity => $data) {
$action = $data['action'];
$color = match ($action) {
'create' => 'green',
'update' => 'yellow',
'skip' => 'gray',
'invalid', 'error' => 'red',
default => 'white',
};
$line = " {$entity}: <fg={$color}>{$action}</>";
if (isset($data['reference'])) {
$line .= " ({$data['reference']})";
}
if (isset($data['existing_id'])) {
$line .= " [ID: {$data['existing_id']}]";
}
$this->line($line);
if ($verbose && ! empty($data['changes'])) {
foreach ($data['changes'] as $field => $change) {
$this->line(" {$field}: {$change['old']}{$change['new']}");
}
}
if (! empty($data['errors'])) {
foreach ($data['errors'] as $error) {
$this->error("{$error}");
}
}
}
}
if (! empty($row['warnings'])) {
foreach ($row['warnings'] as $warning) {
$this->warn("{$warning}");
}
}
if (! empty($row['errors'])) {
foreach ($row['errors'] as $error) {
$this->error("{$error}");
}
}
$this->line('');
}
}
$this->info('Simulation completed successfully.');
return 0;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ProcessLargeImportJob;
use App\Models\Import;
use App\Services\Import\ImportServiceV2;
use Illuminate\Console\Command;
class TestImportV2Command extends Command
{
protected $signature = 'import:test-v2 {import_id : The import ID to process} {--queue : Process via queue}';
protected $description = 'Test ImportServiceV2 with an existing import';
public function handle()
{
$importId = $this->argument('import_id');
$useQueue = $this->option('queue');
$import = Import::find($importId);
if (! $import) {
$this->error("Import {$importId} not found.");
return 1;
}
$this->info("Processing import: {$import->id} ({$import->file_name})");
$this->info("Source: {$import->source_type}");
$this->info("Status: {$import->status}");
$this->newLine();
if ($useQueue) {
$this->info('Dispatching to queue...');
ProcessLargeImportJob::dispatch($import, auth()->id());
$this->info('Job dispatched successfully. Monitor queue for progress.');
return 0;
}
$this->info('Processing synchronously...');
$service = app(ImportServiceV2::class);
try {
$results = $service->process($import, auth()->user());
$this->newLine();
$this->info('Processing completed!');
$this->table(
['Metric', 'Count'],
[
['Total rows', $results['total']],
['Imported', $results['imported']],
['Skipped', $results['skipped']],
['Invalid', $results['invalid']],
]
);
return 0;
} catch (\Throwable $e) {
$this->error('Processing failed: '.$e->getMessage());
$this->error($e->getTraceAsString());
return 1;
}
}
}

View File

@ -22,6 +22,15 @@ protected function schedule(Schedule $schedule): void
'--days' => $days,
])->dailyAt('02:00');
}
// Optional: refresh configured materialized views for reporting
$views = (array) config('reports.materialized_views', []);
if (! empty($views)) {
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
$schedule->command('reports:refresh-mviews', [
'--concurrently' => true,
])->dailyAt($time);
}
}
/**

View File

@ -0,0 +1,224 @@
<?php
namespace App\Helpers;
class LZStringHelper
{
/**
* Decompresses a string compressed with LZ-String's compressToEncodedURIComponent method.
* This is a PHP port of the JavaScript LZ-String library.
*
* @param string $compressed
* @return string|null
*/
public static function decompressFromEncodedURIComponent($compressed)
{
if ($compressed === null || $compressed === '') {
return '';
}
// Replace URL-safe characters back
$compressed = str_replace(' ', '+', $compressed);
return self::decompress(strlen($compressed), 32, function ($index) use ($compressed) {
return self::getBaseValue(self::$keyStrUriSafe, $compressed[$index]);
});
}
private static $keyStrUriSafe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$';
private static function getBaseValue($alphabet, $character)
{
$pos = strpos($alphabet, $character);
return $pos !== false ? $pos : -1;
}
private static function decompress($length, $resetValue, $getNextValue)
{
$dictionary = [];
$enlargeIn = 4;
$dictSize = 4;
$numBits = 3;
$entry = '';
$result = [];
$data = ['val' => $getNextValue(0), 'position' => $resetValue, 'index' => 1];
for ($i = 0; $i < 3; $i++) {
$dictionary[$i] = chr($i);
}
$bits = 0;
$maxpower = pow(2, 2);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$next = $bits;
switch ($next) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = chr($bits);
break;
case 2:
return '';
}
$dictionary[$dictSize++] = $c;
$w = $c;
$result[] = $c;
while (true) {
if ($data['index'] > $length) {
return '';
}
$bits = 0;
$maxpower = pow(2, $numBits);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$c = $bits;
switch ($c) {
case 0:
$bits = 0;
$maxpower = pow(2, 8);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 1:
$bits = 0;
$maxpower = pow(2, 16);
$power = 1;
while ($power != $maxpower) {
$resb = $data['val'] & $data['position'];
$data['position'] >>= 1;
if ($data['position'] == 0) {
$data['position'] = $resetValue;
$data['val'] = $getNextValue($data['index']++);
}
$bits |= ($resb > 0 ? 1 : 0) * $power;
$power <<= 1;
}
$dictionary[$dictSize++] = chr($bits);
$c = $dictSize - 1;
$enlargeIn--;
break;
case 2:
return implode('', $result);
}
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
if (isset($dictionary[$c])) {
$entry = $dictionary[$c];
} else {
if ($c === $dictSize) {
$entry = $w.$w[0];
} else {
return null;
}
}
$result[] = $entry;
$dictionary[$dictSize++] = $w.$entry[0];
$enlargeIn--;
$w = $entry;
if ($enlargeIn == 0) {
$enlargeIn = pow(2, $numBits);
$numBits++;
}
}
}
}

View File

@ -23,9 +23,19 @@ class PackageController extends Controller
{
public function index(Request $request): Response
{
$perPage = $request->input('per_page') ?? 25;
$packages = Package::query()
->latest('id')
->paginate(20);
->paginate($perPage);
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
]);
}
public function create(Request $request): Response
{
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
->where('active', true)
@ -58,8 +68,7 @@ public function index(Request $request): Response
})
->values();
return Inertia::render('Admin/Packages/Index', [
'packages' => $packages,
return Inertia::render('Admin/Packages/Create', [
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
@ -290,6 +299,20 @@ public function cancel(Package $package): RedirectResponse
return back()->with('success', 'Package canceled');
}
public function destroy(Package $package): RedirectResponse
{
// Allow deletion only for drafts (not yet dispatched)
if ($package->status !== Package::STATUS_DRAFT) {
return back()->with('error', 'Package not in a deletable state.');
}
// Remove items first to avoid FK issues
$package->items()->delete();
$package->delete();
return back()->with('success', 'Package deleted');
}
/**
* List contracts for a given segment and include selected phone per person.
*/
@ -298,7 +321,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
$request->validate([
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'],
@ -309,7 +332,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
]);
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
$perPage = (int) ($request->input('per_page') ?? 25);
$query = Contract::query()
->with([
@ -376,9 +399,9 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
});
}
$contracts = $query->paginate($perPage);
$contracts = $query->get();
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone'];
@ -417,13 +440,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
});
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $contracts->currentPage(),
'last_page' => $contracts->lastPage(),
'per_page' => $contracts->perPage(),
'total' => $contracts->total(),
],
'data' => $data
]);
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
use App\Exports\ClientContractsExport;
use App\Http\Requests\ExportClientContractsRequest;
use App\Models\Client;
use App\Services\ReferenceDataCache;
use DB;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@ -13,51 +14,44 @@
class ClientController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Client $client, Request $request)
{
$search = $request->input('search');
$query = $client::query()
->with('person')
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
$q->where('full_name', 'ilike', '%'.$search.'%');
});
->select('clients.*')
->when($search, function ($que) use ($search) {
$que->join('person', 'person.id', '=', 'clients.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('clients.id');
})
->where('active', 1)
->where('clients.active', 1)
// Use LEFT JOINs for aggregated data to avoid subqueries
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('clients.id')
->addSelect([
// Number of client cases for this client that have at least one active contract
'cases_with_active_contracts_count' => DB::query()
->from('client_cases')
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
->selectRaw('COUNT(DISTINCT client_cases.id)')
->whereColumn('client_cases.client_id', 'clients.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
// Sum of account balances for active contracts that belong to this client's cases
'active_contracts_balance_sum' => DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereExists(function ($q) {
$q->from('client_cases')
->whereColumn('client_cases.id', 'contracts.client_case_id')
->whereColumn('client_cases.client_id', 'clients.id');
})
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
// Sum of account balances for active contracts
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->orderByDesc('created_at');
->with('person')
->orderByDesc('clients.created_at');
return Inertia::render('Client/Index', [
'clients' => $query
->paginate($request->integer('perPage', 15))
->paginate($request->integer('per_page', 15))
->withQueryString(),
'filters' => $request->only(['search']),
]);
@ -71,44 +65,37 @@ public function show(Client $client, Request $request)
->findOrFail($client->id);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
return Inertia::render('Client/Show', [
'client' => $data,
'client_cases' => $data->clientCases()
->with(['person', 'client.person'])
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
'person',
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
))
->select('client_cases.*')
->when($request->input('search'), function ($que, $search) {
$que->join('person', 'person.id', '=', 'client_cases.person_id')
->where('person.full_name', 'ilike', '%'.$search.'%')
->groupBy('client_cases.id');
})
->leftJoin('contracts', function ($join) {
$join->on('contracts.client_case_id', '=', 'client_cases.id')
->whereNull('contracts.deleted_at');
})
->leftJoin('contract_segment', function ($join) {
$join->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
})
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
->groupBy('client_cases.id')
->addSelect([
'active_contracts_count' => \DB::query()
->from('contracts')
->selectRaw('COUNT(*)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
'active_contracts_balance_sum' => \DB::query()
->from('contracts')
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
->whereColumn('contracts.client_case_id', 'client_cases.id')
->whereNull('contracts.deleted_at')
->whereExists(function ($q) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.active', true);
}),
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
])
->where('active', 1)
->orderByDesc('created_at')
->paginate($request->integer('perPage', 15))
->with(['person', 'client.person'])
->where('client_cases.active', 1)
->orderByDesc('client_cases.created_at')
->paginate($request->integer('per_page', 15))
->withQueryString(),
'types' => $types,
'filters' => $request->only(['search']),
@ -126,8 +113,30 @@ public function contracts(Client $client, Request $request)
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
$contractsQuery = \App\Models\Contract::query()
->whereHas('clientCase', function ($q) use ($client) {
$q->where('client_id', $client->id);
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
->where('client_cases.client_id', $client->id)
->whereNull('contracts.deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('contracts.start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('contracts.start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
->where(function ($inner) use ($search) {
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
});
})
->when($segmentIds, function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($s) use ($segmentIds) {
$s->whereIn('segments.id', $segmentIds)
->where('contract_segment.active', true);
});
})
->with([
'clientCase:id,uuid,person_id',
@ -138,43 +147,25 @@ public function contracts(Client $client, Request $request)
},
'account:id,accounts.contract_id,balance_amount',
])
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
->whereNull('deleted_at')
->when($from || $to, function ($q) use ($from, $to) {
if (! empty($from)) {
$q->whereDate('start_date', '>=', $from);
}
if (! empty($to)) {
$q->whereDate('start_date', '<=', $to);
}
})
->when($search, function ($q) use ($search) {
$q->where(function ($inner) use ($search) {
$inner->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($p) use ($search) {
$p->where('full_name', 'ilike', '%'.$search.'%');
});
});
})
->when($segmentIds, function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($s) use ($segmentIds) {
$s->whereIn('segments.id', $segmentIds)
->where('contract_segment.active', true);
});
})
->orderByDesc('start_date');
->orderByDesc('contracts.start_date');
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
$types = [
'address_types' => \App\Models\Person\AddressType::all(),
'phone_types' => \App\Models\Person\PhoneType::all(),
'address_types' => $this->referenceCache->getAddressTypes(),
'phone_types' => $this->referenceCache->getPhoneTypes(),
];
// Support custom pagination parameter names used by DataTableNew2
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
return Inertia::render('Client/Contracts', [
'client' => $data,
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segments']),
'contracts' => $contractsQuery
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
->withQueryString(),
'filters' => $request->only(['from', 'to', 'search', 'segment']),
'segments' => $segments,
'types' => $types,
]);
@ -295,14 +286,14 @@ public function store(Request $request)
// \App\Models\Person\PersonAddress::create($address);
return to_route('client');
return back()->with('success', 'Client created')->with('flash_method', 'POST');
}
public function update(Client $client, Request $request)
{
return to_route('client.show', $client);
return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
}
/**

View File

@ -49,7 +49,7 @@ public function store(Request $request)
});
}
return to_route('clientCase.show', $clientCase);
return back()->with('success', 'Contract created')->with('flash_method', 'POST');
}
public function update(Contract $contract, Request $request)

View File

@ -2,15 +2,17 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Document; // assuming model name Import
// assuming model name Import
use App\Models\FieldJob; // if this model exists
use App\Models\Import;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
@ -21,256 +23,188 @@ class DashboardController extends Controller
public function __invoke(SmsService $sms): Response
{
$today = now()->startOfDay();
$yesterday = now()->subDay()->startOfDay();
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
$cacheMinutes = 5;
$clientsTotal = Client::count();
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
} else {
// Prefer assigned_at when present, otherwise created_at
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
}
$documentsToday = Document::whereDate('created_at', $today)->count();
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
$activeContracts = Contract::where('active', 1)->count();
// Active clients count - cached
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Client::where('active', true)->count();
});
// Basic activities deferred list (limit 10)
$activities = Activity::query()
->with(['clientCase:id,uuid'])
->latest()
->limit(10)
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
->map(fn ($a) => [
'id' => $a->id,
'note' => $a->note,
'created_at' => $a->created_at,
'client_case_id' => $a->client_case_id,
'client_case_uuid' => $a->clientCase?->uuid,
'contract_id' => $a->contract_id,
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
// Active contracts count - cached
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Contract::whereNull('deleted_at')->count();
});
// 7-day trends (including today)
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
// Sum of active contracts' account balance - cached
$totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})->sum('balance_amount') ?? 0;
});
$dateKeys = collect(range(0, 6))
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$trends = [
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
// Stale client cases (no activity in last 7 days)
$staleCases = \App\Models\ClientCase::query()
->leftJoin('activities', function ($join) {
$join->on('activities.client_case_id', '=', 'client_cases.id')
->whereNull('activities.deleted_at');
// Active promises count (not expired or expires today) - cached
$activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return Account::whereHas('contract', function ($q) {
$q->whereNull('deleted_at');
})
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
->limit(10)
->get()
->map(function ($c) {
// Reference point: last activity if exists, else creation.
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
$daysFraction = $minutes / 1440; // 60 * 24
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
$daysInteger = (int) floor($daysFraction);
->whereNotNull('promise_date')
->whereDate('promise_date', '>=', $today)
->count();
});
return [
'id' => $c->id,
'uuid' => $c->uuid,
'client_ref' => $c->client_ref,
'last_activity_at' => $c->last_activity_at,
'created_at' => $c->created_at,
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
'days_stale' => $daysInteger, // legacy key (integer)
'has_activity' => (bool) $c->last_activity_at,
];
});
// Activities (limit 10) - cached
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
return Activity::query()
->with(['clientCase:id,uuid'])
->latest()
->limit(10)
->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id'])
->map(fn ($a) => [
'id' => $a->id,
'note' => $a->note,
'created_at' => $a->created_at,
'client_case_id' => $a->client_case_id,
'client_case_uuid' => $a->clientCase?->uuid,
'contract_id' => $a->contract_id,
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
});
// Field jobs assigned today
$fieldJobsAssignedToday = FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get()
->map(function ($fj) {
$contract = $fj->contract;
$segmentId = null;
if ($contract && method_exists($contract, 'segments')) {
// Determine active segment via pivot active flag if present
$activeSeg = $contract->segments->first();
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
$segmentId = $activeSeg->id;
// 7-day trends for field jobs - cached
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
$dateKeys = collect(range(0, 6))
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
return [
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
});
// Field jobs assigned today - cached
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
return FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
->with(['contract' => function ($q) {
$q->select('id', 'uuid', 'reference', 'client_case_id')
->with(['clientCase:id,uuid,person_id', 'clientCase.person:id,full_name', 'segments:id,name']);
}])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get()
->map(function ($fj) {
$contract = $fj->contract;
$segmentId = null;
if ($contract && method_exists($contract, 'segments')) {
$activeSeg = $contract->segments->first();
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
$segmentId = $activeSeg->id;
}
}
}
return [
'id' => $fj->id,
'priority' => $fj->priority,
// Normalize to ISO8601 strings so FE retains timezone & time component
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
] : null,
];
});
return [
'id' => $fj->id,
'priority' => $fj->priority,
'assigned_at' => $fj->assigned_at?->toIso8601String(),
'created_at' => $fj->created_at?->toIso8601String(),
'contract' => $contract ? [
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'client_case_uuid' => optional($contract->clientCase)->uuid,
'person_full_name' => optional(optional($contract->clientCase)->person)->full_name,
'segment_id' => $segmentId,
] : null,
];
});
});
// Imports in progress (queued / processing)
$importsInProgress = Import::query()
->whereIn('status', ['queued', 'processing'])
->latest('created_at')
->limit(10)
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
->map(fn ($i) => [
'id' => $i->id,
'uuid' => $i->uuid,
'file_name' => $i->file_name,
'status' => $i->status,
'total_rows' => $i->total_rows,
'imported_rows' => $i->imported_rows,
'valid_rows' => $i->valid_rows,
'invalid_rows' => $i->invalid_rows,
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
'started_at' => $i->started_at,
]);
// Active document templates summary (active versions)
$activeTemplates = \App\Models\DocumentTemplate::query()
->where('active', true)
->latest('updated_at')
->limit(10)
->get(['id', 'name', 'slug', 'version', 'updated_at']);
// System health (deferred)
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
// System health for timestamp
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
$lastActivityMinutes = null;
if ($recentActivity) {
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
}
$systemHealth = [
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_activity_minutes' => $lastActivityMinutes,
'last_activity_iso' => $recentActivity?->toIso8601String(),
'generated_at' => now()->toIso8601String(),
];
return Inertia::render('Dashboard', [
return Inertia::render('Dashboard/Index', [
'kpis' => [
'clients_total' => $clientsTotal,
'clients_new_7d' => $clientsNew7d,
'field_jobs_today' => $fieldJobsToday,
'documents_today' => $documentsToday,
'active_imports' => $activeImports,
'active_contracts' => $activeContracts,
'active_clients' => $activeClientsCount,
'active_contracts' => $activeContractsCount,
'total_balance' => $totalBalance,
'active_promises' => $activePromisesCount,
],
'trends' => $trends,
])->with([ // deferred props (Inertia v2 style)
])->with([
'activities' => fn () => $activities,
'systemHealth' => fn () => $systemHealth,
'staleCases' => fn () => $staleCases,
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'importsInProgress' => fn () => $importsInProgress,
'activeTemplates' => fn () => $activeTemplates,
'smsStats' => function () use ($sms, $today) {
// Aggregate counts per profile for today
$counts = SmsLog::query()
->whereDate('created_at', $today)
->selectRaw('profile_id, status, COUNT(*) as c')
->groupBy('profile_id', 'status')
->get()
->groupBy('profile_id')
->map(function ($rows) {
$map = [
'queued' => 0,
'sent' => 0,
'delivered' => 0,
'failed' => 0,
];
foreach ($rows as $r) {
$map[$r->status] = (int) $r->c;
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
// SMS stats - cached
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
$counts = SmsLog::query()
->whereDate('created_at', $today)
->selectRaw('profile_id, status, COUNT(*) as c')
->groupBy('profile_id', 'status')
->get()
->groupBy('profile_id')
->map(function ($rows) {
$map = [
'queued' => 0,
'sent' => 0,
'delivered' => 0,
'failed' => 0,
];
foreach ($rows as $r) {
$map[$r->status] = (int) $r->c;
}
$map['total'] = array_sum($map);
return $map;
});
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
try {
$balance = $sms->getCreditBalance($p);
} catch (\Throwable $e) {
$balance = '—';
}
$map['total'] = array_sum($map);
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
return $map;
});
// Important: include credential fields so provider calls have proper credentials
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
// Provider balance may fail; guard and present a placeholder.
try {
$balance = $sms->getCreditBalance($p);
} catch (\Throwable $e) {
$balance = '—';
}
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
return [
'id' => $p->id,
'name' => $p->name,
'active' => (bool) $p->active,
'balance' => $balance,
'today' => $c,
];
})->values();
return [
'id' => $p->id,
'name' => $p->name,
'active' => (bool) $p->active,
'balance' => $balance,
'today' => $c,
];
})->values();
});
},
]);
}

View File

@ -25,56 +25,109 @@ public function index(Request $request)
optional($setting)->segment_id,
])->filter()->unique()->values();
$contracts = Contract::query()
->with(['clientCase.person', 'clientCase.client.person', 'type', 'account'])
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
$q->whereHas('segments', function ($sq) use ($segmentIds) {
// Relation already filters on active pivots
$sq->whereIn('segments.id', $segmentIds);
});
}, function ($q) {
// No segments configured on FieldJobSetting -> return none
$q->whereRaw('1 = 0');
})
->latest('id')
->limit(50)
->get();
$search = $request->input('search');
$searchAssigned = $request->input('search_assigned');
$assignedUserId = $request->input('assigned_user_id');
$unassignedClientUuids = $request->input('unassigned_client_uuids');
$assignedClientUuids = $request->input('assigned_client_uuids');
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name
$contracts->each(function (Contract $contract): void {
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
$contract->setRelation('client', $contract->clientCase->client);
}
});
$unassignedContracts = Contract::query()
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account'])
->when($segmentIds->isNotEmpty(), fn($q) =>
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
fn($q) => $q->whereRaw('1 = 0')
)
->when( !empty($search), fn ($q) =>
$q->where(fn($sq) =>
$sq->where('reference', 'like', "%{$search}%")
->orWhereHas('clientCase.person', fn($psq) =>
$psq->where('full_name', 'ilike', "%{$search}%")
)
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
$ccpaq->where('address', 'ilike', "%{$search}")
)
)
)
->when(!empty($unassignedClientUuids) && is_array($unassignedClientUuids), fn ($q) =>
$q->whereHas('clientCase.client', fn($cq) =>
$cq->whereIn('uuid', $unassignedClientUuids)
)
)
->whereDoesntHave('fieldJobs', fn ($q) =>
$q->whereNull('completed_at')
->whereNull('cancelled_at')
)
->latest('id');
// Build active assignment map keyed by contract uuid for quicker UI checks
$assignments = collect();
if ($contracts->isNotEmpty()) {
$activeJobs = FieldJob::query()
->whereIn('contract_id', $contracts->pluck('id'))
->whereNull('completed_at')
->whereNull('cancelled_at')
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
->get();
$unassignedClients = $unassignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id')
->values();
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
return [
optional($job->contract)->uuid => [
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
'assigned_at' => $job->assigned_at,
],
];
})->filter();
}
$assignedContracts = Contract::query()
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user'])
->when($segmentIds->isNotEmpty(), fn($q) =>
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
fn($q) => $q->whereRaw('1 = 0')
)
->when( !empty($searchAssigned), fn ($q) =>
$q->where(fn($sq) =>
$sq->where('reference', 'like', "%{$searchAssigned}%")
->orWhereHas('clientCase.person', fn($psq) =>
$psq->where('full_name', 'ilike', "%{$searchAssigned}%")
)
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
$ccpaq->where('address', 'ilike', "%{$searchAssigned}")
)
)
)
->when(!empty($assignedClientUuids) && is_array($assignedClientUuids), fn ($q) =>
$q->whereHas('clientCase.client', fn($cq) =>
$cq->whereIn('uuid', $assignedClientUuids)
)
)
->whereHas('lastFieldJobs', fn ($q) =>
$q->whereNull('completed_at')
->whereNull('cancelled_at')
->when($assignedUserId && $assignedUserId !== 'all', fn ($jq) =>
$jq->where('assigned_user_id', $assignedUserId))
)
->latest('id');
$assignedClients = $assignedContracts->get()
->pluck('clientCase.client')
->filter()
->unique('id')
->values();
$users = User::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('FieldJob/Index', [
'setting' => $setting,
'contracts' => $contracts,
'unassignedContracts' => $unassignedContracts->paginate(
$request->input('per_page_contracts', 10),
['*'],
'page_contracts',
$request->input('page_contracts', 1)
),
'assignedContracts' => $assignedContracts->paginate(
$request->input('per_page_assignments', 10),
['*'],
'page_assignments',
$request->input('page_assignments', 1)
),
'unassignedClients' => $unassignedClients,
'assignedClients' => $assignedClients,
'users' => $users,
'assignments' => $assignments,
'filters' => [
'search' => $search,
'search_assigned' => $searchAssigned,
'assigned_user_id' => $assignedUserId,
'unassigned_client_uuids' => $unassignedClientUuids,
'assigned_client_uuids' => $assignedClientUuids,
],
]);
}

View File

@ -9,6 +9,8 @@
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
use App\Services\CsvImportService;
use App\Services\Import\ImportServiceV2;
use App\Services\Import\ImportSimulationServiceV2;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -21,14 +23,35 @@ class ImportController extends Controller
// List imports (paginated)
public function index(Request $request)
{
$paginator = Import::query()
$query = Import::query()
->with([
'client:id,uuid,person_id',
'client.person:id,uuid,full_name',
'template:id,name',
])
->orderByDesc('created_at')
->paginate(15);
->orderByDesc('created_at');
// Apply search filter
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('original_name', 'LIKE', "%{$search}%")
->orWhere('status', 'LIKE', "%{$search}%")
->orWhereHas('client.person', function ($q) use ($search) {
$q->where('full_name', 'LIKE', "%{$search}%");
})
->orWhereHas('template', function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%");
});
});
}
// Get per_page from request, default to 25
$perPage = (int) $request->input('per_page', 25);
if ($perPage < 1 || $perPage > 100) {
$perPage = 25;
}
$paginator = $query->paginate($perPage);
$imports = [
'data' => $paginator->items(),
@ -164,9 +187,24 @@ public function store(Request $request)
public function process(Import $import, Request $request, ImportProcessor $processor)
{
$import->update(['status' => 'validating', 'started_at' => now()]);
$result = $processor->process($import, user: $request->user());
return response()->json($result);
try {
$result = $processor->process($import, user: $request->user());
return response()->json($result);
} catch (\Throwable $e) {
\Log::error('Import processing failed', [
'import_id' => $import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$import->update(['status' => 'failed']);
return response()->json([
'success' => false,
'message' => 'Import processing failed: ' . $e->getMessage(),
], 500);
}
}
// Analyze the uploaded file and return column headers or positional indices
@ -405,7 +443,7 @@ public function missingContracts(Import $import)
// Query active, non-archived contracts for this client that were not in import
// Include person full_name (owner of the client case) and aggregate active accounts' balance_amount
$contractsQ = \App\Models\Contract::query()
$contractsQ = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'person.id', '=', 'client_cases.person_id')
->leftJoin('accounts', function ($join) {
@ -493,7 +531,7 @@ public function getEvents(Import $import)
public function missingKeyrefRows(Import $import)
{
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
$rowIds = \App\Models\ImportEvent::query()
$rowIds = ImportEvent::query()
->where('import_id', $import->id)
->where(function ($q) {
$q->where('event', 'contract_keyref_not_found')
@ -673,6 +711,10 @@ public function simulatePayments(Import $import, Request $request)
* using the first N rows and current saved mappings. Works for both payments and non-payments
* templates. For payments templates, payment-specific summaries/entities will be included
* automatically by the simulation service when mappings contain the payment root.
*
* @param Import $import
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function simulate(Import $import, Request $request)
{
@ -683,7 +725,7 @@ public function simulate(Import $import, Request $request)
$limit = (int) ($validated['limit'] ?? 100);
$verbose = (bool) ($validated['verbose'] ?? false);
$service = app(\App\Services\ImportSimulationService::class);
$service = app(ImportSimulationServiceV2::class);
$result = $service->simulate($import, $limit, $verbose);
return response()->json($result);
@ -785,6 +827,6 @@ public function destroy(Request $request, Import $import)
$import->delete();
return back()->with(['ok' => true]);
return back()->with('success', 'Import deleted successfully');
}
}

View File

@ -23,6 +23,16 @@ public function index()
->orderBy('name')
->get();
// Preload options for import mapping
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
->get(['clients.uuid', DB::raw('person.full_name as name')]);
$segments = Segment::query()->orderBy('name')->get(['id', 'name']);
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('Imports/Templates/Index', [
'templates' => $templates->map(fn ($t) => [
'uuid' => $t->uuid,
@ -35,6 +45,10 @@ public function index()
'name' => $t->client->person?->full_name,
] : null,
]),
'clients' => $clients,
'segments' => $segments,
'decisions' => $decisions,
'actions' => $actions,
]);
}
@ -547,6 +561,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'options' => 'nullable|array',
'position' => 'nullable|integer',
])->validate();
$mapping->update([
'source_column' => $data['source_column'],
'entity' => $data['entity'] ?? null,
@ -557,8 +572,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'position' => $data['position'] ?? $mapping->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping updated');
return back()->with('success', 'Mapping updated');
}
// Delete a mapping
@ -643,6 +657,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
$import->update([
'import_template_id' => $template->id,
'reactivate' => $template->reactivate,
'meta' => $merged,
]);
});
@ -664,4 +679,138 @@ public function destroy(ImportTemplate $template)
return redirect()->route('importTemplates.index')->with('success', 'Template deleted');
}
// Export template as JSON file
public function export(ImportTemplate $template)
{
$template->load('mappings');
$data = [
'name' => $template->name,
'description' => $template->description,
'source_type' => $template->source_type,
'default_record_type' => $template->default_record_type,
'sample_headers' => $template->sample_headers,
'is_active' => $template->is_active,
'reactivate' => $template->reactivate,
'meta' => $template->meta,
'mappings' => $template->mappings->map(fn ($m) => [
'source_column' => $m->source_column,
'entity' => $m->entity,
'target_field' => $m->target_field,
'transform' => $m->transform,
'apply_mode' => $m->apply_mode,
'options' => $m->options,
'position' => $m->position,
])->values()->toArray(),
];
$filename = Str::slug($template->name).'-'.now()->format('Y-m-d').'.json';
return response()->json($data)
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
}
// Import template from JSON file
public function import(Request $request)
{
$data = $request->validate([
'file' => 'required|file|mimes:json,txt|max:10240',
'client_uuid' => 'nullable|string|exists:clients,uuid',
'segment_id' => 'nullable|integer|exists:segments,id',
'decision_id' => 'nullable|integer|exists:decisions,id',
'action_id' => 'nullable|integer|exists:actions,id',
'activity_action_id' => 'nullable|integer|exists:actions,id',
'activity_decision_id' => 'nullable|integer|exists:decisions,id',
]);
$file = $request->file('file');
$contents = file_get_contents($file->getRealPath());
$json = json_decode($contents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return back()->withErrors(['file' => 'Invalid JSON file']);
}
// Validate structure
$validator = validator($json, [
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'source_type' => 'required|string|in:csv,xml,xls,xlsx,json',
'default_record_type' => 'nullable|string|max:50',
'sample_headers' => 'nullable|array',
'is_active' => 'nullable|boolean',
'reactivate' => 'nullable|boolean',
'meta' => 'nullable|array',
'mappings' => 'nullable|array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
'mappings.*.options' => 'nullable|array',
'mappings.*.position' => 'nullable|integer',
]);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
$clientId = null;
if (! empty($data['client_uuid'])) {
$clientId = Client::where('uuid', $data['client_uuid'])->value('id');
}
// Replace IDs in meta if provided
$meta = $json['meta'] ?? [];
if (! empty($data['segment_id'])) {
$meta['segment_id'] = (int) $data['segment_id'];
}
if (! empty($data['decision_id'])) {
$meta['decision_id'] = (int) $data['decision_id'];
}
if (! empty($data['action_id'])) {
$meta['action_id'] = (int) $data['action_id'];
}
if (! empty($data['activity_action_id'])) {
$meta['activity_action_id'] = (int) $data['activity_action_id'];
}
if (! empty($data['activity_decision_id'])) {
$meta['activity_decision_id'] = (int) $data['activity_decision_id'];
}
$template = null;
DB::transaction(function () use (&$template, $request, $json, $clientId, $meta) {
$template = ImportTemplate::create([
'uuid' => (string) Str::uuid(),
'name' => $json['name'],
'description' => $json['description'] ?? null,
'source_type' => $json['source_type'],
'default_record_type' => $json['default_record_type'] ?? null,
'sample_headers' => $json['sample_headers'] ?? null,
'user_id' => $request->user()?->id,
'client_id' => $clientId,
'is_active' => $json['is_active'] ?? true,
'reactivate' => $json['reactivate'] ?? false,
'meta' => $meta,
]);
foreach (($json['mappings'] ?? []) as $m) {
ImportTemplateMapping::create([
'import_template_id' => $template->id,
'entity' => $m['entity'] ?? null,
'source_column' => $m['source_column'],
'target_field' => $m['target_field'] ?? null,
'transform' => $m['transform'] ?? null,
'apply_mode' => $m['apply_mode'] ?? 'both',
'options' => $m['options'] ?? null,
'position' => $m['position'] ?? null,
]);
}
});
return redirect()
->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Template imported successfully');
}
}

View File

@ -19,7 +19,7 @@ public function unread(Request $request)
}
$today = now()->toDateString();
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
$search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientId = null;

View File

@ -26,18 +26,10 @@ public function update(Person $person, Request $request)
$person->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Person updated');
}
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
return response()->json([
'person' => [
'full_name' => $person->full_name,
'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_number,
'description' => $person->description,
],
]);
}
public function createAddress(Person $person, Request $request)
@ -60,13 +52,8 @@ public function createAddress(Person $person, Request $request)
], $attributes);
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address created');
}
return back()->with('success', 'Address created')->with('flash_method', 'POST');
return response()->json([
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
]);
}
public function updateAddress(Person $person, int $address_id, Request $request)
@ -84,13 +71,8 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address updated');
}
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
return response()->json([
'address' => $address,
]);
}
public function deleteAddress(Person $person, int $address_id, Request $request)
@ -98,11 +80,8 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
}
public function createPhone(Person $person, Request $request)
@ -122,7 +101,7 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
return back()->with('success', 'Phone added successfully');
return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
}
public function updatePhone(Person $person, int $phone_id, Request $request)
@ -140,7 +119,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
$phone->update($attributes);
return back()->with('success', 'Phone updated successfully');
return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
}
public function deletePhone(Person $person, int $phone_id, Request $request)
@ -148,7 +127,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
return back()->with('success', 'Phone deleted');
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
}
public function createEmail(Person $person, Request $request)
@ -170,7 +149,7 @@ public function createEmail(Person $person, Request $request)
'value' => $attributes['value'],
], $attributes);
return back()->with('success', 'Email added successfully');
return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
}
public function updateEmail(Person $person, int $email_id, Request $request)
@ -191,7 +170,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
$email->update($attributes);
return back()->with('success', 'Email updated successfully');
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
}
public function deleteEmail(Person $person, int $email_id, Request $request)
@ -203,7 +182,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
return back()->with('success', 'Email deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
}
// TRR (bank account) CRUD
@ -225,13 +204,10 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR added successfully');
}
return response()->json([
'trr' => BankAccount::findOrFail($trr->id),
]);
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
}
public function updateTrr(Person $person, int $trr_id, Request $request)
@ -253,13 +229,8 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR updated successfully');
}
return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
return response()->json([
'trr' => $trr,
]);
}
public function deleteTrr(Person $person, int $trr_id, Request $request)
@ -267,10 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
}
}

View File

@ -3,16 +3,22 @@
namespace App\Http\Controllers;
use App\Models\FieldJob;
use App\Services\ReferenceDataCache;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PhoneViewController extends Controller
{
public function __construct(protected ReferenceDataCache $referenceCache) {}
public function index(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$jobs = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at')
@ -21,32 +27,78 @@ public function index(Request $request)
$q->with([
'type:id,name',
'account',
'clientCase.person' => function ($pq) {
$pq->with(['addresses', 'phones']);
},
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('assigned_at')
->limit(100)
->get();
->orderByDesc('assigned_at');
// Apply client filter
if ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter);
});
}
// Apply search filter
if ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
});
});
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
$q->where('assigned_user_id', $userId)
->whereNull('completed_at')
->whereNull('cancelled_at');
})
->with(['person:id,full_name'])
->get(['uuid', 'person_id'])
->map(fn ($c) => [
'uuid' => (string) $c->uuid,
'name' => (string) optional($c->person)->full_name,
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return Inertia::render('Phone/Index', [
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'assigned',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]);
}
public function completedToday(Request $request)
{
$userId = $request->user()->id;
$search = $request->input('search');
$clientFilter = $request->input('client');
$perPage = $request->integer('per_page', 15);
$perPage = max(1, min(100, $perPage));
$start = now()->startOfDay();
$end = now()->endOfDay();
$jobs = FieldJob::query()
$query = FieldJob::query()
->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end])
@ -55,21 +107,63 @@ public function completedToday(Request $request)
$q->with([
'type:id,name',
'account',
'clientCase.person' => function ($pq) {
$pq->with(['addresses', 'phones']);
},
'clientCase.person.address.type',
'clientCase.person.phones',
'clientCase.client:id,uuid,person_id',
'clientCase.client.person:id,full_name',
]);
},
])
->orderByDesc('completed_at')
->limit(100)
->get();
->orderByDesc('completed_at');
// Apply client filter
if ($clientFilter) {
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
$q->where('uuid', $clientFilter);
});
}
// Apply search filter
if ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contract', function ($cq) use ($search) {
$cq->where('reference', 'ilike', '%'.$search.'%')
->orWhereHas('clientCase.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
})
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
$pq->where('full_name', 'ilike', '%'.$search.'%');
});
});
});
}
$jobs = $query->paginate($perPage)->withQueryString();
// Get unique clients for filter dropdown
$clients = \App\Models\Client::query()
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
$q->where('assigned_user_id', $userId)
->whereNull('cancelled_at')
->whereBetween('completed_at', [$start, $end]);
})
->with(['person:id,full_name'])
->get(['uuid', 'person_id'])
->map(fn ($c) => [
'uuid' => (string) $c->uuid,
'name' => (string) optional($c->person)->full_name,
])
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
->values();
return Inertia::render('Phone/Index', [
'jobs' => $jobs,
'clients' => $clients,
'view_mode' => 'completed-today',
'filters' => [
'search' => $search,
'client' => $clientFilter,
],
]);
}
@ -79,7 +173,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
$completedMode = $request->boolean('completed');
// Eager load case with person details
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
$case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
// Query contracts based on field jobs
$contractsQuery = FieldJob::query()
@ -129,7 +223,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
->unique();
return Inertia::render('Phone/Case/Index', [
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
'client_case' => $case,
'contracts' => $contracts,
'documents' => $documents,

View File

@ -0,0 +1,423 @@
<?php
namespace App\Http\Controllers;
use App\Models\Report;
use App\Services\ReportQueryBuilder;
use Illuminate\Http\Request;
use Inertia\Inertia;
// facades referenced with fully-qualified names below to satisfy static analysis
class ReportController extends Controller
{
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
public function index(Request $request)
{
$reports = Report::where('enabled', true)
->orderBy('order')
->orderBy('name')
->get()
->map(fn ($r) => [
'slug' => $r->slug,
'name' => $r->name,
'description' => $r->description,
'category' => $r->category,
])
->values();
return Inertia::render('Reports/Index', [
'reports' => $reports,
]);
}
public function show(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
// Accept filters & pagination from query and return initial data for server-driven table
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return Inertia::render('Reports/Show', [
'slug' => $report->slug,
'name' => $report->name,
'description' => $report->description,
'inputs' => $inputs,
'columns' => $this->buildColumnsArray($report),
'rows' => $rows,
'meta' => [
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'last_page' => $paginator->lastPage(),
],
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
]);
}
public function data(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$perPage = (int) ($request->integer('per_page') ?: 25);
$query = $this->queryBuilder->build($report, $filters);
$paginator = $query->paginate($perPage);
$rows = collect($paginator->items())
->map(fn ($row) => $this->normalizeRow($row))
->values();
return response()->json([
'data' => $rows,
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
]);
}
public function export(string $slug, Request $request)
{
$report = Report::with(['filters', 'columns'])
->where('slug', $slug)
->where('enabled', true)
->firstOrFail();
$inputs = $this->buildInputsArray($report);
$filters = $this->validateFilters($inputs, $request);
$format = strtolower((string) $request->get('format', 'csv'));
$query = $this->queryBuilder->build($report, $filters);
$rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
$columns = $this->buildColumnsArray($report);
$filename = $report->slug.'-'.now()->format('Ymd_His');
if ($format === 'pdf') {
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
'name' => $report->name,
'columns' => $columns,
'rows' => $rows,
]);
return $pdf->download($filename.'.pdf');
}
if ($format === 'xlsx') {
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
// Convert values for correct Excel rendering (dates, numbers, text)
$array = $this->prepareXlsxArray($rows, $keys);
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
$columnFormats = [];
$textColumns = [];
$dateColumns = [];
foreach ($keys as $i => $key) {
$letter = $this->excelColumnLetter($i + 1);
if ($key === 'contract_reference') {
$columnFormats[$letter] = '@';
$textColumns[] = $letter;
continue;
}
if (str_ends_with($key, '_at')) {
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
$dateColumns[] = $letter;
continue;
}
}
// Anonymous export with custom value binder to force text where needed
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
{
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
public function array(): array
{
return $this->array;
}
public function headings(): array
{
return $this->headings;
}
public function columnFormats(): array
{
return $this->formats;
}
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
{
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
// Force text for configured columns or very long digit-only strings (>15)
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
public function registerEvents(): array
{
return [
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
$sheet = $event->sheet->getDelegate();
// Data starts at row 2 (row 1 is headings)
$rowIndex = 2;
foreach ($this->array as $row) {
foreach (array_values($row) as $i => $val) {
$colLetter = $this->colLetter($i + 1);
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
continue; // already handled via columnFormats or binder
}
$coord = $colLetter.$rowIndex;
$fmt = null;
if (is_int($val)) {
// Integer: thousands separator, no decimals
$fmt = '#,##0';
} elseif (is_float($val)) {
// Float: show decimals only if fractional part exists
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
}
if ($fmt) {
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
}
}
$rowIndex++;
}
},
];
}
private function colLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
};
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
}
// Default CSV export
$keys = array_map(fn ($c) => $c['key'], $columns);
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
$csv = fopen('php://temp', 'r+');
fputcsv($csv, $headings);
foreach ($rows as $r) {
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
fputcsv($csv, $line);
}
rewind($csv);
$content = stream_get_contents($csv) ?: '';
fclose($csv);
return response($content, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
]);
}
/**
* Lightweight users lookup for filters: id + name, optional search and limit.
*/
public function users(Request $request)
{
$search = trim((string) $request->get('search', ''));
$limit = (int) ($request->integer('limit') ?: 10);
$q = \App\Models\User::query()->orderBy('name');
if ($search !== '') {
$like = '%'.mb_strtolower($search).'%';
$q->where(function ($qq) use ($like) {
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
});
}
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
return response()->json($users);
}
/**
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
*/
public function clients(Request $request)
{
$clients = \App\Models\Client::query()
->with('person:id,full_name')
->get()
->map(fn($c) => [
'id' => $c->uuid,
'name' => $c->person->full_name ?? 'Unknown'
])
->sortBy('name')
->values();
return response()->json($clients);
}
/**
* Build validation rules based on inputs descriptor and validate.
*
* @param array<int, array<string, mixed>> $inputs
* @return array<string, mixed>
*/
protected function validateFilters(array $inputs, Request $request): array
{
$rules = [];
foreach ($inputs as $inp) {
$key = $inp['key'];
$type = $inp['type'] ?? 'string';
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
$rules[$key] = match ($type) {
'date' => [$nullable, 'date'],
'integer' => [$nullable, 'integer'],
'select:user' => [$nullable, 'integer', 'exists:users,id'],
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
default => [$nullable, 'string'],
};
}
return $request->validate($rules);
}
/**
* Build inputs array from report filters.
*/
protected function buildInputsArray(Report $report): array
{
return $report->filters->map(fn($filter) => [
'key' => $filter->key,
'type' => $filter->type,
'label' => $filter->label,
'nullable' => $filter->nullable,
'default' => $filter->default_value,
'options' => $filter->options,
])->toArray();
}
/**
* Build columns array from report columns.
*/
protected function buildColumnsArray(Report $report): array
{
return $report->columns
->where('visible', true)
->map(fn($col) => [
'key' => $col->key,
'label' => $col->label,
])
->toArray();
}
/**
* Ensure derived export/display fields exist on row objects.
*/
protected function normalizeRow(object $row): object
{
if (isset($row->contract) && ! isset($row->contract_reference)) {
$row->contract_reference = $row->contract->reference ?? null;
}
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
$row->assigned_user_name = $row->assignedUser->name ?? null;
}
return $row;
}
/**
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
*
* @param iterable<int, object|array> $rows
* @param array<int, string> $keys
* @return array<int, array<int, mixed>>
*/
protected function prepareXlsxArray(iterable $rows, array $keys): array
{
$out = [];
foreach ($rows as $r) {
$line = [];
foreach ($keys as $k) {
$v = data_get($r, $k);
if ($k === 'contract_reference') {
$line[] = (string) $v;
continue;
}
if (str_ends_with($k, '_at')) {
if (empty($v)) {
$line[] = null;
} else {
try {
$dt = \Carbon\Carbon::parse($v);
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
} catch (\Throwable $e) {
$line[] = (string) $v;
}
}
continue;
}
if (is_int($v) || is_float($v)) {
$line[] = $v;
} elseif (is_numeric($v) && is_string($v)) {
// cast numeric-like strings unless they are identifiers that we want as text
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
} else {
$line[] = $v;
}
}
$out[] = $line;
}
return $out;
}
/**
* Convert 1-based index to Excel column letter.
*/
protected function excelColumnLetter(int $index): string
{
$letter = '';
while ($index > 0) {
$mod = ($index - 1) % 26;
$letter = chr(65 + $mod).$letter;
$index = intdiv($index - $mod, 26) - 1;
}
return $letter;
}
}

View File

@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Report;
use App\Models\ReportEntity;
use App\Models\ReportColumn;
use App\Models\ReportFilter;
use App\Models\ReportCondition;
use App\Models\ReportOrder;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ReportSettingsController extends Controller
{
public function index()
{
$reports = Report::orderBy('order')->orderBy('name')->get();
return Inertia::render('Settings/Reports/Index', [
'reports' => $reports,
]);
}
public function edit(Report $report)
{
$report->load(['entities', 'columns', 'filters', 'conditions', 'orders']);
return Inertia::render('Settings/Reports/Edit', [
'report' => $report,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'slug' => 'required|string|unique:reports,slug|max:255',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'enabled' => 'boolean',
'order' => 'integer',
]);
$report = Report::create($validated);
return redirect()->route('settings.reports.index')
->with('success', 'Report created successfully.');
}
public function update(Request $request, Report $report)
{
$validated = $request->validate([
'slug' => 'required|string|unique:reports,slug,' . $report->id . '|max:255',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'nullable|string|max:100',
'enabled' => 'boolean',
'order' => 'integer',
]);
$report->update($validated);
return redirect()->route('settings.reports.index')
->with('success', 'Report updated successfully.');
}
public function destroy(Report $report)
{
$report->delete();
return redirect()->route('settings.reports.index')
->with('success', 'Report deleted successfully.');
}
public function toggleEnabled(Report $report)
{
$report->update(['enabled' => !$report->enabled]);
return back()->with('success', 'Report status updated.');
}
// Entity CRUD
public function storeEntity(Request $request, Report $report)
{
$validated = $request->validate([
'model_class' => 'required|string|max:255',
'alias' => 'nullable|string|max:50',
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
'join_first' => 'nullable|string|max:100',
'join_operator' => 'nullable|string|max:10',
'join_second' => 'nullable|string|max:100',
'order' => 'integer',
]);
$report->entities()->create($validated);
return back()->with('success', 'Entity added successfully.');
}
public function updateEntity(Request $request, ReportEntity $entity)
{
$validated = $request->validate([
'model_class' => 'required|string|max:255',
'alias' => 'nullable|string|max:50',
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
'join_first' => 'nullable|string|max:100',
'join_operator' => 'nullable|string|max:10',
'join_second' => 'nullable|string|max:100',
'order' => 'integer',
]);
$entity->update($validated);
return back()->with('success', 'Entity updated successfully.');
}
public function destroyEntity(ReportEntity $entity)
{
$entity->delete();
return back()->with('success', 'Entity deleted successfully.');
}
// Column CRUD
public function storeColumn(Request $request, Report $report)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'expression' => 'required|string',
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'nullable|array',
]);
$report->columns()->create($validated);
return back()->with('success', 'Column added successfully.');
}
public function updateColumn(Request $request, ReportColumn $column)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'expression' => 'required|string',
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'nullable|array',
]);
$column->update($validated);
return back()->with('success', 'Column updated successfully.');
}
public function destroyColumn(ReportColumn $column)
{
$column->delete();
return back()->with('success', 'Column deleted successfully.');
}
// Filter CRUD
public function storeFilter(Request $request, Report $report)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'nullable' => 'boolean',
'default_value' => 'nullable|string',
'options' => 'nullable|array',
'data_source' => 'nullable|string|max:255',
'order' => 'integer',
]);
$report->filters()->create($validated);
return back()->with('success', 'Filter added successfully.');
}
public function updateFilter(Request $request, ReportFilter $filter)
{
$validated = $request->validate([
'key' => 'required|string|max:100',
'label' => 'required|string|max:255',
'type' => 'required|string|max:50',
'nullable' => 'boolean',
'default_value' => 'nullable|string',
'options' => 'nullable|array',
'data_source' => 'nullable|string|max:255',
'order' => 'integer',
]);
$filter->update($validated);
return back()->with('success', 'Filter updated successfully.');
}
public function destroyFilter(ReportFilter $filter)
{
$filter->delete();
return back()->with('success', 'Filter deleted successfully.');
}
// Condition CRUD
public function storeCondition(Request $request, Report $report)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'operator' => 'required|string|max:50',
'value_type' => 'required|in:static,filter,expression',
'value' => 'nullable|string',
'filter_key' => 'nullable|string|max:100',
'logical_operator' => 'required|in:AND,OR',
'group_id' => 'nullable|integer',
'order' => 'integer',
'enabled' => 'boolean',
]);
$report->conditions()->create($validated);
return back()->with('success', 'Condition added successfully.');
}
public function updateCondition(Request $request, ReportCondition $condition)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'operator' => 'required|string|max:50',
'value_type' => 'required|in:static,filter,expression',
'value' => 'nullable|string',
'filter_key' => 'nullable|string|max:100',
'logical_operator' => 'required|in:AND,OR',
'group_id' => 'nullable|integer',
'order' => 'integer',
'enabled' => 'boolean',
]);
$condition->update($validated);
return back()->with('success', 'Condition updated successfully.');
}
public function destroyCondition(ReportCondition $condition)
{
$condition->delete();
return back()->with('success', 'Condition deleted successfully.');
}
// Order CRUD
public function storeOrder(Request $request, Report $report)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'direction' => 'required|in:ASC,DESC',
'order' => 'integer',
]);
$report->orders()->create($validated);
return back()->with('success', 'Order clause added successfully.');
}
public function updateOrder(Request $request, ReportOrder $order)
{
$validated = $request->validate([
'column' => 'required|string|max:255',
'direction' => 'required|in:ASC,DESC',
'order' => 'integer',
]);
$order->update($validated);
return back()->with('success', 'Order clause updated successfully.');
}
public function destroyOrder(ReportOrder $order)
{
$order->delete();
return back()->with('success', 'Order clause deleted successfully.');
}
}

View File

@ -57,6 +57,7 @@ public function share(Request $request): array
'error' => fn () => $request->session()->get('error'),
'warning' => fn () => $request->session()->get('warning'),
'info' => fn () => $request->session()->get('info'),
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
],
'notifications' => function () use ($request) {
try {

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ActivityCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
// Transform data to add user_name attribute
$this->collection->transform(function ($activity) {
$activity->setAttribute('user_name', optional($activity->user)->name);
return $activity;
});
return $this->resource->toArray();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ContractCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return $this->resource->toArray();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class DocumentCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Services\Import\ImportServiceV2;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessLargeImportJob implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 3600; // 1 hour
public $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public Import $import,
public ?int $userId = null
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info('ProcessLargeImportJob started', [
'import_id' => $this->import->id,
'user_id' => $this->userId,
]);
try {
$user = $this->userId ? \App\Models\User::find($this->userId) : null;
$service = app(ImportServiceV2::class);
$results = $service->process($this->import, $user);
Log::info('ProcessLargeImportJob completed', [
'import_id' => $this->import->id,
'results' => $results,
]);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_completed',
'level' => 'info',
'message' => sprintf(
'Queued import completed: %d imported, %d skipped, %d invalid',
$results['imported'],
$results['skipped'],
$results['invalid']
),
'context' => $results,
]);
} catch (\Throwable $e) {
Log::error('ProcessLargeImportJob failed', [
'import_id' => $this->import->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->import->update(['status' => 'failed']);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_failed',
'level' => 'error',
'message' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('ProcessLargeImportJob permanently failed', [
'import_id' => $this->import->id,
'error' => $exception->getMessage(),
]);
$this->import->update(['status' => 'failed']);
ImportEvent::create([
'import_id' => $this->import->id,
'user_id' => $this->userId,
'event' => 'queue_job_permanently_failed',
'level' => 'error',
'message' => 'Import job failed after maximum retries: '.$exception->getMessage(),
]);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -57,6 +59,69 @@ protected static function booted()
});
}
/**
* Scope activities to those linked to contracts within a specific segment.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder
{
return $query->where(function ($q) use ($contractIds) {
$q->whereNull('contract_id');
if (! empty($contractIds)) {
$q->orWhereIn('contract_id', $contractIds);
}
});
}
/**
* Scope activities with decoded base64 filters.
*/
#[Scope]
public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder
{
if (empty($encodedFilters)) {
return $query;
}
try {
$decompressed = base64_decode($encodedFilters);
$filters = json_decode($decompressed, true);
if (! is_array($filters)) {
return $query;
}
if (! empty($filters['action_id'])) {
$query->where('action_id', $filters['action_id']);
}
if (! empty($filters['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']);
if ($contract) {
$query->where('contract_id', $contract->id);
}
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
} catch (\Throwable $e) {
\Log::error('Invalid activity filter format', [
'error' => $e->getMessage(),
]);
}
return $query;
}
public function action(): BelongsTo
{
return $this->belongsTo(\App\Models\Action::class);

View File

@ -3,6 +3,8 @@
namespace App\Models;
use App\Traits\Uuid;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -27,6 +29,7 @@ class Contract extends Model
'end_date',
'client_case_id',
'type_id',
'active',
'description',
'meta',
];
@ -78,6 +81,20 @@ protected function endDate(): Attribute
);
}
/**
* Scope contracts to those in a specific segment with active pivot.
*/
#[Scope]
public function scopeForSegment(Builder $query, int $segmentId): Builder
{
return $query->whereExists(function ($q) use ($segmentId) {
$q->from('contract_segment')
->whereColumn('contract_segment.contract_id', 'contracts.id')
->where('contract_segment.segment_id', $segmentId)
->where('contract_segment.active', true);
});
}
public function type(): BelongsTo
{
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
@ -124,6 +141,10 @@ public function fieldJobs(): HasMany
return $this->hasMany(\App\Models\FieldJob::class);
}
public function lastFieldJobs(): HasOne {
return $this->hasOne(\App\Models\FieldJob::class)->latestOfMany();
}
public function latestObject(): HasOne
{
return $this->hasOne(\App\Models\CaseObject::class)

View File

@ -17,6 +17,11 @@ class ImportEntity extends Model
'meta',
'rules',
'ui',
'handler_class',
'validation_rules',
'processing_options',
'is_active',
'priority',
];
protected $casts = [
@ -27,5 +32,9 @@ class ImportEntity extends Model
'meta' => 'boolean',
'rules' => 'array',
'ui' => 'array',
'validation_rules' => 'array',
'processing_options' => 'array',
'is_active' => 'boolean',
'priority' => 'integer',
];
}

View File

@ -22,6 +22,11 @@ class ImportTemplate extends Model
'reactivate' => 'boolean',
];
public function getRouteKeyName(): string
{
return 'uuid';
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

48
app/Models/Report.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Report extends Model
{
protected $fillable = [
'slug',
'name',
'description',
'category',
'enabled',
'order',
];
protected $casts = [
'enabled' => 'boolean',
'order' => 'integer',
];
public function entities(): HasMany
{
return $this->hasMany(ReportEntity::class)->orderBy('order');
}
public function columns(): HasMany
{
return $this->hasMany(ReportColumn::class)->orderBy('order');
}
public function filters(): HasMany
{
return $this->hasMany(ReportFilter::class)->orderBy('order');
}
public function conditions(): HasMany
{
return $this->hasMany(ReportCondition::class)->orderBy('order');
}
public function orders(): HasMany
{
return $this->hasMany(ReportOrder::class)->orderBy('order');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportColumn extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'expression',
'sortable',
'visible',
'order',
'format_options',
];
protected $casts = [
'sortable' => 'boolean',
'visible' => 'boolean',
'order' => 'integer',
'format_options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportCondition extends Model
{
protected $fillable = [
'report_id',
'column',
'operator',
'value_type',
'value',
'filter_key',
'logical_operator',
'group_id',
'order',
'enabled',
];
protected $casts = [
'enabled' => 'boolean',
'order' => 'integer',
'group_id' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportEntity extends Model
{
protected $fillable = [
'report_id',
'model_class',
'alias',
'join_type',
'join_first',
'join_operator',
'join_second',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportFilter extends Model
{
protected $fillable = [
'report_id',
'key',
'label',
'type',
'nullable',
'default_value',
'options',
'data_source',
'order',
];
protected $casts = [
'nullable' => 'boolean',
'order' => 'integer',
'options' => 'array',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportOrder extends Model
{
protected $fillable = [
'report_id',
'column',
'direction',
'order',
];
protected $casts = [
'order' => 'integer',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Services;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class ClientCaseDataService
{
/**
* Get paginated contracts for a client case with optional segment filtering.
*/
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
{
$query = $clientCase->contracts()
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
->with([
'type:id,name',
'account' => function ($q) {
$q->select([
'accounts.id',
'accounts.contract_id',
'accounts.type_id',
'accounts.initial_amount',
'accounts.balance_amount',
'accounts.promise_date',
'accounts.created_at',
'accounts.updated_at',
])->orderByDesc('accounts.id');
},
'segments:id,name',
'objects:id,contract_id,reference,name,description,type,created_at',
])
->orderByDesc('created_at');
if (! empty($segmentId)) {
$query->forSegment($segmentId);
}
$perPage = max(1, min(100, $perPage));
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
}
/**
* Get paginated activities for a client case with optional segment and filter constraints.
*/
public function getActivities(
ClientCase $clientCase,
?int $segmentId = null,
?string $encodedFilters = null,
array $contractIds = [],
int $perPage = 20
): LengthAwarePaginator {
$query = $clientCase->activities()
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
->orderByDesc('created_at');
if (! empty($segmentId)) {
$query->forSegment($segmentId, $contractIds);
}
if (! empty($encodedFilters)) {
$query->withFilters($encodedFilters, $clientCase);
}
$perPage = max(1, min(100, $perPage));
return $query->paginate($perPage, ['*'], 'activities_page')->withQueryString();
}
/**
* Get merged documents from case and its contracts.
*/
public function getDocuments(ClientCase $clientCase, array $contractIds = [], int $perPage = 15): LengthAwarePaginator
{
$query = null;
$caseDocsQuery = Document::query()
->select([
'documents.id',
'documents.uuid',
'documents.documentable_id',
'documents.documentable_type',
'documents.name',
'documents.file_name',
'documents.original_name',
'documents.extension',
'documents.mime_type',
'documents.size',
'documents.created_at',
'documents.is_public',
\DB::raw('NULL as contract_reference'),
\DB::raw('NULL as contract_uuid'),
\DB::raw("'{$clientCase->uuid}' as client_case_uuid"),
\DB::raw('users.name as created_by'),
])
->join('users', 'documents.user_id', '=', 'users.id')
->where('documents.documentable_type', ClientCase::class)
->where('documents.documentable_id', $clientCase->id);
if (! empty($contractIds)) {
// Get contract references for mapping
$contracts = Contract::query()
->whereIn('id', $contractIds)
->get(['id', 'uuid', 'reference'])
->keyBy('id');
$contractDocsQuery = Document::query()
->select([
'documents.id',
'documents.uuid',
'documents.documentable_id',
'documents.documentable_type',
'documents.name',
'documents.file_name',
'documents.original_name',
'documents.extension',
'documents.mime_type',
'documents.size',
'documents.created_at',
'documents.is_public',
'contracts.reference as contract_reference',
'contracts.uuid as contract_uuid',
\DB::raw('NULL as client_case_uuid'),
\DB::raw('users.name as created_by'),
])
->join('users', 'documents.user_id', '=', 'users.id')
->join('contracts', 'documents.documentable_id', '=', 'contracts.id')
->where('documents.documentable_type', Contract::class)
->whereIn('documents.documentable_id', $contractIds);
// Union the queries
$query = $caseDocsQuery->union($contractDocsQuery);
} else {
$query = $caseDocsQuery;
}
return \DB::table(\DB::raw("({$query->toSql()}) as documents"))
->mergeBindings($query->getQuery())
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'documentsPage')
->withQueryString();
}
/**
* Get archive metadata from latest non-reactivate archive setting.
*/
public function getArchiveMeta(): array
{
$latestArchiveSetting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where(function ($q) {
$q->whereNull('reactivate')->orWhere('reactivate', false);
})
->orderByDesc('id')
->first();
$archiveSegmentId = optional($latestArchiveSetting)->segment_id;
$relatedArchiveTables = [];
if ($latestArchiveSetting) {
$entities = (array) $latestArchiveSetting->entities;
foreach ($entities as $edef) {
if (isset($edef['related']) && is_array($edef['related'])) {
foreach ($edef['related'] as $rel) {
$relatedArchiveTables[] = $rel;
}
}
}
$relatedArchiveTables = array_values(array_unique($relatedArchiveTables));
}
return [
'archive_segment_id' => $archiveSegmentId,
'related_tables' => $relatedArchiveTables,
];
}
}

View File

@ -2,57 +2,11 @@
namespace App\Services;
class DateNormalizer
/**
* Backward compatibility alias for DateNormalizer.
* Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer.
*/
class DateNormalizer extends \App\Services\Import\DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// Rebuild date with corrected year
$month = (int) $dt->format('m');
$day = (int) $dt->format('d');
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
return $dt->format('Y-m-d');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
// This class extends the actual DateNormalizer for backward compatibility
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Services\Documents;
use App\Models\Document;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentStreamService
{
/**
* Stream a document either inline or as attachment with all Windows/public fallbacks.
*/
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
{
$disk = $document->disk ?: 'public';
$relPath = $this->normalizePath($document->path ?? '');
// Handle DOC/DOCX previews for inline viewing
if ($inline) {
$previewResponse = $this->tryPreview($document);
if ($previewResponse) {
return $previewResponse;
}
}
// Try to find the file using multiple path candidates
$found = $this->findFile($disk, $relPath);
if (! $found) {
// Try public/ fallback
$found = $this->tryPublicFallback($relPath);
if (! $found) {
abort(404, 'Document file not found');
}
}
$headers = $this->buildHeaders($document, $inline);
// Try streaming first
$stream = Storage::disk($disk)->readStream($found);
if ($stream !== false) {
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, $headers);
}
// Fallbacks on readStream failure
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
}
/**
* Normalize path for Windows and legacy prefixes.
*/
protected function normalizePath(string $path): string
{
$path = str_replace('\\', '/', $path);
$path = ltrim($path, '/');
if (str_starts_with($path, 'public/')) {
$path = substr($path, 7);
}
return $path;
}
/**
* Build path candidates to try.
*/
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
{
$candidates = [$relPath];
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null;
if ($raw && $raw !== $relPath) {
$candidates[] = $raw;
}
if (str_starts_with($relPath, 'storage/')) {
$candidates[] = substr($relPath, 8);
}
if ($raw && str_starts_with($raw, 'storage/')) {
$candidates[] = substr($raw, 8);
}
return array_unique($candidates);
}
/**
* Try to find file using path candidates.
*/
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
{
$candidates = $this->buildPathCandidates($relPath, $documentPath);
foreach ($candidates as $cand) {
if (Storage::disk($disk)->exists($cand)) {
return $cand;
}
}
return null;
}
/**
* Try public/ fallback path.
*/
protected function tryPublicFallback(string $relPath): ?string
{
$publicFull = public_path($relPath);
$real = @realpath($publicFull);
$publicRoot = @realpath(public_path());
$realN = $real ? str_replace('\\\\', '/', $real) : null;
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
return $real;
}
return null;
}
/**
* Try to stream preview for DOC/DOCX files.
*/
protected function tryPreview(Document $document): StreamedResponse|Response|null
{
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
if (! in_array($ext, ['doc', 'docx'])) {
return null;
}
$previewDisk = config('files.preview_disk', 'public');
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
if ($stream !== false) {
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
return response()->stream(function () use ($stream) {
fpassthru($stream);
}, 200, [
'Content-Type' => $document->preview_mime ?: 'application/pdf',
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
]);
}
}
// Queue preview generation if not available
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
return response('Preview is being generated. Please try again shortly.', 202);
}
/**
* Build response headers.
*/
protected function buildHeaders(Document $document, bool $inline): array
{
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
return [
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
'Cache-Control' => 'private, max-age=0, no-cache',
'Pragma' => 'no-cache',
];
}
/**
* Fallback streaming methods when readStream fails.
*/
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
{
// Fallback 1: get() the bytes directly
try {
$bytes = Storage::disk($disk)->get($found);
if (! is_null($bytes) && $bytes !== false) {
return response($bytes, 200, $headers);
}
} catch (\Throwable $e) {
// Continue to next fallback
}
// Fallback 2: open via absolute storage path
$abs = null;
try {
if (method_exists(Storage::disk($disk), 'path')) {
$abs = Storage::disk($disk)->path($found);
}
} catch (\Throwable $e) {
$abs = null;
}
if ($abs && is_file($abs)) {
$fp = @fopen($abs, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
// Fallback 3: serve from public path if available
$publicFull = public_path($found);
$real = @realpath($publicFull);
if ($real && is_file($real)) {
$fp = @fopen($real, 'rb');
if ($fp !== false) {
return response()->stream(function () use ($fp) {
fpassthru($fp);
}, 200, $headers);
}
}
abort(404, 'Document file could not be streamed');
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\Import;
use App\Models\ImportEntity;
use App\Services\Import\Contracts\EntityHandlerInterface;
use Illuminate\Support\Facades\Validator;
abstract class BaseEntityHandler implements EntityHandlerInterface
{
protected ?ImportEntity $entityConfig;
public function __construct(?ImportEntity $entityConfig = null)
{
$this->entityConfig = $entityConfig;
}
/**
* Validate mapped data using configuration rules.
*/
public function validate(array $mapped): array
{
$rules = $this->entityConfig?->validation_rules ?? [];
if (empty($rules)) {
return ['valid' => true, 'errors' => []];
}
$validator = Validator::make($mapped, $rules);
if ($validator->fails()) {
return [
'valid' => false,
'errors' => $validator->errors()->all(),
];
}
return ['valid' => true, 'errors' => []];
}
/**
* Get processing options from config.
*/
protected function getOption(string $key, mixed $default = null): mixed
{
return $this->entityConfig?->processing_options[$key] ?? $default;
}
/**
* Determine if a field has changed.
*/
protected function hasChanged($model, string $field, mixed $newValue): bool
{
$current = $model->{$field};
if (is_null($newValue) && is_null($current)) {
return false;
}
return $current != $newValue;
}
/**
* Track which fields were applied/changed.
*/
protected function trackAppliedFields($model, array $payload): array
{
$applied = [];
foreach ($payload as $field => $value) {
if ($this->hasChanged($model, $field, $value)) {
$applied[] = $field;
}
}
return $applied;
}
/**
* Default implementation returns null - override in specific handlers.
*/
public function resolve(array $mapped, array $context = []): mixed
{
return null;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Import\Contracts;
use App\Models\Import;
interface EntityHandlerInterface
{
/**
* Process a single row for this entity.
*
* @param Import $import The import instance
* @param array $mapped Mapped data for this entity
* @param array $raw Raw row data
* @param array $context Additional context (previous entity results, etc.)
* @return array Result with action, entity instance, applied_fields, etc.
*/
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
/**
* Validate mapped data before processing.
*
* @param array $mapped Mapped data for this entity
* @return array Validation result ['valid' => bool, 'errors' => array]
*/
public function validate(array $mapped): array;
/**
* Get the entity class name this handler manages.
*
* @return string
*/
public function getEntityClass(): string;
/**
* Resolve existing entity by key/reference.
*
* @param array $mapped Mapped data for this entity
* @param array $context Additional context
* @return mixed|null Existing entity instance or null
*/
public function resolve(array $mapped, array $context = []): mixed;
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Import;
class DateNormalizer
{
/**
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
*/
public static function toDate(?string $raw): ?string
{
if ($raw === null) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// Common European and ISO formats first (day-first, then ISO)
$candidates = [
'd.m.Y', 'd.m.y',
'd/m/Y', 'd/m/y',
'd-m-Y', 'd-m-y',
'Y-m-d', 'Y/m/d', 'Y.m.d',
];
foreach ($candidates as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $raw);
if ($dt instanceof \DateTime) {
$errors = \DateTime::getLastErrors();
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
$year = (int) $dt->format('Y');
if ($year < 100) {
$year += ($year <= 69) ? 2000 : 1900;
// Rebuild date with corrected year
$month = (int) $dt->format('m');
$day = (int) $dt->format('d');
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
return $dt->format('Y-m-d');
}
}
}
// Fallback: strtotime (permissive). If fails, return null.
$ts = @strtotime($raw);
if ($ts === false) {
return null;
}
return date('Y-m-d', $ts);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Import;
class DecimalNormalizer
{
/**
* Normalize a raw decimal string to a standard format (period as decimal separator).
* Handles European format (comma as decimal) and American format (period as decimal).
*
* Examples:
* - "958,31" => "958.31"
* - "1.234,56" => "1234.56"
* - "1,234.56" => "1234.56"
* - "1234" => "1234"
*
* Based on ImportProcessor::normalizeDecimal()
*/
public static function normalize(?string $raw): ?string
{
if ($raw === null) {
return null;
}
// Keep digits, comma, dot, and minus to detect separators
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
$s = trim($s);
if ($s === '') {
return null;
}
$lastComma = strrpos($s, ',');
$lastDot = strrpos($s, '.');
// Determine decimal separator by last occurrence
$decimalSep = null;
if ($lastComma !== false || $lastDot !== false) {
if ($lastComma === false) {
$decimalSep = '.';
} elseif ($lastDot === false) {
$decimalSep = ',';
} else {
$decimalSep = $lastComma > $lastDot ? ',' : '.';
}
}
// Remove all thousand separators and unify decimal to '.'
if ($decimalSep === ',') {
// Remove all dots (thousand separators)
$s = str_replace('.', '', $s);
// Replace last comma with dot
$pos = strrpos($s, ',');
if ($pos !== false) {
$s[$pos] = '.';
}
// Remove any remaining commas (unlikely)
$s = str_replace(',', '', $s);
} elseif ($decimalSep === '.') {
// Remove all commas (thousand separators)
$s = str_replace(',', '', $s);
// Dot is already decimal separator
} else {
// No decimal separator: remove commas/dots entirely
$s = str_replace([',', '.'], '', $s);
}
// Handle negative numbers
$s = ltrim($s, '+');
$neg = false;
if (str_starts_with($s, '-')) {
$neg = true;
$s = ltrim($s, '-');
}
// Remove any stray minus signs
$s = str_replace('-', '', $s);
if ($neg) {
$s = '-' . $s;
}
return $s;
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace App\Services\Import;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Email;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use Illuminate\Support\Facades\Log;
/**
* EntityResolutionService - Resolves existing entities to prevent duplication.
*
* This service checks for existing entities before creating new ones,
* following the V1 deduplication hierarchy:
* 1. Contract reference ClientCase Person
* 2. ClientCase client_ref Person
* 3. Contact values (email/phone/address) Person
* 4. Person identifiers (tax_number/ssn) Person
*/
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*
* @param Import $import
* @param array $mapped Mapped data from CSV row
* @param array $context Processing context with previously processed entities
* @return int|null Person ID if found, null if should create new
*/
public function resolvePersonFromContext(Import $import, array $mapped, array $context): ?int
{
// 1. Check if Contract already processed in this row
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->getPersonFromContract($contract);
if ($personId) {
Log::info('EntityResolutionService: Found Person from processed Contract', [
'person_id' => $personId,
'contract_id' => $contract->id,
]);
return $personId;
}
}
// 2. Check if ClientCase already processed in this row
if ($clientCase = $context['client_case']['entity'] ?? null) {
if ($clientCase->person_id) {
Log::info('EntityResolutionService: Found Person from processed ClientCase', [
'person_id' => $clientCase->person_id,
'client_case_id' => $clientCase->id,
]);
return $clientCase->person_id;
}
}
// 3. Check for existing Contract by reference (before it's processed)
if ($contractRef = $mapped['contract']['reference'] ?? null) {
$personId = $this->getPersonFromContractReference($import->client_id, $contractRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing Contract reference', [
'person_id' => $personId,
'contract_reference' => $contractRef,
]);
return $personId;
}
}
// 4. Check for existing ClientCase by client_ref (before it's processed)
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
$personId = $this->getPersonFromClientRef($import->client_id, $clientRef);
if ($personId) {
Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $personId;
}
}
// 5. Check for existing Person by contact values (email/phone/address)
$personId = $this->resolvePersonByContacts($mapped);
if ($personId) {
Log::info('EntityResolutionService: Found Person from contact values', [
'person_id' => $personId,
]);
return $personId;
}
// No existing Person found
return null;
}
/**
* Check if ClientCase exists for this client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return bool
*/
public function clientCaseExists(?int $clientId, string $clientRef): bool
{
if (!$clientId || !$clientRef) {
return false;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->exists();
}
/**
* Check if Contract exists for this reference.
*
* @param int|null $clientId
* @param string $reference
* @return bool
*/
public function contractExists(?int $clientId, string $reference): bool
{
if (!$clientId || !$reference) {
return false;
}
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->exists();
}
/**
* Get existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return ClientCase|null
*/
public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase
{
if (!$clientId || !$clientRef) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
/**
* Get existing Contract by reference for this client.
*
* @param int|null $clientId
* @param string $reference
* @return Contract|null
*/
public function getExistingContract(?int $clientId, string $reference): ?Contract
{
if (!$clientId || !$reference) {
return null;
}
return Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->select('contracts.*')
->first();
}
/**
* Get Person ID from a Contract entity.
*
* @param Contract $contract
* @return int|null
*/
protected function getPersonFromContract(Contract $contract): ?int
{
if ($contract->client_case_id) {
return ClientCase::where('id', $contract->client_case_id)
->value('person_id');
}
return null;
}
/**
* Get Person ID from existing Contract by reference.
*
* @param int|null $clientId
* @param string $reference
* @return int|null
*/
protected function getPersonFromContractReference(?int $clientId, string $reference): ?int
{
if (!$clientId) {
return null;
}
$clientCaseId = Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->where('contracts.reference', $reference)
->value('contracts.client_case_id');
if ($clientCaseId) {
return ClientCase::where('id', $clientCaseId)
->value('person_id');
}
return null;
}
/**
* Get Person ID from existing ClientCase by client_ref.
*
* @param int|null $clientId
* @param string $clientRef
* @return int|null
*/
protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int
{
if (!$clientId) {
return null;
}
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->value('person_id');
}
/**
* Resolve Person by contact values (email, phone, address).
* Checks existing contact records and returns associated Person ID.
*
* @param array $mapped
* @return int|null
*/
protected function resolvePersonByContacts(array $mapped): ?int
{
// Check email (support both single and array formats)
$email = $this->extractContactValue($mapped, 'email', 'value', 'emails');
if ($email) {
$personId = Email::where('value', trim($email))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check phone (support both single and array formats)
$phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones');
if ($phone) {
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
if ($personId) {
return $personId;
}
}
// Check address (support both single and array formats)
$address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses');
if ($address) {
$personId = PersonAddress::where('address', trim($address))->value('person_id');
if ($personId) {
return $personId;
}
}
return null;
}
/**
* Extract contact value from mapped data, supporting multiple formats.
*
* @param array $mapped
* @param string $singularKey e.g., 'email', 'phone', 'address'
* @param string $field Field name within the contact data
* @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses'
* @return string|null
*/
protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string
{
// Try singular key first (e.g., 'email')
if (isset($mapped[$singularKey][$field])) {
return $mapped[$singularKey][$field];
}
// Try plural key (e.g., 'emails')
if (isset($mapped[$pluralKey])) {
// If it's an array of contacts
if (is_array($mapped[$pluralKey])) {
// Try first element if it's an indexed array
if (isset($mapped[$pluralKey][0][$field])) {
return $mapped[$pluralKey][0][$field];
}
// Try direct field access if it's a single hash
if (isset($mapped[$pluralKey][$field])) {
return $mapped[$pluralKey][$field];
}
}
}
return null;
}
/**
* Check if this row should skip Person creation based on existing entities.
* Used by PersonHandler to determine if Person already exists via chain.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return bool True if Person should be skipped (already exists)
*/
public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool
{
// If we can resolve existing Person, we should skip creation
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
return $personId !== null;
}
/**
* Get or create ClientCase for Contract creation.
* Reuses existing ClientCase if found by client_ref.
*
* @param Import $import
* @param array $mapped
* @param array $context
* @return int|null ClientCase ID
*/
public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int
{
$clientId = $import->client_id;
if (!$clientId) {
return null;
}
// If ClientCase already processed in this row, use it
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
return $clientCaseId;
}
// Try to find by client_ref
$clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null;
if ($clientRef) {
$existing = $this->getExistingClientCase($clientId, $clientRef);
if ($existing) {
Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [
'client_case_id' => $existing->id,
'client_ref' => $clientRef,
]);
return $existing->id;
}
}
// Need to create new ClientCase
// Get Person from context (should be processed before Contract now)
$personId = $context['person']['entity']?->id ?? null;
if (!$personId) {
// Person wasn't in import or wasn't found, try to resolve
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
if (!$personId) {
// Create minimal Person as last resort
$defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1);
$personId = Person::create([
'type_id' => 1,
'group_id' => $defaultGroupId,
])->id;
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
'person_id' => $personId,
'group_id' => $defaultGroupId,
]);
}
}
$clientCase = ClientCase::create([
'client_id' => $clientId,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
Log::info('EntityResolutionService: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $personId,
'client_ref' => $clientRef,
]);
return $clientCase->id;
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\DecimalNormalizer;
class AccountHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Account::class;
}
/**
* Override validate to handle contract_id and reference from context.
* Both contract_id and reference are populated in process() (reference defaults to contract reference).
*/
public function validate(array $mapped): array
{
// Remove contract_id and reference from validation - both will be populated in process()
// Reference defaults to contract.reference if not set (matching v1 behavior)
$rules = $this->entityConfig?->validation_rules ?? [];
unset($rules['contract_id'], $rules['reference']);
if (empty($rules)) {
return ['valid' => true, 'errors' => []];
}
$validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules);
if ($validator->fails()) {
return [
'valid' => false,
'errors' => $validator->errors()->all(),
];
}
return ['valid' => true, 'errors' => []];
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null;
if (! $reference || ! $contractId) {
return null;
}
return Account::where('contract_id', $contractId)
->where('reference', $reference)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Ensure contract context
if (! isset($context['contract'])) {
return [
'action' => 'skipped',
'message' => 'Account requires contract context',
];
}
// Fallback: if account.reference is empty, use contract.reference (matching v1 behavior)
if (empty($mapped['reference'])) {
$contractReference = $context['contract']['entity']->reference ?? null;
if ($contractReference) {
$mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference));
}
}
$contractId = $context['contract']['entity']->id;
$mapped['contract_id'] = $contractId;
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Track old balance for activity creation
$oldBalance = (float) ($existing->balance_amount ?? 0);
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
// Create activity if balance changed and tracking is enabled
if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) {
$this->createBalanceChangeActivity($existing, $oldBalance, $import, $context);
}
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new account
$account = new Account;
$payload = $this->buildPayload($mapped, $account);
// Ensure required defaults for new accounts
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultAccountTypeId();
}
$account->fill($payload);
$account->save();
return [
'action' => 'inserted',
'entity' => $account,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fieldMap = [
'contract_id' => 'contract_id',
'reference' => 'reference',
'title' => 'title',
'description' => 'description',
'balance_amount' => 'balance_amount',
'currency' => 'currency',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$value = $mapped[$source];
// Normalize decimal fields (convert comma to period)
if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) {
$value = DecimalNormalizer::normalize($value);
}
$payload[$target] = $value;
}
}
return $payload;
}
/**
* Create activity when account balance changes.
*/
protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void
{
if (! $this->getOption('create_activity_on_balance_change', true)) {
return;
}
try {
$newBalance = (float) ($account->balance_amount ?? 0);
// Skip if balance didn't actually change
if ($newBalance === $oldBalance) {
return;
}
$currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR';
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
// Get client_case_id
$clientCaseId = $account->contract?->client_case_id;
if ($clientCaseId) {
// Use action_id from import meta if available
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
if ($metaActionId > 0) {
\App\Models\Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $metaActionId,
'decision_id' => $import->meta['decision_id'] ?? null,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
}
}
} catch (\Throwable $e) {
\Log::warning('Failed to create balance change activity', [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Get default account type ID.
*/
protected function getDefaultAccountTypeId(): int
{
return (int) (\App\Models\AccountType::min('id') ?? 1);
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Activity;
use App\Models\Import;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
class ActivityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Activity::class;
}
/**
* Override validate to skip validation if note is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$note = $mapped['note'] ?? null;
// If array, check if all values are empty
if (is_array($note)) {
$hasValue = false;
foreach ($note as $n) {
if (!empty($n) && trim((string)$n) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($note) || trim((string)$note) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
// Activities typically don't have a unique reference for deduplication
// Override this method if you have specific deduplication logic
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple activities if note is an array
$notes = $mapped['note'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($notes)) {
$notes = [$notes];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
// Get context IDs once
$clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null;
foreach ($notes as $note) {
// Skip if note is empty
if (empty($note) || trim((string)$note) === '') {
$skippedCount++;
continue;
}
// Require at least client_case_id or contract_id based on options
$requireCase = $this->getOption('require_client_case', false);
$requireContract = $this->getOption('require_contract', false);
if ($requireCase && ! $clientCaseId) {
$skippedCount++;
continue;
}
if ($requireContract && ! $contractId) {
$skippedCount++;
continue;
}
// Build activity payload for this note
$payload = ['note' => $note];
$payload['client_case_id'] = $clientCaseId;
$payload['contract_id'] = $contractId;
// Set action_id and decision_id from template meta if not in mapped data
if (!isset($mapped['action_id'])) {
$payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId();
} else {
$payload['action_id'] = $mapped['action_id'];
}
if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) {
$payload['decision_id'] = $import->template->meta['activity_decision_id'];
}
// Create activity
$activity = new \App\Models\Activity;
$activity->fill($payload);
$activity->save();
$results[] = $activity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All activities empty or missing requirements',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'],
'count' => $insertedCount,
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map activity fields
if (isset($mapped['due_date'])) {
$payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']);
}
if (isset($mapped['amount'])) {
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
}
if (isset($mapped['note'])) {
$payload['note'] = $mapped['note'];
}
if (isset($mapped['action_id'])) {
$payload['action_id'] = (int) $mapped['action_id'];
}
if (isset($mapped['decision_id'])) {
$payload['decision_id'] = (int) $mapped['decision_id'];
}
return $payload;
}
/**
* Get default action ID (use minimum ID from actions table).
*/
private function getDefaultActionId(): int
{
return (int) (\App\Models\Action::min('id') ?? 1);
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonAddress;
use App\Services\Import\BaseEntityHandler;
class AddressHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonAddress::class;
}
/**
* Override validate to skip validation if address is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$address = $mapped['address'] ?? null;
// If array, check if all values are empty
if (is_array($address)) {
$hasValue = false;
foreach ($address as $addr) {
if (!empty($addr) && trim((string)$addr) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty
if (empty($address) || trim((string)$address) === '') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$address = $mapped['address'] ?? null;
$personId = $mapped['person_id']
?? ($context['person']['entity']->id ?? null)
?? ($context['person']?->entity?->id ?? null);
if (! $address || ! $personId) {
return null;
}
// Find existing address by exact match for this person
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple addresses if address is an array
$addresses = $mapped['address'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($addresses)) {
$addresses = [$addresses];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
foreach ($addresses as $address) {
// Skip if address is empty or blank
if (empty($address) || trim((string)$address) === '') {
$skippedCount++;
continue;
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
$skippedCount++;
continue;
}
$existing = $this->resolveAddress($address, $personId);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
$skippedCount++;
continue;
}
// Create new address
$payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId;
$addressEntity = new PersonAddress;
$addressEntity->fill($payload);
$addressEntity->save();
$results[] = $addressEntity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All addresses empty or duplicates',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['address', 'person_id'],
'count' => $insertedCount,
];
}
protected function resolveAddress(string $address, int $personId): mixed
{
return PersonAddress::where('person_id', $personId)
->where('address', $address)
->first();
}
protected function buildPayloadForAddress(string $address): array
{
return [
'address' => $address,
'type_id' => 1, // Default to permanent address
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\CaseObject;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class CaseObjectHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return CaseObject::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$name = $mapped['name'] ?? null;
if (! $reference && ! $name) {
return null;
}
// Try to find by reference first
if ($reference) {
$object = CaseObject::where('reference', $reference)->first();
if ($object) {
return $object;
}
}
// Fall back to name if reference not found
if ($name) {
return CaseObject::where('name', $name)->first();
}
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update existing object
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new case object
$payload = $this->buildPayload($mapped, new CaseObject);
$caseObject = new CaseObject;
$caseObject->fill($payload);
$caseObject->save();
return [
'action' => 'inserted',
'entity' => $caseObject,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['reference', 'name', 'description', 'type', 'contract_id'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}

View File

@ -0,0 +1,163 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ClientCaseHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return ClientCase::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$clientRef = $mapped['client_ref'] ?? null;
$clientId = $context['import']?->client_id ?? null;
if (! $clientRef || ! $clientId) {
return null;
}
// Find existing case by client_ref for this client
return ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$clientId = $import->client_id ?? null;
if (! $clientId) {
return [
'action' => 'skipped',
'message' => 'ClientCase requires client_id',
];
}
// PHASE 5: Use Person from context (already processed due to reversed priorities)
// Priority order: explicit person_id > context person > resolved person
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
// If no Person in context, try to resolve using EntityResolutionService
if (!$personId) {
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
if ($personId) {
Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [
'person_id' => $personId,
]);
} else {
Log::warning('ClientCaseHandler: No Person found in context or via resolution', [
'has_person_context' => isset($context['person']),
'has_mapped_person_id' => isset($mapped['person_id']),
]);
}
} else {
Log::info('ClientCaseHandler: Using Person from context/mapping', [
'person_id' => $personId,
'source' => $mapped['person_id'] ? 'mapped' : 'context',
]);
}
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'ClientCase already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
// Update person_id if provided and different
if ($personId && $existing->person_id !== $personId) {
$payload['person_id'] = $personId;
}
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
Log::info('ClientCaseHandler: Updated existing ClientCase', [
'client_case_id' => $existing->id,
'person_id' => $existing->person_id,
'applied_fields' => $appliedFields,
]);
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new client case
$payload = $this->buildPayload($mapped, new ClientCase);
$payload['client_id'] = $clientId;
if ($personId) {
$payload['person_id'] = $personId;
}
$clientCase = new ClientCase;
$clientCase->fill($payload);
$clientCase->save();
Log::info('ClientCaseHandler: Created new ClientCase', [
'client_case_id' => $clientCase->id,
'person_id' => $clientCase->person_id,
'client_ref' => $clientCase->client_ref,
]);
return [
'action' => 'inserted',
'entity' => $clientCase,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fields = ['client_ref'];
foreach ($fields as $field) {
if (array_key_exists($field, $mapped)) {
$payload[$field] = $mapped[$field];
}
}
return $payload;
}
}

View File

@ -0,0 +1,316 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ContractHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return Contract::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
if (! $reference) {
return null;
}
$query = Contract::query();
// Scope by client if available
if ($clientId = $context['import']->client_id) {
$query->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->where('client_cases.client_id', $clientId)
->select('contracts.*');
}
return $query->where('contracts.reference', $reference)->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Check for existing contract (using resolve method which handles client scoping)
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Check for reactivation FIRST (before update_mode check)
$reactivate = $this->shouldReactivate($context);
Log::info('ContractHandler: Found existing Contract', [
'contract_id' => $existing->id,
'reference' => $mapped['reference'] ?? null,
'context' => $context['import']
]);
if ($reactivate && $this->needsReactivation($existing)) {
$reactivated = $this->attemptReactivation($existing, $context);
Log::info('ContractHandler: Reactivate', ['reactivated' => $reactivated]);
if ($reactivated) {
return [
'action' => 'reactivated',
'entity' => $existing,
'message' => 'Contract reactivated',
];
}
}
// Check update mode
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Contract already exists (skip mode)',
];
}
// Update existing contract
$payload = $this->buildPayload($mapped, $existing);
$payload = $this->mergeJsonFields($payload, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new contract
$contract = new Contract;
$payload = $this->buildPayload($mapped, $contract);
// Get client_case_id from context or mapped data
$clientCaseId = $mapped['client_case_id']
?? $context['client_case']?->entity?->id
?? null;
// If no client_case_id, try to create/find one automatically (using EntityResolutionService)
if (!$clientCaseId) {
// Add mapped data to context for EntityResolutionService
$context['mapped'] = $mapped;
$clientCaseId = $this->findOrCreateClientCaseId($context);
}
if (!$clientCaseId) {
return [
'action' => 'invalid',
'message' => 'Contract requires client_case_id (import must have client_id)',
];
}
$payload['client_case_id'] = $clientCaseId;
// Ensure required defaults
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultContractTypeId();
}
if (!isset($payload['start_date'])) {
$payload['start_date'] = now()->toDateString();
}
$contract->fill($payload);
$contract->save();
return [
'action' => 'inserted',
'entity' => $contract,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map fields according to contract schema
$fieldMap = [
'reference' => 'reference',
'description' => 'description',
'amount' => 'amount',
'currency' => 'currency',
'start_date' => 'start_date',
'end_date' => 'end_date',
'active' => 'active',
'type_id' => 'type_id',
'client_case_id' => 'client_case_id',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$payload[$target] = $mapped[$source];
}
}
// Handle meta field - merge grouped meta into flat structure
if (!empty($mapped['meta']) && is_array($mapped['meta'])) {
$metaData = [];
foreach ($mapped['meta'] as $grp => $entries) {
if (!is_array($entries)) {
continue;
}
foreach ($entries as $k => $v) {
$metaData[$k] = $v;
}
}
if (!empty($metaData)) {
$payload['meta'] = $metaData;
}
}
return $payload;
}
private function getDefaultContractTypeId(): int
{
return (int) (\App\Models\ContractType::min('id') ?? 1);
}
/**
* Check if reactivation should be attempted.
*/
protected function shouldReactivate(array $context): bool
{
// Row-level reactivate column takes precedence
if (isset($context['raw']['reactivate'])) {
return filter_var($context['raw']['reactivate'], FILTER_VALIDATE_BOOLEAN);
}
// Then import-level
if (isset($context['import']->reactivate)) {
return (bool) $context['import']->reactivate;
}
// Finally template-level
if (isset($context['import']->template?->reactivate)) {
return (bool) $context['import']->template->reactivate;
}
return false;
}
/**
* Check if entity needs reactivation.
*/
protected function needsReactivation($entity): bool
{
return $entity->active == 0 || $entity->deleted_at !== null;
}
/**
* Attempt to reactivate soft-deleted or inactive contract.
*/
protected function attemptReactivation(Contract $contract, array $context): bool
{
if (! $this->getOption('supports_reactivation', false)) {
return false;
}
try {
if ($contract->trashed()) {
$contract->restore();
}
$contract->active = 1;
$contract->save();
return true;
} catch (\Throwable $e) {
\Log::error('Contract reactivation failed', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Merge JSON fields instead of overwriting.
*/
protected function mergeJsonFields(array $payload, $existing): array
{
$mergeFields = $this->getOption('merge_json_fields', []);
foreach ($mergeFields as $field) {
if (isset($payload[$field]) && isset($existing->{$field})) {
$existingData = is_array($existing->{$field}) ? $existing->{$field} : [];
$newData = is_array($payload[$field]) ? $payload[$field] : [];
$payload[$field] = array_merge($existingData, $newData);
}
}
return $payload;
}
/**
* Find or create a ClientCase for this contract (using EntityResolutionService).
*/
protected function findOrCreateClientCaseId(array $context): ?int
{
$import = $context['import'] ?? null;
$mapped = $context['mapped'] ?? [];
$clientId = $import?->client_id ?? null;
if (!$clientId) {
return null;
}
// PHASE 4: Use EntityResolutionService to resolve or create ClientCase
// This will reuse existing ClientCase when possible
$clientCaseId = $this->resolutionService->resolveOrCreateClientCaseForContract(
$import,
$mapped,
$context
);
if ($clientCaseId) {
Log::info('ContractHandler: Resolved/Created ClientCase for Contract', [
'client_case_id' => $clientCaseId,
]);
}
return $clientCaseId;
}
/**
* Generate a unique client_ref.
*/
protected function generateClientRef(int $clientId): string
{
$timestamp = now()->format('ymdHis');
$random = substr(md5(uniqid()), 0, 4);
return "C{$clientId}-{$timestamp}-{$random}";
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Email;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class EmailHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Email::class;
}
/**
* Override validate to skip validation if email is empty or invalid.
* Invalid emails should be skipped, not cause transaction rollback.
*/
public function validate(array $mapped): array
{
$email = $mapped['value'] ?? null;
if (empty($email) || trim((string)$email) === '') {
return ['valid' => true, 'errors' => []];
}
// Validate email format - if invalid, mark as valid to skip instead of failing
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Return valid=true but we'll skip it in process()
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$value = $mapped['value'] ?? null;
if (! $value) {
return null;
}
return Email::where('value', strtolower(trim($value)))->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Skip if email is empty or blank
$email = $mapped['value'] ?? null;
if (empty($email) || trim((string)$email) === '') {
return [
'action' => 'skipped',
'message' => 'Email is empty',
];
}
// Skip if email format is invalid
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return [
'action' => 'skipped',
'message' => 'Invalid email format',
];
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
return [
'action' => 'skipped',
'message' => 'Email requires person_id',
];
}
// Check if this email already exists for THIS person
$existing = Email::where('person_id', $personId)
->where('value', strtolower(trim($email)))
->first();
// If email already exists for this person, skip
if ($existing) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Email already exists for this person',
];
}
// Create new email for this person
$payload = $this->buildPayload($mapped, new Email);
$payload['person_id'] = $personId;
$email = new Email;
$email->fill($payload);
$email->save();
return [
'action' => 'inserted',
'entity' => $email,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
if (isset($mapped['value'])) {
$payload['value'] = strtolower(trim($mapped['value']));
}
if (isset($mapped['is_primary'])) {
$payload['is_primary'] = (bool) $mapped['is_primary'];
}
if (isset($mapped['label'])) {
$payload['label'] = $mapped['label'];
}
return $payload;
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Booking;
use App\Models\Import;
use App\Models\Payment;
use App\Models\PaymentSetting;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use Illuminate\Support\Facades\Log;
class PaymentHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Payment::class;
}
/**
* Override validate to skip validation if amount is empty.
*/
public function validate(array $mapped): array
{
$amount = $mapped['amount'] ?? null;
if (empty($amount) || !is_numeric($amount)) {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
$reference = $mapped['reference'] ?? null;
if (! $accountId || ! $reference) {
return null;
}
return Payment::where('account_id', $accountId)
->where('reference', $reference)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Skip if amount is empty or invalid
$amount = $mapped['amount'] ?? null;
if (empty($amount) || !is_numeric($amount)) {
return [
'action' => 'skipped',
'message' => 'Payment amount is empty or invalid',
];
}
// Resolve account - either from mapped data or context
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
if (! $accountId) {
return [
'action' => 'skipped',
'message' => 'Payment requires an account',
];
}
// Check for duplicates if configured
if ($this->getOption('deduplicate_by', [])) {
$existing = $this->resolve($mapped, ['account' => (object) ['entity' => (object) ['id' => $accountId]]]);
if ($existing) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Payment already exists (duplicate by reference)',
];
}
}
// Build payment payload
$payload = $this->buildPayload($mapped, new Payment);
$payload['account_id'] = $accountId;
$payload['created_by'] = $context['user']?->getAuthIdentifier();
// Get account balance before payment
$account = Account::find($accountId);
$balanceBefore = $account ? (float) ($account->balance_amount ?? 0) : 0;
// Create payment
$payment = new Payment;
$payment->fill($payload);
$payment->balance_before = $balanceBefore;
try {
$payment->save();
} catch (\Throwable $e) {
// Handle unique constraint violations gracefully
if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) {
return [
'action' => 'skipped',
'message' => 'Payment duplicate detected (database constraint)',
];
}
throw $e;
}
// Create booking if configured
if ($this->getOption('create_booking', true) && isset($payment->amount)) {
try {
Booking::create([
'account_id' => $accountId,
'payment_id' => $payment->id,
'amount_cents' => (int) round(((float) $payment->amount) * 100),
'type' => 'credit',
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
'booked_at' => $payment->paid_at ?? now(),
]);
} catch (\Throwable $e) {
Log::warning('Failed to create booking for payment', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
}
}
// Create activity if configured
if ($this->getOption('create_activity', false)) {
$this->createPaymentActivity($payment, $account, $balanceBefore);
}
return [
'action' => 'inserted',
'entity' => $payment,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
// Map payment fields
if (isset($mapped['reference'])) {
$payload['reference'] = is_string($mapped['reference']) ? trim($mapped['reference']) : $mapped['reference'];
}
// Handle amount - support both amount and amount_cents
if (array_key_exists('amount', $mapped)) {
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
} elseif (array_key_exists('amount_cents', $mapped)) {
$payload['amount'] = ((int) $mapped['amount_cents']) / 100.0;
}
// Payment date - support both paid_at and payment_date
$dateValue = $mapped['paid_at'] ?? $mapped['payment_date'] ?? null;
if ($dateValue) {
$payload['paid_at'] = DateNormalizer::toDate((string) $dateValue);
}
$payload['currency'] = $mapped['currency'] ?? 'EUR';
// Handle meta
$meta = [];
if (is_array($mapped['meta'] ?? null)) {
$meta = $mapped['meta'];
}
if (! empty($mapped['payment_nu'])) {
$meta['payment_nu'] = trim((string) $mapped['payment_nu']);
}
if (! empty($meta)) {
$payload['meta'] = $meta;
}
return $payload;
}
protected function createPaymentActivity(Payment $payment, ?Account $account, float $balanceBefore): void
{
try {
$settings = PaymentSetting::first();
if (! $settings || ! ($settings->create_activity_on_payment ?? false)) {
return;
}
$amountCents = (int) round(((float) $payment->amount) * 100);
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
$note = str_replace(
['{amount}', '{currency}'],
[number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'],
$note
);
// Get updated balance
$account?->refresh();
$balanceAfter = $account ? (float) ($account->balance_amount ?? 0) : 0;
$beforeStr = number_format($balanceBefore, 2, ',', '.').' '.($payment->currency ?? 'EUR');
$afterStr = number_format($balanceAfter, 2, ',', '.').' '.($payment->currency ?? 'EUR');
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
// Resolve client_case_id
$account?->loadMissing('contract');
$clientCaseId = $account?->contract?->client_case_id;
if ($clientCaseId) {
$activity = \App\Models\Activity::create([
'due_date' => null,
'amount' => $amountCents / 100,
'note' => $note,
'action_id' => $settings->default_action_id,
'decision_id' => $settings->default_decision_id,
'client_case_id' => $clientCaseId,
'contract_id' => $account->contract_id,
]);
$payment->update(['activity_id' => $activity->id]);
}
} catch (\Throwable $e) {
Log::warning('Failed to create activity for payment', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonType;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class PersonHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->resolutionService = new EntityResolutionService();
}
public function getEntityClass(): string
{
return Person::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
// PHASE 3: Use EntityResolutionService to check chain-based deduplication
// This prevents creating duplicate Persons when Contract/ClientCase already exists
$import = $context['import'] ?? null;
if ($import) {
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
if ($personId) {
$person = Person::find($personId);
if ($person) {
Log::info('PersonHandler: Resolved existing Person via chain', [
'person_id' => $personId,
'resolution_method' => 'EntityResolutionService',
]);
return $person;
}
}
}
// Fall back to configured deduplication fields (tax_number, SSN)
$dedupeBy = $this->getOption('deduplicate_by', ['tax_number', 'social_security_number']);
foreach ($dedupeBy as $field) {
if (! empty($mapped[$field])) {
$person = Person::where($field, $mapped[$field])->first();
if ($person) {
Log::info('PersonHandler: Resolved existing Person by identifier', [
'person_id' => $person->id,
'field' => $field,
]);
return $person;
}
}
}
return null;
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Add import to context for EntityResolutionService
$context['import'] = $import;
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update if configured
$mode = $this->getOption('update_mode', 'update');
if ($mode === 'skip') {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'Person already exists (skip mode)',
];
}
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create new person
Log::info('PersonHandler: Creating new Person (no existing entity found)', [
'has_tax_number' => !empty($mapped['tax_number']),
'has_ssn' => !empty($mapped['social_security_number']),
'has_contract' => isset($context['contract']),
'has_client_case' => isset($context['client_case']),
]);
$person = new Person;
$payload = $this->buildPayload($mapped, $person);
// Ensure required foreign keys have defaults
if (!isset($payload['group_id'])) {
$payload['group_id'] = $this->getDefaultPersonGroupId();
}
if (!isset($payload['type_id'])) {
$payload['type_id'] = $this->getDefaultPersonTypeId();
}
Log::debug('PersonHandler: Payload before fill', [
'payload' => $payload,
'has_group_id' => isset($payload['group_id']),
'group_id_value' => $payload['group_id'] ?? null,
]);
$person->fill($payload);
Log::debug('PersonHandler: Person attributes after fill', [
'attributes' => $person->getAttributes(),
'has_group_id' => isset($person->group_id),
'group_id_value' => $person->group_id ?? null,
]);
$person->save();
Log::info('PersonHandler: Created new Person', [
'person_id' => $person->id,
]);
return [
'action' => 'inserted',
'entity' => $person,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
$payload = [];
$fieldMap = [
'first_name' => 'first_name',
'last_name' => 'last_name',
'full_name' => 'full_name',
'gender' => 'gender',
'birthday' => 'birthday',
'tax_number' => 'tax_number',
'social_security_number' => 'social_security_number',
'description' => 'description',
'group_id' => 'group_id',
'type_id' => 'type_id',
];
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $mapped)) {
$value = $mapped[$source];
// Normalize date fields
if ($source === 'birthday' && $value) {
$value = DateNormalizer::toDate((string) $value);
}
$payload[$target] = $value;
}
}
return $payload;
}
private function getDefaultPersonGroupId(): int
{
return (int) (PersonGroup::min('id') ?? 1);
}
private function getDefaultPersonTypeId(): int
{
return (int) (PersonType::min('id') ?? 1);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonPhone;
use App\Services\Import\BaseEntityHandler;
class PhoneHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonPhone::class;
}
/**
* Override validate to skip validation if phone is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$phone = $mapped['nu'] ?? null;
// If array, check if all values are empty/invalid
if (is_array($phone)) {
$hasValue = false;
foreach ($phone as $ph) {
if (!empty($ph) && trim((string)$ph) !== '' && $ph !== '0') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => true, 'errors' => []];
}
// Skip parent validation for arrays - we'll validate in process()
return ['valid' => true, 'errors' => []];
}
// Single value - check if empty or invalid
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
return ['valid' => true, 'errors' => []];
}
return parent::validate($mapped);
}
public function resolve(array $mapped, array $context = []): mixed
{
$nu = $mapped['nu'] ?? null;
$personId = $mapped['person_id']
?? ($context['person']['entity']->id ?? null)
?? ($context['person']?->entity?->id ?? null);
if (! $nu || ! $personId) {
return null;
}
// Normalize phone number for comparison
$normalizedNu = $this->normalizePhoneNumber($nu);
// Find existing phone by normalized number for this person
return PersonPhone::where('person_id', $personId)
->where('nu', $normalizedNu)
->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
// Handle multiple phones if nu is an array
$phones = $mapped['nu'] ?? null;
// If single value, convert to array for uniform processing
if (!is_array($phones)) {
$phones = [$phones];
}
$results = [];
$insertedCount = 0;
$skippedCount = 0;
foreach ($phones as $phone) {
// Skip if phone number is empty or blank or '0'
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
$skippedCount++;
continue;
}
// Resolve person_id from context
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
if (! $personId) {
$skippedCount++;
continue;
}
// Normalize phone number
$normalizedPhone = $this->normalizePhoneNumber($phone);
$existing = $this->resolvePhone($normalizedPhone, $personId);
// Check for duplicates if configured
if ($this->getOption('deduplicate', true) && $existing) {
$skippedCount++;
continue;
}
// Create new phone
$payload = [
'nu' => $normalizedPhone,
'person_id' => $personId,
'type_id' => 1, // Default to mobile
];
$phoneEntity = new PersonPhone;
$phoneEntity->fill($payload);
$phoneEntity->save();
$results[] = $phoneEntity;
$insertedCount++;
}
if ($insertedCount === 0 && $skippedCount > 0) {
return [
'action' => 'skipped',
'message' => 'All phones empty, invalid or duplicates',
];
}
return [
'action' => 'inserted',
'entity' => $results[0] ?? null,
'entities' => $results,
'applied_fields' => ['nu', 'person_id'],
'count' => $insertedCount,
];
}
protected function resolvePhone(string $normalizedPhone, int $personId): mixed
{
return PersonPhone::where('person_id', $personId)
->where('nu', $normalizedPhone)
->first();
}
/**
* Normalize phone number by removing spaces, dashes, and parentheses.
*/
protected function normalizePhoneNumber(string $phone): string
{
return preg_replace('/[\s\-\(\)]/', '', $phone);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,832 @@
<?php
namespace App\Services\Import;
use App\Models\Import;
use App\Models\ImportEntity;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* ImportSimulationServiceV2 - Simulates imports using V2 handler architecture.
*
* Processes rows using entity handlers without persisting any data to the database.
* Returns preview data showing what would be created/updated for each row.
*
* Deduplication: Uses EntityResolutionService through handlers to accurately simulate
* Person resolution from Contract/ClientCase chains, matching production behavior.
*/
class ImportSimulationServiceV2
{
protected array $handlers = [];
protected array $entityConfigs = [];
/**
* Simulate an import and return preview data.
*
* @param Import $import Import record with mappings
* @param int $limit Maximum number of rows to simulate (default: 100)
* @param bool $verbose Include detailed information (default: false)
* @return array Simulation results with row previews and statistics
*/
public function simulate(Import $import, int $limit = 100, bool $verbose = false): array
{
try {
// Load entity configurations and handlers
$this->loadEntityConfigurations();
// Only CSV/TXT supported
if (! in_array($import->source_type, ['csv', 'txt'])) {
return $this->errorPayload('Podprti so samo CSV/TXT formati.');
}
$filePath = $import->path;
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
return $this->errorPayload("Datoteka ni najdena: {$filePath}");
}
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
$fh = fopen($fullPath, 'r');
if (! $fh) {
return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}");
}
$meta = $import->meta ?? [];
$hasHeader = (bool) ($meta['has_header'] ?? true);
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
$mappings = $this->loadMappings($import);
if (empty($mappings)) {
fclose($fh);
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
}
$header = null;
$rowNum = 0;
// Read header if present
if ($hasHeader) {
$header = fgetcsv($fh, 0, $delimiter);
$rowNum++;
}
$simRows = [];
$summaries = $this->initSummaries();
$rowCount = 0;
while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) {
$rowNum++;
$rowCount++;
try {
$rawAssoc = $this->buildRowAssoc($row, $header);
// Skip empty rows
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
continue;
}
$mapped = $this->applyMappings($rawAssoc, $mappings);
// Group mapped data by entity (from "entity.field" to nested structure)
$groupedMapped = $this->groupMappedDataByEntity($mapped);
\Log::info('ImportSimulation: Grouped entities', [
'row' => $rowNum,
'entity_keys' => array_keys($groupedMapped),
'config_roots' => array_keys($this->entityConfigs),
]);
// Simulate processing for this row
// Context must include 'import' for EntityResolutionService to work
$context = [
'import' => $import,
'simulation' => true,
];
$rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose);
// Update summaries - handle both single and array results
foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) {
// Extract entity root from key (e.g., 'person', 'contract', etc.)
$root = explode('.', $entityKey)[0];
// Handle array of results (grouped entities)
if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) {
foreach ($entityDataOrArray as $entityData) {
$action = $entityData['action'] ?? 'skip';
if (!isset($summaries[$root])) {
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
}
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
}
} else {
// Single result
$action = $entityDataOrArray['action'] ?? 'skip';
if (!isset($summaries[$root])) {
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
}
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
}
}
$simRows[] = [
'row_number' => $rowNum,
'raw_data' => $verbose ? $rawAssoc : null,
'entities' => $rowResult['entities'],
'warnings' => $rowResult['warnings'] ?? [],
'errors' => $rowResult['errors'] ?? [],
];
} catch (\Throwable $e) {
$simRows[] = [
'row_number' => $rowNum,
'raw_data' => $verbose ? ($rawAssoc ?? null) : null,
'entities' => [],
'warnings' => [],
'errors' => [$e->getMessage()],
];
}
}
fclose($fh);
return [
'success' => true,
'total_simulated' => $rowCount,
'limit' => $limit,
'summaries' => $summaries,
'rows' => $simRows,
'meta' => [
'has_header' => $hasHeader,
'delimiter' => $delimiter,
'mappings_count' => count($mappings),
],
];
} catch (\Throwable $e) {
return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage());
}
}
/**
* Simulate processing a single row without database writes.
*
* Updated to match ImportServiceV2 logic:
* - Process entities in priority order from entity configs
* - Accumulate entity results in context for chain resolution
* - Pass proper context to handlers for EntityResolutionService
*/
protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array
{
$entities = [];
$warnings = [];
$errors = [];
$entityResults = [];
// Process entities in configured priority order (like ImportServiceV2)
foreach ($this->entityConfigs as $root => $config) {
// Check if this entity exists in mapped data
$mappedKey = $this->findMappedKey($mapped, $root, $config);
if (!$mappedKey || !isset($mapped[$mappedKey])) {
continue;
}
$handler = $this->handlers[$root] ?? null;
if (!$handler) {
continue;
}
try {
// Check if this is an array of entities (grouped)
$entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0])
? $mapped[$mappedKey]
: [$mapped[$mappedKey]];
$results = [];
foreach ($entityDataArray as $entityData) {
// Validate
$validation = $handler->validate($entityData);
if (!$validation['valid']) {
$results[] = [
'action' => 'invalid',
'data' => $entityData,
'errors' => $validation['errors'],
];
continue;
}
// Skip empty/invalid data that handlers would skip during real import
// Phone: skip if nu is 0, empty, or #N/A
if ($root === 'phone') {
$nu = $entityData['nu'] ?? null;
if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') {
continue; // Skip this phone entirely
}
}
// Address: skip if address is empty or #N/A
if ($root === 'address') {
$address = $entityData['address'] ?? null;
if (empty($address) || $address === '#N/A' || trim((string)$address) === '') {
continue; // Skip this address entirely
}
}
// Email: skip if value is 0, empty, or #N/A
if ($root === 'email') {
$email = $entityData['value'] ?? null;
if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') {
continue; // Skip this email entirely
}
}
// DEBUG: Log context for grouped entities
if (in_array($root, ['phone', 'address'])) {
Log::info("ImportSimulation: Resolving grouped entity", [
'entity' => $root,
'data' => $entityData,
'has_person_in_context' => isset($entityResults['person']),
'person_id' => $entityResults['person']['entity']->id ?? null,
'context_keys' => array_keys(array_merge($context, $entityResults)),
]);
}
// Resolve existing entity (uses EntityResolutionService internally)
// Pass accumulated entityResults as context for chain resolution
try {
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
} catch (\Throwable $resolutionError) {
// In simulation mode, resolution may fail due to simulated entities
// Just treat as new entity
\Log::debug("ImportSimulation: Resolution failed (treating as new)", [
'entity' => $root,
'error' => $resolutionError->getMessage(),
]);
$existingEntity = null;
}
if ($existingEntity) {
// Would update existing
$results[] = [
'action' => 'update',
'reference' => $this->getEntityReference($existingEntity, $root),
'existing_id' => $existingEntity->id ?? null,
'data' => $entityData,
'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null,
'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null,
];
// Add to entityResults for subsequent handlers
$entityResults[$root] = [
'entity' => $existingEntity,
'action' => 'updated',
];
} else {
// Would create new
$results[] = [
'action' => 'create',
'data' => $entityData,
];
// Simulate entity creation for context (no actual ID)
// Mark as simulated so resolution service knows not to use model methods
$simulatedEntity = (object) $entityData;
$simulatedEntity->_simulated = true;
$entityResults[$root] = [
'entity' => $simulatedEntity,
'action' => 'inserted',
'_simulated' => true,
];
}
}
// Store results (single or array)
$entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results;
} catch (\Throwable $e) {
$entities[$mappedKey] = [
'action' => 'error',
'errors' => [$e->getMessage()],
];
$errors[] = "{$root}: {$e->getMessage()}";
}
}
return compact('entities', 'warnings', 'errors');
}
/**
* Find the mapped key for an entity (supports aliases and common variations).
*/
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
{
// Check canonical root exactly
if (isset($mapped[$canonicalRoot])) {
return $canonicalRoot;
}
// Build comprehensive list of variations
$variations = [$canonicalRoot];
// Generate plural variations (handle -y endings correctly)
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
// activity -> activities
$variations[] = substr($canonicalRoot, 0, -1) . 'ies';
} else {
// address -> addresses
$variations[] = $canonicalRoot . 's';
}
// Add singular form (remove trailing s or ies)
if (str_ends_with($canonicalRoot, 'ies')) {
$variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity
} else {
$variations[] = rtrim($canonicalRoot, 's'); // addresses -> address
}
// Add person_ prefixed versions
$variations[] = 'person_' . $canonicalRoot;
// person_activity -> person_activities
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
$variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies';
} else {
$variations[] = 'person_' . $canonicalRoot . 's';
}
// Special handling: if canonical is 'address', also check 'person_addresses'
if ($canonicalRoot === 'address') {
$variations[] = 'person_addresses';
}
// Special handling: if canonical is 'phone', also check 'person_phones'
if ($canonicalRoot === 'phone') {
$variations[] = 'person_phones';
}
// Reverse: if canonical has 'person_', also check without it
if (str_starts_with($canonicalRoot, 'person_')) {
$withoutPerson = str_replace('person_', '', $canonicalRoot);
$variations[] = $withoutPerson;
// Handle plural variations
if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) {
$variations[] = substr($withoutPerson, 0, -1) . 'ies';
} else {
$variations[] = rtrim($withoutPerson, 's');
$variations[] = $withoutPerson . 's';
}
}
$variations = array_unique($variations);
foreach ($variations as $variation) {
if (isset($mapped[$variation])) {
\Log::debug("ImportSimulation: Matched entity", [
'canonical_root' => $canonicalRoot,
'matched_key' => $variation,
]);
return $variation;
}
}
// Check aliases if configured
if (isset($config->options['aliases'])) {
$aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : [];
foreach ($aliases as $alias) {
if (isset($mapped[$alias])) {
return $alias;
}
}
}
\Log::debug("ImportSimulation: No match found for entity", [
'canonical_root' => $canonicalRoot,
'tried_variations' => array_slice($variations, 0, 5),
'available_keys' => array_keys($mapped),
]);
return null;
}
/**
* Group mapped data by entity from "entity.field" format to nested structure.
* Handles both single values and arrays (for grouped entities like multiple addresses).
*
* Special handling:
* - activity.note arrays are kept together (single activity with multiple notes)
* - Other array values create separate entity instances (e.g., multiple addresses)
*
* Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']]
* Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]]
*/
protected function groupMappedDataByEntity(array $mapped): array
{
$grouped = [];
foreach ($mapped as $key => $value) {
if (!str_contains($key, '.')) {
continue;
}
[$entity, $field] = explode('.', $key, 2);
// Handle array values
if (is_array($value)) {
// Special case: activity.note should be kept as array in single instance
if ($entity === 'activity' || $entity === 'activities') {
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
$grouped[$entity][$field] = $value; // Keep as array
} else {
// For other entities, only create multiple instances if:
// 1. Entity doesn't exist yet, OR
// 2. Entity has no other fields yet (is empty array)
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
// If entity already has string-keyed fields, just set the array as field value
// Otherwise, create separate instances
$hasStringKeys = !empty($grouped[$entity]) && isset(array_keys($grouped[$entity])[0]) && is_string(array_keys($grouped[$entity])[0]);
if ($hasStringKeys) {
// Entity has fields already - don't split, keep array as-is
$grouped[$entity][$field] = $value;
} else {
// Create separate entity instances for each array value
foreach ($value as $idx => $val) {
if (!isset($grouped[$entity][$idx])) {
$grouped[$entity][$idx] = [];
}
$grouped[$entity][$idx][$field] = $val;
}
}
}
} else {
// Single value
if (!isset($grouped[$entity])) {
$grouped[$entity] = [];
}
// Check if entity is already an array of instances (from previous grouped field)
if (!empty($grouped[$entity]) && is_int(array_key_first($grouped[$entity]))) {
// Entity has multiple instances - add field to all instances
foreach ($grouped[$entity] as &$instance) {
if (is_array($instance)) {
$instance[$field] = $value;
}
}
unset($instance);
} else {
// Simple associative array - add field
$grouped[$entity][$field] = $value;
}
}
}
return $grouped;
}
/**
* Determine if entity data is grouped (array of instances).
*/
protected function isGroupedEntity($data): bool
{
if (!is_array($data)) {
return false;
}
// Check if numeric array (multiple instances)
$keys = array_keys($data);
return isset($keys[0]) && is_int($keys[0]);
}
/**
* Extract existing entity data as array.
*/
protected function extractExistingData($entity): array
{
if (method_exists($entity, 'toArray')) {
return $entity->toArray();
}
return (array) $entity;
}
/**
* Detect changes between existing entity and new data.
*/
protected function detectChanges($existingEntity, array $newData): array
{
$changes = [];
foreach ($newData as $key => $newValue) {
$oldValue = $existingEntity->{$key} ?? null;
// Convert to comparable formats
if ($oldValue instanceof \Carbon\Carbon) {
$oldValue = $oldValue->format('Y-m-d');
}
if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) {
$changes[$key] = [
'old' => $oldValue,
'new' => $newValue,
];
}
}
return $changes;
}
/**
* Get a reference string for an entity.
*/
protected function getEntityReference($entity, string $root): string
{
if (isset($entity->reference)) {
return (string) $entity->reference;
}
if (isset($entity->value)) {
return (string) $entity->value;
}
if (isset($entity->title)) {
return (string) $entity->title;
}
if (isset($entity->id)) {
return "{$root}#{$entity->id}";
}
return 'N/A';
}
/**
* Load entity configurations from database.
*/
protected function loadEntityConfigurations(): void
{
$entities = ImportEntity::where('is_active', true)
->orderBy('priority', 'desc')
->get();
foreach ($entities as $entity) {
$this->entityConfigs[$entity->canonical_root] = $entity;
// Instantiate handler if configured
if ($entity->handler_class && class_exists($entity->handler_class)) {
$this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]);
}
}
}
/**
* Get handler for entity root.
*/
protected function getHandler(string $root)
{
return $this->handlers[$root] ?? null;
}
/**
* Load mappings from import_mappings table.
* Uses target_field in "entity.field" format.
* Supports multiple sources mapping to same target (for groups).
*/
protected function loadMappings(Import $import): array
{
$rows = \DB::table('import_mappings')
->where('import_id', $import->id)
->orderBy('position')
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
$mappings = [];
foreach ($rows as $row) {
$source = trim((string) $row->source_column);
$target = trim((string) $row->target_field);
if ($source === '' || $target === '') {
continue;
}
// Use unique key combining source and target to avoid overwriting
$key = $source . '→' . $target;
// target_field is in "entity.field" format
$mappings[$key] = [
'source' => $source,
'target' => $target,
'transform' => $row->transform ?? null,
'apply_mode' => $row->apply_mode ?? 'both',
'options' => $row->options ? json_decode($row->options, true) : [],
];
}
return $mappings;
}
/**
* Build associative array from row data.
*/
protected function buildRowAssoc(array $row, ?array $header): array
{
if ($header) {
return array_combine($header, array_pad($row, count($header), null));
}
// Use numeric indices if no header
return array_combine(
array_map(fn ($i) => "col_{$i}", array_keys($row)),
$row
);
}
/**
* Check if row is effectively empty.
*/
protected function rowIsEffectivelyEmpty(array $assoc): bool
{
foreach ($assoc as $value) {
if ($value !== null && $value !== '') {
return false;
}
}
return true;
}
/**
* Apply mappings to raw row data.
* Returns array keyed by "entity.field".
*
* Updated to match ImportServiceV2:
* - Supports group option for concatenating multiple sources
* - Returns flat array with "entity.field" keys (no nesting)
*/
protected function applyMappings(array $raw, array $mappings): array
{
$mapped = [];
// Group mappings by target field to handle concatenation (same as ImportServiceV2)
$groupedMappings = [];
foreach ($mappings as $mapping) {
$target = $mapping['target'];
if (!isset($groupedMappings[$target])) {
$groupedMappings[$target] = [];
}
$groupedMappings[$target][] = $mapping;
}
foreach ($groupedMappings as $targetField => $fieldMappings) {
// Group by group number from options
$valuesByGroup = [];
foreach ($fieldMappings as $mapping) {
$source = $mapping['source'];
if (!isset($raw[$source])) {
continue;
}
$value = $raw[$source];
// Apply transform if specified
if (!empty($mapping['transform'])) {
$value = $this->applyTransform($value, $mapping['transform']);
}
// Get group from options
$options = $mapping['options'] ?? [];
$group = $options['group'] ?? null;
// Group values by their group number (same logic as ImportServiceV2)
if ($group !== null) {
// Same group = concatenate
if (!isset($valuesByGroup[$group])) {
$valuesByGroup[$group] = [];
}
$valuesByGroup[$group][] = $value;
} else {
// No group = each gets its own group
$valuesByGroup[] = [$value];
}
}
// Now set the values - KEEP FLAT, DON'T NEST
foreach ($valuesByGroup as $values) {
if (count($values) === 1) {
// Single value - add to array if key exists, otherwise set directly
if (isset($mapped[$targetField])) {
// Convert to array and append
if (!is_array($mapped[$targetField])) {
$mapped[$targetField] = [$mapped[$targetField]];
}
$mapped[$targetField][] = $values[0];
} else {
$mapped[$targetField] = $values[0];
}
} else {
// Multiple values in same group - concatenate with newline
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
if (!empty($concatenated)) {
if (isset($mapped[$targetField])) {
// Convert to array and append
if (!is_array($mapped[$targetField])) {
$mapped[$targetField] = [$mapped[$targetField]];
}
$mapped[$targetField][] = $concatenated;
} else {
$mapped[$targetField] = $concatenated;
}
}
}
}
}
return $mapped;
}
/**
* Set nested value in array using dot notation.
* If the key already exists, convert to array and append the new value.
*
* Same logic as ImportServiceV2.
*/
protected function setNestedValue(array &$array, string $key, mixed $value): void
{
$keys = explode('.', $key);
$current = &$array;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
// If key already exists, convert to array and append
if (isset($current[$k])) {
// Convert existing single value to array if needed
if (!is_array($current[$k])) {
$current[$k] = [$current[$k]];
}
// Append new value
$current[$k][] = $value;
} else {
// Set as single value
$current[$k] = $value;
}
} else {
if (!isset($current[$k]) || !is_array($current[$k])) {
$current[$k] = [];
}
$current = &$current[$k];
}
}
}
/**
* Apply transform to a value.
*/
protected function applyTransform(mixed $value, string $transform): mixed
{
return match ($transform) {
'trim' => trim((string) $value),
'upper' => strtoupper((string) $value),
'lower' => strtolower((string) $value),
'decimal' => (float) str_replace(',', '.', (string) $value),
default => $value,
};
}
/**
* Initialize summary counters.
*/
protected function initSummaries(): array
{
$summaries = [];
foreach (array_keys($this->entityConfigs) as $root) {
$summaries[$root] = [
'create' => 0,
'update' => 0,
'skip' => 0,
'invalid' => 0,
];
}
return $summaries;
}
/**
* Create error payload.
*/
protected function errorPayload(string $message): array
{
return [
'success' => false,
'error' => $message,
'total_simulated' => 0,
'summaries' => [],
'rows' => [],
];
}
}

View File

@ -0,0 +1,347 @@
# Import System V2 Architecture
## Overview
ImportServiceV2 is a refactored, database-driven import processing system that replaces the monolithic ImportProcessor.php with a modular, maintainable architecture.
## Key Features
- **Database-driven configuration**: Entity processing rules, validation, and handlers configured in `import_entities` table
- **Pluggable handlers**: Each entity type has its own handler class implementing `EntityHandlerInterface`
- **Queue support**: Large imports can be processed asynchronously via `ProcessLargeImportJob`
- **Validation**: Entity-level validation rules stored in database
- **Priority-based processing**: Entities processed in configured priority order
- **Extensible**: Easy to add new entity types without modifying core service
## Directory Structure
```
app/Services/Import/
├── Contracts/
│ └── EntityHandlerInterface.php # Handler contract
├── Handlers/
│ ├── ContractHandler.php # Contract entity handler
│ ├── AccountHandler.php # Account entity handler
│ ├── PaymentHandler.php # Payment handler (to be implemented)
│ ├── ActivityHandler.php # Activity handler (to be implemented)
│ └── ... # Additional handlers
├── BaseEntityHandler.php # Base handler with common logic
└── ImportServiceV2.php # Main import service
```
## Database Schema
### import_entities Table
| Column | Type | Description |
|--------|------|-------------|
| id | bigint | Primary key |
| key | string | UI key (plural, e.g., "contracts") |
| canonical_root | string | Canonical root for processor (singular, e.g., "contract") |
| label | string | Human-readable label |
| fields | json | Array of field names |
| field_aliases | json | Field alias mappings |
| aliases | json | Root aliases |
| supports_multiple | boolean | Whether entity supports multiple items per row |
| meta | boolean | Whether entity is metadata |
| rules | json | Suggestion rules |
| ui | json | UI configuration |
| handler_class | string | Fully qualified handler class name |
| validation_rules | json | Laravel validation rules |
| processing_options | json | Handler-specific options |
| is_active | boolean | Whether entity is enabled |
| priority | integer | Processing priority (higher = first) |
| created_at | timestamp | Creation timestamp |
| updated_at | timestamp | Update timestamp |
## Handler Interface
All entity handlers must implement `EntityHandlerInterface`:
```php
interface EntityHandlerInterface
{
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
public function validate(array $mapped): array;
public function getEntityClass(): string;
public function resolve(array $mapped, array $context = []): mixed;
}
```
### Handler Methods
- **process()**: Main processing method, returns result with action (inserted/updated/skipped) and entity
- **validate()**: Validates mapped data before processing
- **getEntityClass()**: Returns the model class name this handler manages
- **resolve()**: Resolves existing entity by key/reference
## Creating a New Handler
1. Create handler class extending `BaseEntityHandler`:
```php
<?php
namespace App\Services\Import\Handlers;
use App\Models\YourEntity;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class YourEntityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return YourEntity::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
// Implement entity resolution logic
return YourEntity::where('key', $mapped['key'])->first();
}
public function process(Import $import, array $mapped, array $raw, array $context = []): array
{
$existing = $this->resolve($mapped, $context);
if ($existing) {
// Update logic
$payload = $this->buildPayload($mapped, $existing);
$appliedFields = $this->trackAppliedFields($existing, $payload);
if (empty($appliedFields)) {
return [
'action' => 'skipped',
'entity' => $existing,
'message' => 'No changes detected',
];
}
$existing->fill($payload);
$existing->save();
return [
'action' => 'updated',
'entity' => $existing,
'applied_fields' => $appliedFields,
];
}
// Create logic
$entity = new YourEntity;
$payload = $this->buildPayload($mapped, $entity);
$entity->fill($payload);
$entity->save();
return [
'action' => 'inserted',
'entity' => $entity,
'applied_fields' => array_keys($payload),
];
}
protected function buildPayload(array $mapped, $model): array
{
// Map fields to model attributes
return [
'field1' => $mapped['field1'] ?? null,
'field2' => $mapped['field2'] ?? null,
];
}
}
```
2. Add configuration to `import_entities` table:
```php
ImportEntity::create([
'key' => 'your_entities',
'canonical_root' => 'your_entity',
'label' => 'Your Entities',
'fields' => ['field1', 'field2'],
'handler_class' => \App\Services\Import\Handlers\YourEntityHandler::class,
'validation_rules' => [
'field1' => 'required|string',
'field2' => 'nullable|integer',
],
'processing_options' => [
'update_mode' => 'update',
],
'is_active' => true,
'priority' => 100,
]);
```
## Usage
### Synchronous Processing
```php
use App\Services\Import\ImportServiceV2;
$service = app(ImportServiceV2::class);
$results = $service->process($import, $user);
```
### Queue Processing (Large Imports)
```php
use App\Jobs\ProcessLargeImportJob;
ProcessLargeImportJob::dispatch($import, $user->id);
```
## Processing Options
Handler-specific options stored in `processing_options` JSON column:
### Contract Handler
- `update_mode`: 'update' | 'skip' | 'error'
- `create_missing`: boolean
### Account Handler
- `update_mode`: 'update' | 'skip'
- `require_contract`: boolean
### Payment Handler (planned)
- `deduplicate_by`: array of fields
- `create_booking`: boolean
- `create_activity`: boolean
## Migration Path
### Phase 1: Setup (Current)
- ✅ Create directory structure
- ✅ Add v2 columns to import_entities
- ✅ Create base interfaces and classes
- ✅ Implement ContractHandler and AccountHandler
- ✅ Create ProcessLargeImportJob
- ✅ Create seeder for entity configurations
### Phase 2: Implementation
- [ ] Implement remaining handlers (Payment, Activity, Person, Contacts)
- [ ] Add comprehensive tests
- [ ] Update controllers to use ImportServiceV2
- [ ] Add feature flag to toggle between v1 and v2
### Phase 3: Migration
- [ ] Run both systems in parallel
- [ ] Compare results and fix discrepancies
- [ ] Migrate all imports to v2
- [ ] Remove ImportProcessor.php (v1)
## Testing
```bash
# Run migrations
php artisan migrate
# Seed entity configurations
php artisan db:seed --class=ImportEntitiesV2Seeder
# Run tests
php artisan test --filter=ImportServiceV2
```
## Benefits Over V1
1. **Maintainability**: Each entity has its own handler, easier to understand and modify
2. **Testability**: Handlers can be tested independently
3. **Extensibility**: New entities added without touching core service
4. **Configuration**: Business rules in database, no code deployment needed
5. **Queue Support**: Built-in queue support for large imports
6. **Validation**: Entity-level validation separate from processing logic
7. **Priority Control**: Process entities in configurable order
8. **Reusability**: Handlers can be reused across different import scenarios
## Simulation Service
ImportSimulationServiceV2 provides a way to preview what an import would do without persisting any data to the database. This is useful for:
- Validating mappings before processing
- Previewing create/update actions
- Detecting errors before running actual import
- Testing handler logic
### Usage
```php
use App\Services\Import\ImportSimulationServiceV2;
$service = app(ImportSimulationServiceV2::class);
// Simulate first 100 rows (default)
$result = $service->simulate($import);
// Simulate 50 rows with verbose output
$result = $service->simulate($import, limit: 50, verbose: true);
// Result structure:
// [
// 'success' => true,
// 'total_simulated' => 50,
// 'limit' => 50,
// 'summaries' => [
// 'contract' => ['create' => 10, 'update' => 5, 'skip' => 0, 'invalid' => 1],
// 'account' => ['create' => 20, 'update' => 3, 'skip' => 0, 'invalid' => 0],
// ],
// 'rows' => [
// [
// 'row_number' => 2,
// 'entities' => [
// 'contract' => [
// 'action' => 'update',
// 'reference' => 'CNT-001',
// 'existing_id' => 123,
// 'data' => ['reference', 'title', 'amount'],
// 'changes' => ['title' => ['old' => 'Old', 'new' => 'New']],
// ],
// ],
// 'warnings' => [],
// 'errors' => [],
// ],
// ],
// 'meta' => [
// 'has_header' => true,
// 'delimiter' => ',',
// 'mappings_count' => 8,
// ],
// ]
```
### CLI Command
```bash
# Simulate import with ID 123
php artisan import:simulate-v2 123
# Simulate with custom limit
php artisan import:simulate-v2 123 --limit=50
# Verbose mode shows field-level changes
php artisan import:simulate-v2 123 --verbose
```
### Action Types
- **create**: Entity doesn't exist, would be created
- **update**: Entity exists, would be updated
- **skip**: Entity exists but update_mode is 'skip'
- **invalid**: Validation failed
- **error**: Processing error occurred
### Comparison with V1 Simulation
| Feature | ImportSimulationService (V1) | ImportSimulationServiceV2 |
|---------|------------------------------|---------------------------|
| Handler-based | ❌ Hardcoded logic | ✅ Uses V2 handlers |
| Configuration | ❌ In code | ✅ From database |
| Validation | ❌ Manual | ✅ Handler validation |
| Extensibility | ❌ Modify service | ✅ Add handlers |
| Change detection | ✅ Yes | ✅ Yes |
| Priority ordering | ❌ Fixed | ✅ Configurable |
| Error handling | ✅ Basic | ✅ Comprehensive |
## Original ImportProcessor.php
The original file remains at `app/Services/ImportProcessor.php` and can be used as reference for implementing remaining handlers.

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\AccountType;
use App\Models\ContractType;
use App\Models\Person\AddressType;
use App\Models\Person\PhoneType;
use Illuminate\Support\Facades\Cache;
class ReferenceDataCache
{
private const TTL = 3600; // 1 hour
public function getAddressTypes()
{
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
}
public function getPhoneTypes()
{
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
}
public function getAccountTypes()
{
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
}
public function getContractTypes()
{
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
}
/**
* Clear all reference data cache.
*/
public function clearAll(): void
{
Cache::forget('reference_data:address_types');
Cache::forget('reference_data:phone_types');
Cache::forget('reference_data:account_types');
Cache::forget('reference_data:contract_types');
}
/**
* Clear specific reference data cache.
*/
public function clear(string $type): void
{
Cache::forget("reference_data:{$type}");
}
/**
* Get all types as an array for convenience.
*/
public function getAllTypes(): array
{
return [
'address_types' => $this->getAddressTypes(),
'phone_types' => $this->getPhoneTypes(),
'account_types' => $this->getAccountTypes(),
'contract_types' => $this->getContractTypes(),
];
}
}

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services;
use App\Models\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ReportQueryBuilder
{
/**
* Build a query from a database-driven report configuration.
*/
public function build(Report $report, array $filters = []): Builder
{
// Load all required relationships
$report->load(['entities', 'columns', 'conditions', 'orders']);
// 1. Start with base model query
$query = $this->buildBaseQuery($report);
// 2. Apply joins from report_entities
$this->applyJoins($query, $report);
// 3. Select columns from report_columns
$this->applySelects($query, $report);
// 4. Apply GROUP BY if aggregate functions are used
$this->applyGroupBy($query, $report);
// 5. Apply conditions from report_conditions
$this->applyConditions($query, $report, $filters);
// 6. Apply ORDER BY from report_orders
$this->applyOrders($query, $report);
return $query;
}
/**
* Build the base query from the first entity.
*/
protected function buildBaseQuery(Report $report): Builder
{
$baseEntity = $report->entities->firstWhere('join_type', 'base');
if (!$baseEntity) {
throw new \RuntimeException("Report {$report->slug} has no base entity defined.");
}
$modelClass = $baseEntity->model_class;
if (!class_exists($modelClass)) {
throw new \RuntimeException("Model class {$modelClass} does not exist.");
}
return $modelClass::query();
}
/**
* Apply joins from report entities.
*/
protected function applyJoins(Builder $query, Report $report): void
{
$entities = $report->entities->where('join_type', '!=', 'base');
foreach ($entities as $entity) {
$table = $this->getTableFromModel($entity->model_class);
// Use alias if provided
if ($entity->alias) {
$table = "{$table} as {$entity->alias}";
}
$joinMethod = $entity->join_type;
$query->{$joinMethod}(
$table,
$entity->join_first,
$entity->join_operator ?? '=',
$entity->join_second
);
}
}
/**
* Apply column selections.
*/
protected function applySelects(Builder $query, Report $report): void
{
$columns = $report->columns
->where('visible', true)
->map(fn($col) => DB::raw("{$col->expression} as {$col->key}"))
->toArray();
if (!empty($columns)) {
$query->select($columns);
}
}
/**
* Apply GROUP BY clause if aggregate functions are detected.
*/
protected function applyGroupBy(Builder $query, Report $report): void
{
$visibleColumns = $report->columns->where('visible', true);
// Check if any column uses aggregate functions
$hasAggregates = $visibleColumns->contains(function ($col) {
return preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
});
if (!$hasAggregates) {
return; // No aggregates, no grouping needed
}
// Find non-aggregate columns that need to be in GROUP BY
$groupByColumns = $visibleColumns
->filter(function ($col) {
// Check if this column does NOT use an aggregate function
return !preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
})
->map(function ($col) {
// Extract the actual column expression (before any COALESCE, CAST, etc.)
// For COALESCE(segments.name, 'default'), we need segments.name
if (preg_match('/COALESCE\s*\(\s*([^,]+)\s*,/i', $col->expression, $matches)) {
return trim($matches[1]);
}
// For simple columns, use as-is
return $col->expression;
})
->filter() // Remove empty values
->values()
->toArray();
if (!empty($groupByColumns)) {
foreach ($groupByColumns as $column) {
$query->groupBy(DB::raw($column));
}
}
}
/**
* Apply conditions (WHERE clauses).
*/
protected function applyConditions(Builder $query, Report $report, array $filters): void
{
$conditions = $report->conditions->where('enabled', true);
// Group conditions by group_id
$groups = $conditions->groupBy('group_id');
foreach ($groups as $groupId => $groupConditions) {
$query->where(function ($subQuery) use ($groupConditions, $filters) {
foreach ($groupConditions as $condition) {
$value = $this->resolveConditionValue($condition, $filters);
// Skip if filter-based and no value provided
if ($condition->value_type === 'filter' && $value === null) {
continue;
}
$method = $condition->logical_operator === 'OR' ? 'orWhere' : 'where';
$this->applyCondition($subQuery, $condition, $value, $method);
}
});
}
}
/**
* Apply a single condition to the query.
*/
protected function applyCondition(Builder $query, $condition, $value, string $method): void
{
$column = $condition->column;
$operator = strtoupper($condition->operator);
switch ($operator) {
case 'IS NULL':
$query->{$method . 'Null'}($column);
break;
case 'IS NOT NULL':
$query->{$method . 'NotNull'}($column);
break;
case 'IN':
$values = is_array($value) ? $value : explode(',', $value);
$query->{$method . 'In'}($column, $values);
break;
case 'NOT IN':
$values = is_array($value) ? $value : explode(',', $value);
$query->{$method . 'NotIn'}($column, $values);
break;
case 'BETWEEN':
if (is_array($value) && count($value) === 2) {
$query->{$method . 'Between'}($column, $value);
}
break;
case 'LIKE':
$query->{$method}($column, 'LIKE', $value);
break;
default:
$query->{$method}($column, $operator, $value);
break;
}
}
/**
* Resolve condition value based on value_type.
*/
protected function resolveConditionValue($condition, array $filters)
{
return match ($condition->value_type) {
'static' => $condition->value,
'filter' => $filters[$condition->filter_key] ?? null,
'expression' => DB::raw($condition->value),
default => null,
};
}
/**
* Apply ORDER BY clauses.
*/
protected function applyOrders(Builder $query, Report $report): void
{
foreach ($report->orders as $order) {
$query->orderBy($order->column, $order->direction);
}
}
/**
* Get table name from model class.
*/
protected function getTableFromModel(string $modelClass): string
{
if (!class_exists($modelClass)) {
throw new \RuntimeException("Model class {$modelClass} does not exist.");
}
return (new $modelClass)->getTable();
}
}

147
clean-duplicates.php Normal file
View File

@ -0,0 +1,147 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
echo "=== Checking for duplicates ===\n\n";
// Check Actions table
echo "ACTIONS TABLE:\n";
echo "-------------\n";
$actionDuplicates = DB::table('actions')
->select('name', DB::raw('COUNT(*) as total_count'))
->groupBy('name')
->havingRaw('COUNT(*) > 1')
->get();
if ($actionDuplicates->count() > 0) {
echo "Found duplicate actions:\n";
foreach ($actionDuplicates as $dup) {
echo " - '{$dup->name}' appears {$dup->total_count} times\n";
// Get all IDs for this name
$records = DB::table('actions')
->where('name', $dup->name)
->orderBy('id')
->get(['id', 'name', 'created_at']);
echo " IDs: ";
foreach ($records as $record) {
echo $record->id . " ";
}
echo "\n";
}
} else {
echo "No duplicates found.\n";
}
echo "\n";
// Check Decisions table
echo "DECISIONS TABLE:\n";
echo "---------------\n";
$decisionDuplicates = DB::table('decisions')
->select('name', DB::raw('COUNT(*) as total_count'))
->groupBy('name')
->havingRaw('COUNT(*) > 1')
->get();
if ($decisionDuplicates->count() > 0) {
echo "Found duplicate decisions:\n";
foreach ($decisionDuplicates as $dup) {
echo " - '{$dup->name}' appears {$dup->total_count} times\n";
// Get all IDs for this name
$records = DB::table('decisions')
->where('name', $dup->name)
->orderBy('id')
->get(['id', 'name', 'created_at']);
echo " IDs: ";
foreach ($records as $record) {
echo $record->id . " ";
}
echo "\n";
}
} else {
echo "No duplicates found.\n";
}
echo "\n=== Removing duplicates ===\n\n";
// Remove duplicate actions (keep the first one)
if ($actionDuplicates->count() > 0) {
foreach ($actionDuplicates as $dup) {
$records = DB::table('actions')
->where('name', $dup->name)
->orderBy('id')
->get(['id']);
// Keep the first, delete the rest
$toDelete = $records->skip(1)->pluck('id')->toArray();
if (count($toDelete) > 0) {
DB::table('actions')->whereIn('id', $toDelete)->delete();
echo "Deleted duplicate actions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n";
}
}
}
// Remove duplicate decisions (keep the first one)
if ($decisionDuplicates->count() > 0) {
foreach ($decisionDuplicates as $dup) {
$records = DB::table('decisions')
->where('name', $dup->name)
->orderBy('id')
->get(['id']);
// Keep the first, delete the rest
$toDelete = $records->skip(1)->pluck('id')->toArray();
if (count($toDelete) > 0) {
DB::table('decisions')->whereIn('id', $toDelete)->delete();
echo "Deleted duplicate decisions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n";
}
}
}
echo "\n";
// Check and clean action_decision pivot table
echo "ACTION_DECISION PIVOT TABLE:\n";
echo "---------------------------\n";
// Find duplicates in pivot table
$pivotDuplicates = DB::table('action_decision')
->select('action_id', 'decision_id', DB::raw('COUNT(*) as total_count'))
->groupBy('action_id', 'decision_id')
->havingRaw('COUNT(*) > 1')
->get();
if ($pivotDuplicates->count() > 0) {
echo "Found duplicate pivot entries:\n";
foreach ($pivotDuplicates as $dup) {
echo " - action_id: {$dup->action_id}, decision_id: {$dup->decision_id} appears {$dup->total_count} times\n";
// Get all IDs for this combination
$records = DB::table('action_decision')
->where('action_id', $dup->action_id)
->where('decision_id', $dup->decision_id)
->orderBy('id')
->get(['id']);
// Keep the first, delete the rest
$toDelete = $records->skip(1)->pluck('id')->toArray();
if (count($toDelete) > 0) {
DB::table('action_decision')->whereIn('id', $toDelete)->delete();
echo " Deleted duplicate pivot entries: IDs " . implode(', ', $toDelete) . "\n";
}
}
} else {
echo "No duplicates found.\n";
}
echo "\n=== Cleanup complete ===\n";

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/Components",
"utils": "@/lib/utils",
"ui": "@/Components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View File

@ -7,6 +7,7 @@
"require": {
"php": "^8.2",
"arielmejiadev/larapex-charts": "^2.1",
"barryvdh/laravel-dompdf": "^3.1",
"diglactic/laravel-breadcrumbs": "^10.0",
"http-interop/http-factory-guzzle": "^1.2",
"inertiajs/inertia-laravel": "^2.0",

381
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
"packages": [
{
"name": "arielmejiadev/larapex-charts",
@ -113,6 +113,83 @@
},
"time": "2024-10-01T13:55:55+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9|^10",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-02-13T15:07:54+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@ -761,6 +838,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
},
"time": "2025-10-29T12:43:30+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
},
"time": "2024-12-02T14:37:59+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
},
"time": "2024-04-29T13:26:35+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.4.0",
@ -2995,16 +3227,16 @@
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"shasum": ""
},
"require": {
@ -3015,7 +3247,7 @@
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
@ -3061,7 +3293,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
},
"funding": [
{
@ -3069,7 +3301,7 @@
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
"time": "2025-07-17T11:15:13+00:00"
},
{
"name": "markbaker/complex",
@ -3178,6 +3410,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "meilisearch/meilisearch-php",
"version": "v1.13.0",
@ -5031,6 +5330,72 @@
],
"time": "2025-02-28T15:16:05+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v8.9.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
"rawr/cross-data-providers": "^2.0.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
},
"time": "2025-07-11T13:20:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.0",

View File

@ -60,7 +60,7 @@
'features' => [
// Features::termsAndPrivacyPolicy(),
// Features::profilePhotos(),
Features::api(),
// Features::api(),
// Features::teams(['invitations' => true]),
Features::accountDeletion(),
],

10
config/reports.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
// Optionally list Postgres materialized view names to refresh on schedule
'materialized_views' => [
// e.g., 'mv_activities_daily', 'mv_segment_activity_counts'
],
// Time for scheduled refresh (24h format HH:MM)
'refresh_time' => '03:00',
];

View File

@ -0,0 +1,143 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Contracts table indexes
Schema::table('contracts', function (Blueprint $table) {
if (! $this->indexExists('contracts', 'contracts_client_case_id_active_deleted_at_index')) {
$table->index(['client_case_id', 'active', 'deleted_at'], 'contracts_client_case_id_active_deleted_at_index');
}
if (! $this->indexExists('contracts', 'contracts_start_date_end_date_index')) {
$table->index(['start_date', 'end_date'], 'contracts_start_date_end_date_index');
}
});
// Contract segment pivot table indexes
Schema::table('contract_segment', function (Blueprint $table) {
if (! $this->indexExists('contract_segment', 'contract_segment_contract_id_active_index')) {
$table->index(['contract_id', 'active'], 'contract_segment_contract_id_active_index');
}
if (! $this->indexExists('contract_segment', 'contract_segment_segment_id_active_index')) {
$table->index(['segment_id', 'active'], 'contract_segment_segment_id_active_index');
}
});
// Activities table indexes
Schema::table('activities', function (Blueprint $table) {
if (! $this->indexExists('activities', 'activities_client_case_id_created_at_index')) {
$table->index(['client_case_id', 'created_at'], 'activities_client_case_id_created_at_index');
}
if (! $this->indexExists('activities', 'activities_contract_id_created_at_index')) {
$table->index(['contract_id', 'created_at'], 'activities_contract_id_created_at_index');
}
});
// Client cases table indexes
Schema::table('client_cases', function (Blueprint $table) {
if (! $this->indexExists('client_cases', 'client_cases_client_id_active_index')) {
$table->index(['client_id', 'active'], 'client_cases_client_id_active_index');
}
if (! $this->indexExists('client_cases', 'client_cases_person_id_active_index')) {
$table->index(['person_id', 'active'], 'client_cases_person_id_active_index');
}
});
// Documents table indexes for polymorphic relations
Schema::table('documents', function (Blueprint $table) {
if (! $this->indexExists('documents', 'documents_documentable_type_documentable_id_index')) {
$table->index(['documentable_type', 'documentable_id'], 'documents_documentable_type_documentable_id_index');
}
if (! $this->indexExists('documents', 'documents_created_at_index')) {
$table->index(['created_at'], 'documents_created_at_index');
}
});
// Field jobs indexes
Schema::table('field_jobs', function (Blueprint $table) {
if (! $this->indexExists('field_jobs', 'field_jobs_assigned_user_id_index')) {
$table->index(['assigned_user_id'], 'field_jobs_assigned_user_id_index');
}
if (! $this->indexExists('field_jobs', 'field_jobs_contract_id_index')) {
$table->index(['contract_id'], 'field_jobs_contract_id_index');
}
if (! $this->indexExists('field_jobs', 'field_jobs_completed_at_index')) {
$table->index(['completed_at'], 'field_jobs_completed_at_index');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
$table->dropIndex('contracts_client_case_id_active_deleted_at_index');
$table->dropIndex('contracts_start_date_end_date_index');
});
Schema::table('contract_segment', function (Blueprint $table) {
$table->dropIndex('contract_segment_contract_id_active_index');
$table->dropIndex('contract_segment_segment_id_active_index');
});
Schema::table('activities', function (Blueprint $table) {
$table->dropIndex('activities_client_case_id_created_at_index');
$table->dropIndex('activities_contract_id_created_at_index');
});
Schema::table('client_cases', function (Blueprint $table) {
$table->dropIndex('client_cases_client_id_active_index');
$table->dropIndex('client_cases_person_id_active_index');
});
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex('documents_documentable_type_documentable_id_index');
$table->dropIndex('documents_created_at_index');
});
Schema::table('field_jobs', function (Blueprint $table) {
$table->dropIndex('field_jobs_assigned_user_id_index');
$table->dropIndex('field_jobs_contract_id_index');
$table->dropIndex('field_jobs_completed_at_index');
});
}
/**
* Check if an index exists on a table.
*/
protected function indexExists(string $table, string $index): bool
{
$connection = Schema::getConnection();
$driver = $connection->getDriverName();
if ($driver === 'pgsql') {
// PostgreSQL uses pg_indexes system catalog
$result = $connection->select(
"SELECT COUNT(*) as count FROM pg_indexes
WHERE schemaname = 'public' AND tablename = ? AND indexname = ?",
[$table, $index]
);
} else {
// MySQL/MariaDB uses information_schema.statistics
$databaseName = $connection->getDatabaseName();
$result = $connection->select(
"SELECT COUNT(*) as count FROM information_schema.statistics
WHERE table_schema = ? AND table_name = ? AND index_name = ?",
[$databaseName, $table, $index]
);
}
return $result[0]->count > 0;
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('import_entities', function (Blueprint $table) {
$table->string('handler_class')->nullable()->after('meta');
$table->json('validation_rules')->nullable()->after('handler_class');
$table->json('processing_options')->nullable()->after('validation_rules');
$table->boolean('is_active')->default(true)->after('processing_options');
$table->integer('priority')->default(0)->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('import_entities', function (Blueprint $table) {
$table->dropColumn(['handler_class', 'validation_rules', 'processing_options', 'is_active', 'priority']);
});
}
};

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::unprepared('
CREATE OR REPLACE FUNCTION delete_client_case_cascade(case_id INTEGER)
RETURNS TABLE(deleted_table TEXT, deleted_count INTEGER) AS $$
DECLARE
v_deleted_count INTEGER;
BEGIN
-- Delete bookings related to payments in this case
WITH deleted AS (
DELETE FROM bookings
WHERE payment_id IN (
SELECT p.id FROM payments p
INNER JOIN accounts a ON p.account_id = a.id
INNER JOIN contracts c ON a.contract_id = c.id
WHERE c.client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'bookings\'::TEXT, v_deleted_count;
-- Delete payments related to accounts in this case
WITH deleted AS (
DELETE FROM payments
WHERE account_id IN (
SELECT a.id FROM accounts a
INNER JOIN contracts c ON a.contract_id = c.id
WHERE c.client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'payments\'::TEXT, v_deleted_count;
-- Delete activities
WITH deleted AS (
DELETE FROM activities WHERE client_case_id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'activities\'::TEXT, v_deleted_count;
-- Delete accounts
WITH deleted AS (
DELETE FROM accounts
WHERE contract_id IN (
SELECT id FROM contracts WHERE client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'accounts\'::TEXT, v_deleted_count;
-- Delete contracts
WITH deleted AS (
DELETE FROM contracts WHERE client_case_id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'contracts\'::TEXT, v_deleted_count;
-- Delete the client_case itself
WITH deleted AS (
DELETE FROM client_cases WHERE id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'client_cases\'::TEXT, v_deleted_count;
END;
$$ LANGUAGE plpgsql;
');
}
public function down(): void
{
DB::unprepared('DROP FUNCTION IF EXISTS delete_client_case_cascade(INTEGER);');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('name');
$table->text('description')->nullable();
$table->string('category', 100)->nullable();
$table->boolean('enabled')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
$table->index('slug');
$table->index(['enabled', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reports');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_columns', function (Blueprint $table) {
$table->id();
$table->foreignId('report_id')->constrained()->cascadeOnDelete();
$table->string('key', 100);
$table->string('label');
$table->string('type', 50)->default('string');
$table->text('expression');
$table->boolean('sortable')->default(true);
$table->boolean('visible')->default(true);
$table->integer('order')->default(0);
$table->json('format_options')->nullable();
$table->timestamps();
$table->index(['report_id', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('report_columns');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_entities', function (Blueprint $table) {
$table->id();
$table->foreignId('report_id')->constrained()->cascadeOnDelete();
$table->string('model_class');
$table->string('alias', 50)->nullable();
$table->enum('join_type', ['base', 'join', 'leftJoin', 'rightJoin'])->default('base');
$table->string('join_first', 100)->nullable();
$table->string('join_operator', 10)->nullable();
$table->string('join_second', 100)->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->index(['report_id', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('report_entities');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_filters', function (Blueprint $table) {
$table->id();
$table->foreignId('report_id')->constrained()->cascadeOnDelete();
$table->string('key', 100);
$table->string('label');
$table->string('type', 50);
$table->boolean('nullable')->default(true);
$table->text('default_value')->nullable();
$table->json('options')->nullable();
$table->string('data_source')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->index(['report_id', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('report_filters');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_conditions', function (Blueprint $table) {
$table->id();
$table->foreignId('report_id')->constrained()->cascadeOnDelete();
$table->string('column');
$table->string('operator', 50);
$table->string('value_type', 50);
$table->text('value')->nullable();
$table->string('filter_key', 100)->nullable();
$table->enum('logical_operator', ['AND', 'OR'])->default('AND');
$table->integer('group_id')->nullable();
$table->integer('order')->default(0);
$table->boolean('enabled')->default(true);
$table->timestamps();
$table->index(['report_id', 'group_id', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('report_conditions');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('report_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('report_id')->constrained()->cascadeOnDelete();
$table->string('column');
$table->enum('direction', ['ASC', 'DESC'])->default('ASC');
$table->integer('order')->default(0);
$table->timestamps();
$table->index(['report_id', 'order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('report_orders');
}
};

View File

@ -0,0 +1,306 @@
<?php
namespace Database\Seeders;
use App\Models\ImportEntity;
use Illuminate\Database\Seeder;
class ImportEntitiesV2Seeder extends Seeder
{
/**
* Seed import_entities with v2 handler configurations.
*/
public function run(): void
{
$entities = [
[
'key' => 'contracts',
'canonical_root' => 'contract',
'label' => 'Pogodbe',
'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id', 'meta'],
'field_aliases' => [],
'aliases' => ['contract', 'contracts'],
'supports_multiple' => false,
'meta' => true,
'rules' => [],
'ui' => ['default_field' => 'reference', 'order' => 1],
'handler_class' => \App\Services\Import\Handlers\ContractHandler::class,
'validation_rules' => [
'reference' => 'required|string|max:255',
],
'processing_options' => [
'update_mode' => 'update', // update, skip, error
'create_missing' => true,
'supports_reactivation' => true,
'merge_json_fields' => ['meta'],
'post_actions' => [
'attach_segment_id' => null,
'create_activity' => false,
],
],
'is_active' => true,
'priority' => 100, // Highest - process first to establish chain
],
[
'key' => 'accounts',
'canonical_root' => 'account',
'label' => 'Računi',
'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'field_aliases' => [],
'aliases' => ['account', 'accounts'],
'supports_multiple' => false,
'meta' => false,
'rules' => [],
'ui' => ['default_field' => 'reference', 'order' => 2],
'handler_class' => \App\Services\Import\Handlers\AccountHandler::class,
'validation_rules' => [
'reference' => 'required|string|max:255',
'contract_id' => 'required|integer|exists:contracts,id',
],
'processing_options' => [
'update_mode' => 'update',
'require_contract' => true,
'track_balance_changes' => true,
'create_activity_on_balance_change' => true,
'history_import' => [
'skip_updates' => true,
'force_zero_balances' => true,
'auto_create_for_contract' => true,
],
],
'is_active' => true,
'priority' => 50, // After Person and contacts
],
[
'key' => 'payments',
'canonical_root' => 'payment',
'label' => 'Plačila',
'fields' => [
'reference',
'payment_nu',
'payment_date',
'amount',
'type_id',
'active',
// optional helpers for mapping by related records
'debt_id',
'account_id',
'account_reference',
'contract_reference'
],
'field_aliases' => [
'datum' => 'payment_date',
'paid_at' => 'payment_date',
'number' => 'payment_nu',
'znesek' => 'amount',
'value' => 'amount'
],
'aliases' => ['payment', 'payments'],
'supports_multiple' => false,
'meta' => false,
'rules' => [],
'ui' => ['default_field' => 'reference', 'order' => 3],
'handler_class' => \App\Services\Import\Handlers\PaymentHandler::class,
'validation_rules' => [
'amount' => 'required|numeric',
],
'processing_options' => [
'deduplicate_by' => ['account_id', 'reference'],
'create_booking' => true,
'create_activity' => false, // Based on PaymentSetting
'track_balance' => true,
'activity_note_template' => 'Prejeto plačilo [amount] [currency]',
'payments_import' => [
'require_fields' => ['contract.reference', 'payment.amount', 'payment.payment_date'],
'contract_key_mode' => 'reference',
],
],
'is_active' => true,
'priority' => 40, // After Account
],
[
'key' => 'activities',
'canonical_root' => 'activity',
'label' => 'Aktivnosti',
'fields' => ['client_case_id', 'contract_id', 'due_date', 'amount', 'note', 'action_id', 'decision_id'],
'field_aliases' => [],
'aliases' => ['activity', 'activities'],
'supports_multiple' => false,
'meta' => false,
'rules' => [],
'ui' => ['default_field' => 'note', 'order' => 4],
'handler_class' => \App\Services\Import\Handlers\ActivityHandler::class,
'validation_rules' => [],
'processing_options' => [
'require_contract' => false,
'require_client_case' => false,
],
'is_active' => true,
'priority' => 30, // After all primary entities
],
[
'key' => 'person',
'canonical_root' => 'person',
'label' => 'Osebe',
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
'field_aliases' => [],
'aliases' => ['person'],
'supports_multiple' => false,
'meta' => false,
'rules' => [],
'ui' => ['default_field' => 'full_name', 'order' => 5],
'handler_class' => \App\Services\Import\Handlers\PersonHandler::class,
'validation_rules' => [],
'processing_options' => [
'deduplicate_by' => ['tax_number', 'social_security_number'],
'update_mode' => 'update',
],
'is_active' => true,
'priority' => 90, // Third - derive from Contract/ClientCase chain if exists
],
[
'key' => 'emails',
'canonical_root' => 'email',
'label' => 'Email naslovi',
'fields' => ['value', 'is_primary', 'label'],
'field_aliases' => [],
'aliases' => ['email', 'emails'],
'supports_multiple' => true,
'meta' => false,
'rules' => [],
'ui' => ['default_field' => 'value', 'order' => 6],
'handler_class' => \App\Services\Import\Handlers\EmailHandler::class,
'validation_rules' => [
'value' => 'required|email',
],
'processing_options' => [
'deduplicate' => true,
],
'is_active' => true,
'priority' => 80, // After Person
],
[
'key' => 'person_addresses',
'canonical_root' => 'address',
'label' => 'Naslovi oseb',
'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'],
'field_aliases' => [
'ulica' => 'address',
'naslov' => 'address',
'mesto' => 'city',
'posta' => 'postal_code',
'pošta' => 'postal_code',
'zip' => 'postal_code',
'drzava' => 'country',
'država' => 'country',
'opis' => 'description',
],
'aliases' => ['person_addresses', 'address', 'addresses'],
'supports_multiple' => true,
'meta' => false,
'rules' => [
['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'],
['pattern' => '/^(mesto|city|kraj)\b/i', 'field' => 'city'],
['pattern' => '/^(posta|pošta|zip|postal)\b/i', 'field' => 'postal_code'],
['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
],
'ui' => ['default_field' => 'address', 'order' => 7],
'handler_class' => \App\Services\Import\Handlers\AddressHandler::class,
'validation_rules' => [
'address' => 'required|string|max:255',
],
'processing_options' => [
'deduplicate' => true,
'parent_entity' => 'person',
],
'is_active' => true,
'priority' => 70, // After Person
],
[
'key' => 'person_phones',
'canonical_root' => 'phone',
'label' => 'Telefoni oseb',
'fields' => ['nu', 'country_code', 'type_id', 'description'],
'field_aliases' => ['number' => 'nu'],
'aliases' => ['phone', 'person_phones'],
'supports_multiple' => true,
'meta' => false,
'rules' => [
['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'],
],
'ui' => ['default_field' => 'nu', 'order' => 8],
'handler_class' => \App\Services\Import\Handlers\PhoneHandler::class,
'validation_rules' => [
'nu' => 'required|string|max:50',
],
'processing_options' => [
'deduplicate' => true,
'parent_entity' => 'person',
],
'is_active' => true,
'priority' => 60, // After Person
],
[
'key' => 'client_cases',
'canonical_root' => 'client_case',
'label' => 'Primeri',
'fields' => ['client_ref'],
'field_aliases' => [],
'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'],
'supports_multiple' => false,
'meta' => false,
'rules' => [
['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'],
],
'ui' => ['default_field' => 'client_ref', 'order' => 9],
'handler_class' => \App\Services\Import\Handlers\ClientCaseHandler::class,
'validation_rules' => [
'client_ref' => 'required|string|max:255',
],
'processing_options' => [
'deduplicate_by' => ['client_ref'],
'update_mode' => 'update',
],
'is_active' => true,
'priority' => 95, // Second - process after Contract to establish chain
],
[
'key' => 'case_objects',
'canonical_root' => 'case_object',
'label' => 'Predmeti',
'fields' => ['reference', 'name', 'description', 'type', 'contract_id'],
'field_aliases' => [],
'aliases' => ['case_object', 'case_objects', 'object', 'objects', 'predmet', 'predmeti'],
'supports_multiple' => false,
'meta' => false,
'rules' => [
['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'],
['pattern' => '/^(ime|naziv|name|title)\b/i', 'field' => 'name'],
['pattern' => '/^(tip|vrsta|type|kind)\b/i', 'field' => 'type'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
['pattern' => '/^(contract\s*id|contract_id|pogodba\s*id|pogodba_id)\b/i', 'field' => 'contract_id'],
],
'ui' => ['default_field' => 'name', 'order' => 10],
'handler_class' => \App\Services\Import\Handlers\CaseObjectHandler::class,
'validation_rules' => [
'name' => 'required|string|max:255',
],
'processing_options' => [
'require_contract' => false,
],
'is_active' => true,
'priority' => 10,
],
];
foreach ($entities as $entity) {
ImportEntity::updateOrCreate(
['key' => $entity['key']],
$entity
);
}
$this->command->info('Import entities v2 seeded successfully.');
}
}

View File

@ -0,0 +1,786 @@
<?php
namespace Database\Seeders;
use App\Models\Report;
use Illuminate\Database\Seeder;
class ReportsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Clear existing reports (cascade will delete all related records)
Report::truncate();
$this->seedActiveContractsReport();
$this->seedFieldJobsCompletedReport();
$this->seedDecisionsCountReport();
$this->seedSegmentActivityCountsReport();
$this->seedActionsDecisionsCountReport();
$this->seedActivitiesPerPeriodReport();
}
protected function seedActiveContractsReport(): void
{
$report = Report::create([
'slug' => 'active-contracts',
'name' => 'Aktivne pogodbe',
'description' => 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.',
'category' => 'contracts',
'enabled' => true,
'order' => 1,
]);
// Entities (joins)
$report->entities()->create([
'model_class' => 'App\\Models\\Contract',
'join_type' => 'base',
'order' => 0,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\ClientCase',
'join_type' => 'join',
'join_first' => 'contracts.client_case_id',
'join_operator' => '=',
'join_second' => 'client_cases.id',
'order' => 1,
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Client',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.client_id',
'join_operator' => '=',
'join_second' => 'clients.id',
'order' => 2,
]);
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'client_people',
'join_type' => 'leftJoin',
'join_first' => 'clients.person_id',
'join_operator' => '=',
'join_second' => 'client_people.id',
'order' => 3,
],
[
'model_class' => 'App\\Models\\Person\\Person',
'alias' => 'subject_people',
'join_type' => 'leftJoin',
'join_first' => 'client_cases.person_id',
'join_operator' => '=',
'join_second' => 'subject_people.id',
'order' => 4,
],
]);
$report->entities()->create([
'model_class' => 'App\\Models\\Account',
'join_type' => 'leftJoin',
'join_first' => 'contracts.id',
'join_operator' => '=',
'join_second' => 'accounts.contract_id',
'order' => 5,
]);
// Columns
$report->columns()->createMany([
[
'key' => 'contract_reference',
'label' => 'Pogodba',
'type' => 'string',
'expression' => 'contracts.reference',
'order' => 0,
],
[
'key' => 'client_name',
'label' => 'Stranka',
'type' => 'string',
'expression' => 'client_people.full_name',
'order' => 1,
],
[
'key' => 'person_name',
'label' => 'Zadeva (oseba)',
'type' => 'string',
'expression' => 'subject_people.full_name',
'order' => 2,
],
[
'key' => 'start_date',
'label' => 'Začetek',
'type' => 'date',
'expression' => 'contracts.start_date',
'order' => 3,
],
[
'key' => 'end_date',
'label' => 'Konec',
'type' => 'date',
'expression' => 'contracts.end_date',
'order' => 4,
],
[
'key' => 'balance_amount',
'label' => 'Saldo',
'type' => 'currency',
'expression' => 'CAST(accounts.balance_amount AS FLOAT)',
'order' => 5,
],
]);
// Filters
$report->filters()->create([
'key' => 'client_uuid',
'label' => 'Stranka',
'type' => 'select:client',
'nullable' => true,
'order' => 0,
]);
// Conditions - Active as of today
$asOf = 'CURRENT_DATE';
// start_date <= as_of (or null)
$report->conditions()->create([
'column' => 'contracts.start_date',
'operator' => '<=',
'value_type' => 'expression',
'value' => $asOf,
'logical_operator' => 'OR',
'group_id' => 1,
'order' => 0,
]);
$report->conditions()->create([
'column' => 'contracts.start_date',
'operator' => 'IS NULL',
'value_type' => 'static',
'logical_operator' => 'OR',
'group_id' => 1,
'order' => 1,
]);
// end_date >= as_of (or null)
$report->conditions()->create([
'column' => 'contracts.end_date',
'operator' => '>=',
'value_type' => 'expression',
'value' => $asOf,
'logical_operator' => 'OR',
'group_id' => 2,
'order' => 0,
]);
$report->conditions()->create([
'column' => 'contracts.end_date',
'operator' => 'IS NULL',
'value_type' => 'static',
'logical_operator' => 'OR',
'group_id' => 2,
'order' => 1,
]);
// client_uuid filter condition
$report->conditions()->create([
'column' => 'clients.uuid',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'client_uuid',
'logical_operator' => 'AND',
'group_id' => 3,
'order' => 0,
]);
// Orders
$report->orders()->create([
'column' => 'contracts.start_date',
'direction' => 'ASC',
'order' => 0,
]);
}
protected function seedFieldJobsCompletedReport(): void
{
$report = Report::create([
'slug' => 'field-jobs-completed',
'name' => 'Zaključeni tereni',
'description' => 'Pregled zaključenih terenov po datumu in uporabniku.',
'category' => 'field',
'enabled' => true,
'order' => 2,
]);
// Base entity
$report->entities()->create([
'model_class' => 'App\\Models\\FieldJob',
'join_type' => 'base',
'order' => 0,
]);
// Join contracts table
$report->entities()->create([
'model_class' => 'App\\Models\\Contract',
'join_type' => 'leftJoin',
'join_first' => 'field_jobs.contract_id',
'join_operator' => '=',
'join_second' => 'contracts.id',
'order' => 1,
]);
// Join users table
$report->entities()->create([
'model_class' => 'App\\Models\\User',
'join_type' => 'leftJoin',
'join_first' => 'field_jobs.assigned_user_id',
'join_operator' => '=',
'join_second' => 'users.id',
'order' => 2,
]);
// Columns
$report->columns()->createMany([
[
'key' => 'id',
'label' => '#',
'type' => 'number',
'expression' => 'field_jobs.id',
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'contract_reference',
'label' => 'Pogodba',
'type' => 'string',
'expression' => 'contracts.reference',
'sortable' => true,
'visible' => true,
'order' => 1,
],
[
'key' => 'assigned_user_name',
'label' => 'Terenski',
'type' => 'string',
'expression' => 'users.name',
'sortable' => true,
'visible' => true,
'order' => 2,
],
[
'key' => 'completed_at',
'label' => 'Zaključeno',
'type' => 'date',
'expression' => 'field_jobs.completed_at',
'sortable' => true,
'visible' => true,
'order' => 3,
],
[
'key' => 'notes',
'label' => 'Opombe',
'type' => 'string',
'expression' => 'field_jobs.notes',
'sortable' => false,
'visible' => true,
'order' => 4,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'from',
'label' => 'Od',
'type' => 'date',
'nullable' => false,
'default_value' => now()->startOfMonth()->toDateString(),
'order' => 0,
],
[
'key' => 'to',
'label' => 'Do',
'type' => 'date',
'nullable' => false,
'default_value' => now()->toDateString(),
'order' => 1,
],
[
'key' => 'user_id',
'label' => 'Uporabnik',
'type' => 'select:user',
'nullable' => true,
'order' => 2,
],
]);
// Conditions
$report->conditions()->createMany([
[
'column' => 'field_jobs.cancelled_at',
'operator' => 'IS NULL',
'value_type' => 'static',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'field_jobs.completed_at',
'operator' => 'BETWEEN',
'value_type' => 'filter',
'filter_key' => 'from,to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
[
'column' => 'field_jobs.assigned_user_id',
'operator' => '=',
'value_type' => 'filter',
'filter_key' => 'user_id',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 2,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'field_jobs.completed_at',
'direction' => 'DESC',
'order' => 0,
]);
}
protected function seedDecisionsCountReport(): void
{
$report = Report::create([
'slug' => 'decisions-counts',
'name' => 'Odločitve štetje',
'description' => 'Število aktivnosti po odločitvah v izbranem obdobju.',
'category' => 'activities',
'enabled' => true,
'order' => 3,
]);
// Entities
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
],
[
'model_class' => 'App\\Models\\Decision',
'join_type' => 'leftJoin',
'join_first' => 'activities.decision_id',
'join_operator' => '=',
'join_second' => 'decisions.id',
'order' => 1,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'decision_name',
'label' => 'Odločitev',
'type' => 'string',
'expression' => "COALESCE(decisions.name, '—')",
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'activities_count',
'label' => 'Št. aktivnosti',
'type' => 'number',
'expression' => 'COUNT(*)',
'sortable' => true,
'visible' => true,
'order' => 1,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'from',
'label' => 'Od',
'type' => 'date',
'nullable' => true,
'order' => 0,
],
[
'key' => 'to',
'label' => 'Do',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
]);
// Conditions
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities_count',
'direction' => 'DESC',
'order' => 0,
]);
}
protected function seedSegmentActivityCountsReport(): void
{
$report = Report::create([
'slug' => 'segment-activity-counts',
'name' => 'Aktivnosti po segmentih',
'description' => 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).',
'category' => 'activities',
'enabled' => true,
'order' => 4,
]);
// Entities
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
],
[
'model_class' => 'App\\Models\\Action',
'join_type' => 'join',
'join_first' => 'activities.action_id',
'join_operator' => '=',
'join_second' => 'actions.id',
'order' => 1,
],
[
'model_class' => 'App\\Models\\Segment',
'join_type' => 'leftJoin',
'join_first' => 'actions.segment_id',
'join_operator' => '=',
'join_second' => 'segments.id',
'order' => 2,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'segment_name',
'label' => 'Segment',
'type' => 'string',
'expression' => "COALESCE(segments.name, 'Brez segmenta')",
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'activities_count',
'label' => 'Št. aktivnosti',
'type' => 'number',
'expression' => 'COUNT(*)',
'sortable' => true,
'visible' => true,
'order' => 1,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'from',
'label' => 'Od',
'type' => 'date',
'nullable' => true,
'order' => 0,
],
[
'key' => 'to',
'label' => 'Do',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
]);
// Conditions
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities_count',
'direction' => 'DESC',
'order' => 0,
]);
}
protected function seedActionsDecisionsCountReport(): void
{
$report = Report::create([
'slug' => 'actions-decisions-counts',
'name' => 'Dejanja / Odločitve štetje',
'description' => 'Število aktivnosti po dejanjih in odločitvah v obdobju.',
'category' => 'activities',
'enabled' => true,
'order' => 5,
]);
// Entities
$report->entities()->createMany([
[
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
],
[
'model_class' => 'App\\Models\\Action',
'join_type' => 'leftJoin',
'join_first' => 'activities.action_id',
'join_operator' => '=',
'join_second' => 'actions.id',
'order' => 1,
],
[
'model_class' => 'App\\Models\\Decision',
'join_type' => 'leftJoin',
'join_first' => 'activities.decision_id',
'join_operator' => '=',
'join_second' => 'decisions.id',
'order' => 2,
],
]);
// Columns
$report->columns()->createMany([
[
'key' => 'action_name',
'label' => 'Dejanje',
'type' => 'string',
'expression' => "COALESCE(actions.name, '—')",
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'decision_name',
'label' => 'Odločitev',
'type' => 'string',
'expression' => "COALESCE(decisions.name, '—')",
'sortable' => true,
'visible' => true,
'order' => 1,
],
[
'key' => 'activities_count',
'label' => 'Št. aktivnosti',
'type' => 'number',
'expression' => 'COUNT(*)',
'sortable' => true,
'visible' => true,
'order' => 2,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'from',
'label' => 'Od',
'type' => 'date',
'nullable' => true,
'order' => 0,
],
[
'key' => 'to',
'label' => 'Do',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
]);
// Conditions
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'activities_count',
'direction' => 'DESC',
'order' => 0,
]);
}
protected function seedActivitiesPerPeriodReport(): void
{
$report = Report::create([
'slug' => 'activities-per-period',
'name' => 'Aktivnosti po obdobjih',
'description' => 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.',
'category' => 'activities',
'enabled' => true,
'order' => 6,
]);
// Base entity
$report->entities()->create([
'model_class' => 'App\\Models\\Activity',
'join_type' => 'base',
'order' => 0,
]);
// Columns (simplified - period grouping handled in ReportQueryBuilder or controller)
$report->columns()->createMany([
[
'key' => 'period',
'label' => 'Obdobje',
'type' => 'string',
'expression' => 'DATE(activities.created_at)',
'sortable' => true,
'visible' => true,
'order' => 0,
],
[
'key' => 'activities_count',
'label' => 'Št. aktivnosti',
'type' => 'number',
'expression' => 'COUNT(*)',
'sortable' => true,
'visible' => true,
'order' => 1,
],
]);
// Filters
$report->filters()->createMany([
[
'key' => 'from',
'label' => 'Od',
'type' => 'date',
'nullable' => true,
'order' => 0,
],
[
'key' => 'to',
'label' => 'Do',
'type' => 'date',
'nullable' => true,
'order' => 1,
],
[
'key' => 'period',
'label' => 'Obdobje (day/week/month)',
'type' => 'string',
'nullable' => false,
'default_value' => 'day',
'order' => 2,
],
]);
// Conditions
$report->conditions()->createMany([
[
'column' => 'activities.created_at',
'operator' => '>=',
'value_type' => 'filter',
'filter_key' => 'from',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 0,
'enabled' => true,
],
[
'column' => 'activities.created_at',
'operator' => '<=',
'value_type' => 'filter',
'filter_key' => 'to',
'logical_operator' => 'AND',
'group_id' => 1,
'order' => 1,
'enabled' => true,
],
]);
// Order
$report->orders()->create([
'column' => 'period',
'direction' => 'ASC',
'order' => 0,
]);
}
}

74
deploy.sh Normal file
View File

@ -0,0 +1,74 @@
#!/bin/bash
# Teren App Deployment Script
# This script handles automated deployment from Gitea
set -e # Exit on any error
echo "🚀 Starting deployment..."
# Configuration
PROJECT_DIR="/var/www/Teren-app"
BRANCH="main" # Change to your production branch
GITEA_REPO="git@your-gitea-server.com:username/Teren-app.git"
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Change to project directory
cd $PROJECT_DIR
echo "📥 Pulling latest changes from $BRANCH..."
git fetch origin $BRANCH
git reset --hard origin/$BRANCH
echo "🔧 Copying production environment file..."
if [ ! -f .env ]; then
echo "${RED}❌ .env file not found! Please create it from .env.production.example${NC}"
exit 1
fi
echo "🐳 Building and starting Docker containers..."
docker-compose down
docker-compose build --no-cache app
docker-compose up -d
echo "⏳ Waiting for containers to be healthy..."
sleep 10
echo "📦 Installing/updating Composer dependencies..."
docker-compose exec -T app composer install --no-dev --optimize-autoloader --no-interaction
echo "🎨 Building frontend assets..."
# If you build assets locally or in CI/CD, uncomment:
# npm ci
# npm run build
echo "🔑 Optimizing Laravel..."
docker-compose exec -T app php artisan config:cache
docker-compose exec -T app php artisan route:cache
docker-compose exec -T app php artisan view:cache
docker-compose exec -T app php artisan event:cache
echo "📊 Running database migrations..."
docker-compose exec -T app php artisan migrate --force
echo "🗄️ Clearing old caches..."
docker-compose exec -T app php artisan cache:clear
docker-compose exec -T app php artisan queue:restart
echo "🔄 Restarting queue workers..."
docker-compose restart app
echo "${GREEN}✅ Deployment completed successfully!${NC}"
# Optional: Send notification (Slack, Discord, etc.)
# curl -X POST -H 'Content-type: application/json' \
# --data '{"text":"🚀 Teren App deployed successfully!"}' \
# YOUR_WEBHOOK_URL
# Show running containers
echo "📋 Running containers:"
docker-compose ps

189
docker-compose.yaml.example Normal file
View File

@ -0,0 +1,189 @@
version: '3.8'
services:
# Laravel Application
app:
build:
context: .
dockerfile: Dockerfile
args:
- PHP_VERSION=8.4
container_name: teren-app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./storage:/var/www/storage
- ./bootstrap/cache:/var/www/bootstrap/cache
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- DB_CONNECTION=pgsql
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_HOST=redis
- REDIS_PORT=6379
- QUEUE_CONNECTION=redis
- LIBREOFFICE_BIN=/usr/bin/soffice
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- teren-network
# Supervisor runs inside the container (defined in Dockerfile)
# Includes PHP-FPM, Laravel queue workers, and queue-sms workers
# Nginx Web Server (VPN-only access)
nginx:
image: nginx:alpine
container_name: teren-nginx
restart: unless-stopped
ports:
- "10.13.13.1:80:80" # Only accessible via WireGuard VPN
- "10.13.13.1:443:443" # Only accessible via WireGuard VPN
volumes:
- ./:/var/www
- ./docker/nginx/conf.d:/etc/nginx/conf.d
- ./docker/nginx/ssl:/etc/nginx/ssl
- ./docker/certbot/conf:/etc/letsencrypt
- ./docker/certbot/www:/var/www/certbot
depends_on:
- app
networks:
- teren-network
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
# Certbot for SSL certificates
certbot:
image: certbot/certbot
container_name: teren-certbot
restart: unless-stopped
volumes:
- ./docker/certbot/conf:/etc/letsencrypt
- ./docker/certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
- teren-network
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: teren-postgres
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432" # Only accessible via localhost (or VPN)
environment:
- POSTGRES_DB=${DB_DATABASE}
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- teren-network
# pgAdmin - PostgreSQL UI
pgadmin:
image: dpage/pgadmin4:latest
container_name: teren-pgadmin
restart: unless-stopped
ports:
- "127.0.0.1:5050:80" # Only accessible via localhost (or VPN)
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@admin.com}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-admin}
- PGADMIN_CONFIG_SERVER_MODE=True
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=True
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- teren-network
# Redis for caching and queues
redis:
image: redis:7-alpine
container_name: teren-redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- teren-network
# WireGuard VPN with Web UI Dashboard
wireguard:
image: weejewel/wg-easy:latest
container_name: teren-wireguard
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- WG_HOST=${WG_SERVERURL} # Your VPS public IP or domain
- PASSWORD=${WG_UI_PASSWORD} # Password for WireGuard UI
- WG_PORT=51820
- WG_DEFAULT_ADDRESS=10.13.13.x
- WG_DEFAULT_DNS=1.1.1.1,1.0.0.1
- WG_MTU=1420
- WG_PERSISTENT_KEEPALIVE=25
- WG_ALLOWED_IPS=10.13.13.0/24
volumes:
- wireguard-data:/etc/wireguard
ports:
- "51820:51820/udp" # WireGuard VPN port (public)
- "51821:51821/tcp" # WireGuard Web UI (public for initial setup, then VPN-only)
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv4.ip_forward=1
networks:
- teren-network
# Portainer - Docker Management UI (VPN-only access)
portainer:
image: portainer/portainer-ce:latest
container_name: teren-portainer
restart: unless-stopped
ports:
- "10.13.13.1:9000:9000" # Portainer UI (VPN-only)
- "10.13.13.1:9443:9443" # Portainer HTTPS (VPN-only)
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer-data:/data
networks:
- teren-network
networks:
teren-network:
driver: bridge
volumes:
postgres-data:
driver: local
pgadmin-data:
driver: local
redis-data:
driver: local
wireguard-data:
driver: local
portainer-data:
driver: local

View File

@ -0,0 +1,86 @@
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com; # Change this to your domain
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com; # Change this to your domain
root /var/www/public;
index index.php index.html;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # Change this
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Change this
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Laravel location configuration
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
# Increase timeouts for long-running requests
fastcgi_read_timeout 300;
fastcgi_send_timeout 300;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Deny access to sensitive files
location ~ /\.env {
deny all;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
gzip_disable "msie6";
client_max_body_size 100M;
}

View File

@ -0,0 +1,53 @@
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php index.html;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Laravel location configuration
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
# Increase timeouts for debugging
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Deny access to sensitive files
location ~ /\.env {
deny all;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
gzip_disable "msie6";
client_max_body_size 100M;
}

23
docker/php/custom.ini Normal file
View File

@ -0,0 +1,23 @@
; PHP Custom Configuration for Production
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
; OPcache settings
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1
; Production settings
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php_errors.log

View File

@ -0,0 +1,25 @@
[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --timeout=300 --verbose
autostart=true
autorestart=true
user=www
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/worker.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
stopwaitsecs=360
[program:laravel-queue-sms]
process_name=%(program_name)s_%(process_num)02d
command=/usr/local/bin/php /var/www/artisan queue:work --queue=sms --sleep=3 --tries=3 --timeout=90 --verbose
autostart=true
autorestart=true
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/worker-sms.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
stopwaitsecs=360

View File

@ -0,0 +1,11 @@
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf
autostart=true
autorestart=true
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stdout_events_enabled=true
stderr_events_enabled=true

View File

@ -0,0 +1,19 @@
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
user=root
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[include]
files = /etc/supervisor/conf.d/*.conf

18
mark-import-failed.php Normal file
View File

@ -0,0 +1,18 @@
<?php
require __DIR__ . '/vendor/autoload.php';
$app = require_once __DIR__ . '/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$uuid = '68c7b8f8-fdf0-4575-9cbc-3ab2b3544d8f';
$import = \App\Models\Import::where('uuid', $uuid)->first();
if ($import) {
$import->status = 'failed';
$import->save();
echo "Import {$uuid} marked as failed\n";
echo "Current status: {$import->status}\n";
} else {
echo "Import not found\n";
}

4980
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,46 +3,66 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@inertiajs/vue3": "2.0",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.16",
"axios": "^1.7.4",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^7.1.7",
"vue": "^3.3.13"
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.3",
"@vitejs/plugin-vue": "^6.0.2",
"autoprefixer": "^10.4.22",
"axios": "^1.13.2",
"laravel-vite-plugin": "^2.0.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"vite": "^7.2.7",
"vue": "^3.3.13",
"vue-tsc": "^3.1.8"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/vue-fontawesome": "^3.0.8",
"quill": "^1.3.7",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.1.2",
"@guolao/vue-monaco-editor": "^1.6.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.9.0",
"@vuepic/vue-datepicker": "^11.0.2",
"apexcharts": "^4.0.0",
"flowbite": "^2.5.2",
"flowbite-vue": "^0.1.6",
"@heroicons/vue": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/zod": "^4.15.1",
"@vuepic/vue-datepicker": "^11.0.3",
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clean": "^4.0.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-vue-next": "^0.552.0",
"material-design-icons-iconfont": "^6.7.0",
"monaco-editor": "^0.55.1",
"preline": "^2.7.0",
"reka-ui": "^2.5.1",
"quill": "^1.3.7",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-inner-border": "^0.2.0",
"v-calendar": "^3.1.2",
"vue-multiselect": "^3.1.0",
"vue-search-input": "^1.1.16",
"vue3-apexcharts": "^1.7.0",
"vee-validate": "^4.15.1",
"vue-currency-input": "^3.2.1",
"vue-multiselect": "^3.4.0",
"vue-search-input": "^1.1.19",
"vue-sonner": "^2.0.9",
"vue3-apexcharts": "^1.10.0",
"vuedraggable": "^4.1.0",
"vue-currency-input": "^3.2.1"
"zod": "^3.25.76"
}
}

View File

@ -1,6 +1,9 @@
import tailwindcss from '@tailwindcss/postcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: [
tailwindcss(),
autoprefixer(),
],
};

Some files were not shown because too many files have changed in this diff Show More