Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8147fedd04 | |||
| b1c531bb70 | |||
| 9cc1b7072c | |||
| 2968bcf3f8 | |||
| ad0f7a7a01 | |||
| 368b0a7cf7 | |||
| aa375ce0da | |||
| 340e16c610 | |||
| 33b236d881 | |||
| fb7704027b | |||
| e5902706f1 | |||
| 229c100cc4 | |||
| 9a4897bf0c | |||
| d779e4d7a1 | |||
| b2a9350d0f | |||
| d64a67cf76 | |||
| 068bbdf583 | |||
| cc4c07717e | |||
| 28f28be1b8 | |||
| 27bdb942ab | |||
| ebf9f29200 | |||
| 7eaab16e30 | |||
| 6a2dd860fa | |||
| 091fb07646 | |||
| 357a254e82 | |||
| aa93c96d31 | |||
| ca8754cd94 | |||
| 8fdc0d6359 | |||
| df6c3133ec | |||
| f646b6530a | |||
| 7fc4520dbf | |||
| f66bbbf842 | |||
| 4f605451e1 | |||
| dc41862afc | |||
| c4d2f6e473 | |||
| 711438d79f | |||
| fb6474ab88 | |||
| 6871fe8796 | |||
| 137e0b45ad | |||
| 2ad24216ae | |||
| c4d9ecb39e | |||
| 70a5d015e0 | |||
| 8031501d25 | |||
| 703b52ff59 | |||
| 9fc5b54b8a | |||
| 082a637719 | |||
| b9f66cbfbe | |||
| 36b63a180d | |||
| 84b75143df | |||
| dea7432deb | |||
| f8623a6071 | |||
| ee641586c3 | |||
| adc2a64687 | |||
| 11206fb4f7 | |||
| 39a597f6eb | |||
| 5d4498ac5a | |||
| 622f53e401 | |||
| 96473fd60b | |||
| 5ddca35389 | |||
| 94ad0c0772 | |||
| 2140181a76 | |||
| 06fa443b3e | |||
| 6c45063e47 | |||
| b8c9b51f29 | |||
| a4db37adfa | |||
| 76f76f73b4 | |||
| 85922bdac0 | |||
| d69f4dd6f6 | |||
| 3291e9b439 | |||
| a596177a68 | |||
| c7164be323 | |||
| 80948d2944 | |||
| aa40ebed5c | |||
| 79de54eef0 | |||
| 53941c054e | |||
| 1a7d2793b0 | |||
| a6ec92ec6b | |||
| fa54cf48f3 | |||
| d2287ef963 |
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
+19
@@ -20,3 +20,22 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/shadcn-vue
|
/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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+83
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||||
|
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
|
class ClientContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||||
|
{
|
||||||
|
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||||
|
|
||||||
|
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $columnLetterMap = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string}>
|
||||||
|
*/
|
||||||
|
public const COLUMN_METADATA = [
|
||||||
|
'reference' => ['label' => 'Referenca'],
|
||||||
|
'customer' => ['label' => 'Stranka'],
|
||||||
|
'address' => ['label' => 'Naslov'],
|
||||||
|
'start' => ['label' => 'Začetek'],
|
||||||
|
'segment' => ['label' => 'Segment'],
|
||||||
|
'balance' => ['label' => 'Stanje'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $columns
|
||||||
|
*/
|
||||||
|
public function __construct(private Builder $query, private array $columns) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function allowedColumns(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::COLUMN_METADATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function columnLabel(string $column): string
|
||||||
|
{
|
||||||
|
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
public function map($row): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function columnFormats(): array
|
||||||
|
{
|
||||||
|
$formats = [];
|
||||||
|
|
||||||
|
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||||
|
if ($column === 'reference') {
|
||||||
|
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($column === 'start') {
|
||||||
|
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValue(Contract $contract, string $column): mixed
|
||||||
|
{
|
||||||
|
return match ($column) {
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'customer' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||||
|
'start' => $this->formatDate($contract->start_date),
|
||||||
|
'segment' => $contract->segments?->first()?->name,
|
||||||
|
'balance' => optional($contract->account)->balance_amount,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(?string $date): mixed
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$carbon = Carbon::parse($date);
|
||||||
|
|
||||||
|
return ExcelDate::dateTimeToExcel($carbon);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getColumnLetterMap(): array
|
||||||
|
{
|
||||||
|
if ($this->columnLetterMap !== []) {
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
$letter = 'A';
|
||||||
|
foreach ($this->columns as $column) {
|
||||||
|
$this->columnLetterMap[$letter] = $column;
|
||||||
|
$letter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindValue(Cell $cell, $value): bool
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$cell->setValueExplicit($value, DataType::TYPE_NUMERIC);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::bindValue($cell, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Models\Contract;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||||
|
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithCustomValueBinder;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
|
class SegmentContractsExport extends DefaultValueBinder implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithCustomValueBinder, WithHeadings, WithMapping
|
||||||
|
{
|
||||||
|
public const DATE_EXCEL_FORMAT = 'dd"."mm"."yyyy';
|
||||||
|
|
||||||
|
public const TEXT_EXCEL_FORMAT = NumberFormat::FORMAT_TEXT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $columnLetterMap = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string}>
|
||||||
|
*/
|
||||||
|
public const COLUMN_METADATA = [
|
||||||
|
'reference' => ['label' => 'Pogodba'],
|
||||||
|
'client_case' => ['label' => 'Primer'],
|
||||||
|
'address' => ['label' => 'Naslov'],
|
||||||
|
'client' => ['label' => 'Stranka'],
|
||||||
|
'type' => ['label' => 'Vrsta'],
|
||||||
|
'start_date' => ['label' => 'Začetek'],
|
||||||
|
'end_date' => ['label' => 'Konec'],
|
||||||
|
'account' => ['label' => 'Stanje'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $columns
|
||||||
|
*/
|
||||||
|
public function __construct(private Builder $query, private array $columns) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function allowedColumns(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::COLUMN_METADATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function columnLabel(string $column): string
|
||||||
|
{
|
||||||
|
return self::COLUMN_METADATA[$column]['label'] ?? $column;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(): Builder
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, mixed>
|
||||||
|
*/
|
||||||
|
public function map($row): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => $this->resolveValue($row, $column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return array_map(fn (string $column) => self::columnLabel($column), $this->columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function columnFormats(): array
|
||||||
|
{
|
||||||
|
$formats = [];
|
||||||
|
|
||||||
|
foreach ($this->getColumnLetterMap() as $letter => $column) {
|
||||||
|
if ($column === 'reference') {
|
||||||
|
$formats[$letter] = self::TEXT_EXCEL_FORMAT;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($column, ['start_date', 'end_date'], true)) {
|
||||||
|
$formats[$letter] = self::DATE_EXCEL_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveValue(Contract $contract, string $column): mixed
|
||||||
|
{
|
||||||
|
return match ($column) {
|
||||||
|
'reference' => $contract->reference,
|
||||||
|
'client_case' => optional($contract->clientCase?->person)->full_name,
|
||||||
|
'address' => optional($contract->clientCase?->person?->address)->address,
|
||||||
|
'client' => optional($contract->clientCase?->client?->person)->full_name,
|
||||||
|
'type' => optional($contract->type)->name,
|
||||||
|
'start_date' => $this->formatDate($contract->start_date),
|
||||||
|
'end_date' => $this->formatDate($contract->end_date),
|
||||||
|
'account' => optional($contract->account)->balance_amount,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDate(mixed $value): ?float
|
||||||
|
{
|
||||||
|
$carbon = Carbon::make($value);
|
||||||
|
|
||||||
|
if (! $carbon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExcelDate::dateTimeToExcel($carbon->copy()->startOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnLetter(int $index): string
|
||||||
|
{
|
||||||
|
$index++;
|
||||||
|
$letter = '';
|
||||||
|
|
||||||
|
while ($index > 0) {
|
||||||
|
$remainder = ($index - 1) % 26;
|
||||||
|
$letter = chr(65 + $remainder).$letter;
|
||||||
|
$index = intdiv($index - 1, 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $letter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindValue(Cell $cell, $value): bool
|
||||||
|
{
|
||||||
|
$columnKey = $this->getColumnLetterMap()[$cell->getColumn()] ?? null;
|
||||||
|
|
||||||
|
if ($columnKey === 'reference') {
|
||||||
|
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::bindValue($cell, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getColumnLetterMap(): array
|
||||||
|
{
|
||||||
|
if ($this->columnLetterMap === []) {
|
||||||
|
foreach ($this->columns as $index => $column) {
|
||||||
|
$this->columnLetterMap[$this->columnLetter($index)] = $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnLetterMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
use App\Models\SmsTemplate;
|
use App\Models\SmsTemplate;
|
||||||
use App\Services\Contact\PhoneSelector;
|
use App\Services\Contact\PhoneSelector;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
@@ -23,9 +24,19 @@ class PackageController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$perPage = $request->input('per_page') ?? 25;
|
||||||
|
|
||||||
$packages = Package::query()
|
$packages = Package::query()
|
||||||
->latest('id')
|
->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)
|
// Minimal lookups for create form (active only)
|
||||||
$profiles = \App\Models\SmsProfile::query()
|
$profiles = \App\Models\SmsProfile::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
@@ -40,6 +51,7 @@ public function index(Request $request): Response
|
|||||||
->get(['id', 'name', 'content']);
|
->get(['id', 'name', 'content']);
|
||||||
$segments = \App\Models\Segment::query()
|
$segments = \App\Models\Segment::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
|
->where('exclude', false)
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name']);
|
||||||
// Provide a lightweight list of recent clients with person names for filtering
|
// Provide a lightweight list of recent clients with person names for filtering
|
||||||
@@ -58,8 +70,7 @@ public function index(Request $request): Response
|
|||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Index', [
|
return Inertia::render('Admin/Packages/Create', [
|
||||||
'packages' => $packages,
|
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
'senders' => $senders,
|
'senders' => $senders,
|
||||||
'templates' => $templates,
|
'templates' => $templates,
|
||||||
@@ -223,6 +234,8 @@ public function store(StorePackageRequest $request): RedirectResponse
|
|||||||
'created_by' => optional($request->user())->id,
|
'created_by' => optional($request->user())->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
dd($data['items']);
|
||||||
|
|
||||||
$items = collect($data['items'])
|
$items = collect($data['items'])
|
||||||
->map(function (array $row) {
|
->map(function (array $row) {
|
||||||
return new PackageItem([
|
return new PackageItem([
|
||||||
@@ -310,7 +323,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
'q' => ['nullable', 'string'],
|
'q' => ['nullable', 'string'],
|
||||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
||||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||||
'only_mobile' => ['nullable', 'boolean'],
|
'only_mobile' => ['nullable', 'boolean'],
|
||||||
'only_validated' => ['nullable', 'boolean'],
|
'only_validated' => ['nullable', 'boolean'],
|
||||||
@@ -321,13 +333,13 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
|
||||||
|
|
||||||
$query = Contract::query()
|
$query = Contract::query()
|
||||||
->with([
|
->with([
|
||||||
'clientCase.person.phones',
|
'clientCase.person.phones',
|
||||||
'clientCase.client.person',
|
'clientCase.client.person',
|
||||||
'account',
|
'account',
|
||||||
|
'segments:id,name',
|
||||||
])
|
])
|
||||||
->select('contracts.*')
|
->select('contracts.*')
|
||||||
->latest('contracts.id');
|
->latest('contracts.id');
|
||||||
@@ -339,6 +351,15 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
->where('contract_segment.segment_id', '=', $segmentId)
|
->where('contract_segment.segment_id', '=', $segmentId)
|
||||||
->where('contract_segment.active', true);
|
->where('contract_segment.active', true);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Only include contracts that have at least one active, non-excluded segment
|
||||||
|
$query->whereExists(fn ($exist) => $exist->select(\DB::raw(1))
|
||||||
|
->from('contract_segment')
|
||||||
|
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
|
||||||
|
->where('contract_segment.active', true)
|
||||||
|
->where('segments.exclude', false)
|
||||||
|
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q = trim((string) $request->input('q'))) {
|
if ($q = trim((string) $request->input('q'))) {
|
||||||
@@ -388,13 +409,14 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$contracts = $query->paginate($perPage);
|
$contracts = $query->limit(500)->get();
|
||||||
|
|
||||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||||
$person = $contract->clientCase?->person;
|
$person = $contract->clientCase?->person;
|
||||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||||
$phone = $selected['phone'];
|
$phone = $selected['phone'];
|
||||||
$clientPerson = $contract->clientCase?->client?->person;
|
$clientPerson = $contract->clientCase?->client?->person;
|
||||||
|
$segment = collect($contract->segments)->last();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $contract->id,
|
'id' => $contract->id,
|
||||||
@@ -412,6 +434,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
'uuid' => $person?->uuid,
|
'uuid' => $person?->uuid,
|
||||||
'full_name' => $person?->full_name,
|
'full_name' => $person?->full_name,
|
||||||
],
|
],
|
||||||
|
'segment' => $segment,
|
||||||
// Stranka: the client person
|
// Stranka: the client person
|
||||||
'client' => $clientPerson ? [
|
'client' => $clientPerson ? [
|
||||||
'id' => $contract->clientCase?->client?->id,
|
'id' => $contract->clientCase?->client?->id,
|
||||||
@@ -430,12 +453,6 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'meta' => [
|
|
||||||
'current_page' => $contracts->currentPage(),
|
|
||||||
'last_page' => $contracts->lastPage(),
|
|
||||||
'per_page' => $contracts->perPage(),
|
|
||||||
'total' => $contracts->total(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,12 +488,12 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
|
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
|
||||||
if ($seen->contains($key)) {
|
/*if ($seen->contains($key)) {
|
||||||
// skip duplicates across multiple contracts/persons
|
// skip duplicates across multiple contracts/persons
|
||||||
$skipped++;
|
$skipped++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}*/
|
||||||
$seen->push($key);
|
$seen->push($key);
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'number' => (string) $phone->nu,
|
'number' => (string) $phone->nu,
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
|
|
||||||
use App\Http\Requests\StoreContractRequest;
|
use App\Http\Requests\StoreContractRequest;
|
||||||
use App\Http\Requests\UpdateContractRequest;
|
use App\Http\Requests\UpdateContractRequest;
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\ClientCase;
|
use App\Models\ClientCase;
|
||||||
use App\Models\Contract;
|
use App\Models\Contract;
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
|
use App\Models\Segment;
|
||||||
use App\Services\Documents\DocumentStreamService;
|
use App\Services\Documents\DocumentStreamService;
|
||||||
use App\Services\ReferenceDataCache;
|
use App\Services\ReferenceDataCache;
|
||||||
use App\Services\Sms\SmsService;
|
use App\Services\Sms\SmsService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
@@ -30,6 +33,16 @@ public function __construct(
|
|||||||
public function index(ClientCase $clientCase, Request $request)
|
public function index(ClientCase $clientCase, Request $request)
|
||||||
{
|
{
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
|
$from = $this->normalizeDate($request->input('from'));
|
||||||
|
$to = $this->normalizeDate($request->input('to'));
|
||||||
|
$clientFilter = collect(explode(',', (string) $request->input('clients')))
|
||||||
|
->filter()
|
||||||
|
->map(fn ($value) => (int) $value)
|
||||||
|
->filter(fn ($value) => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$perPage = $this->resolvePerPage($request);
|
||||||
|
|
||||||
$query = $clientCase::query()
|
$query = $clientCase::query()
|
||||||
->select('client_cases.*')
|
->select('client_cases.*')
|
||||||
@@ -39,7 +52,6 @@ public function index(ClientCase $clientCase, Request $request)
|
|||||||
->groupBy('client_cases.id');
|
->groupBy('client_cases.id');
|
||||||
})
|
})
|
||||||
->where('client_cases.active', 1)
|
->where('client_cases.active', 1)
|
||||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
|
||||||
->leftJoin('contracts', function ($join) {
|
->leftJoin('contracts', function ($join) {
|
||||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||||
->whereNull('contracts.deleted_at');
|
->whereNull('contracts.deleted_at');
|
||||||
@@ -49,11 +61,18 @@ public function index(ClientCase $clientCase, Request $request)
|
|||||||
->where('contract_segment.active', true);
|
->where('contract_segment.active', true);
|
||||||
})
|
})
|
||||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||||
|
->when($clientFilter->isNotEmpty(), function ($que) use ($clientFilter) {
|
||||||
|
$que->whereIn('client_cases.client_id', $clientFilter->all());
|
||||||
|
})
|
||||||
|
->when($from, function ($que) use ($from) {
|
||||||
|
$que->whereDate('client_cases.created_at', '>=', $from);
|
||||||
|
})
|
||||||
|
->when($to, function ($que) use ($to) {
|
||||||
|
$que->whereDate('client_cases.created_at', '<=', $to);
|
||||||
|
})
|
||||||
->groupBy('client_cases.id')
|
->groupBy('client_cases.id')
|
||||||
->addSelect([
|
->addSelect([
|
||||||
// Count of active contracts (a contract is considered active if it has an active pivot in contract_segment)
|
|
||||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||||
// Sum of balances for accounts of 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'),
|
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||||
])
|
])
|
||||||
->with(['person.client', 'client.person'])
|
->with(['person.client', 'client.person'])
|
||||||
@@ -61,12 +80,49 @@ public function index(ClientCase $clientCase, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Cases/Index', [
|
return Inertia::render('Cases/Index', [
|
||||||
'client_cases' => $query
|
'client_cases' => $query
|
||||||
->paginate($request->integer('perPage', 15), ['*'], 'clientCasesPage')
|
->paginate($perPage, ['*'], 'clientCasesPage')
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'filters' => $request->only(['search']),
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'clients' => $clientFilter->map(fn ($value) => (string) $value)->all(),
|
||||||
|
'perPage' => $perPage,
|
||||||
|
],
|
||||||
|
'clients' => Client::query()
|
||||||
|
->select(['clients.id', 'person.full_name as name'])
|
||||||
|
->join('person', 'person.id', '=', 'clients.person_id')
|
||||||
|
->orderBy('person.full_name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($client) => [
|
||||||
|
'id' => (int) $client->id,
|
||||||
|
'name' => (string) ($client->name ?? ''),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvePerPage(Request $request): int
|
||||||
|
{
|
||||||
|
$allowed = [10, 15, 25, 50, 100];
|
||||||
|
|
||||||
|
$perPage = (int) $request->integer('perPage', 15);
|
||||||
|
|
||||||
|
return in_array($perPage, $allowed, true) ? $perPage : 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDate(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (! $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value)->toDateString();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* Show the form for creating a new resource.
|
||||||
*/
|
*/
|
||||||
@@ -255,53 +311,113 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||||
'contract_uuid' => 'nullable|uuid',
|
'contract_uuid' => 'nullable|uuid',
|
||||||
|
'contract_uuids' => 'nullable|array',
|
||||||
|
'contract_uuids.*' => 'uuid',
|
||||||
|
'create_for_all_contracts' => 'nullable|boolean',
|
||||||
|
'phone_view' => 'nullable|boolean',
|
||||||
'send_auto_mail' => 'sometimes|boolean',
|
'send_auto_mail' => 'sometimes|boolean',
|
||||||
'attachment_document_ids' => 'sometimes|array',
|
'attachment_document_ids' => 'sometimes|array',
|
||||||
'attachment_document_ids.*' => 'integer',
|
'attachment_document_ids.*' => 'integer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Map contract_uuid to contract_id within the same client case, if provided
|
$isPhoneView = $attributes['phone_view'] ?? false;
|
||||||
$contractId = null;
|
$createForAll = $attributes['create_for_all_contracts'] ?? false;
|
||||||
if (! empty($attributes['contract_uuid'])) {
|
$contractUuids = $attributes['contract_uuids'] ?? [];
|
||||||
|
|
||||||
|
// Determine which contracts to process
|
||||||
|
$contractIds = [];
|
||||||
|
if ($createForAll && !empty($contractUuids)) {
|
||||||
|
// Get all contract IDs from the provided UUIDs
|
||||||
|
$contracts = Contract::withTrashed()
|
||||||
|
->whereIn('uuid', $contractUuids)
|
||||||
|
->where('client_case_id', $clientCase->id)
|
||||||
|
->get();
|
||||||
|
$contractIds = $contracts->pluck('id')->toArray();
|
||||||
|
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||||
|
// Single contract mode
|
||||||
|
$contract = Contract::withTrashed()
|
||||||
|
->where('uuid', $contractUuids[0])
|
||||||
|
->where('client_case_id', $clientCase->id)
|
||||||
|
->first();
|
||||||
|
if ($contract) {
|
||||||
|
$contractIds = [$contract->id];
|
||||||
|
}
|
||||||
|
} elseif (!empty($attributes['contract_uuid'])) {
|
||||||
|
// Legacy single contract_uuid support
|
||||||
$contract = Contract::withTrashed()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $attributes['contract_uuid'])
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
->where('client_case_id', $clientCase->id)
|
->where('client_case_id', $clientCase->id)
|
||||||
->first();
|
->first();
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
// Archived contracts are allowed: link activity regardless of active flag
|
$contractIds = [$contract->id];
|
||||||
$contractId = $contract->id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create activity
|
// If no contracts specified, create a single activity without contract
|
||||||
$row = $clientCase->activities()->create([
|
if (empty($contractIds)) {
|
||||||
'due_date' => $attributes['due_date'] ?? null,
|
$contractIds = [null];
|
||||||
'amount' => $attributes['amount'] ?? null,
|
}
|
||||||
'note' => $attributes['note'] ?? null,
|
|
||||||
'action_id' => $attributes['action_id'],
|
|
||||||
'decision_id' => $attributes['decision_id'],
|
|
||||||
'contract_id' => $contractId,
|
|
||||||
]);
|
|
||||||
/*foreach ($activity->decision->events as $e) {
|
|
||||||
$class = '\\App\\Events\\' . $e->name;
|
|
||||||
event(new $class($clientCase));
|
|
||||||
}*/
|
|
||||||
|
|
||||||
logger()->info('Activity successfully inserted', $attributes);
|
$createdActivities = [];
|
||||||
|
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||||
|
|
||||||
// Auto mail dispatch (best-effort)
|
// Disable auto mail if creating activities for multiple contracts
|
||||||
try {
|
if ($sendFlag && count($contractIds) > 1) {
|
||||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
$sendFlag = false;
|
||||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
|
||||||
// Filter attachments to those belonging to the selected contract
|
}
|
||||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
|
||||||
->filter()
|
foreach ($contractIds as $contractId) {
|
||||||
->map(fn ($v) => (int) $v)
|
// Create activity
|
||||||
->values();
|
$row = $clientCase->activities()->create([
|
||||||
$validAttachmentIds = collect();
|
'due_date' => $attributes['due_date'] ?? null,
|
||||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
'amount' => $attributes['amount'] ?? null,
|
||||||
$validAttachmentIds = \App\Models\Document::query()
|
'note' => $attributes['note'] ?? null,
|
||||||
->where('documentable_type', \App\Models\Contract::class)
|
'action_id' => $attributes['action_id'],
|
||||||
|
'decision_id' => $attributes['decision_id'],
|
||||||
|
'contract_id' => $contractId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createdActivities[] = $row;
|
||||||
|
|
||||||
|
if ($isPhoneView && $contractId) {
|
||||||
|
$contract = Contract::find($contractId);
|
||||||
|
if ($contract) {
|
||||||
|
$fieldJob = $contract->fieldJobs()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->where('assigned_user_id', \Auth::id())
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($fieldJob) {
|
||||||
|
$fieldJob->update([
|
||||||
|
'added_activity' => true,
|
||||||
|
'last_activity' => $row->created_at,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
|
||||||
|
|
||||||
|
// Auto mail dispatch (best-effort)
|
||||||
|
try {
|
||||||
|
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||||
|
// Filter attachments to those belonging to the selected contract
|
||||||
|
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||||
|
->filter()
|
||||||
|
->map(fn ($v) => (int) $v)
|
||||||
|
->values();
|
||||||
|
$validAttachmentIds = collect();
|
||||||
|
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||||
|
$validAttachmentIds = Document::query()
|
||||||
|
->where('documentable_type', Contract::class)
|
||||||
|
->where('documentable_id', $contractId)
|
||||||
|
->whereIn('id', $attachmentIds)
|
||||||
|
->pluck('id');
|
||||||
|
$validAttachmentIds = Document::query()
|
||||||
|
->where('documentable_type', Contract::class)
|
||||||
->where('documentable_id', $contractId)
|
->where('documentable_id', $contractId)
|
||||||
->whereIn('id', $attachmentIds)
|
->whereIn('id', $attachmentIds)
|
||||||
->pluck('id');
|
->pluck('id');
|
||||||
@@ -311,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||||||
]);
|
]);
|
||||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||||
// If template requires contract and user attempted to send, surface a validation message
|
// If template requires contract and user attempted to send, surface a validation message
|
||||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||||
}
|
}
|
||||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
// Do not fail activity creation due to mailing issues
|
// Do not fail activity creation due to mailing issues
|
||||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$activityCount = count($createdActivities);
|
||||||
|
$successMessage = $activityCount > 1
|
||||||
|
? "Successfully created {$activityCount} activities!"
|
||||||
|
: 'Successfully created activity!';
|
||||||
|
|
||||||
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
||||||
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
||||||
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
|
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
|
||||||
} catch (QueryException $e) {
|
} catch (QueryException $e) {
|
||||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
@@ -846,178 +968,265 @@ public function archiveContract(ClientCase $clientCase, string $uuid, Request $r
|
|||||||
{
|
{
|
||||||
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
|
$contract = Contract::query()->where('uuid', $uuid)->firstOrFail();
|
||||||
if ($contract->client_case_id !== $clientCase->id) {
|
if ($contract->client_case_id !== $clientCase->id) {
|
||||||
|
\Log::warning('Contract not found uuid: {uuid}', ['uuid' => $uuid]);
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
$reactivateRequested = (bool) $request->boolean('reactivate');
|
|
||||||
// Determine applicable settings based on intent (archive vs reactivate)
|
$attr = $request->validate([
|
||||||
if ($reactivateRequested) {
|
'reactivate' => 'boolean',
|
||||||
$latestReactivate = \App\Models\ArchiveSetting::query()
|
]);
|
||||||
->where('enabled', true)
|
|
||||||
->where('reactivate', true)
|
$reactivate = $attr['reactivate'] ?? false;
|
||||||
->whereIn('strategy', ['immediate', 'manual'])
|
|
||||||
->orderByDesc('id')
|
$setting = \App\Models\ArchiveSetting::query()
|
||||||
->first();
|
->where('enabled', true)
|
||||||
if (! $latestReactivate) {
|
->whereIn('strategy', ['immediate', 'manual'])
|
||||||
return back()->with('warning', __('contracts.reactivate_not_allowed'));
|
->where('reactivate', $reactivate)
|
||||||
}
|
->orderByDesc('id')
|
||||||
$settings = collect([$latestReactivate]);
|
->first();
|
||||||
$hasReactivateRule = true;
|
|
||||||
} else {
|
if (! $setting->exists()) {
|
||||||
$settings = \App\Models\ArchiveSetting::query()
|
\Log::warning('No archive settings found!');
|
||||||
->where('enabled', true)
|
|
||||||
->whereIn('strategy', ['immediate', 'manual'])
|
return back()->with('warning', 'No settings found');
|
||||||
->where(function ($q) { // exclude reactivate-only rules from archive run
|
|
||||||
$q->whereNull('reactivate')->orWhere('reactivate', false);
|
|
||||||
})
|
|
||||||
->get();
|
|
||||||
if ($settings->isEmpty()) {
|
|
||||||
return back()->with('warning', __('contracts.no_archive_settings'));
|
|
||||||
}
|
|
||||||
$hasReactivateRule = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service archive executor
|
||||||
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||||
|
$result = null;
|
||||||
|
|
||||||
$context = [
|
$context = [
|
||||||
'contract_id' => $contract->id,
|
'contract_id' => $contract->id,
|
||||||
'client_case_id' => $clientCase->id,
|
'client_case_id' => $clientCase->id,
|
||||||
|
'account_id' => $contract->account->id ?? null,
|
||||||
];
|
];
|
||||||
if ($contract->account) {
|
|
||||||
$context['account_id'] = $contract->account->id;
|
try {
|
||||||
|
$result = $executor->executeSetting($setting, $context, \Auth::id());
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::error('There was an error executing ArchiveExecutor::executeSetting {msg}', ['msg' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return back()->with('warning', 'Something went wrong!');
|
||||||
}
|
}
|
||||||
|
|
||||||
$overall = [];
|
try {
|
||||||
$hadAnyEffect = false;
|
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||||
foreach ($settings as $setting) {
|
// Create an Activity record logging this archive if an action or decision is tied to any setting
|
||||||
|
if ($setting->action_id && $setting->decision_id) {
|
||||||
$res = $executor->executeSetting($setting, $context, optional($request->user())->id);
|
|
||||||
foreach ($res as $table => $count) {
|
|
||||||
$overall[$table] = ($overall[$table] ?? 0) + $count;
|
|
||||||
if ($count > 0) {
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reactivateRequested && $hasReactivateRule) {
|
|
||||||
// Reactivation path: ensure contract becomes active and soft-delete cleared.
|
|
||||||
if ($contract->active == 0 || $contract->deleted_at) {
|
|
||||||
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
|
|
||||||
$overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1;
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Ensure the contract itself is archived even if rule conditions would have excluded it
|
|
||||||
if (! empty($contract->getAttributes()) && $contract->active) {
|
|
||||||
if (! array_key_exists('contracts', $overall)) {
|
|
||||||
$contract->update(['active' => 0]);
|
|
||||||
$overall['contracts'] = ($overall['contracts'] ?? 0) + 1;
|
|
||||||
} else {
|
|
||||||
$contract->refresh();
|
|
||||||
}
|
|
||||||
$hadAnyEffect = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an Activity record logging this archive if an action or decision is tied to any setting
|
|
||||||
if ($hadAnyEffect) {
|
|
||||||
$activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id));
|
|
||||||
if ($activitySetting) {
|
|
||||||
try {
|
|
||||||
if ($reactivateRequested) {
|
|
||||||
$note = 'Ponovna aktivacija pogodba '.$contract->reference;
|
|
||||||
} else {
|
|
||||||
$noteKey = 'contracts.archived_activity_note';
|
|
||||||
$note = __($noteKey, ['reference' => $contract->reference]);
|
|
||||||
if ($note === $noteKey) {
|
|
||||||
$note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$activityData = [
|
$activityData = [
|
||||||
'client_case_id' => $clientCase->id,
|
'client_case_id' => $clientCase->id,
|
||||||
'action_id' => $activitySetting->action_id,
|
'action_id' => $setting->action_id,
|
||||||
'decision_id' => $activitySetting->decision_id,
|
'decision_id' => $setting->decision_id,
|
||||||
'note' => $note,
|
'note' => ($reactivate)
|
||||||
'active' => 1,
|
? "Ponovno aktivirana pogodba $contract->reference"
|
||||||
'user_id' => optional($request->user())->id,
|
: "Arhivirana pogodba $contract->reference",
|
||||||
];
|
];
|
||||||
if ($reactivateRequested) {
|
|
||||||
// Attach the contract_id when reactivated as per requirement
|
try {
|
||||||
$activityData['contract_id'] = $contract->id;
|
\App\Models\Activity::create($activityData);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::warning('Activity could not be created!');
|
||||||
}
|
}
|
||||||
\App\Models\Activity::create($activityData);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
logger()->warning('Failed to create archive/reactivate activity', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'contract_id' => $contract->id,
|
|
||||||
'setting_id' => optional($activitySetting)->id,
|
|
||||||
'reactivate' => $reactivateRequested,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
|
||||||
// If any archive setting specifies a segment_id, move the contract to that segment (archive bucket)
|
if ($setting->segment_id) {
|
||||||
$segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified
|
$segmentId = $setting->segment_id;
|
||||||
if ($segmentSetting && $segmentSetting->segment_id) {
|
|
||||||
try {
|
$contract->segments()
|
||||||
$segmentId = $segmentSetting->segment_id;
|
->allRelatedIds()
|
||||||
\DB::transaction(function () use ($contract, $segmentId, $clientCase) {
|
->map(fn (int $val, int|string $key) => $contract->segments()->updateExistingPivot($val, [
|
||||||
// Ensure the segment is attached to the client case (activate if previously inactive)
|
'active' => false,
|
||||||
$casePivot = \DB::table('client_case_segment')
|
'updated_at' => now(),
|
||||||
->where('client_case_id', $clientCase->id)
|
])
|
||||||
->where('segment_id', $segmentId)
|
);
|
||||||
->first();
|
|
||||||
if (! $casePivot) {
|
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||||
\DB::table('client_case_segment')->insert([
|
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||||
'client_case_id' => $clientCase->id,
|
|
||||||
'segment_id' => $segmentId,
|
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
} elseif (! $casePivot->active) {
|
|
||||||
\DB::table('client_case_segment')
|
|
||||||
->where('id', $casePivot->id)
|
|
||||||
->update(['active' => true, 'updated_at' => now()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deactivate all current active contract segments
|
|
||||||
\DB::table('contract_segment')
|
|
||||||
->where('contract_id', $contract->id)
|
|
||||||
->where('active', true)
|
|
||||||
->update(['active' => false, 'updated_at' => now()]);
|
|
||||||
|
|
||||||
// Attach or activate the archive segment for this contract
|
|
||||||
$existing = \DB::table('contract_segment')
|
|
||||||
->where('contract_id', $contract->id)
|
|
||||||
->where('segment_id', $segmentId)
|
|
||||||
->first();
|
|
||||||
if ($existing) {
|
|
||||||
\DB::table('contract_segment')
|
|
||||||
->where('id', $existing->id)
|
|
||||||
->update(['active' => true, 'updated_at' => now()]);
|
|
||||||
} else {
|
} else {
|
||||||
\DB::table('contract_segment')->insert([
|
$contract->segments()->attach(
|
||||||
'contract_id' => $contract->id,
|
$segmentId,
|
||||||
'segment_id' => $segmentId,
|
[
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract->fieldJobs()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->update([
|
||||||
|
'cancelled_at' => date('Y-m-d'),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::warning('Something went wrong with inserting / updating archive setting partials!');
|
||||||
|
|
||||||
|
return back()->with('warning', 'Something went wrong!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $reactivate
|
||||||
|
? __('contracts.reactivated')
|
||||||
|
: __('contracts.archived')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive multiple contracts in a batch operation
|
||||||
|
*/
|
||||||
|
public function archiveBatch(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'contracts' => 'required|array',
|
||||||
|
'contracts.*' => 'required|uuid|exists:contracts,uuid',
|
||||||
|
'reactivate' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reactivate = $validated['reactivate'] ?? false;
|
||||||
|
|
||||||
|
// Get archive setting
|
||||||
|
$setting = \App\Models\ArchiveSetting::query()
|
||||||
|
->where('enabled', true)
|
||||||
|
->whereIn('strategy', ['immediate', 'manual'])
|
||||||
|
->where('reactivate', $reactivate)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $setting) {
|
||||||
|
\Log::warning('No archive settings found for batch archive');
|
||||||
|
return back()->with('flash', [
|
||||||
|
'error' => 'No archive settings found',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
|
||||||
|
$successCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($validated['contracts'] as $contractUuid) {
|
||||||
|
try {
|
||||||
|
$contract = Contract::where('uuid', $contractUuid)->firstOrFail();
|
||||||
|
|
||||||
|
// Skip if contract is already archived (active = 0)
|
||||||
|
if (!$contract->active) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientCase = $contract->clientCase;
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'contract_id' => $contract->id,
|
||||||
|
'client_case_id' => $clientCase->id,
|
||||||
|
'account_id' => $contract->account->id ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Execute archive setting
|
||||||
|
$executor->executeSetting($setting, $context, \Auth::id());
|
||||||
|
|
||||||
|
// Transaction for segment updates and activity logging
|
||||||
|
\DB::transaction(function () use ($contract, $clientCase, $setting, $reactivate) {
|
||||||
|
// Create activity log
|
||||||
|
if ($setting->action_id && $setting->decision_id) {
|
||||||
|
$activityData = [
|
||||||
|
'client_case_id' => $clientCase->id,
|
||||||
|
'action_id' => $setting->action_id,
|
||||||
|
'decision_id' => $setting->decision_id,
|
||||||
|
'note' => ($reactivate)
|
||||||
|
? "Ponovno aktivirana pogodba $contract->reference"
|
||||||
|
: "Arhivirana pogodba $contract->reference",
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
\App\Models\Activity::create($activityData);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::warning('Activity could not be created during batch archive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to archive segment if specified
|
||||||
|
if ($setting->segment_id) {
|
||||||
|
$segmentId = $setting->segment_id;
|
||||||
|
|
||||||
|
// Deactivate all current segments
|
||||||
|
$contract->segments()
|
||||||
|
->allRelatedIds()
|
||||||
|
->map(fn (int $val) => $contract->segments()->updateExistingPivot($val, [
|
||||||
|
'active' => false,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Activate archive segment
|
||||||
|
if ($contract->attachedSegments()->find($segmentId)->pluck('id')->isNotEmpty()) {
|
||||||
|
$contract->attachedSegments()->updateExistingPivot($segmentId, [
|
||||||
|
'active' => true,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$contract->segments()->attach($segmentId, [
|
||||||
|
'active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel pending field jobs
|
||||||
|
$contract->fieldJobs()
|
||||||
|
->whereNull('completed_at')
|
||||||
|
->whereNull('cancelled_at')
|
||||||
|
->update([
|
||||||
|
'cancelled_at' => date('Y-m-d'),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
logger()->warning('Failed to move contract to archive segment', [
|
$successCount++;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
\Log::error('Error archiving contract in batch', [
|
||||||
|
'uuid' => $contractUuid,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'contract_id' => $contract->id,
|
|
||||||
'segment_id' => $segmentSetting->segment_id,
|
|
||||||
'setting_id' => $segmentSetting->id,
|
|
||||||
]);
|
]);
|
||||||
|
$errors[] = [
|
||||||
|
'uuid' => $contractUuid,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived');
|
if (count($errors) > 0) {
|
||||||
|
$message = "Archived $successCount contracts";
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$message .= ", skipped $skippedCount already archived";
|
||||||
|
}
|
||||||
|
$message .= ", " . count($errors) . " failed";
|
||||||
|
|
||||||
return back()->with('success', $message)->with('flash_method', 'PATCH');
|
return back()->with('flash', [
|
||||||
|
'error' => $message,
|
||||||
|
'details' => $errors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $reactivate
|
||||||
|
? "Successfully reactivated $successCount contracts"
|
||||||
|
: "Successfully archived $successCount contracts";
|
||||||
|
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$message .= " ($skippedCount already archived)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('flash', [
|
||||||
|
'success' => $message,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1031,7 +1240,7 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
|
|||||||
if ($existing && ! $existing->trashed()) {
|
if ($existing && ! $existing->trashed()) {
|
||||||
return back()->with('flash', [
|
return back()->with('flash', [
|
||||||
'type' => 'info',
|
'type' => 'info',
|
||||||
'message' => 'Person already exists – emergency creation not needed.',
|
'message' => 'Person already exists ÔÇô emergency creation not needed.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,10 +1345,10 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
if (! empty($validated['sender_id'])) {
|
if (! empty($validated['sender_id'])) {
|
||||||
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
|
||||||
if (! $sender) {
|
if (! $sender) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
|
||||||
}
|
}
|
||||||
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
|
||||||
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (! $profile) {
|
if (! $profile) {
|
||||||
@@ -1182,7 +1391,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create an activity before sending
|
// Create an activity before sending
|
||||||
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
$activityNote = sprintf('Št: %s | Telo: %s', (string) $phone->nu, (string) $validated['message']);
|
||||||
$activityData = [
|
$activityData = [
|
||||||
'note' => $activityNote,
|
'note' => $activityNote,
|
||||||
'user_id' => optional($request->user())->id,
|
'user_id' => optional($request->user())->id,
|
||||||
@@ -1220,7 +1429,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
activityId: $activity?->id,
|
activityId: $activity?->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return back()->with('success', 'SMS je bil dodan v čakalno vrsto.');
|
return back()->with('success', 'SMS je bil dodan v ─Źakalno vrsto.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Log::warning('SMS enqueue failed', [
|
\Log::warning('SMS enqueue failed', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
@@ -1228,7 +1437,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
|
|||||||
'phone_id' => $phone_id,
|
'phone_id' => $phone_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
|
return back()->with('error', 'SMS ni bil dodan v ─Źakalno vrsto.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exports\ClientContractsExport;
|
||||||
|
use App\Http\Requests\ExportClientContractsRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Services\ReferenceDataCache;
|
use App\Services\ReferenceDataCache;
|
||||||
use DB;
|
use DB;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
class ClientController extends Controller
|
class ClientController extends Controller
|
||||||
{
|
{
|
||||||
@@ -23,7 +27,7 @@ public function index(Client $client, Request $request)
|
|||||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||||
->groupBy('clients.id');
|
->groupBy('clients.id');
|
||||||
})
|
})
|
||||||
->where('clients.active', 1)
|
//->where('clients.active', 1)
|
||||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||||
->leftJoin('contracts', function ($join) {
|
->leftJoin('contracts', function ($join) {
|
||||||
@@ -47,7 +51,7 @@ public function index(Client $client, Request $request)
|
|||||||
|
|
||||||
return Inertia::render('Client/Index', [
|
return Inertia::render('Client/Index', [
|
||||||
'clients' => $query
|
'clients' => $query
|
||||||
->paginate($request->integer('per_page', 15))
|
->paginate($request->integer('per_page', default: 100))
|
||||||
->withQueryString(),
|
->withQueryString(),
|
||||||
'filters' => $request->only(['search']),
|
'filters' => $request->only(['search']),
|
||||||
]);
|
]);
|
||||||
@@ -137,6 +141,7 @@ public function contracts(Client $client, Request $request)
|
|||||||
->with([
|
->with([
|
||||||
'clientCase:id,uuid,person_id',
|
'clientCase:id,uuid,person_id',
|
||||||
'clientCase.person:id,full_name',
|
'clientCase.person:id,full_name',
|
||||||
|
'clientCase.person.address',
|
||||||
'segments' => function ($q) {
|
'segments' => function ($q) {
|
||||||
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||||
},
|
},
|
||||||
@@ -166,6 +171,84 @@ public function contracts(Client $client, Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function exportContracts(ExportClientContractsRequest $request, Client $client)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$columns = array_values(array_unique($data['columns']));
|
||||||
|
|
||||||
|
$from = $data['from'] ?? null;
|
||||||
|
$to = $data['to'] ?? null;
|
||||||
|
$search = $data['search'] ?? null;
|
||||||
|
$segmentsParam = $data['segments'] ?? null;
|
||||||
|
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||||
|
|
||||||
|
$query = \App\Models\Contract::query()
|
||||||
|
->whereHas('clientCase', function ($q) use ($client) {
|
||||||
|
$q->where('client_id', $client->id);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'clientCase:id,uuid,person_id',
|
||||||
|
'clientCase.person:id,full_name',
|
||||||
|
'clientCase.person.address',
|
||||||
|
'segments' => function ($q) {
|
||||||
|
$q->wherePivot('active', true)->select('segments.id', 'segments.name');
|
||||||
|
},
|
||||||
|
'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');
|
||||||
|
|
||||||
|
if (($data['scope'] ?? ExportClientContractsRequest::SCOPE_ALL) === ExportClientContractsRequest::SCOPE_CURRENT) {
|
||||||
|
$page = max(1, (int) ($data['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $this->buildExportFilename($client);
|
||||||
|
|
||||||
|
return Excel::download(new ClientContractsExport($query, $columns), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportFilename(Client $client): string
|
||||||
|
{
|
||||||
|
$datePrefix = now()->format('dmy');
|
||||||
|
$clientName = $this->slugify($client->person?->full_name ?? 'stranka');
|
||||||
|
|
||||||
|
return sprintf('%s_%s-Pogodbe.xlsx', $datePrefix, $clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(?string $value): string
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::slug($value, '-') ?: 'data';
|
||||||
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -25,56 +25,109 @@ public function index(Request $request)
|
|||||||
optional($setting)->segment_id,
|
optional($setting)->segment_id,
|
||||||
])->filter()->unique()->values();
|
])->filter()->unique()->values();
|
||||||
|
|
||||||
$contracts = Contract::query()
|
$search = $request->input('search');
|
||||||
->with(['clientCase.person', 'clientCase.client.person', 'type', 'account'])
|
$searchAssigned = $request->input('search_assigned');
|
||||||
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
|
$assignedUserId = $request->input('assigned_user_id');
|
||||||
$q->whereHas('segments', function ($sq) use ($segmentIds) {
|
$unassignedClientUuids = $request->input('unassigned_client_uuids');
|
||||||
// Relation already filters on active pivots
|
$assignedClientUuids = $request->input('assigned_client_uuids');
|
||||||
$sq->whereIn('segments.id', $segmentIds);
|
|
||||||
});
|
|
||||||
}, function ($q) {
|
|
||||||
// No segments configured on FieldJobSetting -> return none
|
|
||||||
$q->whereRaw('1 = 0');
|
|
||||||
})
|
|
||||||
->latest('id')
|
|
||||||
->limit(50)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name
|
$unassignedContracts = Contract::query()
|
||||||
$contracts->each(function (Contract $contract): void {
|
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account'])
|
||||||
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
->when($segmentIds->isNotEmpty(), fn($q) =>
|
||||||
$contract->setRelation('client', $contract->clientCase->client);
|
$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
|
$unassignedClients = $unassignedContracts->get()
|
||||||
$assignments = collect();
|
->pluck('clientCase.client')
|
||||||
if ($contracts->isNotEmpty()) {
|
->filter()
|
||||||
$activeJobs = FieldJob::query()
|
->unique('id')
|
||||||
->whereIn('contract_id', $contracts->pluck('id'))
|
->values();
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
|
|
||||||
return [
|
$assignedContracts = Contract::query()
|
||||||
optional($job->contract)->uuid => [
|
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user'])
|
||||||
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
|
->when($segmentIds->isNotEmpty(), fn($q) =>
|
||||||
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
|
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
|
||||||
'assigned_at' => $job->assigned_at,
|
fn($q) => $q->whereRaw('1 = 0')
|
||||||
],
|
)
|
||||||
];
|
->when( !empty($searchAssigned), fn ($q) =>
|
||||||
})->filter();
|
$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']);
|
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
return Inertia::render('FieldJob/Index', [
|
return Inertia::render('FieldJob/Index', [
|
||||||
'setting' => $setting,
|
'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,
|
'users' => $users,
|
||||||
'assignments' => $assignments,
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'search_assigned' => $searchAssigned,
|
||||||
|
'assigned_user_id' => $assignedUserId,
|
||||||
|
'unassigned_client_uuids' => $unassignedClientUuids,
|
||||||
|
'assigned_client_uuids' => $assignedClientUuids,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
use App\Models\ImportEvent;
|
use App\Models\ImportEvent;
|
||||||
use App\Models\ImportTemplate;
|
use App\Models\ImportTemplate;
|
||||||
use App\Services\CsvImportService;
|
use App\Services\CsvImportService;
|
||||||
|
use App\Services\Import\ImportSimulationServiceV2;
|
||||||
use App\Services\ImportProcessor;
|
use App\Services\ImportProcessor;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -21,14 +22,35 @@ class ImportController extends Controller
|
|||||||
// List imports (paginated)
|
// List imports (paginated)
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$paginator = Import::query()
|
$query = Import::query()
|
||||||
->with([
|
->with([
|
||||||
'client:id,uuid,person_id',
|
'client:id,uuid,person_id',
|
||||||
'client.person:id,uuid,full_name',
|
'client.person:id,uuid,full_name',
|
||||||
'template:id,name',
|
'template:id,name',
|
||||||
])
|
])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at');
|
||||||
->paginate(15);
|
|
||||||
|
// 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 = [
|
$imports = [
|
||||||
'data' => $paginator->items(),
|
'data' => $paginator->items(),
|
||||||
@@ -164,9 +186,25 @@ public function store(Request $request)
|
|||||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||||
{
|
{
|
||||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
$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
|
// 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
|
// 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
|
// 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('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||||
->join('person', 'person.id', '=', 'client_cases.person_id')
|
->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||||
->leftJoin('accounts', function ($join) {
|
->leftJoin('accounts', function ($join) {
|
||||||
@@ -493,7 +531,7 @@ public function getEvents(Import $import)
|
|||||||
public function missingKeyrefRows(Import $import)
|
public function missingKeyrefRows(Import $import)
|
||||||
{
|
{
|
||||||
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
|
// 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('import_id', $import->id)
|
||||||
->where(function ($q) {
|
->where(function ($q) {
|
||||||
$q->where('event', 'contract_keyref_not_found')
|
$q->where('event', 'contract_keyref_not_found')
|
||||||
@@ -673,6 +711,8 @@ public function simulatePayments(Import $import, Request $request)
|
|||||||
* using the first N rows and current saved mappings. Works for both payments and non-payments
|
* 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
|
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||||
* automatically by the simulation service when mappings contain the payment root.
|
* automatically by the simulation service when mappings contain the payment root.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
*/
|
*/
|
||||||
public function simulate(Import $import, Request $request)
|
public function simulate(Import $import, Request $request)
|
||||||
{
|
{
|
||||||
@@ -683,7 +723,7 @@ public function simulate(Import $import, Request $request)
|
|||||||
$limit = (int) ($validated['limit'] ?? 100);
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
$verbose = (bool) ($validated['verbose'] ?? false);
|
$verbose = (bool) ($validated['verbose'] ?? false);
|
||||||
|
|
||||||
$service = app(\App\Services\ImportSimulationService::class);
|
$service = app(ImportSimulationServiceV2::class);
|
||||||
$result = $service->simulate($import, $limit, $verbose);
|
$result = $service->simulate($import, $limit, $verbose);
|
||||||
|
|
||||||
return response()->json($result);
|
return response()->json($result);
|
||||||
@@ -785,6 +825,21 @@ public function destroy(Request $request, Import $import)
|
|||||||
|
|
||||||
$import->delete();
|
$import->delete();
|
||||||
|
|
||||||
return back()->with(['ok' => true]);
|
return back()->with('success', 'Import deleted successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the original import file
|
||||||
|
public function download(Import $import)
|
||||||
|
{
|
||||||
|
// Verify file exists
|
||||||
|
if (! $import->disk || ! $import->path || ! Storage::disk($import->disk)->exists($import->path)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'File not found',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = $import->original_name ?? 'import_'.$import->uuid;
|
||||||
|
|
||||||
|
return Storage::disk($import->disk)->download($import->path, $fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ public function index()
|
|||||||
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
||||||
'ui' => ['order' => 6],
|
'ui' => ['order' => 6],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'activities',
|
||||||
|
'canonical_root' => 'activity',
|
||||||
|
'label' => 'Activities',
|
||||||
|
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
|
||||||
|
'ui' => ['order' => 7],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Ensure fields are arrays for frontend consumption
|
// Ensure fields are arrays for frontend consumption
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ public function index()
|
|||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->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', [
|
return Inertia::render('Imports/Templates/Index', [
|
||||||
'templates' => $templates->map(fn ($t) => [
|
'templates' => $templates->map(fn ($t) => [
|
||||||
'uuid' => $t->uuid,
|
'uuid' => $t->uuid,
|
||||||
@@ -35,6 +45,10 @@ public function index()
|
|||||||
'name' => $t->client->person?->full_name,
|
'name' => $t->client->person?->full_name,
|
||||||
] : null,
|
] : null,
|
||||||
]),
|
]),
|
||||||
|
'clients' => $clients,
|
||||||
|
'segments' => $segments,
|
||||||
|
'decisions' => $decisions,
|
||||||
|
'actions' => $actions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +125,10 @@ public function store(Request $request)
|
|||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'reactivate' => 'boolean',
|
'reactivate' => 'boolean',
|
||||||
'entities' => 'nullable|array',
|
'entities' => 'nullable|array',
|
||||||
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
|
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'mappings' => 'array',
|
'mappings' => 'array',
|
||||||
'mappings.*.source_column' => 'required|string',
|
'mappings.*.source_column' => 'required|string',
|
||||||
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
|
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'mappings.*.target_field' => 'nullable|string',
|
'mappings.*.target_field' => 'nullable|string',
|
||||||
'mappings.*.transform' => 'nullable|string|max:50',
|
'mappings.*.transform' => 'nullable|string|max:50',
|
||||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
@@ -124,7 +138,11 @@ public function store(Request $request)
|
|||||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
|
'meta.activity_created_at' => 'nullable|date',
|
||||||
'meta.payments_import' => 'nullable|boolean',
|
'meta.payments_import' => 'nullable|boolean',
|
||||||
|
'meta.history_import' => 'nullable|boolean',
|
||||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
@@ -142,7 +160,28 @@ public function store(Request $request)
|
|||||||
$template = null;
|
$template = null;
|
||||||
DB::transaction(function () use (&$template, $request, $data) {
|
DB::transaction(function () use (&$template, $request, $data) {
|
||||||
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
|
$paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false);
|
||||||
|
$historyImport = (bool) (data_get($data, 'meta.history_import') ?? false);
|
||||||
$entities = $data['entities'] ?? [];
|
$entities = $data['entities'] ?? [];
|
||||||
|
if ($historyImport) {
|
||||||
|
$paymentsImport = false; // history import cannot be combined with payments mode
|
||||||
|
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
|
||||||
|
$entities = array_values(array_intersect($entities, $allowedHistoryEntities));
|
||||||
|
// If contracts are present, ensure accounts are included implicitly for reference consistency
|
||||||
|
if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) {
|
||||||
|
$entities[] = 'accounts';
|
||||||
|
}
|
||||||
|
// Reject mappings that target disallowed entities for history import
|
||||||
|
$disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) {
|
||||||
|
if (empty($m['entity'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! in_array($m['entity'], $allowedHistoryEntities, true);
|
||||||
|
});
|
||||||
|
if ($disallowedMappings->isNotEmpty()) {
|
||||||
|
abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.');
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($paymentsImport) {
|
if ($paymentsImport) {
|
||||||
$entities = ['contracts', 'accounts', 'payments'];
|
$entities = ['contracts', 'accounts', 'payments'];
|
||||||
}
|
}
|
||||||
@@ -162,7 +201,11 @@ public function store(Request $request)
|
|||||||
'segment_id' => data_get($data, 'meta.segment_id'),
|
'segment_id' => data_get($data, 'meta.segment_id'),
|
||||||
'decision_id' => data_get($data, 'meta.decision_id'),
|
'decision_id' => data_get($data, 'meta.decision_id'),
|
||||||
'action_id' => data_get($data, 'meta.action_id'),
|
'action_id' => data_get($data, 'meta.action_id'),
|
||||||
|
'activity_action_id' => data_get($data, 'meta.activity_action_id'),
|
||||||
|
'activity_decision_id' => data_get($data, 'meta.activity_decision_id'),
|
||||||
|
'activity_created_at' => data_get($data, 'meta.activity_created_at'),
|
||||||
'payments_import' => $paymentsImport ?: null,
|
'payments_import' => $paymentsImport ?: null,
|
||||||
|
'history_import' => $historyImport ?: null,
|
||||||
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
|
'contract_key_mode' => data_get($data, 'meta.contract_key_mode'),
|
||||||
], fn ($v) => ! is_null($v) && $v !== ''),
|
], fn ($v) => ! is_null($v) && $v !== ''),
|
||||||
]);
|
]);
|
||||||
@@ -244,7 +287,7 @@ public function addMapping(Request $request, ImportTemplate $template)
|
|||||||
}
|
}
|
||||||
$data = validator($raw, [
|
$data = validator($raw, [
|
||||||
'source_column' => 'required|string',
|
'source_column' => 'required|string',
|
||||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
|
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'target_field' => 'nullable|string',
|
'target_field' => 'nullable|string',
|
||||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
@@ -314,7 +357,11 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
'meta.segment_id' => 'nullable|integer|exists:segments,id',
|
||||||
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
'meta.action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_action_id' => 'nullable|integer|exists:actions,id',
|
||||||
|
'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id',
|
||||||
|
'meta.activity_created_at' => 'nullable|date',
|
||||||
'meta.payments_import' => 'nullable|boolean',
|
'meta.payments_import' => 'nullable|boolean',
|
||||||
|
'meta.history_import' => 'nullable|boolean',
|
||||||
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
'meta.contract_key_mode' => 'nullable|string|in:reference',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
@@ -342,6 +389,11 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
unset($newMeta[$k]);
|
unset($newMeta[$k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) {
|
||||||
|
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
|
||||||
|
unset($newMeta[$k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize meta (ensure payments entities forced if enabled)
|
// Finalize meta (ensure payments entities forced if enabled)
|
||||||
@@ -349,6 +401,20 @@ public function update(Request $request, ImportTemplate $template)
|
|||||||
if (! empty($finalMeta['payments_import'])) {
|
if (! empty($finalMeta['payments_import'])) {
|
||||||
$finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
|
$finalMeta['entities'] = ['contracts', 'accounts', 'payments'];
|
||||||
}
|
}
|
||||||
|
if (! empty($finalMeta['history_import'])) {
|
||||||
|
$finalMeta['payments_import'] = false;
|
||||||
|
$allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases'];
|
||||||
|
$finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities));
|
||||||
|
if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) {
|
||||||
|
$finalMeta['entities'][] = 'accounts';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('activities', $finalMeta['entities'] ?? [], true)) {
|
||||||
|
if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) {
|
||||||
|
return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@@ -381,7 +447,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
|
|||||||
}
|
}
|
||||||
$data = validator($raw, [
|
$data = validator($raw, [
|
||||||
'sources' => 'required|string', // comma and/or newline separated
|
'sources' => 'required|string', // comma and/or newline separated
|
||||||
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments',
|
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities',
|
||||||
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
|
||||||
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
'apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||||
'transform' => 'nullable|string|in:trim,upper,lower',
|
'transform' => 'nullable|string|in:trim,upper,lower',
|
||||||
@@ -495,6 +561,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
|||||||
'options' => 'nullable|array',
|
'options' => 'nullable|array',
|
||||||
'position' => 'nullable|integer',
|
'position' => 'nullable|integer',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
$mapping->update([
|
$mapping->update([
|
||||||
'source_column' => $data['source_column'],
|
'source_column' => $data['source_column'],
|
||||||
'entity' => $data['entity'] ?? null,
|
'entity' => $data['entity'] ?? null,
|
||||||
@@ -505,8 +572,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
|||||||
'position' => $data['position'] ?? $mapping->position,
|
'position' => $data['position'] ?? $mapping->position,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
|
return back()->with('success', 'Mapping updated');
|
||||||
->with('success', 'Mapping updated');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a mapping
|
// Delete a mapping
|
||||||
@@ -583,11 +649,15 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
|||||||
'segment_id' => $tplMeta['segment_id'] ?? null,
|
'segment_id' => $tplMeta['segment_id'] ?? null,
|
||||||
'decision_id' => $tplMeta['decision_id'] ?? null,
|
'decision_id' => $tplMeta['decision_id'] ?? null,
|
||||||
'action_id' => $tplMeta['action_id'] ?? null,
|
'action_id' => $tplMeta['action_id'] ?? null,
|
||||||
|
'activity_action_id' => $tplMeta['activity_action_id'] ?? null,
|
||||||
|
'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null,
|
||||||
|
'activity_created_at' => $tplMeta['activity_created_at'] ?? null,
|
||||||
'template_name' => $template->name,
|
'template_name' => $template->name,
|
||||||
], fn ($v) => ! is_null($v) && $v !== ''));
|
], fn ($v) => ! is_null($v) && $v !== ''));
|
||||||
|
|
||||||
$import->update([
|
$import->update([
|
||||||
'import_template_id' => $template->id,
|
'import_template_id' => $template->id,
|
||||||
|
'reactivate' => $template->reactivate,
|
||||||
'meta' => $merged,
|
'meta' => $merged,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -609,4 +679,138 @@ public function destroy(ImportTemplate $template)
|
|||||||
|
|
||||||
return redirect()->route('importTemplates.index')->with('success', 'Template deleted');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public function unread(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$today = now()->toDateString();
|
$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', ''));
|
$search = trim((string) $request->input('search', ''));
|
||||||
$clientUuid = trim((string) $request->input('client', ''));
|
$clientUuid = trim((string) $request->input('client', ''));
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ public function __construct(protected ReferenceDataCache $referenceCache) {}
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$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)
|
->where('assigned_user_id', $userId)
|
||||||
->whereNull('completed_at')
|
->whereNull('completed_at')
|
||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
@@ -23,32 +27,78 @@ public function index(Request $request)
|
|||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
'account',
|
'account',
|
||||||
'clientCase.person' => function ($pq) {
|
'clientCase.person.address.type',
|
||||||
$pq->with(['addresses', 'phones']);
|
'clientCase.person.phones',
|
||||||
},
|
|
||||||
'clientCase.client:id,uuid,person_id',
|
'clientCase.client:id,uuid,person_id',
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at');
|
||||||
->limit(100)
|
|
||||||
->get();
|
// 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', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'jobs' => $jobs,
|
||||||
|
'clients' => $clients,
|
||||||
'view_mode' => 'assigned',
|
'view_mode' => 'assigned',
|
||||||
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'client' => $clientFilter,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completedToday(Request $request)
|
public function completedToday(Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$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();
|
$start = now()->startOfDay();
|
||||||
$end = now()->endOfDay();
|
$end = now()->endOfDay();
|
||||||
|
|
||||||
$jobs = FieldJob::query()
|
$query = FieldJob::query()
|
||||||
->where('assigned_user_id', $userId)
|
->where('assigned_user_id', $userId)
|
||||||
->whereNull('cancelled_at')
|
->whereNull('cancelled_at')
|
||||||
->whereBetween('completed_at', [$start, $end])
|
->whereBetween('completed_at', [$start, $end])
|
||||||
@@ -57,190 +107,144 @@ public function completedToday(Request $request)
|
|||||||
$q->with([
|
$q->with([
|
||||||
'type:id,name',
|
'type:id,name',
|
||||||
'account',
|
'account',
|
||||||
'clientCase.person' => function ($pq) {
|
'clientCase.person.address.type',
|
||||||
$pq->with(['addresses', 'phones']);
|
'clientCase.person.phones',
|
||||||
},
|
|
||||||
'clientCase.client:id,uuid,person_id',
|
'clientCase.client:id,uuid,person_id',
|
||||||
'clientCase.client.person:id,full_name',
|
'clientCase.client.person:id,full_name',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
->orderByDesc('completed_at')
|
->orderByDesc('completed_at');
|
||||||
->limit(100)
|
|
||||||
->get();
|
// 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', [
|
return Inertia::render('Phone/Index', [
|
||||||
'jobs' => $jobs,
|
'jobs' => $jobs,
|
||||||
|
'clients' => $clients,
|
||||||
'view_mode' => 'completed-today',
|
'view_mode' => 'completed-today',
|
||||||
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'client' => $clientFilter,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$userId = $request->user()->id;
|
||||||
$completedMode = (bool) $request->boolean('completed');
|
$completedMode = $request->boolean('completed');
|
||||||
|
|
||||||
// Eager load client case with person details
|
// Eager load case with person details
|
||||||
$case = \App\Models\ClientCase::query()
|
$case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||||
->with(['person' => fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts'])])
|
|
||||||
->findOrFail($clientCase->id);
|
|
||||||
|
|
||||||
// Determine contracts of this case relevant to the current user
|
// Query contracts based on field jobs
|
||||||
// - Normal mode: contracts assigned to me and still active (not completed/cancelled)
|
$contractsQuery = FieldJob::query()
|
||||||
// - Completed mode (?completed=1): contracts where my field job was completed today
|
->where('assigned_user_id', $userId)
|
||||||
if ($completedMode) {
|
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
||||||
$start = now()->startOfDay();
|
->when($completedMode,
|
||||||
$end = now()->endOfDay();
|
fn ($q) => $q->whereNull('cancelled_at')->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]),
|
||||||
$contractIds = FieldJob::query()
|
fn ($q) => $q->whereNull('completed_at')->whereNull('cancelled_at')
|
||||||
->where('assigned_user_id', $userId)
|
);
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereBetween('completed_at', [$start, $end])
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->pluck('contract_id')
|
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
} else {
|
|
||||||
$contractIds = FieldJob::query()
|
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereNull('completed_at')
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->pluck('contract_id')
|
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get contracts with relationships
|
||||||
$contracts = \App\Models\Contract::query()
|
$contracts = \App\Models\Contract::query()
|
||||||
->where('client_case_id', $case->id)
|
->where('client_case_id', $case->id)
|
||||||
->whereIn('id', $contractIds)
|
->whereIn('id', $contractsQuery->pluck('contract_id')->unique())
|
||||||
->with(['type:id,name', 'account'])
|
->with(['type:id,name', 'account', 'latestObject'])
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Attach latest object (if any) to each contract as last_object for display
|
// Build merged documents
|
||||||
if ($contracts->isNotEmpty()) {
|
$documents = $case->documents()
|
||||||
$byId = $contracts->keyBy('id');
|
|
||||||
$latestObjects = \App\Models\CaseObject::query()
|
|
||||||
->whereIn('contract_id', $byId->keys())
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->select('id', 'reference', 'name', 'description', 'type', 'contract_id', 'created_at')
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
->groupBy('contract_id')
|
|
||||||
->map(function ($group) {
|
|
||||||
return $group->first();
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach ($latestObjects as $cid => $obj) {
|
|
||||||
if (isset($byId[$cid])) {
|
|
||||||
$byId[$cid]->setAttribute('last_object', $obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build merged documents: case documents + documents of assigned contracts
|
|
||||||
$contractRefMap = [];
|
|
||||||
foreach ($contracts as $c) {
|
|
||||||
$contractRefMap[$c->id] = $c->reference;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contractDocs = \App\Models\Document::query()
|
|
||||||
->where('documentable_type', \App\Models\Contract::class)
|
|
||||||
->whereIn('documentable_id', $contractIds)
|
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get()
|
->get()
|
||||||
->map(function ($d) use ($contractRefMap) {
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
$arr = $d->toArray();
|
'documentable_type' => \App\Models\ClientCase::class,
|
||||||
$arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null;
|
'client_case_uuid' => $case->uuid,
|
||||||
$arr['documentable_type'] = \App\Models\Contract::class;
|
]))
|
||||||
$arr['contract_uuid'] = optional(\App\Models\Contract::withTrashed()->find($d->documentable_id))->uuid;
|
->concat(
|
||||||
|
\App\Models\Document::query()
|
||||||
return $arr;
|
->where('documentable_type', \App\Models\Contract::class)
|
||||||
});
|
->whereIn('documentable_id', $contracts->pluck('id'))
|
||||||
|
->with('documentable:id,uuid,reference')
|
||||||
$caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) {
|
->orderByDesc('created_at')
|
||||||
$arr = $d->toArray();
|
->get()
|
||||||
$arr['documentable_type'] = \App\Models\ClientCase::class;
|
->map(fn ($d) => array_merge($d->toArray(), [
|
||||||
$arr['client_case_uuid'] = $case->uuid;
|
'contract_reference' => $d->documentable?->reference,
|
||||||
|
'contract_uuid' => $d->documentable?->uuid,
|
||||||
return $arr;
|
]))
|
||||||
});
|
|
||||||
|
|
||||||
$documents = $caseDocs->concat($contractDocs)->sortByDesc('created_at')->values();
|
|
||||||
|
|
||||||
// Provide minimal types for PersonInfoGrid
|
|
||||||
$types = [
|
|
||||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
|
||||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Case activities (compact for phone): latest 20 with relations
|
|
||||||
$activities = $case->activities()
|
|
||||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->limit(20)
|
|
||||||
->get()
|
|
||||||
->map(function ($a) {
|
|
||||||
$a->setAttribute('user_name', optional($a->user)->name);
|
|
||||||
|
|
||||||
return $a;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine segment filters from FieldJobSettings for this case/user context
|
|
||||||
$settingIds = FieldJob::query()
|
|
||||||
->where('assigned_user_id', $userId)
|
|
||||||
->whereHas('contract', fn ($q) => $q->where('client_case_id', $case->id))
|
|
||||||
->when(
|
|
||||||
$completedMode,
|
|
||||||
function ($q) {
|
|
||||||
$q->whereNull('cancelled_at')
|
|
||||||
->whereBetween('completed_at', [now()->startOfDay(), now()->endOfDay()]);
|
|
||||||
},
|
|
||||||
function ($q) {
|
|
||||||
$q->whereNull('completed_at')->whereNull('cancelled_at');
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
->pluck('field_job_setting_id')
|
->sortByDesc('created_at')
|
||||||
->filter()
|
|
||||||
->unique()
|
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$segmentIds = collect();
|
// Get segment IDs for filtering actions
|
||||||
if ($settingIds->isNotEmpty()) {
|
$segmentIds = \App\Models\FieldJobSetting::query()
|
||||||
$segmentIds = \App\Models\FieldJobSetting::query()
|
->whereIn('id', $contractsQuery->pluck('field_job_setting_id')->filter()->unique())
|
||||||
->whereIn('id', $settingIds)
|
->pluck('segment_id')
|
||||||
->pluck('segment_id')
|
->filter()
|
||||||
->filter()
|
->unique();
|
||||||
->unique()
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter actions and their decisions by the derived segment ids (decisions.segment_id)
|
|
||||||
$actions = \App\Models\Action::query()
|
|
||||||
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
|
|
||||||
// Filter actions by their segment_id matching the FieldJobSetting segment(s)
|
|
||||||
$q->whereIn('segment_id', $segmentIds);
|
|
||||||
})
|
|
||||||
->with([
|
|
||||||
'decisions' => function ($q) {
|
|
||||||
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
|
|
||||||
},
|
|
||||||
'decisions.emailTemplate' => function ($q) {
|
|
||||||
$q->select('id', 'name', 'entity_types', 'allow_attachments');
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->get(['id', 'name', 'color_tag', 'segment_id']);
|
|
||||||
|
|
||||||
return Inertia::render('Phone/Case/Index', [
|
return Inertia::render('Phone/Case/Index', [
|
||||||
'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
|
'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||||
'client_case' => $case,
|
'client_case' => $case,
|
||||||
'contracts' => $contracts,
|
'contracts' => $contracts,
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
'types' => $types,
|
'types' => [
|
||||||
'account_types' => $this->referenceCache->getAccountTypes(),
|
'address_types' => \App\Models\Person\AddressType::all(),
|
||||||
// Provide decisions (filtered by segment) with linked email template metadata (entity_types, allow_attachments)
|
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||||
'actions' => $actions,
|
],
|
||||||
'activities' => $activities,
|
'account_types' => \App\Models\AccountType::all(),
|
||||||
|
'actions' => \App\Models\Action::query()
|
||||||
|
->when($segmentIds->isNotEmpty(), fn ($q) => $q->whereIn('segment_id', $segmentIds))
|
||||||
|
->with([
|
||||||
|
'decisions:id,name,color_tag,auto_mail,email_template_id',
|
||||||
|
'decisions.emailTemplate:id,name,entity_types,allow_attachments',
|
||||||
|
])
|
||||||
|
->get(['id', 'name', 'color_tag', 'segment_id']),
|
||||||
|
'activities' => $case->activities()
|
||||||
|
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->map(fn ($a) => $a->setAttribute('user_name', $a->user?->name)),
|
||||||
'completed_mode' => $completedMode,
|
'completed_mode' => $completedMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Reports\ReportRegistry;
|
use App\Models\Report;
|
||||||
|
use App\Services\ReportQueryBuilder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
@@ -10,15 +11,19 @@
|
|||||||
|
|
||||||
class ReportController extends Controller
|
class ReportController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected ReportRegistry $registry) {}
|
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$reports = collect($this->registry->all())
|
$reports = Report::where('enabled', true)
|
||||||
|
->orderBy('order')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
->map(fn ($r) => [
|
->map(fn ($r) => [
|
||||||
'slug' => $r->slug(),
|
'slug' => $r->slug,
|
||||||
'name' => $r->name(),
|
'name' => $r->name,
|
||||||
'description' => $r->description(),
|
'description' => $r->description,
|
||||||
|
'category' => $r->category,
|
||||||
])
|
])
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
@@ -29,26 +34,30 @@ public function index(Request $request)
|
|||||||
|
|
||||||
public function show(string $slug, Request $request)
|
public function show(string $slug, Request $request)
|
||||||
{
|
{
|
||||||
$report = $this->registry->findBySlug($slug);
|
$report = Report::with(['filters', 'columns'])
|
||||||
abort_if(! $report, 404);
|
->where('slug', $slug)
|
||||||
$report->authorize($request);
|
->where('enabled', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
// Accept filters & pagination from query and return initial data for server-driven table
|
// Accept filters & pagination from query and return initial data for server-driven table
|
||||||
$filters = $this->validateFilters($report->inputs(), $request);
|
$inputs = $this->buildInputsArray($report);
|
||||||
|
$filters = $this->validateFilters($inputs, $request);
|
||||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||||
|
|
||||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||||
$paginator = $report->paginate($filters, $perPage);
|
$query = $this->queryBuilder->build($report, $filters);
|
||||||
|
$paginator = $query->paginate($perPage);
|
||||||
|
|
||||||
$rows = collect($paginator->items())
|
$rows = collect($paginator->items())
|
||||||
->map(fn ($row) => $this->normalizeRow($row))
|
->map(fn ($row) => $this->normalizeRow($row))
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Reports/Show', [
|
return Inertia::render('Reports/Show', [
|
||||||
'slug' => $report->slug(),
|
'slug' => $report->slug,
|
||||||
'name' => $report->name(),
|
'name' => $report->name,
|
||||||
'description' => $report->description(),
|
'description' => $report->description,
|
||||||
'inputs' => $report->inputs(),
|
'inputs' => $inputs,
|
||||||
'columns' => $report->columns(),
|
'columns' => $this->buildColumnsArray($report),
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'total' => $paginator->total(),
|
'total' => $paginator->total(),
|
||||||
@@ -62,14 +71,17 @@ public function show(string $slug, Request $request)
|
|||||||
|
|
||||||
public function data(string $slug, Request $request)
|
public function data(string $slug, Request $request)
|
||||||
{
|
{
|
||||||
$report = $this->registry->findBySlug($slug);
|
$report = Report::with(['filters', 'columns'])
|
||||||
abort_if(! $report, 404);
|
->where('slug', $slug)
|
||||||
$report->authorize($request);
|
->where('enabled', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$filters = $this->validateFilters($report->inputs(), $request);
|
$inputs = $this->buildInputsArray($report);
|
||||||
|
$filters = $this->validateFilters($inputs, $request);
|
||||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||||
|
|
||||||
$paginator = $report->paginate($filters, $perPage);
|
$query = $this->queryBuilder->build($report, $filters);
|
||||||
|
$paginator = $query->paginate($perPage);
|
||||||
|
|
||||||
$rows = collect($paginator->items())
|
$rows = collect($paginator->items())
|
||||||
->map(fn ($row) => $this->normalizeRow($row))
|
->map(fn ($row) => $this->normalizeRow($row))
|
||||||
@@ -85,20 +97,23 @@ public function data(string $slug, Request $request)
|
|||||||
|
|
||||||
public function export(string $slug, Request $request)
|
public function export(string $slug, Request $request)
|
||||||
{
|
{
|
||||||
$report = $this->registry->findBySlug($slug);
|
$report = Report::with(['filters', 'columns'])
|
||||||
abort_if(! $report, 404);
|
->where('slug', $slug)
|
||||||
$report->authorize($request);
|
->where('enabled', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$filters = $this->validateFilters($report->inputs(), $request);
|
$inputs = $this->buildInputsArray($report);
|
||||||
|
$filters = $this->validateFilters($inputs, $request);
|
||||||
$format = strtolower((string) $request->get('format', 'csv'));
|
$format = strtolower((string) $request->get('format', 'csv'));
|
||||||
|
|
||||||
$rows = $report->query($filters)->get()->map(fn ($row) => $this->normalizeRow($row));
|
$query = $this->queryBuilder->build($report, $filters);
|
||||||
$columns = $report->columns();
|
$rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||||
$filename = $report->slug().'-'.now()->format('Ymd_His');
|
$columns = $this->buildColumnsArray($report);
|
||||||
|
$filename = $report->slug.'-'.now()->format('Ymd_His');
|
||||||
|
|
||||||
if ($format === 'pdf') {
|
if ($format === 'pdf') {
|
||||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
||||||
'name' => $report->name(),
|
'name' => $report->name,
|
||||||
'columns' => $columns,
|
'columns' => $columns,
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
]);
|
]);
|
||||||
@@ -299,6 +314,35 @@ protected function validateFilters(array $inputs, Request $request): array
|
|||||||
return $request->validate($rules);
|
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.
|
* Ensure derived export/display fields exist on row objects.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exports\SegmentContractsExport;
|
||||||
|
use App\Http\Requests\ExportSegmentContractsRequest;
|
||||||
use App\Http\Requests\StoreSegmentRequest;
|
use App\Http\Requests\StoreSegmentRequest;
|
||||||
use App\Http\Requests\UpdateSegmentRequest;
|
use App\Http\Requests\UpdateSegmentRequest;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Contract;
|
||||||
use App\Models\Segment;
|
use App\Models\Segment;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
|
||||||
class SegmentController extends Controller
|
class SegmentController extends Controller
|
||||||
{
|
{
|
||||||
@@ -44,64 +52,26 @@ public function index()
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(\App\Models\Segment $segment)
|
public function show(Segment $segment)
|
||||||
{
|
{
|
||||||
// Retrieve contracts that are active in this segment, eager-loading required relations
|
|
||||||
$search = request('search');
|
$search = request('search');
|
||||||
$clientFilter = request('client') ?? request('client_id'); // support either ?client=<uuid|id> or ?client_id=<id>
|
$clientFilter = request('client') ?? request('client_id');
|
||||||
$contractsQuery = \App\Models\Contract::query()
|
$perPage = request()->integer('perPage', request()->integer('per_page', 15));
|
||||||
->whereHas('segments', function ($q) use ($segment) {
|
$perPage = max(1, min(200, $perPage));
|
||||||
$q->where('segments.id', $segment->id)
|
|
||||||
->where('contract_segment.active', '=', 1);
|
|
||||||
})
|
|
||||||
->with([
|
|
||||||
'clientCase.person',
|
|
||||||
'clientCase.client.person',
|
|
||||||
'type',
|
|
||||||
'account',
|
|
||||||
])
|
|
||||||
->latest('id');
|
|
||||||
|
|
||||||
// Optional filter by client (accepts numeric id or client uuid)
|
$contracts = $this->buildContractsQuery($segment, $search, $clientFilter)
|
||||||
if (! empty($clientFilter)) {
|
->paginate($perPage)
|
||||||
$contractsQuery->whereHas('clientCase.client', function ($q) use ($clientFilter) {
|
|
||||||
if (is_numeric($clientFilter)) {
|
|
||||||
$q->where('clients.id', (int) $clientFilter);
|
|
||||||
} else {
|
|
||||||
$q->where('clients.uuid', $clientFilter);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($search)) {
|
|
||||||
$contractsQuery->where(function ($qq) use ($search) {
|
|
||||||
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
|
|
||||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
|
||||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
|
||||||
})
|
|
||||||
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
|
|
||||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$contracts = $contractsQuery
|
|
||||||
->paginate(15)
|
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
|
|
||||||
// Mirror client onto the contract to simplify frontend access (c.client.person.full_name)
|
$contracts = $this->hydrateClientShortcut($contracts);
|
||||||
$items = collect($contracts->items());
|
|
||||||
$items->each(function ($contract) {
|
|
||||||
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
|
||||||
$contract->setRelation('client', $contract->clientCase->client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (method_exists($contracts, 'setCollection')) {
|
|
||||||
$contracts->setCollection($items);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a full client list for this segment (not limited to current page) for the dropdown
|
// Hide addresses array since we're using the singular address relationship
|
||||||
$clients = \App\Models\Client::query()
|
$contracts->getCollection()->each(function ($contract) {
|
||||||
|
$contract->clientCase?->person?->makeHidden('addresses');
|
||||||
|
$contract->clientCase?->client?->person?->makeHidden('addresses');
|
||||||
|
});
|
||||||
|
|
||||||
|
$clients = Client::query()
|
||||||
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
->whereHas('clientCases.contracts.segments', function ($q) use ($segment) {
|
||||||
$q->where('segments.id', $segment->id)
|
$q->where('segments.id', $segment->id)
|
||||||
->where('contract_segment.active', '=', 1);
|
->where('contract_segment.active', '=', 1);
|
||||||
@@ -124,6 +94,69 @@ public function show(\App\Models\Segment $segment)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function export(ExportSegmentContractsRequest $request, Segment $segment)
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$client = $this->resolveClient($data['client'] ?? null);
|
||||||
|
$columns = array_values(array_unique($data['columns']));
|
||||||
|
$query = $this->buildContractsQuery(
|
||||||
|
$segment,
|
||||||
|
$data['search'] ?? null,
|
||||||
|
$data['client'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($data['scope'] ?? ExportSegmentContractsRequest::SCOPE_ALL) === ExportSegmentContractsRequest::SCOPE_CURRENT) {
|
||||||
|
$page = max(1, (int) ($data['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(200, (int) ($data['per_page'] ?? 15)));
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $this->buildExportFilename($segment, $client);
|
||||||
|
|
||||||
|
return Excel::download(new SegmentContractsExport($query, $columns), $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveClient(?string $identifier): ?Client
|
||||||
|
{
|
||||||
|
if (empty($identifier)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Client::query()->with(['person:id,full_name']);
|
||||||
|
|
||||||
|
if (Str::isUuid($identifier)) {
|
||||||
|
$query->where('uuid', $identifier);
|
||||||
|
} elseif (is_numeric($identifier)) {
|
||||||
|
$query->where('id', (int) $identifier);
|
||||||
|
} else {
|
||||||
|
$query->where('uuid', $identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildExportFilename(Segment $segment, ?Client $client): string
|
||||||
|
{
|
||||||
|
$datePrefix = now()->format('dmy');
|
||||||
|
$segmentName = $this->slugify($segment->name ?? 'segment');
|
||||||
|
$base = sprintf('%s_%s-Pogodbe', $datePrefix, $segmentName);
|
||||||
|
|
||||||
|
if ($client && $client->person?->full_name) {
|
||||||
|
$clientName = $this->slugify($client->person->full_name);
|
||||||
|
|
||||||
|
return sprintf('%s_%s.xlsx', $base, $clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s.xlsx', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $value): string
|
||||||
|
{
|
||||||
|
$slug = trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $value), '-');
|
||||||
|
|
||||||
|
return $slug !== '' ? $slug : 'data';
|
||||||
|
}
|
||||||
|
|
||||||
public function settings(Request $request)
|
public function settings(Request $request)
|
||||||
{
|
{
|
||||||
return Inertia::render('Settings/Segments/Index', [
|
return Inertia::render('Settings/Segments/Index', [
|
||||||
@@ -155,4 +188,59 @@ public function update(UpdateSegmentRequest $request, Segment $segment)
|
|||||||
|
|
||||||
return to_route('settings.segments')->with('success', 'Segment updated');
|
return to_route('settings.segments')->with('success', 'Segment updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildContractsQuery(Segment $segment, ?string $search, ?string $clientFilter): Builder
|
||||||
|
{
|
||||||
|
$query = Contract::query()
|
||||||
|
->whereHas('segments', function ($q) use ($segment) {
|
||||||
|
$q->where('segments.id', $segment->id)
|
||||||
|
->where('contract_segment.active', '=', 1);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'clientCase.person.address',
|
||||||
|
'type',
|
||||||
|
'account',
|
||||||
|
])
|
||||||
|
->latest('id');
|
||||||
|
|
||||||
|
if (! empty($clientFilter)) {
|
||||||
|
$query->whereHas('clientCase.client', function ($q) use ($clientFilter) {
|
||||||
|
if (is_numeric($clientFilter)) {
|
||||||
|
$q->where('clients.id', (int) $clientFilter);
|
||||||
|
} else {
|
||||||
|
$q->where('clients.uuid', $clientFilter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($search)) {
|
||||||
|
$query->where(function ($qq) use ($search) {
|
||||||
|
$qq->where('contracts.reference', 'ilike', '%'.$search.'%')
|
||||||
|
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||||
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
})
|
||||||
|
->orWhereHas('clientCase.client.person', function ($p) use ($search) {
|
||||||
|
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateClientShortcut(LengthAwarePaginator $contracts): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$items = collect($contracts->items());
|
||||||
|
$items->each(function (Contract $contract) {
|
||||||
|
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
||||||
|
$contract->setRelation('client', $contract->clientCase->client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (method_exists($contracts, 'setCollection')) {
|
||||||
|
$contracts->setCollection($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contracts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Exports\ClientContractsExport;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ExportClientContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SCOPE_CURRENT = 'current';
|
||||||
|
|
||||||
|
public const SCOPE_ALL = 'all';
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$columnRule = Rule::in(ClientContractsExport::allowedColumns());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||||
|
'columns' => ['required', 'array', 'min:1'],
|
||||||
|
'columns.*' => ['string', $columnRule],
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'from' => ['nullable', 'date'],
|
||||||
|
'to' => ['nullable', 'date'],
|
||||||
|
'segments' => ['nullable', 'string'],
|
||||||
|
'page' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Exports\SegmentContractsExport;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ExportSegmentContractsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public const SCOPE_CURRENT = 'current';
|
||||||
|
|
||||||
|
public const SCOPE_ALL = 'all';
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$columnRule = Rule::in(SegmentContractsExport::allowedColumns());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scope' => ['required', Rule::in([self::SCOPE_CURRENT, self::SCOPE_ALL])],
|
||||||
|
'columns' => ['required', 'array', 'min:1'],
|
||||||
|
'columns.*' => ['string', $columnRule],
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'client' => ['nullable', 'string', 'max:64'],
|
||||||
|
'page' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'client' => $this->input('client') ?? $this->input('client_id'),
|
||||||
|
'per_page' => $this->input('per_page') ?? $this->input('perPage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,10 +118,10 @@ public function handle(SmsService $sms): void
|
|||||||
if ($template && $case) {
|
if ($template && $case) {
|
||||||
$note = '';
|
$note = '';
|
||||||
if ($log->status === 'sent') {
|
if ($log->status === 'sent') {
|
||||||
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
$note = sprintf('Tel: %s | Telo: %s', (string) $this->to, (string) $this->content);
|
||||||
} elseif ($log->status === 'failed') {
|
} elseif ($log->status === 'failed') {
|
||||||
$note = sprintf(
|
$note = sprintf(
|
||||||
'Št: %s | Telo: %s | Napaka: %s',
|
'Tel: %s | Telo: %s | Napaka: %s',
|
||||||
(string) $this->to,
|
(string) $this->to,
|
||||||
(string) $this->content,
|
(string) $this->content,
|
||||||
'SMS ni bil poslan!'
|
'SMS ni bil poslan!'
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Account extends Model
|
class Account extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
/** @use HasFactory<\Database\Factories\Person/AccountFactory> */
|
||||||
|
use SoftDeletes;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Contract extends Model
|
|||||||
'end_date',
|
'end_date',
|
||||||
'client_case_id',
|
'client_case_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
|
'active',
|
||||||
'description',
|
'description',
|
||||||
'meta',
|
'meta',
|
||||||
];
|
];
|
||||||
@@ -112,6 +113,11 @@ public function segments(): BelongsToMany
|
|||||||
->wherePivot('active', true);
|
->wherePivot('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachedSegments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(\App\Models\Segment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function account(): HasOne
|
public function account(): HasOne
|
||||||
{
|
{
|
||||||
// Use latestOfMany to always surface newest account snapshot if multiple exist.
|
// Use latestOfMany to always surface newest account snapshot if multiple exist.
|
||||||
@@ -130,6 +136,22 @@ public function documents(): MorphMany
|
|||||||
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
return $this->morphMany(\App\Models\Document::class, 'documentable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->latest();
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::created(function (Contract $contract): void {
|
static::created(function (Contract $contract): void {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class FieldJob extends Model
|
|||||||
'priority',
|
'priority',
|
||||||
'notes',
|
'notes',
|
||||||
'address_snapshot ',
|
'address_snapshot ',
|
||||||
|
'last_activity',
|
||||||
|
'added_activity'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -31,6 +33,8 @@ class FieldJob extends Model
|
|||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
'cancelled_at' => 'datetime',
|
'cancelled_at' => 'datetime',
|
||||||
'priority' => 'boolean',
|
'priority' => 'boolean',
|
||||||
|
'last_activity' => 'datetime',
|
||||||
|
'added_activity' => 'boolean',
|
||||||
'address_snapshot ' => 'array',
|
'address_snapshot ' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -90,7 +94,8 @@ public function user(): BelongsTo
|
|||||||
|
|
||||||
public function contract(): BelongsTo
|
public function contract(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Contract::class, 'contract_id');
|
return $this->belongsTo(Contract::class, 'contract_id')
|
||||||
|
->where('active', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class ImportEntity extends Model
|
|||||||
'meta',
|
'meta',
|
||||||
'rules',
|
'rules',
|
||||||
'ui',
|
'ui',
|
||||||
|
'handler_class',
|
||||||
|
'validation_rules',
|
||||||
|
'processing_options',
|
||||||
|
'is_active',
|
||||||
|
'priority',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -27,5 +32,9 @@ class ImportEntity extends Model
|
|||||||
'meta' => 'boolean',
|
'meta' => 'boolean',
|
||||||
'rules' => 'array',
|
'rules' => 'array',
|
||||||
'ui' => 'array',
|
'ui' => 'array',
|
||||||
|
'validation_rules' => 'array',
|
||||||
|
'processing_options' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class ImportTemplate extends Model
|
|||||||
'reactivate' => 'boolean',
|
'reactivate' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Person extends Model
|
|||||||
'group_id',
|
'group_id',
|
||||||
'type_id',
|
'type_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'employer'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -112,6 +113,14 @@ public function addresses(): HasMany
|
|||||||
->orderBy('id');
|
->orderBy('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function address(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(\App\Models\Person\PersonAddress::class)
|
||||||
|
->with(['type'])
|
||||||
|
->where('active', '=', 1)
|
||||||
|
->oldestOfMany('id');
|
||||||
|
}
|
||||||
|
|
||||||
public function emails(): HasMany
|
public function emails(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Models\Email::class, 'person_id')
|
return $this->hasMany(\App\Models\Email::class, 'person_id')
|
||||||
@@ -190,7 +199,7 @@ protected static function joinNameParts(?string $first, ?string $second): ?strin
|
|||||||
|
|
||||||
protected static function normalizeSegment(?string $value): ?string
|
protected static function normalizeSegment(?string $value): ?string
|
||||||
{
|
{
|
||||||
if (blank($value)) {
|
if (empty($value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Reports\ActionsDecisionsCountReport;
|
|
||||||
use App\Reports\ActivitiesPerPeriodReport;
|
|
||||||
use App\Reports\ActiveContractsReport;
|
|
||||||
use App\Reports\FieldJobsCompletedReport;
|
|
||||||
use App\Reports\DecisionsCountReport;
|
|
||||||
use App\Reports\ReportRegistry;
|
|
||||||
use App\Reports\SegmentActivityCountsReport;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class ReportServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->app->singleton(ReportRegistry::class, function () {
|
|
||||||
$registry = new ReportRegistry;
|
|
||||||
// Register built-in reports here
|
|
||||||
$registry->register(new FieldJobsCompletedReport);
|
|
||||||
$registry->register(new SegmentActivityCountsReport);
|
|
||||||
$registry->register(new ActionsDecisionsCountReport);
|
|
||||||
$registry->register(new ActivitiesPerPeriodReport);
|
|
||||||
$registry->register(new DecisionsCountReport);
|
|
||||||
$registry->register(new ActiveContractsReport);
|
|
||||||
|
|
||||||
return $registry;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\Activity;
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ActionsDecisionsCountReport extends BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'actions-decisions-counts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Dejanja / Odločitve – štetje';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Število aktivnosti po dejanjih in odločitvah v obdobju.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
|
||||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'action_name', 'label' => 'Dejanje'],
|
|
||||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
|
||||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters): Builder
|
|
||||||
{
|
|
||||||
return Activity::query()
|
|
||||||
->leftJoin('actions', 'activities.action_id', '=', 'actions.id')
|
|
||||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
|
||||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
|
||||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
|
||||||
->groupBy('actions.name', 'decisions.name')
|
|
||||||
->selectRaw("COALESCE(actions.name, '—') as action_name, COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\Contract;
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ActiveContractsReport extends BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'active-contracts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Aktivne pogodbe';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'client_uuid', 'type' => 'select:client', 'label' => 'Stranka', 'nullable' => true],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
|
||||||
['key' => 'client_name', 'label' => 'Stranka'],
|
|
||||||
['key' => 'person_name', 'label' => 'Zadeva (oseba)'],
|
|
||||||
['key' => 'start_date', 'label' => 'Začetek'],
|
|
||||||
['key' => 'end_date', 'label' => 'Konec'],
|
|
||||||
['key' => 'balance_amount', 'label' => 'Saldo'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters): Builder
|
|
||||||
{
|
|
||||||
$asOf = now()->toDateString();
|
|
||||||
|
|
||||||
return Contract::query()
|
|
||||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
|
||||||
->leftJoin('clients', 'client_cases.client_id', '=', 'clients.id')
|
|
||||||
->leftJoin('person as client_people', 'clients.person_id', '=', 'client_people.id')
|
|
||||||
->leftJoin('person as subject_people', 'client_cases.person_id', '=', 'subject_people.id')
|
|
||||||
->leftJoin('accounts', 'contracts.id', '=', 'accounts.contract_id')
|
|
||||||
->when(! empty($filters['client_uuid']), fn ($q) => $q->where('clients.uuid', $filters['client_uuid']))
|
|
||||||
// Active as of date: start_date <= as_of (or null) AND (end_date is null OR end_date >= as_of)
|
|
||||||
->where(function ($q) use ($asOf) {
|
|
||||||
$q->whereNull('contracts.start_date')
|
|
||||||
->orWhereDate('contracts.start_date', '<=', $asOf);
|
|
||||||
})
|
|
||||||
->where(function ($q) use ($asOf) {
|
|
||||||
$q->whereNull('contracts.end_date')
|
|
||||||
->orWhereDate('contracts.end_date', '>=', $asOf);
|
|
||||||
})
|
|
||||||
->select([
|
|
||||||
'contracts.id',
|
|
||||||
'contracts.start_date',
|
|
||||||
'contracts.end_date',
|
|
||||||
])
|
|
||||||
->addSelect([
|
|
||||||
\DB::raw('contracts.reference as contract_reference'),
|
|
||||||
\DB::raw('client_people.full_name as client_name'),
|
|
||||||
\DB::raw('subject_people.full_name as person_name'),
|
|
||||||
\DB::raw('CAST(accounts.balance_amount AS FLOAT) as balance_amount'),
|
|
||||||
])
|
|
||||||
->orderBy('contracts.start_date', 'asc');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\Activity;
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ActivitiesPerPeriodReport extends BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'activities-per-period';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Aktivnosti po obdobjih';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
|
||||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
|
||||||
['key' => 'period', 'type' => 'string', 'label' => 'Obdobje (day|week|month)', 'default' => 'day'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'period', 'label' => 'Obdobje'],
|
|
||||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters): Builder
|
|
||||||
{
|
|
||||||
$periodRaw = $filters['period'] ?? 'day';
|
|
||||||
$period = in_array($periodRaw, ['day', 'week', 'month'], true) ? $periodRaw : 'day';
|
|
||||||
$driver = DB::getDriverName();
|
|
||||||
|
|
||||||
// Build database-compatible period expressions
|
|
||||||
if ($driver === 'sqlite') {
|
|
||||||
if ($period === 'day') {
|
|
||||||
// Use string slice to avoid timezone conversion differences in SQLite
|
|
||||||
$selectExpr = DB::raw('SUBSTR(activities.created_at, 1, 10) as period');
|
|
||||||
$groupExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
|
||||||
$orderExpr = DB::raw('SUBSTR(activities.created_at, 1, 10)');
|
|
||||||
} elseif ($period === 'month') {
|
|
||||||
$selectExpr = DB::raw("strftime('%Y-%m-01', activities.created_at) as period");
|
|
||||||
$groupExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
|
||||||
$orderExpr = DB::raw("strftime('%Y-%m-01', activities.created_at)");
|
|
||||||
} else { // week
|
|
||||||
$selectExpr = DB::raw("strftime('%Y-%W', activities.created_at) as period");
|
|
||||||
$groupExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
|
||||||
$orderExpr = DB::raw("strftime('%Y-%W', activities.created_at)");
|
|
||||||
}
|
|
||||||
} elseif ($driver === 'mysql') {
|
|
||||||
if ($period === 'day') {
|
|
||||||
$selectExpr = DB::raw('DATE(activities.created_at) as period');
|
|
||||||
$groupExpr = DB::raw('DATE(activities.created_at)');
|
|
||||||
$orderExpr = DB::raw('DATE(activities.created_at)');
|
|
||||||
} elseif ($period === 'month') {
|
|
||||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01') as period");
|
|
||||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
|
||||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%Y-%m-01')");
|
|
||||||
} else { // week
|
|
||||||
// ISO week-year-week number for grouping; adequate for summary grouping
|
|
||||||
$selectExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v') as period");
|
|
||||||
$groupExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
|
||||||
$orderExpr = DB::raw("DATE_FORMAT(activities.created_at, '%x-%v')");
|
|
||||||
}
|
|
||||||
} else { // postgres and others supporting date_trunc
|
|
||||||
$selectExpr = DB::raw("date_trunc('".$period."', activities.created_at) as period");
|
|
||||||
$groupExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
|
||||||
$orderExpr = DB::raw("date_trunc('".$period."', activities.created_at)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Activity::query()
|
|
||||||
->when(! empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
|
||||||
->when(! empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
|
||||||
->groupBy($groupExpr)
|
|
||||||
->orderBy($orderExpr)
|
|
||||||
->select($selectExpr)
|
|
||||||
->selectRaw('COUNT(*) as activities_count');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
abstract class BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function authorize(Request $request): void
|
|
||||||
{
|
|
||||||
// Default: no extra checks. Controllers can gate via middleware.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
*/
|
|
||||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
/** @var EloquentBuilder|QueryBuilder $query */
|
|
||||||
$query = $this->query($filters);
|
|
||||||
|
|
||||||
return $query->paginate($perPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports\Contracts;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
interface Report
|
|
||||||
{
|
|
||||||
public function slug(): string;
|
|
||||||
|
|
||||||
public function name(): string;
|
|
||||||
|
|
||||||
public function description(): ?string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return an array describing input filters (type, label, default, options) for UI.
|
|
||||||
* Example item: ['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => today()]
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function inputs(): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return column definitions for the table and exports.
|
|
||||||
* Example: [ ['key' => 'id', 'label' => '#'], ['key' => 'user', 'label' => 'Uporabnik'] ]
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function columns(): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the data source query for the report based on validated filters.
|
|
||||||
* Should return an Eloquent or Query builder.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
* @return EloquentBuilder|QueryBuilder
|
|
||||||
*/
|
|
||||||
public function query(array $filters);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional per-report authorization logic.
|
|
||||||
*/
|
|
||||||
public function authorize(Request $request): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the report and return a paginator for UI.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
*/
|
|
||||||
public function paginate(array $filters, int $perPage = 25): LengthAwarePaginator;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\Activity;
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class DecisionsCountReport extends BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'decisions-counts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Odločitve – štetje';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Število aktivnosti po odločitvah v izbranem obdobju.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
|
||||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'decision_name', 'label' => 'Odločitev'],
|
|
||||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters): Builder
|
|
||||||
{
|
|
||||||
return Activity::query()
|
|
||||||
->leftJoin('decisions', 'activities.decision_id', '=', 'decisions.id')
|
|
||||||
->when(!empty($filters['from']), fn ($q) => $q->whereDate('activities.created_at', '>=', $filters['from']))
|
|
||||||
->when(!empty($filters['to']), fn ($q) => $q->whereDate('activities.created_at', '<=', $filters['to']))
|
|
||||||
->groupBy('decisions.name')
|
|
||||||
->selectRaw("COALESCE(decisions.name, '—') as decision_name, COUNT(*) as activities_count");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\FieldJob;
|
|
||||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
|
||||||
|
|
||||||
class FieldJobsCompletedReport extends BaseEloquentReport
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'field-jobs-completed';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Zaključeni tereni';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Pregled zaključenih terenov po datumu in uporabniku.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'default' => now()->startOfMonth()->toDateString()],
|
|
||||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'default' => now()->toDateString()],
|
|
||||||
['key' => 'user_id', 'type' => 'select:user', 'label' => 'Uporabnik', 'default' => null],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'id', 'label' => '#'],
|
|
||||||
['key' => 'contract_reference', 'label' => 'Pogodba'],
|
|
||||||
['key' => 'assigned_user_name', 'label' => 'Terenski'],
|
|
||||||
['key' => 'completed_at', 'label' => 'Zaključeno'],
|
|
||||||
['key' => 'notes', 'label' => 'Opombe'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
*/
|
|
||||||
public function query(array $filters): EloquentBuilder
|
|
||||||
{
|
|
||||||
$from = isset($filters['from']) ? now()->parse($filters['from'])->startOfDay() : now()->startOfMonth();
|
|
||||||
$to = isset($filters['to']) ? now()->parse($filters['to'])->endOfDay() : now()->endOfDay();
|
|
||||||
|
|
||||||
return FieldJob::query()
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereBetween('completed_at', [$from, $to])
|
|
||||||
->when(! empty($filters['user_id']), fn ($q) => $q->where('assigned_user_id', $filters['user_id']))
|
|
||||||
->with(['assignedUser:id,name', 'contract:id,reference'])
|
|
||||||
->select(['id', 'assigned_user_id', 'contract_id', 'completed_at', 'notes']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
|
|
||||||
class ReportRegistry
|
|
||||||
{
|
|
||||||
/** @var array<string, Report> */
|
|
||||||
protected array $reports = [];
|
|
||||||
|
|
||||||
public function register(Report $report): void
|
|
||||||
{
|
|
||||||
$this->reports[$report->slug()] = $report;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Report>
|
|
||||||
*/
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return $this->reports;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findBySlug(string $slug): ?Report
|
|
||||||
{
|
|
||||||
return $this->reports[$slug] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Reports;
|
|
||||||
|
|
||||||
use App\Models\Activity;
|
|
||||||
use App\Reports\Contracts\Report;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class SegmentActivityCountsReport extends BaseEloquentReport implements Report
|
|
||||||
{
|
|
||||||
public function slug(): string
|
|
||||||
{
|
|
||||||
return 'segment-activity-counts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'Aktivnosti po segmentih';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): ?string
|
|
||||||
{
|
|
||||||
return 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inputs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'from', 'type' => 'date', 'label' => 'Od', 'nullable' => true],
|
|
||||||
['key' => 'to', 'type' => 'date', 'label' => 'Do', 'nullable' => true],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columns(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['key' => 'segment_name', 'label' => 'Segment'],
|
|
||||||
['key' => 'activities_count', 'label' => 'Št. aktivnosti'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters): Builder
|
|
||||||
{
|
|
||||||
$q = Activity::query()
|
|
||||||
->join('actions', 'activities.action_id', '=', 'actions.id')
|
|
||||||
->leftJoin('segments', 'actions.segment_id', '=', 'segments.id')
|
|
||||||
->when(! empty($filters['from']), fn ($qq) => $qq->whereDate('activities.created_at', '>=', $filters['from']))
|
|
||||||
->when(! empty($filters['to']), fn ($qq) => $qq->whereDate('activities.created_at', '<=', $filters['to']))
|
|
||||||
->groupBy('segments.name')
|
|
||||||
->selectRaw("COALESCE(segments.name, 'Brez segmenta') as segment_name, COUNT(*) as activities_count");
|
|
||||||
|
|
||||||
return $q;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,6 +69,8 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
|
|||||||
$entities = $flat;
|
$entities = $flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dd($entities);
|
||||||
|
|
||||||
foreach ($entities as $entityDef) {
|
foreach ($entities as $entityDef) {
|
||||||
$rawTable = $entityDef['table'] ?? null;
|
$rawTable = $entityDef['table'] ?? null;
|
||||||
if (! $rawTable) {
|
if (! $rawTable) {
|
||||||
@@ -97,7 +99,7 @@ public function executeSetting(ArchiveSetting $setting, ?array $context = null,
|
|||||||
// Process in batches to avoid locking large tables
|
// Process in batches to avoid locking large tables
|
||||||
while (true) {
|
while (true) {
|
||||||
$query = DB::table($table)->whereNull('deleted_at');
|
$query = DB::table($table)->whereNull('deleted_at');
|
||||||
if (Schema::hasColumn($table, 'active')) {
|
if (Schema::hasColumn($table, 'active') && ! $reactivate) {
|
||||||
$query->where('active', 1);
|
$query->where('active', 1);
|
||||||
}
|
}
|
||||||
// Apply context filters or chain derived filters
|
// Apply context filters or chain derived filters
|
||||||
|
|||||||
@@ -2,57 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
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
|
||||||
{
|
{
|
||||||
/**
|
// This class extends the actual DateNormalizer for backward compatibility
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
+96
-3
@@ -25,6 +25,11 @@ class ImportSimulationService
|
|||||||
*/
|
*/
|
||||||
private ?int $clientId = null;
|
private ?int $clientId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History import mode flag (from template meta).
|
||||||
|
*/
|
||||||
|
private bool $historyImport = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public entry: simulate import applying mappings to first $limit rows.
|
* Public entry: simulate import applying mappings to first $limit rows.
|
||||||
* Keeps existing machine keys for backward compatibility, but adds Slovenian
|
* Keeps existing machine keys for backward compatibility, but adds Slovenian
|
||||||
@@ -79,6 +84,7 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||||||
$simRows = [];
|
$simRows = [];
|
||||||
// Determine keyref behavior for contract.reference from mappings/template
|
// Determine keyref behavior for contract.reference from mappings/template
|
||||||
$tplMeta = optional($import->template)->meta ?? [];
|
$tplMeta = optional($import->template)->meta ?? [];
|
||||||
|
$this->historyImport = (bool) ($tplMeta['history_import'] ?? false);
|
||||||
$contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference'
|
$contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference'
|
||||||
$contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref'
|
$contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref'
|
||||||
foreach ($rows as $idx => $rawValues) {
|
foreach ($rows as $idx => $rawValues) {
|
||||||
@@ -489,6 +495,38 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History import: auto-ensure account placeholder when contract exists but no account mapping
|
||||||
|
if ($this->historyImport && $existingContract && isset($rowEntities['contract']['id']) && ! isset($rowEntities['account'])) {
|
||||||
|
if (! isset($summaries['account'])) {
|
||||||
|
$summaries['account'] = [
|
||||||
|
'root' => 'account',
|
||||||
|
'total_rows' => 0,
|
||||||
|
'create' => 0,
|
||||||
|
'update' => 0,
|
||||||
|
'missing_ref' => 0,
|
||||||
|
'invalid' => 0,
|
||||||
|
'duplicate' => 0,
|
||||||
|
'duplicate_db' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$summaries['account']['total_rows']++;
|
||||||
|
$summaries['account']['update']++;
|
||||||
|
$ref = $rowEntities['contract']['reference'] ?? null;
|
||||||
|
if ($ref === null || $ref === '') {
|
||||||
|
$ref = 'HIST-'.$rowEntities['contract']['id'];
|
||||||
|
}
|
||||||
|
$rowEntities['account'] = [
|
||||||
|
'reference' => $ref,
|
||||||
|
'exists' => true,
|
||||||
|
'id' => null,
|
||||||
|
'balance_before' => 0,
|
||||||
|
'balance_after' => 0,
|
||||||
|
'action' => 'implicit_history',
|
||||||
|
'action_label' => $translatedActions['implicit'] ?? 'posredno',
|
||||||
|
'history_zeroed' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Payment (affects account balance; may create implicit account)
|
// Payment (affects account balance; may create implicit account)
|
||||||
if (isset($entityRoots['payment'])) {
|
if (isset($entityRoots['payment'])) {
|
||||||
// Inject inferred account if none mapped explicitly
|
// Inject inferred account if none mapped explicitly
|
||||||
@@ -891,7 +929,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
|
|||||||
'client_case_id' => $contract?->client_case_id,
|
'client_case_id' => $contract?->client_case_id,
|
||||||
'active' => $contract?->active,
|
'active' => $contract?->active,
|
||||||
'deleted_at' => $contract?->deleted_at,
|
'deleted_at' => $contract?->deleted_at,
|
||||||
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
|
'action' => $contract ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
|
||||||
];
|
];
|
||||||
$summaries['contract']['total_rows']++;
|
$summaries['contract']['total_rows']++;
|
||||||
if (! $reference) {
|
if (! $reference) {
|
||||||
@@ -902,6 +940,11 @@ private function simulateContract(callable $val, array $summaries, array $cache,
|
|||||||
$summaries['contract']['create']++;
|
$summaries['contract']['create']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->historyImport && $contract) {
|
||||||
|
$entity['history_reuse'] = true;
|
||||||
|
$entity['message'] = 'Existing contract reused (history import)';
|
||||||
|
}
|
||||||
|
|
||||||
return [$entity, $summaries, $cache];
|
return [$entity, $summaries, $cache];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,7 +974,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
|
|||||||
'exists' => (bool) $account,
|
'exists' => (bool) $account,
|
||||||
'balance_before' => $account?->balance_amount,
|
'balance_before' => $account?->balance_amount,
|
||||||
'balance_after' => $account?->balance_amount,
|
'balance_after' => $account?->balance_amount,
|
||||||
'action' => $account ? 'update' : ($reference ? 'create' : 'skip'),
|
'action' => $account ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Direct balance override support.
|
// Direct balance override support.
|
||||||
@@ -940,7 +983,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
|
|||||||
$rawIncoming = $val('account.balance_amount')
|
$rawIncoming = $val('account.balance_amount')
|
||||||
?? $val('accounts.balance_amount')
|
?? $val('accounts.balance_amount')
|
||||||
?? $val('account.balance');
|
?? $val('account.balance');
|
||||||
if ($rawIncoming !== null && $rawIncoming !== '') {
|
if (! $this->historyImport && $rawIncoming !== null && $rawIncoming !== '') {
|
||||||
$rawStr = (string) $rawIncoming;
|
$rawStr = (string) $rawIncoming;
|
||||||
// Remove currency symbols and non numeric punctuation except , . -
|
// Remove currency symbols and non numeric punctuation except , . -
|
||||||
$clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? '';
|
$clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? '';
|
||||||
@@ -974,6 +1017,19 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
|
|||||||
$summaries['account']['create']++;
|
$summaries['account']['create']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->historyImport) {
|
||||||
|
// History imports keep balances unchanged and do not update accounts
|
||||||
|
$entity['balance_after'] = $account?->balance_amount ?? 0;
|
||||||
|
$entity['balance_before'] = $account?->balance_amount ?? 0;
|
||||||
|
if ($account) {
|
||||||
|
$entity['message'] = 'Existing account left unchanged (history import)';
|
||||||
|
} else {
|
||||||
|
$entity['balance_after'] = 0;
|
||||||
|
$entity['balance_before'] = 0;
|
||||||
|
$entity['history_zeroed'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [$entity, $summaries, $cache];
|
return [$entity, $summaries, $cache];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,6 +1266,10 @@ private function simulateGenericRoot(
|
|||||||
$reference = $val('phone.nu');
|
$reference = $val('phone.nu');
|
||||||
} elseif ($root === 'email') {
|
} elseif ($root === 'email') {
|
||||||
$reference = $val('email.value');
|
$reference = $val('email.value');
|
||||||
|
} elseif ($root === 'activity') {
|
||||||
|
$noteRef = $val('activity.note');
|
||||||
|
$dueRef = $val('activity.due_date');
|
||||||
|
$reference = $noteRef || $dueRef ? trim((string) ($dueRef ?? '')).($noteRef ? ' | '.$noteRef : '') : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1313,13 @@ private function simulateGenericRoot(
|
|||||||
$entity['description'] = $val('case_object.description') ?? null;
|
$entity['description'] = $val('case_object.description') ?? null;
|
||||||
$entity['type'] = $val('case_object.type') ?? null;
|
$entity['type'] = $val('case_object.type') ?? null;
|
||||||
break;
|
break;
|
||||||
|
case 'activity':
|
||||||
|
$entity['note'] = $val('activity.note') ?? null;
|
||||||
|
$entity['due_date'] = $val('activity.due_date') ?? null;
|
||||||
|
$entity['amount'] = $val('activity.amount') ?? null;
|
||||||
|
$entity['action_id'] = $val('activity.action_id') ?? null;
|
||||||
|
$entity['decision_id'] = $val('activity.decision_id') ?? null;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($verbose) {
|
if ($verbose) {
|
||||||
@@ -1367,6 +1434,16 @@ private function genericIdentityCandidates(string $root, callable $val): array
|
|||||||
$ids[] = 'name:'.mb_strtolower(trim((string) $name));
|
$ids[] = 'name:'.mb_strtolower(trim((string) $name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
case 'activity':
|
||||||
|
$note = $val('activity.note');
|
||||||
|
$due = $val('activity.due_date');
|
||||||
|
$contractRef = $val('contract.reference');
|
||||||
|
$ids = [];
|
||||||
|
if ($note || $due) {
|
||||||
|
$ids[] = 'activity:'.mb_strtolower(trim((string) ($note ?? ''))).'|'.mb_strtolower(trim((string) ($due ?? ''))).'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
return $ids;
|
return $ids;
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
@@ -1426,6 +1503,20 @@ private function loadExistingGenericIdentities(string $root): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'activity':
|
||||||
|
foreach (\App\Models\Activity::query()->get(['note', 'due_date', 'contract_id', 'client_case_id']) as $rec) {
|
||||||
|
$note = mb_strtolower(trim((string) ($rec->note ?? '')));
|
||||||
|
$due = $rec->due_date ? mb_strtolower(trim((string) $rec->due_date)) : '';
|
||||||
|
$contractRef = null;
|
||||||
|
if ($rec->contract_id) {
|
||||||
|
$contractRef = Contract::where('id', $rec->contract_id)->value('reference');
|
||||||
|
}
|
||||||
|
$key = 'activity:'.$note.'|'.$due.'|'.mb_strtolower(trim((string) ($contractRef ?? '')));
|
||||||
|
if (trim($key, 'activity:|') !== '') {
|
||||||
|
$set[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// swallow and return what we have
|
// swallow and return what we have
|
||||||
@@ -1730,6 +1821,8 @@ private function actionTranslations(): array
|
|||||||
'skip' => 'preskoči',
|
'skip' => 'preskoči',
|
||||||
'implicit' => 'posredno',
|
'implicit' => 'posredno',
|
||||||
'reactivate' => 'reaktiviraj',
|
'reactivate' => 'reaktiviraj',
|
||||||
|
'skipped_history' => 'preskoči (zgodovina)',
|
||||||
|
'implicit_history' => 'posredno (zgodovina)',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,4 @@
|
|||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
App\Providers\JetstreamServiceProvider::class,
|
||||||
App\Providers\ReportServiceProvider::class,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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";
|
||||||
Generated
+1
-1
@@ -11289,6 +11289,6 @@
|
|||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
'features' => [
|
'features' => [
|
||||||
// Features::termsAndPrivacyPolicy(),
|
// Features::termsAndPrivacyPolicy(),
|
||||||
// Features::profilePhotos(),
|
// Features::profilePhotos(),
|
||||||
Features::api(),
|
// Features::api(),
|
||||||
// Features::teams(['invitations' => true]),
|
// Features::teams(['invitations' => true]),
|
||||||
Features::accountDeletion(),
|
Features::accountDeletion(),
|
||||||
],
|
],
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?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('field_jobs', function (Blueprint $table) {
|
||||||
|
$table->boolean('added_activity')->default(false);
|
||||||
|
$table->timestamp('last_activity')->nullable()->default(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('field_jobs', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('last_activity');
|
||||||
|
$table->dropColumn('added_activity');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('person', function (Blueprint $table){
|
||||||
|
$table->string('employer', 125)->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person', function (Blueprint $table){
|
||||||
|
$table->dropColumn('employer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Add a generated tsvector column for fulltext search
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE person_addresses
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(address, '') || ' ' ||
|
||||||
|
coalesce(post_code, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create GIN index on the tsvector column for fast fulltext search
|
||||||
|
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('person_addresses_search_vector_idx');
|
||||||
|
$table->dropColumn('search_vector');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
<?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('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('person_addresses_search_vector_idx');
|
||||||
|
$table->dropColumn('search_vector');
|
||||||
|
|
||||||
|
|
||||||
|
$table->string('post_code', 50)->nullable()->change();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a generated tsvector column for fulltext search
|
||||||
|
DB::statement("
|
||||||
|
ALTER TABLE person_addresses
|
||||||
|
ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(address, '') || ' ' ||
|
||||||
|
coalesce(post_code, '') || ' ' ||
|
||||||
|
coalesce(city, '')
|
||||||
|
)
|
||||||
|
) STORED
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create GIN index on the tsvector column for fast fulltext search
|
||||||
|
DB::statement('CREATE INDEX person_addresses_search_vector_idx ON person_addresses USING GIN(search_vector)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('person_addresses', function (Blueprint $table) {
|
||||||
|
$table->string('post_code', 20)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ public function run(): void
|
|||||||
'key' => 'person',
|
'key' => 'person',
|
||||||
'canonical_root' => 'person',
|
'canonical_root' => 'person',
|
||||||
'label' => 'Person',
|
'label' => 'Person',
|
||||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description', 'employer'],
|
||||||
'field_aliases' => [
|
'field_aliases' => [
|
||||||
'dob' => 'birthday',
|
'dob' => 'birthday',
|
||||||
'date_of_birth' => 'birthday',
|
'date_of_birth' => 'birthday',
|
||||||
@@ -30,6 +30,7 @@ public function run(): void
|
|||||||
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
|
||||||
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
|
||||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||||
|
['pattern' => '/^(delodajalec|služba)\b/i', 'field' => 'employer']
|
||||||
],
|
],
|
||||||
'ui' => ['order' => 1],
|
'ui' => ['order' => 1],
|
||||||
],
|
],
|
||||||
@@ -175,6 +176,29 @@ public function run(): void
|
|||||||
],
|
],
|
||||||
'ui' => ['order' => 9],
|
'ui' => ['order' => 9],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'activities',
|
||||||
|
'canonical_root' => 'activity',
|
||||||
|
'label' => 'Activities',
|
||||||
|
'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'],
|
||||||
|
'field_aliases' => [
|
||||||
|
'opis' => 'note',
|
||||||
|
'datum' => 'due_date',
|
||||||
|
'rok' => 'due_date',
|
||||||
|
'znesek' => 'amount',
|
||||||
|
],
|
||||||
|
'aliases' => ['activity', 'activities', 'opravilo', 'opravila'],
|
||||||
|
'rules' => [
|
||||||
|
['pattern' => '/^(aktivnost|activity|note|opis)\b/i', 'field' => 'note'],
|
||||||
|
['pattern' => '/^(rok|due|datum|date)\b/i', 'field' => 'due_date'],
|
||||||
|
['pattern' => '/^(znesek|amount|vrednost|value)\b/i', 'field' => 'amount'],
|
||||||
|
['pattern' => '/^(akcija|action)\b/i', 'field' => 'action_id'],
|
||||||
|
['pattern' => '/^(odlocitev|odločitev|decision)\b/i', 'field' => 'decision_id'],
|
||||||
|
['pattern' => '/^(pogodba|contract)\b/i', 'field' => 'contract_id'],
|
||||||
|
['pattern' => '/^(primer|case)\b/i', 'field' => 'client_case_id'],
|
||||||
|
],
|
||||||
|
'ui' => ['order' => 10],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($defs as $d) {
|
foreach ($defs as $d) {
|
||||||
|
|||||||
@@ -155,5 +155,42 @@ public function run(): void
|
|||||||
'options' => $map['options'] ?? null,
|
'options' => $map['options'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activities linked to contracts demo
|
||||||
|
$activities = ImportTemplate::query()->firstOrCreate([
|
||||||
|
'name' => 'Activities CSV (contract linked)',
|
||||||
|
], [
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'description' => 'Activities import linked to existing contracts via reference.',
|
||||||
|
'source_type' => 'csv',
|
||||||
|
'default_record_type' => 'activity',
|
||||||
|
'sample_headers' => ['contract_reference', 'note', 'due_date', 'amount', 'action', 'decision', 'user_email'],
|
||||||
|
'is_active' => true,
|
||||||
|
'meta' => [
|
||||||
|
'delimiter' => ',',
|
||||||
|
'enclosure' => '"',
|
||||||
|
'escape' => '\\',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$activityMappings = [
|
||||||
|
['source_column' => 'contract_reference', 'target_field' => 'contract.reference', 'position' => 1],
|
||||||
|
['source_column' => 'note', 'target_field' => 'activity.note', 'position' => 2],
|
||||||
|
['source_column' => 'due_date', 'target_field' => 'activity.due_date', 'position' => 3],
|
||||||
|
['source_column' => 'amount', 'target_field' => 'activity.amount', 'position' => 4],
|
||||||
|
['source_column' => 'action', 'target_field' => 'activity.action_id', 'position' => 5],
|
||||||
|
['source_column' => 'decision', 'target_field' => 'activity.decision_id', 'position' => 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($activityMappings as $map) {
|
||||||
|
ImportTemplateMapping::firstOrCreate([
|
||||||
|
'import_template_id' => $activities->id,
|
||||||
|
'source_column' => $map['source_column'],
|
||||||
|
], [
|
||||||
|
'target_field' => $map['target_field'],
|
||||||
|
'position' => $map['position'],
|
||||||
|
'options' => $map['options'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user