production #1
29
.dockerignore
Normal file
29
.dockerignore
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.gitattributes
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
node_modules
|
||||
npm-debug.log
|
||||
vendor
|
||||
storage/app/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
bootstrap/cache/*
|
||||
public/storage
|
||||
public/hot
|
||||
*.md
|
||||
!README.md
|
||||
tests
|
||||
.phpunit.result.cache
|
||||
phpunit.xml
|
||||
docker-compose*.yml
|
||||
.editorconfig
|
||||
.styleci.yml
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
82
.env.local.example
Normal file
82
.env.local.example
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
APP_NAME="Teren App"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD=local_password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=false
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# Mail (Mailpit for local testing)
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,localhost:8080,127.0.0.1:8080
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/casesNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Vite
|
||||
VITE_DEV_SERVER_KEY=
|
||||
VITE_DEV_SERVER_CERT=
|
||||
88
.env.production.example
Normal file
88
.env.production.example
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
APP_NAME="Teren App"
|
||||
APP_ENV=production
|
||||
APP_KEY= # Generate with: php artisan key:generate
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=https://example.com # Your domain
|
||||
|
||||
APP_LOCALE=sl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=sl_SI
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
APP_MAINTENANCE_STORE=database
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=teren_app
|
||||
DB_USERNAME=teren_user
|
||||
DB_PASSWORD= # Generate a strong password
|
||||
|
||||
# Redis
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
# Cache
|
||||
CACHE_STORE=redis
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@example.com
|
||||
PGADMIN_PASSWORD= # Generate a strong password
|
||||
|
||||
# WireGuard VPN (REQUIRED - app is VPN-only)
|
||||
WG_SERVERURL=vpn.example.com # Your VPS public IP or domain
|
||||
WG_UI_PASSWORD= # Generate a strong password for WireGuard dashboard
|
||||
|
||||
# Mail (configure as needed)
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PA
|
||||
SCOUT_DRIVER=database
|
||||
SCOUT_PREFIX=
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
# Sanctum
|
||||
SANCTUM_STATEFUL_DOMAINS=example.com,www.example.com,10.13.13.1
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Vite
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# LibreOffice for document previews (Docker container path)
|
||||
LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
|
||||
# Storage configuration for generated previews
|
||||
FILES_PREVIEW_DISK=public
|
||||
FILES_PREVIEW_BASE=previews/cases
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
36
.github/copilot-instructions.md
vendored
36
.github/copilot-instructions.md
vendored
|
|
@ -22,7 +22,7 @@ ## Foundational Context
|
|||
- pestphp/pest (PEST) - v3
|
||||
- phpunit/phpunit (PHPUNIT) - v11
|
||||
- @inertiajs/vue3 (INERTIA) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v3
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- vue (VUE) - v3
|
||||
|
||||
|
||||
|
|
@ -359,11 +359,39 @@ ### Dark Mode
|
|||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v3 rules ===
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 3
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
|
|
|||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -19,3 +19,23 @@ yarn-error.log
|
|||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/shadcn-vue
|
||||
|
||||
# Development/Testing Scripts
|
||||
check-*.php
|
||||
test-*.php
|
||||
fix-*.php
|
||||
clean-*.php
|
||||
mark-*.php
|
||||
|
||||
# Development Documentation
|
||||
IMPORT_*.md
|
||||
V2_*.md
|
||||
REPORTS_*.md
|
||||
DEDUPLICATION_*.md
|
||||
|
||||
# Docker Local Testing
|
||||
docker-compose.local.yaml
|
||||
docker-compose.override.yaml
|
||||
.env.local
|
||||
.env.docker
|
||||
654
DEDUPLICATION_PLAN_V2.md
Normal file
654
DEDUPLICATION_PLAN_V2.md
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
# V2 Deduplication Implementation Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, ImportServiceV2 allows duplicate Person records and related entities when:
|
||||
1. A ClientCase with the same `client_ref` already exists in the database
|
||||
2. A Contract with the same `reference` already exists for the client
|
||||
3. Person data is present in the import row
|
||||
|
||||
This causes data duplication because V2 doesn't check for existing entities before creating Person and related entities (addresses, phones, emails, activities).
|
||||
|
||||
## V1 Deduplication Strategy (Analysis)
|
||||
|
||||
### V1 Person Resolution Order (Lines 913-1015)
|
||||
V1 follows this hierarchical lookup before creating a new Person:
|
||||
|
||||
1. **Contract Reference Lookup** (Lines 913-922)
|
||||
- If contract.reference exists → Find existing Contract → Get ClientCase → Get Person
|
||||
- Prevents creating new Person when Contract already exists
|
||||
|
||||
2. **Account Result Derivation** (Lines 924-936)
|
||||
- If Account processing resolved/created a Contract → Get ClientCase → Get Person
|
||||
|
||||
3. **ClientCase.client_ref Lookup** (Lines 937-945)
|
||||
- If client_ref exists → Find ClientCase by (client_id, client_ref) → Get Person
|
||||
- Prevents creating new Person when ClientCase already exists
|
||||
|
||||
4. **Contact Values Lookup** (Lines 949-964)
|
||||
- Check Email.value → Get Person
|
||||
- Check PersonPhone.nu → Get Person
|
||||
- Check PersonAddress.address → Get Person
|
||||
|
||||
5. **Person Identifiers Lookup** (Lines 1005-1007)
|
||||
- Check tax_number, ssn, etc. via `findPersonIdByIdentifiers()`
|
||||
|
||||
6. **Create New Person** (Lines 1009-1011)
|
||||
- Only if all above fail
|
||||
|
||||
### V1 Contract Deduplication (Lines 2158-2196)
|
||||
|
||||
**Early Contract Lookup** (Lines 2168-2180):
|
||||
```php
|
||||
// Try to find existing contract EARLY by (client_id, reference)
|
||||
// across all cases to prevent duplicates
|
||||
$existing = Contract::query()->withTrashed()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->select('contracts.*')
|
||||
->first();
|
||||
```
|
||||
|
||||
**ClientCase Reuse Logic** (Lines 2214-2228):
|
||||
```php
|
||||
// If we have a client and client_ref, try to reuse existing case
|
||||
// to avoid creating extra persons
|
||||
if ($clientId && $clientRef) {
|
||||
$cc = ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->first();
|
||||
if ($cc) {
|
||||
// Reuse this case
|
||||
$clientCaseId = $cc->id;
|
||||
// If case has no person yet, set it
|
||||
if (!$cc->person_id) {
|
||||
// Find or create person and attach
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key V1 Design Principles
|
||||
|
||||
✅ **Resolution before Creation** - Always check for existing entities first
|
||||
✅ **Chain Derivation** - Contract → ClientCase → Person (reuse existing chain)
|
||||
✅ **Contact Deduplication** - Match by email/phone/address before creating
|
||||
✅ **Client-Scoped Lookups** - All queries scoped to import.client_id
|
||||
✅ **Minimal Person Creation** - Only create Person as last resort
|
||||
|
||||
## V2 Current Architecture Issues
|
||||
|
||||
### Problem Areas
|
||||
|
||||
1. **PersonHandler** (`app/Services/Import/Handlers/PersonHandler.php`)
|
||||
- Currently only deduplicates by tax_number/ssn (Lines 38-58)
|
||||
- Doesn't check if Person exists via Contract/ClientCase
|
||||
- Processes independently without context awareness
|
||||
|
||||
2. **ClientCaseHandler** (`app/Services/Import/Handlers/ClientCaseHandler.php`)
|
||||
- Correctly resolves by client_ref (Lines 16-27)
|
||||
- But doesn't prevent PersonHandler from running afterwards
|
||||
|
||||
3. **ContractHandler** (`app/Services/Import/Handlers/ContractHandler.php`)
|
||||
- Missing early resolution logic
|
||||
- Doesn't derive Person from existing Contract chain
|
||||
|
||||
4. **Processing Order Issue**
|
||||
- Current priority: Person(100) → ClientCase(95) → Contract(90)
|
||||
- Person runs BEFORE we know if ClientCase/Contract exists
|
||||
- Should be reversed: Contract → ClientCase → Person
|
||||
|
||||
## V2 Deduplication Plan
|
||||
|
||||
### Phase 1: Reverse Processing Order ✅
|
||||
|
||||
**Change entity priorities in database seeder:**
|
||||
```php
|
||||
// NEW ORDER (descending priority)
|
||||
Contract: 100
|
||||
ClientCase: 95
|
||||
Person: 90
|
||||
Email: 80
|
||||
Address: 70
|
||||
Phone: 60
|
||||
Account: 50
|
||||
Payment: 40
|
||||
Activity: 30
|
||||
```
|
||||
|
||||
**Rationale:** Process high-level entities first (Contract, ClientCase) so we can derive Person from existing chains.
|
||||
|
||||
### Phase 2: Early Resolution Service 🔧
|
||||
|
||||
**Create:** `app/Services/Import/EntityResolutionService.php`
|
||||
|
||||
This service will be called BEFORE handlers process entities:
|
||||
|
||||
```php
|
||||
class EntityResolutionService
|
||||
{
|
||||
/**
|
||||
* Resolve Person ID from import context (existing entities).
|
||||
* Returns Person ID if found, null otherwise.
|
||||
*/
|
||||
public function resolvePersonFromContext(
|
||||
Import $import,
|
||||
array $mapped,
|
||||
array $context
|
||||
): ?int {
|
||||
// 1. Check if Contract already processed
|
||||
if ($contract = $context['contract']['entity'] ?? null) {
|
||||
$personId = $this->getPersonFromContract($contract);
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
// 2. Check if ClientCase already processed
|
||||
if ($clientCase = $context['client_case']['entity'] ?? null) {
|
||||
if ($clientCase->person_id) {
|
||||
return $clientCase->person_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for existing Contract by reference
|
||||
if ($contractRef = $mapped['contract']['reference'] ?? null) {
|
||||
$personId = $this->getPersonFromContractReference(
|
||||
$import->client_id,
|
||||
$contractRef
|
||||
);
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
// 4. Check for existing ClientCase by client_ref
|
||||
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
|
||||
$personId = $this->getPersonFromClientRef(
|
||||
$import->client_id,
|
||||
$clientRef
|
||||
);
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
// 5. Check for existing Person by contact values
|
||||
$personId = $this->resolvePersonByContacts($mapped);
|
||||
if ($personId) return $personId;
|
||||
|
||||
return null; // No existing Person found
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ClientCase exists for this client_ref.
|
||||
*/
|
||||
public function clientCaseExists(int $clientId, string $clientRef): bool
|
||||
{
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Contract exists for this reference.
|
||||
*/
|
||||
public function contractExists(int $clientId, string $reference): bool
|
||||
{
|
||||
return Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function getPersonFromContract(Contract $contract): ?int
|
||||
{
|
||||
if ($contract->client_case_id) {
|
||||
return ClientCase::where('id', $contract->client_case_id)
|
||||
->value('person_id');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getPersonFromContractReference(
|
||||
?int $clientId,
|
||||
string $reference
|
||||
): ?int {
|
||||
if (!$clientId) return null;
|
||||
|
||||
$clientCaseId = Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->value('contracts.client_case_id');
|
||||
|
||||
if ($clientCaseId) {
|
||||
return ClientCase::where('id', $clientCaseId)
|
||||
->value('person_id');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getPersonFromClientRef(
|
||||
?int $clientId,
|
||||
string $clientRef
|
||||
): ?int {
|
||||
if (!$clientId) return null;
|
||||
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->value('person_id');
|
||||
}
|
||||
|
||||
private function resolvePersonByContacts(array $mapped): ?int
|
||||
{
|
||||
// Check email
|
||||
if ($email = $mapped['email']['value'] ?? $mapped['emails'][0]['value'] ?? null) {
|
||||
$personId = Email::where('value', trim($email))->value('person_id');
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
// Check phone
|
||||
if ($phone = $mapped['phone']['nu'] ?? $mapped['person_phones'][0]['nu'] ?? null) {
|
||||
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
// Check address
|
||||
if ($address = $mapped['address']['address'] ?? $mapped['person_addresses'][0]['address'] ?? null) {
|
||||
$personId = PersonAddress::where('address', trim($address))->value('person_id');
|
||||
if ($personId) return $personId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update PersonHandler 🔧
|
||||
|
||||
**Modify:** `app/Services/Import/Handlers/PersonHandler.php`
|
||||
|
||||
Add resolution service check before creating:
|
||||
|
||||
```php
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// FIRST: Check if Person already resolved from context
|
||||
$resolutionService = app(EntityResolutionService::class);
|
||||
$existingPersonId = $resolutionService->resolvePersonFromContext(
|
||||
$import,
|
||||
$mapped,
|
||||
$context
|
||||
);
|
||||
|
||||
if ($existingPersonId) {
|
||||
$existing = Person::find($existingPersonId);
|
||||
|
||||
// Update if configured
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Person already exists (found via Contract/ClientCase chain)',
|
||||
];
|
||||
}
|
||||
|
||||
// Update logic...
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
// SECOND: Try existing deduplication (tax_number, ssn)
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Update logic...
|
||||
}
|
||||
|
||||
// THIRD: Check contacts deduplication
|
||||
$personIdFromContacts = $resolutionService->resolvePersonByContacts($mapped);
|
||||
if ($personIdFromContacts) {
|
||||
$existing = Person::find($personIdFromContacts);
|
||||
// Update logic...
|
||||
}
|
||||
|
||||
// LAST: Create new Person only if all checks failed
|
||||
$payload = $this->buildPayload($mapped);
|
||||
$person = Person::create($payload);
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $person,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Update ContractHandler 🔧
|
||||
|
||||
**Modify:** `app/Services/Import/Handlers/ContractHandler.php`
|
||||
|
||||
Add early Contract lookup and ClientCase reuse:
|
||||
|
||||
```php
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
$clientId = $import->client_id;
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
|
||||
if (!$clientId || !$reference) {
|
||||
return [
|
||||
'action' => 'invalid',
|
||||
'errors' => ['Contract requires client_id and reference'],
|
||||
];
|
||||
}
|
||||
|
||||
// EARLY LOOKUP: Check if Contract exists across all cases
|
||||
$existing = Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->select('contracts.*')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// Contract exists - update or skip
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract already exists',
|
||||
];
|
||||
}
|
||||
|
||||
// Update logic...
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
// Creating new Contract - resolve/create ClientCase
|
||||
$clientCaseId = $this->resolveOrCreateClientCase($import, $mapped, $context);
|
||||
|
||||
if (!$clientCaseId) {
|
||||
return [
|
||||
'action' => 'invalid',
|
||||
'errors' => ['Unable to resolve client_case_id'],
|
||||
];
|
||||
}
|
||||
|
||||
// Create Contract
|
||||
$payload = array_merge($this->buildPayload($mapped), [
|
||||
'client_case_id' => $clientCaseId,
|
||||
]);
|
||||
|
||||
$contract = Contract::create($payload);
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $contract,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveOrCreateClientCase(
|
||||
Import $import,
|
||||
array $mapped,
|
||||
array $context
|
||||
): ?int {
|
||||
$clientId = $import->client_id;
|
||||
$clientRef = $mapped['client_ref'] ??
|
||||
$context['client_case']['entity']?->client_ref ??
|
||||
null;
|
||||
|
||||
// If ClientCase already processed in this row
|
||||
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
|
||||
return $clientCaseId;
|
||||
}
|
||||
|
||||
// Try to find existing ClientCase by client_ref
|
||||
if ($clientRef) {
|
||||
$existing = ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// REUSE existing ClientCase (and its Person)
|
||||
return $existing->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new ClientCase (Person should already be processed)
|
||||
$personId = $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (!$personId) {
|
||||
// Person wasn't in import, create minimal
|
||||
$personId = Person::create(['type_id' => 1])->id;
|
||||
}
|
||||
|
||||
$clientCase = ClientCase::create([
|
||||
'client_id' => $clientId,
|
||||
'person_id' => $personId,
|
||||
'client_ref' => $clientRef,
|
||||
]);
|
||||
|
||||
return $clientCase->id;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Update ClientCaseHandler 🔧
|
||||
|
||||
**Modify:** `app/Services/Import/Handlers/ClientCaseHandler.php`
|
||||
|
||||
Ensure it uses resolved Person from context:
|
||||
|
||||
```php
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
$clientId = $import->client_id ?? null;
|
||||
$clientRef = $mapped['client_ref'] ?? null;
|
||||
|
||||
// Get Person from context (should be processed first now)
|
||||
$personId = $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (!$clientId) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'ClientCase requires client_id',
|
||||
];
|
||||
}
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'ClientCase already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
|
||||
// Update person_id ONLY if provided and different
|
||||
if ($personId && $existing->person_id !== $personId) {
|
||||
$payload['person_id'] = $personId;
|
||||
}
|
||||
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
$existing->update($payload);
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new ClientCase
|
||||
$payload = $this->buildPayload($mapped);
|
||||
|
||||
// Attach Person if resolved
|
||||
if ($personId) {
|
||||
$payload['person_id'] = $personId;
|
||||
}
|
||||
|
||||
$payload['client_id'] = $clientId;
|
||||
|
||||
$clientCase = ClientCase::create($payload);
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $clientCase,
|
||||
'count' => 1,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Integration into ImportServiceV2 🔧
|
||||
|
||||
**Modify:** `app/Services/Import/ImportServiceV2.php`
|
||||
|
||||
Inject resolution service into processRow:
|
||||
|
||||
```php
|
||||
protected function processRow(Import $import, array $mapped, array $raw, array $context): array
|
||||
{
|
||||
$entityResults = [];
|
||||
$lastEntityType = null;
|
||||
$lastEntityId = null;
|
||||
$hasErrors = false;
|
||||
|
||||
// NEW: Add resolution service to context
|
||||
$context['resolution_service'] = app(EntityResolutionService::class);
|
||||
|
||||
// Process entities in configured priority order
|
||||
foreach ($this->entityConfigs as $root => $config) {
|
||||
// ... existing logic ...
|
||||
}
|
||||
|
||||
// ... rest of method ...
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Step 1: Update Database Priority ✅
|
||||
- [ ] Modify `database/seeders/ImportEntitiesV2Seeder.php`
|
||||
- [ ] Change priorities: Contract(100), ClientCase(95), Person(90)
|
||||
- [ ] Run seeder: `php artisan db:seed --class=ImportEntitiesV2Seeder --force`
|
||||
|
||||
### Step 2: Create EntityResolutionService 🔧
|
||||
- [ ] Create `app/Services/Import/EntityResolutionService.php`
|
||||
- [ ] Implement all resolution methods
|
||||
- [ ] Add comprehensive PHPDoc
|
||||
- [ ] Add logging for debugging
|
||||
|
||||
### Step 3: Update PersonHandler 🔧
|
||||
- [ ] Modify `process()` method to check resolution service first
|
||||
- [ ] Add contact-based deduplication
|
||||
- [ ] Ensure proper skip/update modes
|
||||
|
||||
### Step 4: Update ContractHandler 🔧
|
||||
- [ ] Add early Contract lookup (client_id + reference)
|
||||
- [ ] Implement ClientCase reuse logic
|
||||
- [ ] Prevent duplicate Contract creation
|
||||
|
||||
### Step 5: Update ClientCaseHandler 🔧
|
||||
- [ ] Use Person from context
|
||||
- [ ] Handle person_id properly on updates
|
||||
- [ ] Maintain existing deduplication
|
||||
|
||||
### Step 6: Integrate into ImportServiceV2 🔧
|
||||
- [ ] Add resolution service to context
|
||||
- [ ] Test with existing imports
|
||||
|
||||
### Step 7: Testing 🧪
|
||||
- [ ] Test import with existing client_ref
|
||||
- [ ] Test import with existing contract reference
|
||||
- [ ] Test import with existing email/phone
|
||||
- [ ] Test mixed scenarios
|
||||
- [ ] Verify no duplicate Persons created
|
||||
- [ ] Check all related entities linked correctly
|
||||
|
||||
## Expected Behavior After Implementation
|
||||
|
||||
### Scenario 1: Existing ClientCase by client_ref
|
||||
```
|
||||
Import Row: {client_ref: "B387055", name: "John", email: "john@test.com"}
|
||||
|
||||
Before V2 Fix:
|
||||
❌ Creates new Person (duplicate)
|
||||
❌ Creates new Email (duplicate)
|
||||
✅ Reuses ClientCase
|
||||
|
||||
After V2 Fix:
|
||||
✅ Finds existing Person via ClientCase
|
||||
✅ Updates Person if needed
|
||||
✅ Reuses ClientCase
|
||||
✅ Reuses/updates Email
|
||||
```
|
||||
|
||||
### Scenario 2: Existing Contract by reference
|
||||
```
|
||||
Import Row: {contract.reference: "REF-123", person.name: "Jane"}
|
||||
|
||||
Before V2 Fix:
|
||||
❌ Creates new Person (duplicate)
|
||||
❌ Contract might be created or updated
|
||||
❌ New Person not linked to existing ClientCase
|
||||
|
||||
After V2 Fix:
|
||||
✅ Finds existing Contract
|
||||
✅ Derives Person from Contract → ClientCase chain
|
||||
✅ Updates Person if needed
|
||||
✅ No duplicate Person created
|
||||
```
|
||||
|
||||
### Scenario 3: New Import (no existing entities)
|
||||
```
|
||||
Import Row: {client_ref: "NEW-001", name: "Bob"}
|
||||
|
||||
Behavior:
|
||||
✅ Creates new Person
|
||||
✅ Creates new ClientCase
|
||||
✅ Links correctly
|
||||
✅ No duplicates
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **No duplicate Persons** when client_ref or contract reference exists
|
||||
✅ **Proper entity linking** - all entities connected to correct Person
|
||||
✅ **Backward compatibility** - existing imports still work
|
||||
✅ **Skip mode respected** - handlers honor skip/update modes
|
||||
✅ **Contact deduplication** - matches by email/phone/address
|
||||
✅ **Performance maintained** - no significant slowdown
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues occur:
|
||||
1. Revert priority changes in database
|
||||
2. Disable EntityResolutionService by commenting out context injection
|
||||
3. Fall back to original handler behavior
|
||||
4. Investigate and fix issues
|
||||
5. Re-implement with fixes
|
||||
|
||||
## Notes
|
||||
|
||||
- This plan maintains V2's modular handler architecture
|
||||
- Resolution logic is centralized in EntityResolutionService
|
||||
- Handlers remain independent but context-aware
|
||||
- Similar to V1 but cleaner separation of concerns
|
||||
- Can be implemented incrementally (phase by phase)
|
||||
- Each phase can be tested independently
|
||||
1045
DEPLOYMENT_GUIDE.md
Normal file
1045
DEPLOYMENT_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
83
Dockerfile
Normal file
83
Dockerfile
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
ARG PHP_VERSION=8.4
|
||||
FROM php:${PHP_VERSION}-fpm-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
supervisor \
|
||||
nginx \
|
||||
postgresql-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
libwebp-dev \
|
||||
oniguruma-dev \
|
||||
libxml2-dev \
|
||||
linux-headers \
|
||||
${PHPIZE_DEPS}
|
||||
|
||||
# Configure and install PHP extensions
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
mbstring \
|
||||
exif \
|
||||
pcntl \
|
||||
bcmath \
|
||||
gd \
|
||||
opcache
|
||||
|
||||
# Install Redis extension via PECL
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Install LibreOffice from community repository
|
||||
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
libreoffice-common \
|
||||
libreoffice-writer \
|
||||
libreoffice-calc
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Create system user to run Composer and Artisan Commands
|
||||
RUN addgroup -g 1000 -S www && \
|
||||
adduser -u 1000 -S www -G www
|
||||
|
||||
# Copy application files (will be overridden by volume mount in local development)
|
||||
COPY --chown=www:www . /var/www
|
||||
|
||||
# Copy supervisor configuration
|
||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY docker/supervisor/conf.d /etc/supervisor/conf.d
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www:www /var/www \
|
||||
&& chmod -R 755 /var/www/storage \
|
||||
&& chmod -R 755 /var/www/bootstrap/cache
|
||||
|
||||
# PHP Configuration for production
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# Copy PHP custom configuration
|
||||
COPY docker/php/custom.ini $PHP_INI_DIR/conf.d/custom.ini
|
||||
|
||||
# Configure PHP-FPM to listen on all interfaces (0.0.0.0) instead of just localhost
|
||||
# This is needed for nginx running in a separate container to reach PHP-FPM
|
||||
RUN sed -i 's/listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Expose port 9000 for PHP-FPM
|
||||
EXPOSE 9000
|
||||
|
||||
# Create directories for supervisor logs
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
|
||||
# Start supervisor (which will manage both PHP-FPM and Laravel queue workers)
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
343
LOCAL_TESTING_GUIDE.md
Normal file
343
LOCAL_TESTING_GUIDE.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
# Local Testing Guide - Windows/Mac/Linux
|
||||
|
||||
This guide helps you test the Teren App Docker setup on your local machine without WireGuard VPN.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- Git
|
||||
- 8GB RAM recommended
|
||||
- Ports available: 8080, 5433 (PostgreSQL), 5050, 6379, 9000, 8025, 1025
|
||||
- **Note:** If you have local PostgreSQL on port 5432, the Docker container uses 5433 instead
|
||||
|
||||
### 2. Setup
|
||||
|
||||
```bash
|
||||
# Clone repository (if not already)
|
||||
git clone YOUR_GITEA_URL
|
||||
cd Teren-app
|
||||
|
||||
# Copy local environment file
|
||||
cp .env.local.example .env
|
||||
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Wait for services to start (30 seconds)
|
||||
timeout 30
|
||||
|
||||
# Generate application key
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
|
||||
# Seed database (optional)
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
|
||||
# Install frontend dependencies (if needed)
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Access Services
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| **Laravel App** | http://localhost:8080 | - |
|
||||
| **Portainer** | http://localhost:9000 | Set on first visit |
|
||||
| **pgAdmin** | http://localhost:5050 | admin@local.dev / admin |
|
||||
| **Mailpit** | http://localhost:8025 | - |
|
||||
| **PostgreSQL** | localhost:5433 | teren_user / local_password |
|
||||
| **Redis** | localhost:6379 | - |
|
||||
|
||||
**Note:** PostgreSQL uses port 5433 to avoid conflicts with any local PostgreSQL installation.
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker Compose Commands
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yaml up -d
|
||||
|
||||
# Stop all services
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yaml logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose -f docker-compose.local.yaml logs -f app
|
||||
|
||||
# Restart a service
|
||||
docker compose -f docker-compose.local.yaml restart app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Stop and remove everything (including volumes)
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
```
|
||||
|
||||
### Laravel Commands
|
||||
|
||||
```bash
|
||||
# Run artisan commands
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan [command]
|
||||
|
||||
# Examples:
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan db:seed
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan cache:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan config:clear
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan queue:work
|
||||
|
||||
# Run tests
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan test
|
||||
|
||||
# Access container shell
|
||||
docker compose -f docker-compose.local.yaml exec app sh
|
||||
|
||||
# Run Composer commands
|
||||
docker compose -f docker-compose.local.yaml exec app composer install
|
||||
docker compose -f docker-compose.local.yaml exec app composer update
|
||||
```
|
||||
|
||||
### Database Commands
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL (from inside container)
|
||||
docker compose -f docker-compose.local.yaml exec postgres psql -U teren_user -d teren_app
|
||||
|
||||
# Connect from Windows host
|
||||
psql -h localhost -p 5433 -U teren_user -d teren_app
|
||||
|
||||
# Backup database
|
||||
docker compose -f docker-compose.local.yaml exec postgres pg_dump -U teren_user teren_app > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker compose -f docker-compose.local.yaml exec -T postgres psql -U teren_user teren_app < backup.sql
|
||||
|
||||
# Reset database
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## pgAdmin Setup
|
||||
|
||||
1. Open http://localhost:5050
|
||||
2. Login: `admin@local.dev` / `admin`
|
||||
3. Add Server:
|
||||
- **General Tab:**
|
||||
- Name: `Teren Local`
|
||||
- **Connection Tab:**
|
||||
- Host: `postgres`
|
||||
- Port: `5432`
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Passwo
|
||||
|
||||
**External Connection:** To connect from your Windows machine (e.g., DBeaver, pgAdmin desktop), use:
|
||||
- Host: `localhost`
|
||||
- Port: `5433` (not 5432)
|
||||
- Database: `teren_app`
|
||||
- Username: `teren_user`
|
||||
- Password: `local_password`rd: `local_password`
|
||||
4. Click Save
|
||||
|
||||
## Mailpit - Email Testing
|
||||
|
||||
All emails sent by the application are caught by Mailpit.
|
||||
|
||||
- Access: http://localhost:8025
|
||||
- View all emails in the web interface
|
||||
- Test email sending:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan tinker
|
||||
# In tinker:
|
||||
Mail::raw('Test email', function($msg) {
|
||||
$msg->to('test@example.com')->subject('Test');
|
||||
});
|
||||
```
|
||||
|
||||
## Portainer Setup
|
||||
|
||||
1. Open http://localhost:9000
|
||||
2. On first visit, create admin account
|
||||
3. Select "Docker" environment
|
||||
4. Click "Connect"
|
||||
|
||||
Use Portainer to:
|
||||
- View and manage containers
|
||||
- Check logs
|
||||
- Execute commands in containers
|
||||
- Monitor resource usage
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Frontend Development
|
||||
|
||||
The local setup supports live reloading:
|
||||
|
||||
```bash
|
||||
# Run Vite dev server (outside Docker)
|
||||
npm run dev
|
||||
|
||||
# Or inside Docker
|
||||
docker compose -f docker-compose.local.yaml exec app npm run dev
|
||||
```
|
||||
|
||||
Access: http://localhost:8080
|
||||
|
||||
### Code Changes
|
||||
|
||||
All code changes are automatically reflected because the source code is mounted as a volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www # Live code mounting
|
||||
```
|
||||
|
||||
### Queue Workers
|
||||
|
||||
Queue workers are running via Supervisor inside the container. To restart:
|
||||
|
||||
```bash
|
||||
# Restart queue workers
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl restart all
|
||||
|
||||
# Check status
|
||||
docker compose -f docker-compose.local.yaml exec app supervisorctl status
|
||||
|
||||
# View worker logs
|
||||
docker compose -f docker-compose.local.yaml exec app tail -f storage/logs/worker.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If you get "port is already allocated" error:
|
||||
|
||||
```bash
|
||||
# Windows - Find process using port
|
||||
netstat -ano | findstr :8080
|
||||
|
||||
# Kill process by PID
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
# Or change port in docker-compose.local.yaml
|
||||
ports:
|
||||
- "8081:80" # Change 8080 to 8081
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs app
|
||||
|
||||
# Rebuild containers
|
||||
docker compose -f docker-compose.local.yaml down
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
```
|
||||
|
||||
### Permission Errors (Linux/Mac)
|
||||
|
||||
```bash
|
||||
# Fix storage permissions
|
||||
docker compose -f docker-compose.local.yaml exec app chown -R www:www /var/www/storage
|
||||
docker compose -f docker-compose.local.yaml exec app chmod -R 775 /var/www/storage
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker compose -f docker-compose.local.yaml ps postgres
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.local.yaml logs postgres
|
||||
|
||||
# Restart PostgreSQL
|
||||
docker compose -f docker-compose.local.yaml restart postgres
|
||||
```
|
||||
|
||||
### Clear All Data and Start Fresh
|
||||
|
||||
```bash
|
||||
# Stop and remove everything
|
||||
docker compose -f docker-compose.local.yaml down -v
|
||||
|
||||
# Remove images
|
||||
docker compose -f docker-compose.local.yaml down --rmi all
|
||||
|
||||
# Start fresh
|
||||
docker compose -f docker-compose.local.yaml up -d --build
|
||||
|
||||
# Re-initialize
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan key:generate
|
||||
docker compose -f docker-compose.local.yaml exec app php artisan migrate:fresh --seed
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Windows Performance
|
||||
|
||||
If using WSL2 (recommended):
|
||||
|
||||
1. Clone repo inside WSL2 filesystem, not Windows filesystem
|
||||
2. Use WSL2 terminal for commands
|
||||
3. Enable WSL2 integration in Docker Desktop settings
|
||||
|
||||
### Mac Performance
|
||||
|
||||
1. Enable VirtioFS in Docker Desktop settings
|
||||
2. Disable file watching if not needed
|
||||
3. Use Docker volumes for vendor directories:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- /var/www/vendor # Anonymous volume for vendor
|
||||
- /var/www/node_modules # Anonymous volume for node_modules
|
||||
```
|
||||
|
||||
## Testing Production-Like Setup
|
||||
|
||||
To test the production VPN setup locally (advanced):
|
||||
|
||||
1. Enable WireGuard in `docker-compose.yaml.example`
|
||||
2. Change all `10.13.13.1` bindings to `127.0.0.1`
|
||||
3. Test SSL with self-signed certificates
|
||||
|
||||
## Differences from Production
|
||||
|
||||
| Feature | Local | Production |
|
||||
|---------|-------|------------|
|
||||
| **VPN** | No VPN | WireGuard required |
|
||||
| **Port** | :8080 | :80/:443 |
|
||||
| **SSL** | No SSL | Let's Encrypt |
|
||||
| **Debug** | Enabled | Disabled |
|
||||
| **Emails** | Mailpit | Real SMTP |
|
||||
| **Logs** | Debug level | Error level |
|
||||
| **Code** | Live mount | Built into image |
|
||||
|
||||
## Next Steps
|
||||
|
||||
After testing locally:
|
||||
|
||||
1. Review `docker-compose.yaml.example` for production
|
||||
2. Follow `DEPLOYMENT_GUIDE.md` for VPS setup
|
||||
3. Configure WireGuard VPN
|
||||
4. Deploy to production
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Laravel Docker Documentation](https://laravel.com/docs/deployment)
|
||||
- [PostgreSQL Docker](https://hub.docker.com/_/postgres)
|
||||
- [Mailpit Documentation](https://github.com/axllent/mailpit)
|
||||
159
QUICK_START_VPN.md
Normal file
159
QUICK_START_VPN.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Quick Start: VPN-Only Access Setup
|
||||
|
||||
⚠️ **IMPORTANT:** This application is configured for VPN-ONLY access. It will NOT be publicly accessible.
|
||||
|
||||
## Quick Setup Steps
|
||||
|
||||
### 1. Install Docker (on VPS)
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
### 2. Clone & Configure
|
||||
```bash
|
||||
git clone YOUR_GITEA_REPO/Teren-app.git
|
||||
cd Teren-app
|
||||
cp docker-compose.yaml.example docker-compose.yaml
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
### 3. Edit Configuration
|
||||
```bash
|
||||
vim .env
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
- `WG_SERVERURL` = Your VPS public IP (e.g., `123.45.67.89`)
|
||||
- `WG_UI_PASSWORD` = Strong password for WireGuard dashboard
|
||||
- `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` = Database credentials
|
||||
- `PGADMIN_EMAIL`, `PGADMIN_PASSWORD` = pgAdmin credentials
|
||||
|
||||
### 4. Start WireGuard First
|
||||
```bash
|
||||
# Enable kernel module
|
||||
sudo modprobe wireguard
|
||||
|
||||
# Start WireGuard
|
||||
docker compose up -d wireguard
|
||||
|
||||
# Wait 10 seconds
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
docker compose logs wireguard
|
||||
```
|
||||
|
||||
### 5. Setup VPN Client (on your laptop/desktop)
|
||||
|
||||
**Access WireGuard Dashboard:** `http://YOUR_VPS_IP:51821`
|
||||
|
||||
1. Login with password from step 3
|
||||
2. Click "New Client"
|
||||
3. Name it (e.g., "MyLaptop")
|
||||
4. Download config or scan QR code
|
||||
|
||||
**Install WireGuard Client:**
|
||||
- Windows: https://www.wireguard.com/install/
|
||||
- macOS: App Store
|
||||
- Linux: `sudo apt install wireguard`
|
||||
- Mobile: App Store / Play Store
|
||||
|
||||
**Import config and CONNECT**
|
||||
|
||||
### 6. Verify VPN Works
|
||||
```bash
|
||||
# From your local machine (while connected to VPN)
|
||||
ping 10.13.13.1
|
||||
```
|
||||
|
||||
Should get responses ✅
|
||||
|
||||
### 7. Secure WireGuard Dashboard
|
||||
|
||||
Edit `docker-compose.yaml`:
|
||||
```yaml
|
||||
# Find wireguard service, change:
|
||||
ports:
|
||||
- "51821:51821/tcp"
|
||||
# To:
|
||||
ports:
|
||||
- "10.13.13.1:51821:51821/tcp"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d wireguard
|
||||
```
|
||||
|
||||
### 8. Start All Services
|
||||
```bash
|
||||
# Make sure you're connected to VPN!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 9. Initialize Application
|
||||
```bash
|
||||
# Generate app key
|
||||
docker compose exec app php artisan key:generate
|
||||
|
||||
# Run migrations
|
||||
docker compose exec app php artisan migrate --force
|
||||
|
||||
# Cache config
|
||||
docker compose exec app php artisan config:cache
|
||||
```
|
||||
|
||||
### 10. Access Your Services
|
||||
|
||||
**While connected to VPN:**
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| **Laravel App** | http://10.13.13.1 |
|
||||
| **Portainer** | http://10.13.13.1:9000 |
|
||||
| **pgAdmin** | http://10.13.13.1:5050 |
|
||||
| **WireGuard UI** | http://10.13.13.1:51821 |
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 51820/udp # WireGuard VPN
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
**That's it!** ✅
|
||||
|
||||
## Adding More VPN Clients
|
||||
|
||||
1. Connect to VPN
|
||||
2. Open: `http://10.13.13.1:51821`
|
||||
3. Click "New Client"
|
||||
4. Download config
|
||||
5. Import on new device
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Can't connect to VPN:**
|
||||
```bash
|
||||
docker compose logs wireguard
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
**Can't access app after VPN connection:**
|
||||
```bash
|
||||
ping 10.13.13.1
|
||||
docker compose ps
|
||||
docker compose logs nginx
|
||||
```
|
||||
|
||||
**Check which ports are exposed:**
|
||||
```bash
|
||||
docker compose ps
|
||||
sudo netstat -tulpn | grep 10.13.13.1
|
||||
```
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See `DEPLOYMENT_GUIDE.md` for complete setup instructions, SSL configuration, automated deployments, and troubleshooting.
|
||||
398
REPORTS_BACKEND_REWORK_PLAN.md
Normal file
398
REPORTS_BACKEND_REWORK_PLAN.md
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
# Reports Backend Rework Plan
|
||||
|
||||
## Overview
|
||||
Transform the current hardcoded report system into a flexible, database-driven architecture that allows dynamic report configuration without code changes.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Existing Structure
|
||||
- **Report Classes**: Individual PHP classes (`ActiveContractsReport`, `ActivitiesPerPeriodReport`, etc.)
|
||||
- **Registry Pattern**: `ReportRegistry` stores report instances in memory
|
||||
- **Service Provider**: `ReportServiceProvider` registers reports at boot time
|
||||
- **Base Class**: `BaseEloquentReport` provides common pagination logic
|
||||
- **Contract Interface**: `Report` interface defines required methods (`slug`, `name`, `description`, `inputs`, `columns`, `query`)
|
||||
- **Controller**: `ReportController` handles index, show, data, export routes
|
||||
|
||||
### Current Features
|
||||
1. **Report Definition**: Each report defines:
|
||||
- Slug (unique identifier)
|
||||
- Name & Description
|
||||
- Input parameters (filters)
|
||||
- Column definitions
|
||||
- Eloquent query builder
|
||||
2. **Filter Types**: `date`, `string`, `select:client`, etc.
|
||||
3. **Export**: PDF and CSV export functionality
|
||||
4. **Pagination**: Server-side pagination support
|
||||
|
||||
## Proposed New Architecture
|
||||
|
||||
### 1. Database Schema
|
||||
|
||||
#### `reports` Table
|
||||
```sql
|
||||
CREATE TABLE reports (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
category VARCHAR(100) NULL, -- e.g., 'contracts', 'activities', 'financial'
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
order INT DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
INDEX idx_slug (slug),
|
||||
INDEX idx_enabled_order (enabled, order)
|
||||
);
|
||||
```
|
||||
|
||||
#### `report_entities` Table
|
||||
Defines which database entities (models) the report queries.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_entities (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
model_class VARCHAR(255) NOT NULL, -- e.g., 'App\Models\Contract'
|
||||
alias VARCHAR(50) NULL, -- table alias for joins
|
||||
join_type ENUM('base', 'join', 'leftJoin', 'rightJoin') DEFAULT 'base',
|
||||
join_first VARCHAR(100) NULL, -- first column for join
|
||||
join_operator VARCHAR(10) NULL, -- =, !=, etc.
|
||||
join_second VARCHAR(100) NULL, -- second column for join
|
||||
order INT DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
|
||||
INDEX idx_report_order (report_id, order)
|
||||
);
|
||||
```
|
||||
|
||||
#### `report_columns` Table
|
||||
Defines selectable columns and their presentation.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_columns (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
key VARCHAR(100) NOT NULL, -- column identifier
|
||||
label VARCHAR(255) NOT NULL, -- display label
|
||||
type VARCHAR(50) DEFAULT 'string', -- string, number, date, boolean, currency
|
||||
expression TEXT NOT NULL, -- SQL expression or column path
|
||||
sortable BOOLEAN DEFAULT TRUE,
|
||||
visible BOOLEAN DEFAULT TRUE,
|
||||
order INT DEFAULT 0,
|
||||
format_options JSON NULL, -- { "decimals": 2, "prefix": "$" }
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
|
||||
INDEX idx_report_order (report_id, order)
|
||||
);
|
||||
```
|
||||
|
||||
#### `report_filters` Table
|
||||
Defines available filter parameters.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_filters (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
key VARCHAR(100) NOT NULL, -- filter identifier
|
||||
label VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL, -- date, string, select, multiselect, number, boolean
|
||||
nullable BOOLEAN DEFAULT TRUE,
|
||||
default_value TEXT NULL,
|
||||
options JSON NULL, -- For select/multiselect: [{"label":"...", "value":"..."}]
|
||||
data_source VARCHAR(255) NULL, -- e.g., 'clients', 'segments' for dynamic selects
|
||||
order INT DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
|
||||
INDEX idx_report_order (report_id, order)
|
||||
);
|
||||
```
|
||||
|
||||
#### `report_conditions` Table
|
||||
Defines WHERE clause conditions (rules) for filtering data.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_conditions (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
column VARCHAR(255) NOT NULL, -- e.g., 'contracts.start_date'
|
||||
operator VARCHAR(50) NOT NULL, -- =, !=, >, <, >=, <=, LIKE, IN, BETWEEN, IS NULL, etc.
|
||||
value_type VARCHAR(50) NOT NULL, -- static, filter, expression
|
||||
value TEXT NULL, -- static value or expression
|
||||
filter_key VARCHAR(100) NULL, -- references report_filters.key
|
||||
logical_operator ENUM('AND', 'OR') DEFAULT 'AND',
|
||||
group_id INT NULL, -- for grouping conditions (AND within group, OR between groups)
|
||||
order INT DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
|
||||
INDEX idx_report_group (report_id, group_id, order)
|
||||
);
|
||||
```
|
||||
|
||||
#### `report_orders` Table
|
||||
Defines default ORDER BY clauses.
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_orders (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
report_id BIGINT UNSIGNED NOT NULL,
|
||||
column VARCHAR(255) NOT NULL,
|
||||
direction ENUM('ASC', 'DESC') DEFAULT 'ASC',
|
||||
order INT DEFAULT 0,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
|
||||
INDEX idx_report_order (report_id, order)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Model Structure
|
||||
|
||||
#### Report Model
|
||||
```php
|
||||
class Report extends Model
|
||||
{
|
||||
protected $fillable = ['slug', 'name', 'description', 'category', 'enabled', 'order'];
|
||||
protected $casts = ['enabled' => 'boolean'];
|
||||
|
||||
public function entities(): HasMany { return $this->hasMany(ReportEntity::class); }
|
||||
public function columns(): HasMany { return $this->hasMany(ReportColumn::class); }
|
||||
public function filters(): HasMany { return $this->hasMany(ReportFilter::class); }
|
||||
public function conditions(): HasMany { return $this->hasMany(ReportCondition::class); }
|
||||
public function orders(): HasMany { return $this->hasMany(ReportOrder::class); }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Query Builder Service
|
||||
|
||||
Create `ReportQueryBuilder` service to dynamically construct queries:
|
||||
|
||||
```php
|
||||
class ReportQueryBuilder
|
||||
{
|
||||
public function build(Report $report, array $filters = []): Builder
|
||||
{
|
||||
// 1. Start with base model query
|
||||
// 2. Apply joins from report_entities
|
||||
// 3. Select columns from report_columns
|
||||
// 4. Apply conditions from report_conditions
|
||||
// 5. Apply filter values to parameterized conditions
|
||||
// 6. Apply ORDER BY from report_orders
|
||||
// 7. Return Builder instance
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backward Compatibility Layer
|
||||
|
||||
Keep existing Report classes but load from database:
|
||||
|
||||
```php
|
||||
class DatabaseReport extends BaseEloquentReport implements Report
|
||||
{
|
||||
public function __construct(protected Report $dbReport) {}
|
||||
|
||||
public function slug(): string { return $this->dbReport->slug; }
|
||||
public function name(): string { return $this->dbReport->name; }
|
||||
public function description(): ?string { return $this->dbReport->description; }
|
||||
|
||||
public function inputs(): array {
|
||||
return $this->dbReport->filters()
|
||||
->orderBy('order')
|
||||
->get()
|
||||
->map(fn($f) => [
|
||||
'key' => $f->key,
|
||||
'type' => $f->type,
|
||||
'label' => $f->label,
|
||||
'nullable' => $f->nullable,
|
||||
'default' => $f->default_value,
|
||||
'options' => $f->options,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function columns(): array {
|
||||
return $this->dbReport->columns()
|
||||
->where('visible', true)
|
||||
->orderBy('order')
|
||||
->get()
|
||||
->map(fn($c) => ['key' => $c->key, 'label' => $c->label])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function query(array $filters): Builder {
|
||||
return app(ReportQueryBuilder::class)->build($this->dbReport, $filters);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Migration Strategy
|
||||
|
||||
#### Phase 1: Database Setup
|
||||
1. Create migrations for all new tables
|
||||
2. Create models with relationships
|
||||
3. Create seeders to migrate existing hardcoded reports
|
||||
|
||||
#### Phase 2: Service Layer
|
||||
1. Build `ReportQueryBuilder` service
|
||||
2. Build `DatabaseReport` adapter class
|
||||
3. Update `ReportRegistry` to load from database
|
||||
4. Create report management CRUD (admin UI)
|
||||
|
||||
#### Phase 3: Testing & Validation
|
||||
1. Unit tests for query builder
|
||||
2. Integration tests comparing old vs new results
|
||||
3. Performance benchmarks
|
||||
4. Export functionality validation
|
||||
|
||||
#### Phase 4: Migration Seeder
|
||||
1. Create seeder that converts each hardcoded report into database records
|
||||
2. Example for `ActiveContractsReport`:
|
||||
```php
|
||||
$report = Report::create([
|
||||
'slug' => 'active-contracts',
|
||||
'name' => 'Aktivne pogodbe',
|
||||
'description' => 'Pogodbe, ki so aktivne...',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
// Add entities (joins)
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\Models\Contract',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\Models\ClientCase',
|
||||
'join_type' => 'join',
|
||||
'join_first' => 'contracts.client_case_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'client_cases.id',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Add columns
|
||||
$report->columns()->create([
|
||||
'key' => 'contract_reference',
|
||||
'label' => 'Pogodba',
|
||||
'expression' => 'contracts.reference',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Add filters
|
||||
$report->filters()->create([
|
||||
'key' => 'client_uuid',
|
||||
'label' => 'Stranka',
|
||||
'type' => 'select',
|
||||
'data_source' => 'clients',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Add conditions
|
||||
$report->conditions()->create([
|
||||
'column' => 'contracts.start_date',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'expression',
|
||||
'value' => 'CURRENT_DATE',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
]);
|
||||
```
|
||||
|
||||
#### Phase 5: Remove Old Report System
|
||||
Once the new database-driven system is validated and working:
|
||||
|
||||
1. **Delete Hardcoded Report Classes**:
|
||||
- Remove `app/Reports/ActiveContractsReport.php`
|
||||
- Remove `app/Reports/ActivitiesPerPeriodReport.php`
|
||||
- Remove `app/Reports/ActionsDecisionsCountReport.php`
|
||||
- Remove `app/Reports/DecisionsCountReport.php`
|
||||
- Remove `app/Reports/FieldJobsCompletedReport.php`
|
||||
- Remove `app/Reports/SegmentActivityCountsReport.php`
|
||||
|
||||
2. **Remove Base Classes/Interfaces** (if no longer needed):
|
||||
- Remove `app/Reports/BaseEloquentReport.php`
|
||||
- Remove `app/Reports/Contracts/Report.php` interface
|
||||
|
||||
3. **Remove/Update Service Provider**:
|
||||
- Remove `app/Providers/ReportServiceProvider.php`
|
||||
- Or update it to only load reports from database
|
||||
|
||||
4. **Update ReportRegistry**:
|
||||
- Modify to load from database instead of manual registration
|
||||
- Remove all hardcoded `register()` calls
|
||||
|
||||
5. **Clean Up Config**:
|
||||
- Remove any report-specific configuration files if they exist
|
||||
- Update `bootstrap/providers.php` to remove ReportServiceProvider
|
||||
|
||||
6. **Documentation Cleanup**:
|
||||
- Update any documentation referencing old report classes
|
||||
- Add migration guide for future report creation
|
||||
|
||||
### 6. Admin UI for Report Management
|
||||
|
||||
Create CRUD interface at `Settings/Reports/*`:
|
||||
- **Index**: List all reports with enable/disable toggle
|
||||
- **Create**: Wizard-style form for building new reports
|
||||
- **Edit**: Visual query builder interface
|
||||
- **Test**: Preview report results
|
||||
- **Clone**: Duplicate existing report as starting point
|
||||
|
||||
### 7. Advanced Features (Future)
|
||||
|
||||
1. **Calculated Fields**: Allow expressions like `(column_a + column_b) / 2`
|
||||
2. **Aggregations**: Support SUM, AVG, COUNT, MIN, MAX
|
||||
3. **Subqueries**: Define subquery relationships
|
||||
4. **Report Templates**: Predefined report structures
|
||||
5. **Scheduled Reports**: Email reports on schedule
|
||||
6. **Report Sharing**: Share reports with specific users/roles
|
||||
7. **Version History**: Track report definition changes
|
||||
8. **Report Permissions**: Control who can view/edit reports
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No Code Changes**: Add/modify reports through UI
|
||||
2. **Flexibility**: Non-developers can create reports
|
||||
3. **Consistency**: All reports follow same structure
|
||||
4. **Maintainability**: Centralized report logic
|
||||
5. **Reusability**: Share entities, filters, conditions
|
||||
6. **Version Control**: Track changes to report definitions
|
||||
7. **Performance**: Optimize query builder once
|
||||
8. **Export**: Works with any report automatically
|
||||
|
||||
## Risks & Considerations
|
||||
|
||||
1. **Complexity**: Query builder must handle diverse SQL patterns
|
||||
2. **Performance**: Dynamic query building overhead
|
||||
3. **Security**: SQL injection risks with user input
|
||||
4. **Learning Curve**: Team needs to understand new system
|
||||
5. **Testing**: Comprehensive test suite required
|
||||
6. **Migration**: Convert all existing reports correctly
|
||||
7. **Edge Cases**: Complex queries may be difficult to represent
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1 (Database)**: 2-3 days
|
||||
- **Phase 2 (Services)**: 4-5 days
|
||||
- **Phase 3 (Testing)**: 2-3 days
|
||||
- **Phase 4 (Migration)**: 1-2 days
|
||||
- **Phase 5 (Cleanup)**: 1 day
|
||||
- **Admin UI**: 3-4 days
|
||||
- **Total**: 13-18 days
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ All existing reports work identically
|
||||
2. ✅ New reports can be created via UI
|
||||
3. ✅ Export functionality preserved
|
||||
4. ✅ Performance within 10% of current
|
||||
5. ✅ Zero SQL injection vulnerabilities
|
||||
6. ✅ Comprehensive test coverage (>80%)
|
||||
7. ✅ Documentation complete
|
||||
528
REPORTS_FRONTEND_REWORK_PLAN.md
Normal file
528
REPORTS_FRONTEND_REWORK_PLAN.md
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
# Reports Frontend Rework Plan
|
||||
|
||||
## Overview
|
||||
This plan outlines the modernization of Reports frontend pages (`Index.vue` and `Show.vue`) using shadcn-vue components and AppCard containers, following the same patterns established in the Settings pages rework.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Reports/Index.vue (30 lines)
|
||||
**Current Implementation:**
|
||||
- Simple grid layout with native divs
|
||||
- Report cards: `border rounded-lg p-4 bg-white shadow-sm hover:shadow-md`
|
||||
- Grid: `md:grid-cols-2 lg:grid-cols-3`
|
||||
- Each card shows: name (h2), description (p), Link to report
|
||||
- **No shadcn-vue components used**
|
||||
|
||||
**Identified Issues:**
|
||||
- Native HTML/Tailwind instead of shadcn-vue Card
|
||||
- Inconsistent with Settings pages styling
|
||||
- No icons for visual interest
|
||||
- Basic hover effects only
|
||||
|
||||
### Reports/Show.vue (314 lines)
|
||||
**Current Implementation:**
|
||||
- Complex page with filters, export buttons, and data table
|
||||
- Header section: title, description, export buttons (lines 190-196)
|
||||
- Buttons: `px-3 py-2 rounded bg-gray-200 hover:bg-gray-300`
|
||||
- Filter section: grid layout `md:grid-cols-4` (lines 218-270)
|
||||
- Native inputs: `border rounded px-2 py-1`
|
||||
- Native selects: `border rounded px-2 py-1`
|
||||
- DatePicker component (already working)
|
||||
- Filter buttons: Apply (`bg-indigo-600`) and Reset (`bg-gray-100`)
|
||||
- Data table: DataTableServer component (lines 285-300)
|
||||
- Formatting functions: formatNumberEU, formatDateEU, formatDateTimeEU, formatCell
|
||||
|
||||
**Identified Issues:**
|
||||
- No Card containers for sections
|
||||
- Native buttons instead of shadcn Button
|
||||
- Native input/select elements instead of shadcn Input/Select
|
||||
- No visual separation between sections
|
||||
- Filter section could be extracted to partial
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Pattern Reference from Settings Pages
|
||||
|
||||
**Settings/Index.vue Pattern:**
|
||||
```vue
|
||||
<Card class="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="icon" class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Title</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="ghost">Action →</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Settings/Archive/Index.vue Pattern:**
|
||||
- Uses AppCard for main container
|
||||
- Extracted partials: ArchiveRuleCard, CreateRuleForm, EditRuleForm
|
||||
- Alert components for warnings
|
||||
- Badge components for status indicators
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Reports/Index.vue Rework (Simple)
|
||||
|
||||
**Goal:** Replace native divs with shadcn-vue Card components, add icons
|
||||
|
||||
**Changes:**
|
||||
1. **Import shadcn-vue components:**
|
||||
```js
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { BarChart3, FileText, Activity, Users, TrendingUp, Calendar } from "lucide-vue-next";
|
||||
```
|
||||
|
||||
2. **Add icon mapping for reports:**
|
||||
```js
|
||||
const reportIcons = {
|
||||
'contracts': FileText,
|
||||
'field': TrendingUp,
|
||||
'activities': Activity,
|
||||
// fallback icon
|
||||
default: BarChart3,
|
||||
};
|
||||
|
||||
function getReportIcon(category) {
|
||||
return reportIcons[category] || reportIcons.default;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Replace report card structure:**
|
||||
- Remove native `<div class="border rounded-lg p-4 bg-white shadow-sm hover:shadow-md">`
|
||||
- Use `<Card class="hover:shadow-lg transition-shadow cursor-pointer">`
|
||||
- Structure:
|
||||
```vue
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="getReportIcon(report.category)" class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>{{ report.name }}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{{ report.description }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link :href="route('reports.show', report.slug)">
|
||||
<Button variant="ghost" size="sm" class="w-full justify-start">
|
||||
Odpri →
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
4. **Update page header:**
|
||||
- Wrap in proper container with consistent spacing
|
||||
- Match Settings/Index.vue header style
|
||||
|
||||
**Estimated Changes:**
|
||||
- Lines: 30 → ~65 lines (with imports and icon logic)
|
||||
- Files modified: 1 (Index.vue)
|
||||
- Files created: 0
|
||||
|
||||
**Risk Level:** Low (simple page, straightforward replacement)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Reports/Show.vue Rework - Structure (Medium)
|
||||
|
||||
**Goal:** Add Card containers for sections, replace native buttons
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Import shadcn-vue components:**
|
||||
```js
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import { Download, Filter, RotateCcw } from "lucide-vue-next";
|
||||
```
|
||||
|
||||
2. **Wrap header + export buttons in Card:**
|
||||
```vue
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ name }}</CardTitle>
|
||||
<CardDescription v-if="description">{{ description }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="exportFile('csv')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('pdf')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="exportFile('xlsx')">
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
```
|
||||
|
||||
3. **Wrap filters in Card:**
|
||||
```vue
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Filtri</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Filter grid here -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<!-- Filter inputs -->
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<div class="flex gap-2">
|
||||
<Button @click="applyFilters">
|
||||
<Filter class="mr-2 h-4 w-4" />
|
||||
Prikaži
|
||||
</Button>
|
||||
<Button variant="outline" @click="resetFilters">
|
||||
<RotateCcw class="mr-2 h-4 w-4" />
|
||||
Ponastavi
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
4. **Wrap DataTableServer in Card:**
|
||||
```vue
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rezultati</CardTitle>
|
||||
<CardDescription>
|
||||
Skupaj {{ meta?.total || 0 }} {{ meta?.total === 1 ? 'rezultat' : 'rezultatov' }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTableServer
|
||||
<!-- props -->
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
5. **Replace all native buttons with shadcn Button:**
|
||||
- Export buttons: `variant="outline" size="sm"`
|
||||
- Apply filter button: default variant
|
||||
- Reset button: `variant="outline"`
|
||||
|
||||
**Estimated Changes:**
|
||||
- Lines: 314 → ~350 lines (with imports and Card wrappers)
|
||||
- Files modified: 1 (Show.vue)
|
||||
- Files created: 0
|
||||
- **Keep formatting functions unchanged** (working correctly)
|
||||
|
||||
**Risk Level:** Low-Medium (more complex but no logic changes)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Reports/Show.vue - Replace Native Inputs (Medium)
|
||||
|
||||
**Goal:** Replace native input/select elements with shadcn-vue components
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **Replace date inputs:**
|
||||
```vue
|
||||
<!-- Keep DatePicker as-is (already working) -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ inp.label || inp.key }}</Label>
|
||||
<DatePicker
|
||||
v-model="filters[inp.key]"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datum"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
2. **Replace text/number inputs:**
|
||||
```vue
|
||||
<div class="space-y-2">
|
||||
<Label>{{ inp.label || inp.key }}</Label>
|
||||
<Input
|
||||
v-model="filters[inp.key]"
|
||||
:type="inp.type === 'integer' ? 'number' : 'text'"
|
||||
placeholder="Vnesi vrednost"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Replace select inputs (user/client):**
|
||||
```vue
|
||||
<div class="space-y-2">
|
||||
<Label>{{ inp.label || inp.key }}</Label>
|
||||
<Select v-model="filters[inp.key]">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="— brez —" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">— brez —</SelectItem>
|
||||
<SelectItem v-for="u in userOptions" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="userLoading" class="text-xs text-muted-foreground">Nalagam…</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
4. **Update filter grid layout:**
|
||||
- Change from `md:grid-cols-4` to `md:grid-cols-2 lg:grid-cols-4`
|
||||
- Use `space-y-2` for label/input spacing
|
||||
- Consistent gap: `gap-4`
|
||||
|
||||
**Estimated Changes:**
|
||||
- Lines: ~350 → ~380 lines (shadcn Input/Select have more markup)
|
||||
- Files modified: 1 (Show.vue)
|
||||
- Files created: 0
|
||||
|
||||
**Risk Level:** Medium (v-model binding changes, test thoroughly)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Optional - Extract Filter Section Partial (Optional)
|
||||
|
||||
**Goal:** Reduce Show.vue complexity by extracting filter logic
|
||||
|
||||
**Decision Criteria:**
|
||||
- If filter section exceeds ~80 lines → extract to partial
|
||||
- If multiple filter types need separate handling → extract
|
||||
|
||||
**Potential Partial Structure:**
|
||||
```
|
||||
resources/js/Pages/Reports/Partials/
|
||||
FilterSection.vue
|
||||
```
|
||||
|
||||
**FilterSection.vue:**
|
||||
- Props: `inputs`, `filters` (reactive object), `userOptions`, `clientOptions`, `loading states`
|
||||
- Emits: `@apply`, `@reset`
|
||||
- Contains: entire filter grid + buttons
|
||||
|
||||
**Benefits:**
|
||||
- Show.vue reduced from ~380 lines to ~300 lines
|
||||
- Filter logic isolated and reusable
|
||||
- Easier to maintain filter types
|
||||
|
||||
**Risks:**
|
||||
- Adds complexity with props/emits
|
||||
- Might not be worth it if filter logic is simple
|
||||
|
||||
**Recommendation:** Evaluate after Phase 3 completion. If filter section is clean and under 80 lines, skip this phase.
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
### shadcn-vue Components Needed
|
||||
|
||||
**Already Installed (verify):**
|
||||
- Card, CardHeader, CardTitle, CardDescription, CardContent
|
||||
- Button
|
||||
- Input
|
||||
- Select, SelectTrigger, SelectValue, SelectContent, SelectItem
|
||||
- Label
|
||||
- Badge
|
||||
- Separator
|
||||
|
||||
**Need to Check:**
|
||||
- lucide-vue-next icons (Download, Filter, RotateCcw, BarChart3, FileText, Activity, TrendingUp, Calendar)
|
||||
|
||||
### Custom Components
|
||||
- AppCard (if needed for consistency)
|
||||
- DatePicker (already working, keep as-is)
|
||||
- DataTableServer (keep as-is)
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Reports/Index.vue Testing:
|
||||
- [ ] Cards display with correct icons
|
||||
- [ ] Card hover effects work
|
||||
- [ ] Links navigate to correct report
|
||||
- [ ] Grid layout responsive (2 cols MD, 3 cols LG)
|
||||
- [ ] Icons match report categories
|
||||
|
||||
### Reports/Show.vue Testing:
|
||||
- [ ] Header Card displays title, description, export buttons
|
||||
- [ ] Export buttons work (CSV, PDF, Excel)
|
||||
- [ ] Filter Card displays all filter inputs correctly
|
||||
- [ ] Date filters use DatePicker component
|
||||
- [ ] User/Client selects load options async
|
||||
- [ ] Apply filters button triggers report refresh
|
||||
- [ ] Reset button clears all filters
|
||||
- [ ] DataTableServer Card displays results
|
||||
- [ ] Formatting functions work (dates, numbers, currencies)
|
||||
- [ ] Pagination works
|
||||
- [ ] All 6 reports render correctly:
|
||||
- [ ] active-contracts
|
||||
- [ ] field-jobs-completed
|
||||
- [ ] decisions-counts
|
||||
- [ ] segment-activity-counts
|
||||
- [ ] actions-decisions-counts
|
||||
- [ ] activities-per-period
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Step 1: Reports/Index.vue (30 min)
|
||||
1. Import shadcn-vue components + icons
|
||||
2. Add icon mapping function
|
||||
3. Replace native divs with Card structure
|
||||
4. Test navigation and layout
|
||||
5. Verify responsive grid
|
||||
|
||||
### Step 2: Reports/Show.vue - Structure (45 min)
|
||||
1. Import shadcn-vue components + icons
|
||||
2. Wrap header + exports in Card
|
||||
3. Wrap filters in Card
|
||||
4. Wrap DataTableServer in Card
|
||||
5. Replace all native buttons
|
||||
6. Test all 6 reports
|
||||
|
||||
### Step 3: Reports/Show.vue - Inputs (60 min)
|
||||
1. Replace text/number inputs with shadcn Input
|
||||
2. Replace select inputs with shadcn Select
|
||||
3. Add Label components
|
||||
4. Test v-model bindings
|
||||
5. Test async user/client loading
|
||||
6. Test filter apply/reset
|
||||
7. Verify all filter types work
|
||||
|
||||
### Step 4: Optional Partial Extraction (30 min, if needed)
|
||||
1. Create FilterSection.vue partial
|
||||
2. Move filter logic to partial
|
||||
3. Set up props/emits
|
||||
4. Test with all reports
|
||||
|
||||
### Step 5: Final Testing (30 min)
|
||||
1. Test complete workflow (Index → Show → Filters → Export)
|
||||
2. Verify all 6 reports
|
||||
3. Test responsive layouts (mobile, tablet, desktop)
|
||||
4. Check formatting consistency
|
||||
5. Verify no regressions
|
||||
|
||||
**Total Estimated Time:** 2.5 - 3.5 hours
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk:
|
||||
- Index.vue rework (simple structure, straightforward replacement)
|
||||
- Adding Card containers to Show.vue
|
||||
- Replacing native buttons with shadcn Button
|
||||
|
||||
### Medium Risk:
|
||||
- Replacing native inputs with shadcn Input/Select
|
||||
- v-model bindings might need adjustments
|
||||
- Async select loading needs testing
|
||||
- Number input behavior might differ
|
||||
|
||||
### Mitigation Strategies:
|
||||
1. Test each phase incrementally
|
||||
2. Keep formatting functions unchanged (already working)
|
||||
3. Test v-model bindings immediately after input replacement
|
||||
4. Verify async loading with console logs
|
||||
5. Test all 6 reports after each phase
|
||||
6. Keep git commits small and atomic
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements:
|
||||
✅ All reports navigate from Index page
|
||||
✅ All filters work correctly (date, text, number, user select, client select)
|
||||
✅ Apply filters refreshes report data
|
||||
✅ Reset filters clears all inputs
|
||||
✅ Export buttons generate CSV/PDF/Excel files
|
||||
✅ DataTableServer displays results correctly
|
||||
✅ Pagination works
|
||||
✅ Formatting functions work (dates, numbers)
|
||||
|
||||
### Visual Requirements:
|
||||
✅ Consistent Card-based layout
|
||||
✅ shadcn-vue components throughout
|
||||
✅ Icons for visual interest
|
||||
✅ Hover effects on cards
|
||||
✅ Proper spacing and alignment
|
||||
✅ Responsive layout (mobile, tablet, desktop)
|
||||
✅ Matches Settings pages style
|
||||
|
||||
### Code Quality:
|
||||
✅ No code duplication
|
||||
✅ Clean component imports
|
||||
✅ Consistent naming conventions
|
||||
✅ Proper TypeScript/Vue 3 patterns
|
||||
✅ Formatting functions unchanged
|
||||
✅ No regressions in functionality
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **DatePicker component:** Already working, imported correctly, no changes needed
|
||||
- **Formatting functions:** Keep unchanged (formatNumberEU, formatDateEU, formatDateTimeEU, formatCell)
|
||||
- **DataTableServer:** Keep as-is, already working well
|
||||
- **Async loading:** User/client select loading works, just needs shadcn Select wrapper
|
||||
- **Pattern consistency:** Follow Settings/Index.vue and Settings/Archive/Index.vue patterns
|
||||
- **Icon usage:** Add icons to Index.vue for visual interest, use lucide-vue-next
|
||||
- **Button variants:** Use `variant="outline"` for secondary actions, default for primary
|
||||
|
||||
---
|
||||
|
||||
## Post-Implementation
|
||||
|
||||
After completing all phases:
|
||||
|
||||
1. **Documentation:**
|
||||
- Update this document with actual implementation notes
|
||||
- Document any deviations from plan
|
||||
- Note any unexpected issues
|
||||
|
||||
2. **Code Review:**
|
||||
- Check for consistent component usage
|
||||
- Verify no native HTML/CSS buttons/inputs remain
|
||||
- Ensure proper import structure
|
||||
|
||||
3. **User Feedback:**
|
||||
- Test with actual users
|
||||
- Gather feedback on UI improvements
|
||||
- Note any requested adjustments
|
||||
|
||||
4. **Performance:**
|
||||
- Verify no performance regressions
|
||||
- Check bundle size impact
|
||||
- Monitor async loading times
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This plan provides a structured approach to modernizing the Reports frontend pages using shadcn-vue components. The phased approach allows for incremental testing and reduces risk. The estimated total time is 2.5-3.5 hours, with low to medium risk level.
|
||||
|
||||
**Recommendation:** Start with Phase 1 (Index.vue) as a proof of concept, then proceed to Phase 2 and 3 for Show.vue. Evaluate Phase 4 (partial extraction) after Phase 3 completion based on actual complexity.
|
||||
156
app/Console/Commands/FixImportMappingEntities.php
Normal file
156
app/Console/Commands/FixImportMappingEntities.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FixImportMappingEntities extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'import:fix-mapping-entities {--dry-run : Show changes without applying them}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fix entity names in import_mappings table to use canonical roots';
|
||||
|
||||
/**
|
||||
* Entity name mappings from incorrect to correct canonical roots
|
||||
*/
|
||||
protected array $entityMapping = [
|
||||
'contracts' => 'contract',
|
||||
'contract' => 'contract',
|
||||
'client_cases' => 'client_case',
|
||||
'client_case' => 'client_case',
|
||||
'person_addresses' => 'address',
|
||||
'addresses' => 'address',
|
||||
'address' => 'address',
|
||||
'person_phones' => 'phone',
|
||||
'phones' => 'phone',
|
||||
'phone' => 'phone',
|
||||
'emails' => 'email',
|
||||
'email' => 'email',
|
||||
'activities' => 'activity',
|
||||
'activity' => 'activity',
|
||||
'persons' => 'person',
|
||||
'person' => 'person',
|
||||
'accounts' => 'account',
|
||||
'account' => 'account',
|
||||
'payments' => 'payment',
|
||||
'payment' => 'payment',
|
||||
'bookings' => 'booking',
|
||||
'booking' => 'booking',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('Running in DRY-RUN mode - no changes will be made');
|
||||
}
|
||||
|
||||
$mappings = DB::table('import_mappings')
|
||||
->whereNotNull('entity')
|
||||
->where('entity', '!=', '')
|
||||
->get();
|
||||
|
||||
if ($mappings->isEmpty()) {
|
||||
$this->info('No mappings found to fix.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$mappings->count()} mappings to check");
|
||||
$this->newLine();
|
||||
|
||||
$updates = [];
|
||||
$unchanged = 0;
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
$currentEntity = trim($mapping->entity);
|
||||
|
||||
if (isset($this->entityMapping[$currentEntity])) {
|
||||
$correctEntity = $this->entityMapping[$currentEntity];
|
||||
|
||||
if ($currentEntity !== $correctEntity) {
|
||||
$updates[] = [
|
||||
'id' => $mapping->id,
|
||||
'current' => $currentEntity,
|
||||
'correct' => $correctEntity,
|
||||
'source' => $mapping->source_column,
|
||||
'target' => $mapping->target_field,
|
||||
];
|
||||
} else {
|
||||
$unchanged++;
|
||||
}
|
||||
} else {
|
||||
$this->warn("Unknown entity type: {$currentEntity} (ID: {$mapping->id})");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
$this->info("✓ All {$unchanged} mappings already have correct entity names!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Display changes
|
||||
$this->info("Changes to be made:");
|
||||
$this->newLine();
|
||||
|
||||
$table = [];
|
||||
foreach ($updates as $update) {
|
||||
$table[] = [
|
||||
$update['id'],
|
||||
$update['source'],
|
||||
$update['target'],
|
||||
$update['current'],
|
||||
$update['correct'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Source Column', 'Target Field', 'Current Entity', 'Correct Entity'],
|
||||
$table
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Total changes: " . count($updates));
|
||||
$this->info("Unchanged: {$unchanged}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->warn('DRY-RUN mode: No changes were made. Run without --dry-run to apply changes.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Confirm before proceeding
|
||||
if (!$this->confirm('Do you want to apply these changes?', true)) {
|
||||
$this->info('Operation cancelled.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
$updated = 0;
|
||||
foreach ($updates as $update) {
|
||||
DB::table('import_mappings')
|
||||
->where('id', $update['id'])
|
||||
->update(['entity' => $update['correct']]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("✓ Successfully updated {$updated} mappings!");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/PopulateImportMappingEntities.php
Normal file
113
app/Console/Commands/PopulateImportMappingEntities.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PopulateImportMappingEntities extends Command
|
||||
{
|
||||
protected $signature = 'import:populate-mapping-entities {--dry-run : Show changes without applying them}';
|
||||
|
||||
protected $description = 'Populate entity column from target_field for mappings where entity is null';
|
||||
|
||||
protected array $entityMap = [
|
||||
'contracts' => 'contract',
|
||||
'client_cases' => 'client_case',
|
||||
'person_addresses' => 'address',
|
||||
'person_phones' => 'phone',
|
||||
'emails' => 'email',
|
||||
'activities' => 'activity',
|
||||
'payments' => 'payment',
|
||||
'accounts' => 'account',
|
||||
'persons' => 'person',
|
||||
'person' => 'person',
|
||||
'contract' => 'contract',
|
||||
'client_case' => 'client_case',
|
||||
'address' => 'address',
|
||||
'phone' => 'phone',
|
||||
'email' => 'email',
|
||||
'activity' => 'activity',
|
||||
'payment' => 'payment',
|
||||
'account' => 'account',
|
||||
];
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('Populating entity column from target_field...');
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Get all mappings where entity is null
|
||||
$mappings = DB::table('import_mappings')
|
||||
->whereNull('entity')
|
||||
->get();
|
||||
|
||||
if ($mappings->isEmpty()) {
|
||||
$this->info('No mappings found with null entity.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$mappings->count()} mappings to process.");
|
||||
$this->newLine();
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
$targetField = $mapping->target_field;
|
||||
|
||||
// Parse the target_field to extract entity and field
|
||||
if (str_contains($targetField, '.')) {
|
||||
[$rawEntity, $field] = explode('.', $targetField, 2);
|
||||
} elseif (str_contains($targetField, '->')) {
|
||||
[$rawEntity, $field] = explode('->', $targetField, 2);
|
||||
} else {
|
||||
$this->warn("Skipping mapping ID {$mapping->id}: Cannot parse target_field '{$targetField}'");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawEntity = trim($rawEntity);
|
||||
$field = trim($field);
|
||||
|
||||
// Map to canonical entity name
|
||||
$canonicalEntity = $this->entityMap[$rawEntity] ?? $rawEntity;
|
||||
|
||||
$this->line(sprintf(
|
||||
"ID %d: '%s' -> '%s' => entity='%s', field='%s'",
|
||||
$mapping->id,
|
||||
$mapping->source_column,
|
||||
$targetField,
|
||||
$canonicalEntity,
|
||||
$field
|
||||
));
|
||||
|
||||
if (!$dryRun) {
|
||||
DB::table('import_mappings')
|
||||
->where('id', $mapping->id)
|
||||
->update([
|
||||
'entity' => $canonicalEntity,
|
||||
'target_field' => $field,
|
||||
]);
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
if ($dryRun) {
|
||||
$this->info("Dry run complete. Would have updated {$mappings->count()} mappings.");
|
||||
} else {
|
||||
$this->info("Successfully updated {$updated} mappings.");
|
||||
}
|
||||
|
||||
if ($skipped > 0) {
|
||||
$this->warn("Skipped {$skipped} mappings that couldn't be parsed.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
57
app/Console/Commands/RefreshMaterializedViews.php
Normal file
57
app/Console/Commands/RefreshMaterializedViews.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshMaterializedViews extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'reports:refresh-mviews {--concurrently : Use CONCURRENTLY (Postgres 9.4+; requires indexes)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh configured Postgres materialized views for reporting';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (empty($views)) {
|
||||
$this->info('No materialized views configured.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$concurrently = $this->option('concurrently') ? ' CONCURRENTLY' : '';
|
||||
|
||||
foreach ($views as $view) {
|
||||
$name = trim((string) $view);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$sql = 'REFRESH MATERIALIZED VIEW'.$concurrently.' '.DB::getPdo()->quote($name);
|
||||
// PDO::quote wraps with single quotes; for identifiers we need double quotes or no quotes.
|
||||
// Use a safe fallback: wrap with " if not already quoted
|
||||
$safe = 'REFRESH MATERIALIZED VIEW'.$concurrently.' "'.str_replace('"', '""', $name).'"';
|
||||
try {
|
||||
DB::statement($safe);
|
||||
$this->info("Refreshed: {$name}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to refresh {$name}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
145
app/Console/Commands/SimulateImportV2Command.php
Normal file
145
app/Console/Commands/SimulateImportV2Command.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SimulateImportV2Command extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'import:simulate-v2 {import_id} {--limit=100 : Number of rows to simulate} {--verbose : Include detailed information}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Simulate ImportServiceV2 without persisting data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(ImportSimulationServiceV2 $service): int
|
||||
{
|
||||
$importId = $this->argument('import_id');
|
||||
$limit = (int) $this->option('limit');
|
||||
$verbose = (bool) $this->option('verbose');
|
||||
|
||||
$import = Import::find($importId);
|
||||
|
||||
if (! $import) {
|
||||
$this->error("Import #{$importId} not found.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Simulating import #{$importId} - {$import->file_name}");
|
||||
$this->info("Client: ".($import->client->name ?? 'N/A'));
|
||||
$this->info("Limit: {$limit} rows");
|
||||
$this->line('');
|
||||
|
||||
$result = $service->simulate($import, $limit, $verbose);
|
||||
|
||||
if (! $result['success']) {
|
||||
$this->error('Simulation failed: '.$result['error']);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("✓ Simulated {$result['total_simulated']} rows");
|
||||
$this->line('');
|
||||
|
||||
// Display summaries
|
||||
if (! empty($result['summaries'])) {
|
||||
$this->info('=== Entity Summaries ===');
|
||||
$summaryRows = [];
|
||||
|
||||
foreach ($result['summaries'] as $entity => $stats) {
|
||||
$summaryRows[] = [
|
||||
'entity' => $entity,
|
||||
'create' => $stats['create'],
|
||||
'update' => $stats['update'],
|
||||
'skip' => $stats['skip'],
|
||||
'invalid' => $stats['invalid'],
|
||||
'total' => array_sum($stats),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Entity', 'Create', 'Update', 'Skip', 'Invalid', 'Total'],
|
||||
$summaryRows
|
||||
);
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
// Display row previews (first 5)
|
||||
if (! empty($result['rows'])) {
|
||||
$this->info('=== Row Previews (first 5) ===');
|
||||
|
||||
foreach (array_slice($result['rows'], 0, 5) as $row) {
|
||||
$this->line("Row #{$row['row_number']}:");
|
||||
|
||||
if (! empty($row['entities'])) {
|
||||
foreach ($row['entities'] as $entity => $data) {
|
||||
$action = $data['action'];
|
||||
$color = match ($action) {
|
||||
'create' => 'green',
|
||||
'update' => 'yellow',
|
||||
'skip' => 'gray',
|
||||
'invalid', 'error' => 'red',
|
||||
default => 'white',
|
||||
};
|
||||
|
||||
$line = " {$entity}: <fg={$color}>{$action}</>";
|
||||
|
||||
if (isset($data['reference'])) {
|
||||
$line .= " ({$data['reference']})";
|
||||
}
|
||||
|
||||
if (isset($data['existing_id'])) {
|
||||
$line .= " [ID: {$data['existing_id']}]";
|
||||
}
|
||||
|
||||
$this->line($line);
|
||||
|
||||
if ($verbose && ! empty($data['changes'])) {
|
||||
foreach ($data['changes'] as $field => $change) {
|
||||
$this->line(" {$field}: {$change['old']} → {$change['new']}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($data['errors'])) {
|
||||
foreach ($data['errors'] as $error) {
|
||||
$this->error(" ✗ {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($row['warnings'])) {
|
||||
foreach ($row['warnings'] as $warning) {
|
||||
$this->warn(" ⚠ {$warning}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($row['errors'])) {
|
||||
foreach ($row['errors'] as $error) {
|
||||
$this->error(" ✗ {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Simulation completed successfully.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
68
app/Console/Commands/TestImportV2Command.php
Normal file
68
app/Console/Commands/TestImportV2Command.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ProcessLargeImportJob;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TestImportV2Command extends Command
|
||||
{
|
||||
protected $signature = 'import:test-v2 {import_id : The import ID to process} {--queue : Process via queue}';
|
||||
|
||||
protected $description = 'Test ImportServiceV2 with an existing import';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$importId = $this->argument('import_id');
|
||||
$useQueue = $this->option('queue');
|
||||
|
||||
$import = Import::find($importId);
|
||||
|
||||
if (! $import) {
|
||||
$this->error("Import {$importId} not found.");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Processing import: {$import->id} ({$import->file_name})");
|
||||
$this->info("Source: {$import->source_type}");
|
||||
$this->info("Status: {$import->status}");
|
||||
$this->newLine();
|
||||
|
||||
if ($useQueue) {
|
||||
$this->info('Dispatching to queue...');
|
||||
ProcessLargeImportJob::dispatch($import, auth()->id());
|
||||
$this->info('Job dispatched successfully. Monitor queue for progress.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('Processing synchronously...');
|
||||
$service = app(ImportServiceV2::class);
|
||||
|
||||
try {
|
||||
$results = $service->process($import, auth()->user());
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Processing completed!');
|
||||
$this->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total rows', $results['total']],
|
||||
['Imported', $results['imported']],
|
||||
['Skipped', $results['skipped']],
|
||||
['Invalid', $results['invalid']],
|
||||
]
|
||||
);
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Processing failed: '.$e->getMessage());
|
||||
$this->error($e->getTraceAsString());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,15 @@ protected function schedule(Schedule $schedule): void
|
|||
'--days' => $days,
|
||||
])->dailyAt('02:00');
|
||||
}
|
||||
|
||||
// Optional: refresh configured materialized views for reporting
|
||||
$views = (array) config('reports.materialized_views', []);
|
||||
if (! empty($views)) {
|
||||
$time = (string) (config('reports.refresh_time', '03:00') ?: '03:00');
|
||||
$schedule->command('reports:refresh-mviews', [
|
||||
'--concurrently' => true,
|
||||
])->dailyAt($time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
224
app/Helpers/LZStringHelper.php
Normal file
224
app/Helpers/LZStringHelper.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class LZStringHelper
|
||||
{
|
||||
/**
|
||||
* Decompresses a string compressed with LZ-String's compressToEncodedURIComponent method.
|
||||
* This is a PHP port of the JavaScript LZ-String library.
|
||||
*
|
||||
* @param string $compressed
|
||||
* @return string|null
|
||||
*/
|
||||
public static function decompressFromEncodedURIComponent($compressed)
|
||||
{
|
||||
if ($compressed === null || $compressed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Replace URL-safe characters back
|
||||
$compressed = str_replace(' ', '+', $compressed);
|
||||
|
||||
return self::decompress(strlen($compressed), 32, function ($index) use ($compressed) {
|
||||
return self::getBaseValue(self::$keyStrUriSafe, $compressed[$index]);
|
||||
});
|
||||
}
|
||||
|
||||
private static $keyStrUriSafe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$';
|
||||
|
||||
private static function getBaseValue($alphabet, $character)
|
||||
{
|
||||
$pos = strpos($alphabet, $character);
|
||||
|
||||
return $pos !== false ? $pos : -1;
|
||||
}
|
||||
|
||||
private static function decompress($length, $resetValue, $getNextValue)
|
||||
{
|
||||
$dictionary = [];
|
||||
$enlargeIn = 4;
|
||||
$dictSize = 4;
|
||||
$numBits = 3;
|
||||
$entry = '';
|
||||
$result = [];
|
||||
$data = ['val' => $getNextValue(0), 'position' => $resetValue, 'index' => 1];
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$dictionary[$i] = chr($i);
|
||||
}
|
||||
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, 2);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$next = $bits;
|
||||
|
||||
switch ($next) {
|
||||
case 0:
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, 8);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$c = chr($bits);
|
||||
break;
|
||||
|
||||
case 1:
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, 16);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$c = chr($bits);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
return '';
|
||||
}
|
||||
|
||||
$dictionary[$dictSize++] = $c;
|
||||
$w = $c;
|
||||
$result[] = $c;
|
||||
|
||||
while (true) {
|
||||
if ($data['index'] > $length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, $numBits);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$c = $bits;
|
||||
|
||||
switch ($c) {
|
||||
case 0:
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, 8);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$dictionary[$dictSize++] = chr($bits);
|
||||
$c = $dictSize - 1;
|
||||
$enlargeIn--;
|
||||
break;
|
||||
|
||||
case 1:
|
||||
$bits = 0;
|
||||
$maxpower = pow(2, 16);
|
||||
$power = 1;
|
||||
|
||||
while ($power != $maxpower) {
|
||||
$resb = $data['val'] & $data['position'];
|
||||
$data['position'] >>= 1;
|
||||
|
||||
if ($data['position'] == 0) {
|
||||
$data['position'] = $resetValue;
|
||||
$data['val'] = $getNextValue($data['index']++);
|
||||
}
|
||||
|
||||
$bits |= ($resb > 0 ? 1 : 0) * $power;
|
||||
$power <<= 1;
|
||||
}
|
||||
|
||||
$dictionary[$dictSize++] = chr($bits);
|
||||
$c = $dictSize - 1;
|
||||
$enlargeIn--;
|
||||
break;
|
||||
|
||||
case 2:
|
||||
return implode('', $result);
|
||||
}
|
||||
|
||||
if ($enlargeIn == 0) {
|
||||
$enlargeIn = pow(2, $numBits);
|
||||
$numBits++;
|
||||
}
|
||||
|
||||
if (isset($dictionary[$c])) {
|
||||
$entry = $dictionary[$c];
|
||||
} else {
|
||||
if ($c === $dictSize) {
|
||||
$entry = $w.$w[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $entry;
|
||||
|
||||
$dictionary[$dictSize++] = $w.$entry[0];
|
||||
$enlargeIn--;
|
||||
|
||||
$w = $entry;
|
||||
|
||||
if ($enlargeIn == 0) {
|
||||
$enlargeIn = pow(2, $numBits);
|
||||
$numBits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +23,19 @@ class PackageController extends Controller
|
|||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$perPage = $request->input('per_page') ?? 25;
|
||||
|
||||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(20);
|
||||
->paginate($perPage);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
|
|
@ -58,8 +68,7 @@ public function index(Request $request): Response
|
|||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
|
|
@ -290,6 +299,20 @@ public function cancel(Package $package): RedirectResponse
|
|||
return back()->with('success', 'Package canceled');
|
||||
}
|
||||
|
||||
public function destroy(Package $package): RedirectResponse
|
||||
{
|
||||
// Allow deletion only for drafts (not yet dispatched)
|
||||
if ($package->status !== Package::STATUS_DRAFT) {
|
||||
return back()->with('error', 'Package not in a deletable state.');
|
||||
}
|
||||
|
||||
// Remove items first to avoid FK issues
|
||||
$package->items()->delete();
|
||||
$package->delete();
|
||||
|
||||
return back()->with('success', 'Package deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* List contracts for a given segment and include selected phone per person.
|
||||
*/
|
||||
|
|
@ -298,7 +321,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
|
|
@ -309,7 +332,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
|
|
@ -376,9 +399,9 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
});
|
||||
}
|
||||
|
||||
$contracts = $query->paginate($perPage);
|
||||
$contracts = $query->get();
|
||||
|
||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
|
|
@ -417,13 +440,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $contracts->currentPage(),
|
||||
'last_page' => $contracts->lastPage(),
|
||||
'per_page' => $contracts->perPage(),
|
||||
'total' => $contracts->total(),
|
||||
],
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,7 @@
|
|||
use App\Exports\ClientContractsExport;
|
||||
use App\Http\Requests\ExportClientContractsRequest;
|
||||
use App\Models\Client;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use DB;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -13,51 +14,44 @@
|
|||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
|
||||
public function index(Client $client, Request $request)
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
$query = $client::query()
|
||||
->with('person')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->whereHas('person', function ($q) use ($search) {
|
||||
$q->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
->select('clients.*')
|
||||
->when($search, function ($que) use ($search) {
|
||||
$que->join('person', 'person.id', '=', 'clients.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('clients.id');
|
||||
})
|
||||
->where('active', 1)
|
||||
->where('clients.active', 1)
|
||||
// Use LEFT JOINs for aggregated data to avoid subqueries
|
||||
->leftJoin('client_cases', 'client_cases.client_id', '=', 'clients.id')
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('clients.id')
|
||||
->addSelect([
|
||||
// Number of client cases for this client that have at least one active contract
|
||||
'cases_with_active_contracts_count' => DB::query()
|
||||
->from('client_cases')
|
||||
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->selectRaw('COUNT(DISTINCT client_cases.id)')
|
||||
->whereColumn('client_cases.client_id', 'clients.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
// Sum of account balances for active contracts that belong to this client's cases
|
||||
'active_contracts_balance_sum' => DB::query()
|
||||
->from('contracts')
|
||||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('client_cases')
|
||||
->whereColumn('client_cases.id', 'contracts.client_case_id')
|
||||
->whereColumn('client_cases.client_id', 'clients.id');
|
||||
})
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN client_cases.id END) as cases_with_active_contracts_count'),
|
||||
// Sum of account balances for active contracts
|
||||
DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->orderByDesc('created_at');
|
||||
->with('person')
|
||||
->orderByDesc('clients.created_at');
|
||||
|
||||
return Inertia::render('Client/Index', [
|
||||
'clients' => $query
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
|
|
@ -71,44 +65,37 @@ public function show(Client $client, Request $request)
|
|||
->findOrFail($client->id);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
return Inertia::render('Client/Show', [
|
||||
'client' => $data,
|
||||
'client_cases' => $data->clientCases()
|
||||
->with(['person', 'client.person'])
|
||||
->when($request->input('search'), fn ($que, $search) => $que->whereHas(
|
||||
'person',
|
||||
fn ($q) => $q->where('full_name', 'ilike', '%'.$search.'%')
|
||||
))
|
||||
->select('client_cases.*')
|
||||
->when($request->input('search'), function ($que, $search) {
|
||||
$que->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where('person.full_name', 'ilike', '%'.$search.'%')
|
||||
->groupBy('client_cases.id');
|
||||
})
|
||||
->leftJoin('contracts', function ($join) {
|
||||
$join->on('contracts.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at');
|
||||
})
|
||||
->leftJoin('contract_segment', function ($join) {
|
||||
$join->on('contract_segment.contract_id', '=', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
})
|
||||
->leftJoin('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->groupBy('client_cases.id')
|
||||
->addSelect([
|
||||
'active_contracts_count' => \DB::query()
|
||||
->from('contracts')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
'active_contracts_balance_sum' => \DB::query()
|
||||
->from('contracts')
|
||||
->join('accounts', 'accounts.contract_id', '=', 'contracts.id')
|
||||
->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)')
|
||||
->whereColumn('contracts.client_case_id', 'client_cases.id')
|
||||
->whereNull('contracts.deleted_at')
|
||||
->whereExists(function ($q) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.active', true);
|
||||
}),
|
||||
\DB::raw('COUNT(DISTINCT CASE WHEN contract_segment.id IS NOT NULL THEN contracts.id END) as active_contracts_count'),
|
||||
\DB::raw('COALESCE(SUM(CASE WHEN contract_segment.id IS NOT NULL THEN accounts.balance_amount END), 0) as active_contracts_balance_sum'),
|
||||
])
|
||||
->where('active', 1)
|
||||
->orderByDesc('created_at')
|
||||
->paginate($request->integer('perPage', 15))
|
||||
->with(['person', 'client.person'])
|
||||
->where('client_cases.active', 1)
|
||||
->orderByDesc('client_cases.created_at')
|
||||
->paginate($request->integer('per_page', 15))
|
||||
->withQueryString(),
|
||||
'types' => $types,
|
||||
'filters' => $request->only(['search']),
|
||||
|
|
@ -126,8 +113,30 @@ public function contracts(Client $client, Request $request)
|
|||
$segmentIds = $segmentsParam ? array_filter(explode(',', $segmentsParam)) : [];
|
||||
|
||||
$contractsQuery = \App\Models\Contract::query()
|
||||
->whereHas('clientCase', function ($q) use ($client) {
|
||||
$q->where('client_id', $client->id);
|
||||
->select(['contracts.id', 'contracts.uuid', 'contracts.reference', 'contracts.start_date', 'contracts.client_case_id'])
|
||||
->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id')
|
||||
->where('client_cases.client_id', $client->id)
|
||||
->whereNull('contracts.deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('contracts.start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('contracts.start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->leftJoin('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->where(function ($inner) use ($search) {
|
||||
$inner->where('contracts.reference', 'ilike', '%'.$search.'%')
|
||||
->orWhere('person.full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->with([
|
||||
'clientCase:id,uuid,person_id',
|
||||
|
|
@ -138,43 +147,25 @@ public function contracts(Client $client, Request $request)
|
|||
},
|
||||
'account:id,accounts.contract_id,balance_amount',
|
||||
])
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'client_case_id'])
|
||||
->whereNull('deleted_at')
|
||||
->when($from || $to, function ($q) use ($from, $to) {
|
||||
if (! empty($from)) {
|
||||
$q->whereDate('start_date', '>=', $from);
|
||||
}
|
||||
if (! empty($to)) {
|
||||
$q->whereDate('start_date', '<=', $to);
|
||||
}
|
||||
})
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($p) use ($search) {
|
||||
$p->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
})
|
||||
->when($segmentIds, function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($s) use ($segmentIds) {
|
||||
$s->whereIn('segments.id', $segmentIds)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
})
|
||||
->orderByDesc('start_date');
|
||||
->orderByDesc('contracts.start_date');
|
||||
|
||||
$segments = \App\Models\Segment::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
$types = [
|
||||
'address_types' => \App\Models\Person\AddressType::all(),
|
||||
'phone_types' => \App\Models\Person\PhoneType::all(),
|
||||
'address_types' => $this->referenceCache->getAddressTypes(),
|
||||
'phone_types' => $this->referenceCache->getPhoneTypes(),
|
||||
];
|
||||
|
||||
// Support custom pagination parameter names used by DataTableNew2
|
||||
$perPage = $request->integer('contracts_per_page', $request->integer('per_page', 15));
|
||||
$pageNumber = $request->integer('contracts_page', $request->integer('page', 1));
|
||||
|
||||
return Inertia::render('Client/Contracts', [
|
||||
'client' => $data,
|
||||
'contracts' => $contractsQuery->paginate($request->integer('perPage', 20))->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segments']),
|
||||
'contracts' => $contractsQuery
|
||||
->paginate($perPage, ['*'], 'contracts_page', $pageNumber)
|
||||
->withQueryString(),
|
||||
'filters' => $request->only(['from', 'to', 'search', 'segment']),
|
||||
'segments' => $segments,
|
||||
'types' => $types,
|
||||
]);
|
||||
|
|
@ -295,14 +286,14 @@ public function store(Request $request)
|
|||
|
||||
// \App\Models\Person\PersonAddress::create($address);
|
||||
|
||||
return to_route('client');
|
||||
return back()->with('success', 'Client created')->with('flash_method', 'POST');
|
||||
|
||||
}
|
||||
|
||||
public function update(Client $client, Request $request)
|
||||
{
|
||||
|
||||
return to_route('client.show', $client);
|
||||
return back()->with('success', 'Client updated')->with('flash_method', 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public function store(Request $request)
|
|||
});
|
||||
}
|
||||
|
||||
return to_route('clientCase.show', $clientCase);
|
||||
return back()->with('success', 'Contract created')->with('flash_method', 'POST');
|
||||
}
|
||||
|
||||
public function update(Contract $contract, Request $request)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Client;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document; // assuming model name Import
|
||||
// assuming model name Import
|
||||
use App\Models\FieldJob; // if this model exists
|
||||
use App\Models\Import;
|
||||
use App\Models\SmsLog;
|
||||
use App\Models\SmsProfile;
|
||||
use App\Services\Sms\SmsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -21,25 +23,38 @@ class DashboardController extends Controller
|
|||
public function __invoke(SmsService $sms): Response
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
$yesterday = now()->subDay()->startOfDay();
|
||||
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
|
||||
$cacheMinutes = 5;
|
||||
|
||||
$clientsTotal = Client::count();
|
||||
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
|
||||
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
|
||||
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
|
||||
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
|
||||
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
|
||||
} else {
|
||||
// Prefer assigned_at when present, otherwise created_at
|
||||
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
|
||||
}
|
||||
$documentsToday = Document::whereDate('created_at', $today)->count();
|
||||
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
|
||||
$activeContracts = Contract::where('active', 1)->count();
|
||||
// Active clients count - cached
|
||||
$activeClientsCount = Cache::remember('dashboard:active_clients:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Client::where('active', true)->count();
|
||||
});
|
||||
|
||||
// Basic activities deferred list (limit 10)
|
||||
$activities = Activity::query()
|
||||
// Active contracts count - cached
|
||||
$activeContractsCount = Cache::remember('dashboard:active_contracts:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Contract::whereNull('deleted_at')->count();
|
||||
});
|
||||
|
||||
// Sum of active contracts' account balance - cached
|
||||
$totalBalance = Cache::remember('dashboard:total_balance:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})->sum('balance_amount') ?? 0;
|
||||
});
|
||||
|
||||
// Active promises count (not expired or expires today) - cached
|
||||
$activePromisesCount = Cache::remember('dashboard:active_promises:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return Account::whereHas('contract', function ($q) {
|
||||
$q->whereNull('deleted_at');
|
||||
})
|
||||
->whereNotNull('promise_date')
|
||||
->whereDate('promise_date', '>=', $today)
|
||||
->count();
|
||||
});
|
||||
|
||||
// Activities (limit 10) - cached
|
||||
$activities = Cache::remember('dashboard:activities', $cacheMinutes * 60, function () {
|
||||
return Activity::query()
|
||||
->with(['clientCase:id,uuid'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
|
|
@ -54,30 +69,20 @@ public function __invoke(SmsService $sms): Response
|
|||
'action_id' => $a->action_id,
|
||||
'decision_id' => $a->decision_id,
|
||||
]);
|
||||
});
|
||||
|
||||
// 7-day trends (including today)
|
||||
// 7-day trends for field jobs - cached
|
||||
$trends = Cache::remember('dashboard:field_jobs_trends:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () {
|
||||
$start = now()->subDays(6)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$dateKeys = collect(range(0, 6))
|
||||
->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d'));
|
||||
|
||||
$clientTrendRaw = Client::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$documentTrendRaw = Document::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end])
|
||||
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
$importTrendRaw = Import::whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
|
||||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
// Completed field jobs last 7 days
|
||||
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
|
||||
|
|
@ -86,50 +91,16 @@ public function __invoke(SmsService $sms): Response
|
|||
->groupBy('d')
|
||||
->pluck('c', 'd');
|
||||
|
||||
$trends = [
|
||||
'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
|
||||
'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
|
||||
return [
|
||||
'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
|
||||
'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
|
||||
'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
|
||||
'labels' => $dateKeys,
|
||||
];
|
||||
|
||||
// Stale client cases (no activity in last 7 days)
|
||||
$staleCases = \App\Models\ClientCase::query()
|
||||
->leftJoin('activities', function ($join) {
|
||||
$join->on('activities.client_case_id', '=', 'client_cases.id')
|
||||
->whereNull('activities.deleted_at');
|
||||
})
|
||||
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
|
||||
->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at')
|
||||
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
|
||||
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($c) {
|
||||
// Reference point: last activity if exists, else creation.
|
||||
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
|
||||
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
|
||||
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
|
||||
$daysFraction = $minutes / 1440; // 60 * 24
|
||||
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
|
||||
$daysInteger = (int) floor($daysFraction);
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'uuid' => $c->uuid,
|
||||
'client_ref' => $c->client_ref,
|
||||
'last_activity_at' => $c->last_activity_at,
|
||||
'created_at' => $c->created_at,
|
||||
'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
|
||||
'days_stale' => $daysInteger, // legacy key (integer)
|
||||
'has_activity' => (bool) $c->last_activity_at,
|
||||
];
|
||||
});
|
||||
|
||||
// Field jobs assigned today
|
||||
$fieldJobsAssignedToday = FieldJob::query()
|
||||
// Field jobs assigned today - cached
|
||||
$fieldJobsAssignedToday = Cache::remember('dashboard:field_jobs_assigned_today:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($today) {
|
||||
return FieldJob::query()
|
||||
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
|
||||
->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id'])
|
||||
->with(['contract' => function ($q) {
|
||||
|
|
@ -143,7 +114,6 @@ public function __invoke(SmsService $sms): Response
|
|||
$contract = $fj->contract;
|
||||
$segmentId = null;
|
||||
if ($contract && method_exists($contract, 'segments')) {
|
||||
// Determine active segment via pivot active flag if present
|
||||
$activeSeg = $contract->segments->first();
|
||||
if ($activeSeg && isset($activeSeg->pivot) && ($activeSeg->pivot->active ?? true)) {
|
||||
$segmentId = $activeSeg->id;
|
||||
|
|
@ -153,7 +123,6 @@ public function __invoke(SmsService $sms): Response
|
|||
return [
|
||||
'id' => $fj->id,
|
||||
'priority' => $fj->priority,
|
||||
// Normalize to ISO8601 strings so FE retains timezone & time component
|
||||
'assigned_at' => $fj->assigned_at?->toIso8601String(),
|
||||
'created_at' => $fj->created_at?->toIso8601String(),
|
||||
'contract' => $contract ? [
|
||||
|
|
@ -165,69 +134,35 @@ public function __invoke(SmsService $sms): Response
|
|||
] : null,
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
// Imports in progress (queued / processing)
|
||||
$importsInProgress = Import::query()
|
||||
->whereIn('status', ['queued', 'processing'])
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at'])
|
||||
->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'uuid' => $i->uuid,
|
||||
'file_name' => $i->file_name,
|
||||
'status' => $i->status,
|
||||
'total_rows' => $i->total_rows,
|
||||
'imported_rows' => $i->imported_rows,
|
||||
'valid_rows' => $i->valid_rows,
|
||||
'invalid_rows' => $i->invalid_rows,
|
||||
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null,
|
||||
'started_at' => $i->started_at,
|
||||
]);
|
||||
|
||||
// Active document templates summary (active versions)
|
||||
$activeTemplates = \App\Models\DocumentTemplate::query()
|
||||
->where('active', true)
|
||||
->latest('updated_at')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'slug', 'version', 'updated_at']);
|
||||
|
||||
// System health (deferred)
|
||||
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
|
||||
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
|
||||
// System health for timestamp
|
||||
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
|
||||
$lastActivityMinutes = null;
|
||||
if ($recentActivity) {
|
||||
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
|
||||
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
|
||||
}
|
||||
$systemHealth = [
|
||||
'queue_backlog' => $queueBacklog,
|
||||
'failed_jobs' => $failedJobs,
|
||||
'last_activity_minutes' => $lastActivityMinutes,
|
||||
'last_activity_iso' => $recentActivity?->toIso8601String(),
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
return Inertia::render('Dashboard/Index', [
|
||||
'kpis' => [
|
||||
'clients_total' => $clientsTotal,
|
||||
'clients_new_7d' => $clientsNew7d,
|
||||
'field_jobs_today' => $fieldJobsToday,
|
||||
'documents_today' => $documentsToday,
|
||||
'active_imports' => $activeImports,
|
||||
'active_contracts' => $activeContracts,
|
||||
'active_clients' => $activeClientsCount,
|
||||
'active_contracts' => $activeContractsCount,
|
||||
'total_balance' => $totalBalance,
|
||||
'active_promises' => $activePromisesCount,
|
||||
],
|
||||
'trends' => $trends,
|
||||
])->with([ // deferred props (Inertia v2 style)
|
||||
])->with([
|
||||
'activities' => fn () => $activities,
|
||||
'systemHealth' => fn () => $systemHealth,
|
||||
'staleCases' => fn () => $staleCases,
|
||||
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
|
||||
'importsInProgress' => fn () => $importsInProgress,
|
||||
'activeTemplates' => fn () => $activeTemplates,
|
||||
'smsStats' => function () use ($sms, $today) {
|
||||
// Aggregate counts per profile for today
|
||||
'smsStats' => function () use ($sms, $today, $cacheMinutes) {
|
||||
// SMS stats - cached
|
||||
return Cache::remember('dashboard:sms_stats:'.now()->format('Y-m-d'), $cacheMinutes * 60, function () use ($sms, $today) {
|
||||
$counts = SmsLog::query()
|
||||
->whereDate('created_at', $today)
|
||||
->selectRaw('profile_id, status, COUNT(*) as c')
|
||||
|
|
@ -249,13 +184,11 @@ public function __invoke(SmsService $sms): Response
|
|||
return $map;
|
||||
});
|
||||
|
||||
// Important: include credential fields so provider calls have proper credentials
|
||||
$profiles = SmsProfile::query()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
|
||||
|
||||
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
|
||||
// Provider balance may fail; guard and present a placeholder.
|
||||
try {
|
||||
$balance = $sms->getCreditBalance($p);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -271,6 +204,7 @@ public function __invoke(SmsService $sms): Response
|
|||
'today' => $c,
|
||||
];
|
||||
})->values();
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,56 +25,109 @@ public function index(Request $request)
|
|||
optional($setting)->segment_id,
|
||||
])->filter()->unique()->values();
|
||||
|
||||
$contracts = Contract::query()
|
||||
->with(['clientCase.person', 'clientCase.client.person', 'type', 'account'])
|
||||
->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) {
|
||||
$q->whereHas('segments', function ($sq) use ($segmentIds) {
|
||||
// Relation already filters on active pivots
|
||||
$sq->whereIn('segments.id', $segmentIds);
|
||||
});
|
||||
}, function ($q) {
|
||||
// No segments configured on FieldJobSetting -> return none
|
||||
$q->whereRaw('1 = 0');
|
||||
})
|
||||
->latest('id')
|
||||
->limit(50)
|
||||
->get();
|
||||
$search = $request->input('search');
|
||||
$searchAssigned = $request->input('search_assigned');
|
||||
$assignedUserId = $request->input('assigned_user_id');
|
||||
$unassignedClientUuids = $request->input('unassigned_client_uuids');
|
||||
$assignedClientUuids = $request->input('assigned_client_uuids');
|
||||
|
||||
// Mirror client onto the contract for simpler frontend access: c.client.person.full_name
|
||||
$contracts->each(function (Contract $contract): void {
|
||||
if ($contract->relationLoaded('clientCase') && $contract->clientCase) {
|
||||
$contract->setRelation('client', $contract->clientCase->client);
|
||||
}
|
||||
});
|
||||
|
||||
// Build active assignment map keyed by contract uuid for quicker UI checks
|
||||
$assignments = collect();
|
||||
if ($contracts->isNotEmpty()) {
|
||||
$activeJobs = FieldJob::query()
|
||||
->whereIn('contract_id', $contracts->pluck('id'))
|
||||
->whereNull('completed_at')
|
||||
$unassignedContracts = Contract::query()
|
||||
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account'])
|
||||
->when($segmentIds->isNotEmpty(), fn($q) =>
|
||||
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
|
||||
fn($q) => $q->whereRaw('1 = 0')
|
||||
)
|
||||
->when( !empty($search), fn ($q) =>
|
||||
$q->where(fn($sq) =>
|
||||
$sq->where('reference', 'like', "%{$search}%")
|
||||
->orWhereHas('clientCase.person', fn($psq) =>
|
||||
$psq->where('full_name', 'ilike', "%{$search}%")
|
||||
)
|
||||
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
|
||||
$ccpaq->where('address', 'ilike', "%{$search}")
|
||||
)
|
||||
)
|
||||
)
|
||||
->when(!empty($unassignedClientUuids) && is_array($unassignedClientUuids), fn ($q) =>
|
||||
$q->whereHas('clientCase.client', fn($cq) =>
|
||||
$cq->whereIn('uuid', $unassignedClientUuids)
|
||||
)
|
||||
)
|
||||
->whereDoesntHave('fieldJobs', fn ($q) =>
|
||||
$q->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
|
||||
->get();
|
||||
)
|
||||
->latest('id');
|
||||
|
||||
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
|
||||
return [
|
||||
optional($job->contract)->uuid => [
|
||||
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
|
||||
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
|
||||
'assigned_at' => $job->assigned_at,
|
||||
],
|
||||
];
|
||||
})->filter();
|
||||
}
|
||||
$unassignedClients = $unassignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
|
||||
$assignedContracts = Contract::query()
|
||||
->with(['clientCase.person.addresses', 'clientCase.client.person:id,uuid,full_name', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user'])
|
||||
->when($segmentIds->isNotEmpty(), fn($q) =>
|
||||
$q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)),
|
||||
fn($q) => $q->whereRaw('1 = 0')
|
||||
)
|
||||
->when( !empty($searchAssigned), fn ($q) =>
|
||||
$q->where(fn($sq) =>
|
||||
$sq->where('reference', 'like', "%{$searchAssigned}%")
|
||||
->orWhereHas('clientCase.person', fn($psq) =>
|
||||
$psq->where('full_name', 'ilike', "%{$searchAssigned}%")
|
||||
)
|
||||
->orWhereHas('clientCase.person.addresses', fn ($ccpaq) =>
|
||||
$ccpaq->where('address', 'ilike', "%{$searchAssigned}")
|
||||
)
|
||||
)
|
||||
)
|
||||
->when(!empty($assignedClientUuids) && is_array($assignedClientUuids), fn ($q) =>
|
||||
$q->whereHas('clientCase.client', fn($cq) =>
|
||||
$cq->whereIn('uuid', $assignedClientUuids)
|
||||
)
|
||||
)
|
||||
->whereHas('lastFieldJobs', fn ($q) =>
|
||||
$q->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->when($assignedUserId && $assignedUserId !== 'all', fn ($jq) =>
|
||||
$jq->where('assigned_user_id', $assignedUserId))
|
||||
)
|
||||
->latest('id');
|
||||
|
||||
$assignedClients = $assignedContracts->get()
|
||||
->pluck('clientCase.client')
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('FieldJob/Index', [
|
||||
'setting' => $setting,
|
||||
'contracts' => $contracts,
|
||||
'unassignedContracts' => $unassignedContracts->paginate(
|
||||
$request->input('per_page_contracts', 10),
|
||||
['*'],
|
||||
'page_contracts',
|
||||
$request->input('page_contracts', 1)
|
||||
),
|
||||
'assignedContracts' => $assignedContracts->paginate(
|
||||
$request->input('per_page_assignments', 10),
|
||||
['*'],
|
||||
'page_assignments',
|
||||
$request->input('page_assignments', 1)
|
||||
),
|
||||
'unassignedClients' => $unassignedClients,
|
||||
'assignedClients' => $assignedClients,
|
||||
'users' => $users,
|
||||
'assignments' => $assignments,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'search_assigned' => $searchAssigned,
|
||||
'assigned_user_id' => $assignedUserId,
|
||||
'unassigned_client_uuids' => $unassignedClientUuids,
|
||||
'assigned_client_uuids' => $assignedClientUuids,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportTemplate;
|
||||
use App\Services\CsvImportService;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
use App\Services\ImportProcessor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -21,14 +23,35 @@ class ImportController extends Controller
|
|||
// List imports (paginated)
|
||||
public function index(Request $request)
|
||||
{
|
||||
$paginator = Import::query()
|
||||
$query = Import::query()
|
||||
->with([
|
||||
'client:id,uuid,person_id',
|
||||
'client.person:id,uuid,full_name',
|
||||
'template:id,name',
|
||||
])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(15);
|
||||
->orderByDesc('created_at');
|
||||
|
||||
// Apply search filter
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('original_name', 'LIKE', "%{$search}%")
|
||||
->orWhere('status', 'LIKE', "%{$search}%")
|
||||
->orWhereHas('client.person', function ($q) use ($search) {
|
||||
$q->where('full_name', 'LIKE', "%{$search}%");
|
||||
})
|
||||
->orWhereHas('template', function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get per_page from request, default to 25
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
if ($perPage < 1 || $perPage > 100) {
|
||||
$perPage = 25;
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$imports = [
|
||||
'data' => $paginator->items(),
|
||||
|
|
@ -164,9 +187,24 @@ public function store(Request $request)
|
|||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
'import_id' => $import->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$import->update(['status' => 'failed']);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Import processing failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze the uploaded file and return column headers or positional indices
|
||||
|
|
@ -405,7 +443,7 @@ public function missingContracts(Import $import)
|
|||
|
||||
// Query active, non-archived contracts for this client that were not in import
|
||||
// Include person full_name (owner of the client case) and aggregate active accounts' balance_amount
|
||||
$contractsQ = \App\Models\Contract::query()
|
||||
$contractsQ = Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->join('person', 'person.id', '=', 'client_cases.person_id')
|
||||
->leftJoin('accounts', function ($join) {
|
||||
|
|
@ -493,7 +531,7 @@ public function getEvents(Import $import)
|
|||
public function missingKeyrefRows(Import $import)
|
||||
{
|
||||
// Identify row IDs from events. Prefer specific event key, fallback to message pattern
|
||||
$rowIds = \App\Models\ImportEvent::query()
|
||||
$rowIds = ImportEvent::query()
|
||||
->where('import_id', $import->id)
|
||||
->where(function ($q) {
|
||||
$q->where('event', 'contract_keyref_not_found')
|
||||
|
|
@ -673,6 +711,10 @@ public function simulatePayments(Import $import, Request $request)
|
|||
* using the first N rows and current saved mappings. Works for both payments and non-payments
|
||||
* templates. For payments templates, payment-specific summaries/entities will be included
|
||||
* automatically by the simulation service when mappings contain the payment root.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function simulate(Import $import, Request $request)
|
||||
{
|
||||
|
|
@ -683,7 +725,7 @@ public function simulate(Import $import, Request $request)
|
|||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
$verbose = (bool) ($validated['verbose'] ?? false);
|
||||
|
||||
$service = app(\App\Services\ImportSimulationService::class);
|
||||
$service = app(ImportSimulationServiceV2::class);
|
||||
$result = $service->simulate($import, $limit, $verbose);
|
||||
|
||||
return response()->json($result);
|
||||
|
|
@ -785,6 +827,6 @@ public function destroy(Request $request, Import $import)
|
|||
|
||||
$import->delete();
|
||||
|
||||
return back()->with(['ok' => true]);
|
||||
return back()->with('success', 'Import deleted successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ public function index()
|
|||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Preload options for import mapping
|
||||
$clients = Client::query()
|
||||
->join('person', 'person.id', '=', 'clients.person_id')
|
||||
->orderBy('person.full_name')
|
||||
->get(['clients.uuid', DB::raw('person.full_name as name')]);
|
||||
|
||||
$segments = Segment::query()->orderBy('name')->get(['id', 'name']);
|
||||
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
|
||||
$actions = Action::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('Imports/Templates/Index', [
|
||||
'templates' => $templates->map(fn ($t) => [
|
||||
'uuid' => $t->uuid,
|
||||
|
|
@ -35,6 +45,10 @@ public function index()
|
|||
'name' => $t->client->person?->full_name,
|
||||
] : null,
|
||||
]),
|
||||
'clients' => $clients,
|
||||
'segments' => $segments,
|
||||
'decisions' => $decisions,
|
||||
'actions' => $actions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -547,6 +561,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
|||
'options' => 'nullable|array',
|
||||
'position' => 'nullable|integer',
|
||||
])->validate();
|
||||
|
||||
$mapping->update([
|
||||
'source_column' => $data['source_column'],
|
||||
'entity' => $data['entity'] ?? null,
|
||||
|
|
@ -557,8 +572,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
|
|||
'position' => $data['position'] ?? $mapping->position,
|
||||
]);
|
||||
|
||||
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
|
||||
->with('success', 'Mapping updated');
|
||||
return back()->with('success', 'Mapping updated');
|
||||
}
|
||||
|
||||
// Delete a mapping
|
||||
|
|
@ -643,6 +657,7 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
|
|||
|
||||
$import->update([
|
||||
'import_template_id' => $template->id,
|
||||
'reactivate' => $template->reactivate,
|
||||
'meta' => $merged,
|
||||
]);
|
||||
});
|
||||
|
|
@ -664,4 +679,138 @@ public function destroy(ImportTemplate $template)
|
|||
|
||||
return redirect()->route('importTemplates.index')->with('success', 'Template deleted');
|
||||
}
|
||||
|
||||
// Export template as JSON file
|
||||
public function export(ImportTemplate $template)
|
||||
{
|
||||
$template->load('mappings');
|
||||
|
||||
$data = [
|
||||
'name' => $template->name,
|
||||
'description' => $template->description,
|
||||
'source_type' => $template->source_type,
|
||||
'default_record_type' => $template->default_record_type,
|
||||
'sample_headers' => $template->sample_headers,
|
||||
'is_active' => $template->is_active,
|
||||
'reactivate' => $template->reactivate,
|
||||
'meta' => $template->meta,
|
||||
'mappings' => $template->mappings->map(fn ($m) => [
|
||||
'source_column' => $m->source_column,
|
||||
'entity' => $m->entity,
|
||||
'target_field' => $m->target_field,
|
||||
'transform' => $m->transform,
|
||||
'apply_mode' => $m->apply_mode,
|
||||
'options' => $m->options,
|
||||
'position' => $m->position,
|
||||
])->values()->toArray(),
|
||||
];
|
||||
|
||||
$filename = Str::slug($template->name).'-'.now()->format('Y-m-d').'.json';
|
||||
|
||||
return response()->json($data)
|
||||
->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
|
||||
}
|
||||
|
||||
// Import template from JSON file
|
||||
public function import(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'file' => 'required|file|mimes:json,txt|max:10240',
|
||||
'client_uuid' => 'nullable|string|exists:clients,uuid',
|
||||
'segment_id' => 'nullable|integer|exists:segments,id',
|
||||
'decision_id' => 'nullable|integer|exists:decisions,id',
|
||||
'action_id' => 'nullable|integer|exists:actions,id',
|
||||
'activity_action_id' => 'nullable|integer|exists:actions,id',
|
||||
'activity_decision_id' => 'nullable|integer|exists:decisions,id',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$contents = file_get_contents($file->getRealPath());
|
||||
$json = json_decode($contents, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return back()->withErrors(['file' => 'Invalid JSON file']);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
$validator = validator($json, [
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:255',
|
||||
'source_type' => 'required|string|in:csv,xml,xls,xlsx,json',
|
||||
'default_record_type' => 'nullable|string|max:50',
|
||||
'sample_headers' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'reactivate' => 'nullable|boolean',
|
||||
'meta' => 'nullable|array',
|
||||
'mappings' => 'nullable|array',
|
||||
'mappings.*.source_column' => 'required|string',
|
||||
'mappings.*.entity' => 'nullable|string',
|
||||
'mappings.*.target_field' => 'nullable|string',
|
||||
'mappings.*.transform' => 'nullable|string',
|
||||
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref',
|
||||
'mappings.*.options' => 'nullable|array',
|
||||
'mappings.*.position' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return back()->withErrors($validator)->withInput();
|
||||
}
|
||||
|
||||
$clientId = null;
|
||||
if (! empty($data['client_uuid'])) {
|
||||
$clientId = Client::where('uuid', $data['client_uuid'])->value('id');
|
||||
}
|
||||
|
||||
// Replace IDs in meta if provided
|
||||
$meta = $json['meta'] ?? [];
|
||||
if (! empty($data['segment_id'])) {
|
||||
$meta['segment_id'] = (int) $data['segment_id'];
|
||||
}
|
||||
if (! empty($data['decision_id'])) {
|
||||
$meta['decision_id'] = (int) $data['decision_id'];
|
||||
}
|
||||
if (! empty($data['action_id'])) {
|
||||
$meta['action_id'] = (int) $data['action_id'];
|
||||
}
|
||||
if (! empty($data['activity_action_id'])) {
|
||||
$meta['activity_action_id'] = (int) $data['activity_action_id'];
|
||||
}
|
||||
if (! empty($data['activity_decision_id'])) {
|
||||
$meta['activity_decision_id'] = (int) $data['activity_decision_id'];
|
||||
}
|
||||
|
||||
$template = null;
|
||||
DB::transaction(function () use (&$template, $request, $json, $clientId, $meta) {
|
||||
$template = ImportTemplate::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => $json['name'],
|
||||
'description' => $json['description'] ?? null,
|
||||
'source_type' => $json['source_type'],
|
||||
'default_record_type' => $json['default_record_type'] ?? null,
|
||||
'sample_headers' => $json['sample_headers'] ?? null,
|
||||
'user_id' => $request->user()?->id,
|
||||
'client_id' => $clientId,
|
||||
'is_active' => $json['is_active'] ?? true,
|
||||
'reactivate' => $json['reactivate'] ?? false,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
|
||||
foreach (($json['mappings'] ?? []) as $m) {
|
||||
ImportTemplateMapping::create([
|
||||
'import_template_id' => $template->id,
|
||||
'entity' => $m['entity'] ?? null,
|
||||
'source_column' => $m['source_column'],
|
||||
'target_field' => $m['target_field'] ?? null,
|
||||
'transform' => $m['transform'] ?? null,
|
||||
'apply_mode' => $m['apply_mode'] ?? 'both',
|
||||
'options' => $m['options'] ?? null,
|
||||
'position' => $m['position'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('importTemplates.edit', ['template' => $template->uuid])
|
||||
->with('success', 'Template imported successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public function unread(Request $request)
|
|||
}
|
||||
|
||||
$today = now()->toDateString();
|
||||
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
|
||||
$perPage = max(1, min(100, (int) $request->integer('per_page', 15)));
|
||||
$search = trim((string) $request->input('search', ''));
|
||||
$clientUuid = trim((string) $request->input('client', ''));
|
||||
$clientId = null;
|
||||
|
|
|
|||
|
|
@ -26,18 +26,10 @@ public function update(Person $person, Request $request)
|
|||
|
||||
$person->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Person updated');
|
||||
}
|
||||
return back()->with('success', 'Person updated')->with('flash_method', 'PUT');
|
||||
|
||||
|
||||
|
||||
return response()->json([
|
||||
'person' => [
|
||||
'full_name' => $person->full_name,
|
||||
'tax_number' => $person->tax_number,
|
||||
'social_security_number' => $person->social_security_number,
|
||||
'description' => $person->description,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function createAddress(Person $person, Request $request)
|
||||
|
|
@ -60,13 +52,8 @@ public function createAddress(Person $person, Request $request)
|
|||
], $attributes);
|
||||
|
||||
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address created');
|
||||
}
|
||||
return back()->with('success', 'Address created')->with('flash_method', 'POST');
|
||||
|
||||
return response()->json([
|
||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAddress(Person $person, int $address_id, Request $request)
|
||||
|
|
@ -84,13 +71,8 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
|||
|
||||
$address->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address updated');
|
||||
}
|
||||
return back()->with('success', 'Address updated')->with('flash_method', 'PUT');
|
||||
|
||||
return response()->json([
|
||||
'address' => $address,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||
|
|
@ -98,11 +80,8 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
|||
$address = $person->addresses()->findOrFail($address_id);
|
||||
$address->delete(); // soft delete
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'Address deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
return back()->with('success', 'Address deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
public function createPhone(Person $person, Request $request)
|
||||
|
|
@ -122,7 +101,7 @@ public function createPhone(Person $person, Request $request)
|
|||
'country_code' => $attributes['country_code'] ?? null,
|
||||
], $attributes);
|
||||
|
||||
return back()->with('success', 'Phone added successfully');
|
||||
return back()->with('success', 'Phone added successfully')->with('flash_method', 'POST');
|
||||
}
|
||||
|
||||
public function updatePhone(Person $person, int $phone_id, Request $request)
|
||||
|
|
@ -140,7 +119,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
|
|||
|
||||
$phone->update($attributes);
|
||||
|
||||
return back()->with('success', 'Phone updated successfully');
|
||||
return back()->with('success', 'Phone updated successfully')->with('flash_method', 'PUT');
|
||||
}
|
||||
|
||||
public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||
|
|
@ -148,7 +127,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
|||
$phone = $person->phones()->findOrFail($phone_id);
|
||||
$phone->delete(); // soft delete
|
||||
|
||||
return back()->with('success', 'Phone deleted');
|
||||
return back()->with('success', 'Phone deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
public function createEmail(Person $person, Request $request)
|
||||
|
|
@ -170,7 +149,7 @@ public function createEmail(Person $person, Request $request)
|
|||
'value' => $attributes['value'],
|
||||
], $attributes);
|
||||
|
||||
return back()->with('success', 'Email added successfully');
|
||||
return back()->with('success', 'Email added successfully')->with('flash_method', 'POST');
|
||||
}
|
||||
|
||||
public function updateEmail(Person $person, int $email_id, Request $request)
|
||||
|
|
@ -191,7 +170,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
|
|||
|
||||
$email->update($attributes);
|
||||
|
||||
return back()->with('success', 'Email updated successfully');
|
||||
return back()->with('success', 'Email updated successfully')->with('flash_method', 'PUT');
|
||||
}
|
||||
|
||||
public function deleteEmail(Person $person, int $email_id, Request $request)
|
||||
|
|
@ -203,7 +182,7 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
|
|||
return back()->with('success', 'Email deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
return back()->with('success', 'Email deleted')->with('flash_method', 'DELETE');
|
||||
}
|
||||
|
||||
// TRR (bank account) CRUD
|
||||
|
|
@ -225,13 +204,10 @@ public function createTrr(Person $person, Request $request)
|
|||
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||
$trr = $person->bankAccounts()->create($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR added successfully');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'trr' => BankAccount::findOrFail($trr->id),
|
||||
]);
|
||||
return back()->with('success', 'TRR added successfully')->with('flash_method', 'POST');
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function updateTrr(Person $person, int $trr_id, Request $request)
|
||||
|
|
@ -253,13 +229,8 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->update($attributes);
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR updated successfully');
|
||||
}
|
||||
return back()->with('success', 'TRR updated successfully')->with('flash_method', 'PUT');
|
||||
|
||||
return response()->json([
|
||||
'trr' => $trr,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||
|
|
@ -267,10 +238,8 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
|||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||
$trr->delete();
|
||||
|
||||
if ($request->header('X-Inertia')) {
|
||||
return back()->with('success', 'TRR deleted');
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
return back()->with('success', 'TRR deleted')->with('flash_method', 'DELETE');
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,22 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\FieldJob;
|
||||
use App\Services\ReferenceDataCache;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PhoneViewController extends Controller
|
||||
{
|
||||
public function __construct(protected ReferenceDataCache $referenceCache) {}
|
||||
public function index(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
$query = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
|
|
@ -21,32 +27,78 @@ public function index(Request $request)
|
|||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('assigned_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
->orderByDesc('assigned_at');
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
})
|
||||
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at');
|
||||
})
|
||||
->with(['person:id,full_name'])
|
||||
->get(['uuid', 'person_id'])
|
||||
->map(fn ($c) => [
|
||||
'uuid' => (string) $c->uuid,
|
||||
'name' => (string) optional($c->person)->full_name,
|
||||
])
|
||||
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'assigned',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedToday(Request $request)
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$search = $request->input('search');
|
||||
$clientFilter = $request->input('client');
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
$jobs = FieldJob::query()
|
||||
$query = FieldJob::query()
|
||||
->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end])
|
||||
|
|
@ -55,21 +107,63 @@ public function completedToday(Request $request)
|
|||
$q->with([
|
||||
'type:id,name',
|
||||
'account',
|
||||
'clientCase.person' => function ($pq) {
|
||||
$pq->with(['addresses', 'phones']);
|
||||
},
|
||||
'clientCase.person.address.type',
|
||||
'clientCase.person.phones',
|
||||
'clientCase.client:id,uuid,person_id',
|
||||
'clientCase.client.person:id,full_name',
|
||||
]);
|
||||
},
|
||||
])
|
||||
->orderByDesc('completed_at')
|
||||
->limit(100)
|
||||
->get();
|
||||
->orderByDesc('completed_at');
|
||||
|
||||
// Apply client filter
|
||||
if ($clientFilter) {
|
||||
$query->whereHas('contract.clientCase.client', function ($q) use ($clientFilter) {
|
||||
$q->where('uuid', $clientFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('contract', function ($cq) use ($search) {
|
||||
$cq->where('reference', 'ilike', '%'.$search.'%')
|
||||
->orWhereHas('clientCase.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
})
|
||||
->orWhereHas('clientCase.client.person', function ($pq) use ($search) {
|
||||
$pq->where('full_name', 'ilike', '%'.$search.'%');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$jobs = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get unique clients for filter dropdown
|
||||
$clients = \App\Models\Client::query()
|
||||
->whereHas('clientCases.contracts.fieldJobs', function ($q) use ($userId, $start, $end) {
|
||||
$q->where('assigned_user_id', $userId)
|
||||
->whereNull('cancelled_at')
|
||||
->whereBetween('completed_at', [$start, $end]);
|
||||
})
|
||||
->with(['person:id,full_name'])
|
||||
->get(['uuid', 'person_id'])
|
||||
->map(fn ($c) => [
|
||||
'uuid' => (string) $c->uuid,
|
||||
'name' => (string) optional($c->person)->full_name,
|
||||
])
|
||||
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
|
||||
return Inertia::render('Phone/Index', [
|
||||
'jobs' => $jobs,
|
||||
'clients' => $clients,
|
||||
'view_mode' => 'completed-today',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'client' => $clientFilter,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +173,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
|||
$completedMode = $request->boolean('completed');
|
||||
|
||||
// Eager load case with person details
|
||||
$case = $clientCase->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||
$case = $clientCase->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts');
|
||||
|
||||
// Query contracts based on field jobs
|
||||
$contractsQuery = FieldJob::query()
|
||||
|
|
@ -129,7 +223,7 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
|
|||
->unique();
|
||||
|
||||
return Inertia::render('Phone/Case/Index', [
|
||||
'client' => $case->client->load('person.addresses', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||
'client' => $case->client->load('person.address.type', 'person.phones', 'person.emails', 'person.bankAccounts'),
|
||||
'client_case' => $case,
|
||||
'contracts' => $contracts,
|
||||
'documents' => $documents,
|
||||
|
|
|
|||
423
app/Http/Controllers/ReportController.php
Normal file
423
app/Http/Controllers/ReportController.php
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Report;
|
||||
use App\Services\ReportQueryBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// facades referenced with fully-qualified names below to satisfy static analysis
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function __construct(protected ReportQueryBuilder $queryBuilder) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$reports = Report::where('enabled', true)
|
||||
->orderBy('order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'slug' => $r->slug,
|
||||
'name' => $r->name,
|
||||
'description' => $r->description,
|
||||
'category' => $r->category,
|
||||
])
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Index', [
|
||||
'reports' => $reports,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug, Request $request)
|
||||
{
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
// Accept filters & pagination from query and return initial data for server-driven table
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
\Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]);
|
||||
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return Inertia::render('Reports/Show', [
|
||||
'slug' => $report->slug,
|
||||
'name' => $report->name,
|
||||
'description' => $report->description,
|
||||
'inputs' => $inputs,
|
||||
'columns' => $this->buildColumnsArray($report),
|
||||
'rows' => $rows,
|
||||
'meta' => [
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
'query' => array_filter($filters, fn ($v) => $v !== null && $v !== ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(string $slug, Request $request)
|
||||
{
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
$perPage = (int) ($request->integer('per_page') ?: 25);
|
||||
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$rows = collect($paginator->items())
|
||||
->map(fn ($row) => $this->normalizeRow($row))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows,
|
||||
'total' => $paginator->total(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(string $slug, Request $request)
|
||||
{
|
||||
$report = Report::with(['filters', 'columns'])
|
||||
->where('slug', $slug)
|
||||
->where('enabled', true)
|
||||
->firstOrFail();
|
||||
|
||||
$inputs = $this->buildInputsArray($report);
|
||||
$filters = $this->validateFilters($inputs, $request);
|
||||
$format = strtolower((string) $request->get('format', 'csv'));
|
||||
|
||||
$query = $this->queryBuilder->build($report, $filters);
|
||||
$rows = $query->get()->map(fn ($row) => $this->normalizeRow($row));
|
||||
$columns = $this->buildColumnsArray($report);
|
||||
$filename = $report->slug.'-'.now()->format('Ymd_His');
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('reports.pdf.table', [
|
||||
'name' => $report->name,
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
||||
return $pdf->download($filename.'.pdf');
|
||||
}
|
||||
|
||||
if ($format === 'xlsx') {
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
// Convert values for correct Excel rendering (dates, numbers, text)
|
||||
$array = $this->prepareXlsxArray($rows, $keys);
|
||||
|
||||
// Build base column formats: text for contracts, EU datetime for *_at; numbers are formatted per-cell in AfterSheet
|
||||
$columnFormats = [];
|
||||
$textColumns = [];
|
||||
$dateColumns = [];
|
||||
foreach ($keys as $i => $key) {
|
||||
$letter = $this->excelColumnLetter($i + 1);
|
||||
if ($key === 'contract_reference') {
|
||||
$columnFormats[$letter] = '@';
|
||||
$textColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($key, '_at')) {
|
||||
$columnFormats[$letter] = 'dd.mm.yyyy hh:mm';
|
||||
$dateColumns[] = $letter;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous export with custom value binder to force text where needed
|
||||
$export = new class($array, $headings, $columnFormats, $textColumns, $dateColumns) extends \Maatwebsite\Excel\DefaultValueBinder implements \Maatwebsite\Excel\Concerns\FromArray, \Maatwebsite\Excel\Concerns\ShouldAutoSize, \Maatwebsite\Excel\Concerns\WithColumnFormatting, \Maatwebsite\Excel\Concerns\WithCustomValueBinder, \Maatwebsite\Excel\Concerns\WithEvents, \Maatwebsite\Excel\Concerns\WithHeadings
|
||||
{
|
||||
public function __construct(private array $array, private array $headings, private array $formats, private array $textColumns, private array $dateColumns) {}
|
||||
|
||||
public function array(): array
|
||||
{
|
||||
return $this->array;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return $this->headings;
|
||||
}
|
||||
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return $this->formats;
|
||||
}
|
||||
|
||||
public function bindValue(\PhpOffice\PhpSpreadsheet\Cell\Cell $cell, $value): bool
|
||||
{
|
||||
$col = preg_replace('/\d+/', '', $cell->getCoordinate()); // e.g., B from B2
|
||||
// Force text for configured columns or very long digit-only strings (>15)
|
||||
if (in_array($col, $this->textColumns, true) || (is_string($value) && ctype_digit($value) && strlen($value) > 15)) {
|
||||
$cell->setValueExplicit((string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::bindValue($cell, $value);
|
||||
}
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [
|
||||
\Maatwebsite\Excel\Events\AfterSheet::class => function (\Maatwebsite\Excel\Events\AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
// Data starts at row 2 (row 1 is headings)
|
||||
$rowIndex = 2;
|
||||
foreach ($this->array as $row) {
|
||||
foreach (array_values($row) as $i => $val) {
|
||||
$colLetter = $this->colLetter($i + 1);
|
||||
if (in_array($colLetter, $this->textColumns, true) || in_array($colLetter, $this->dateColumns, true)) {
|
||||
continue; // already handled via columnFormats or binder
|
||||
}
|
||||
$coord = $colLetter.$rowIndex;
|
||||
$fmt = null;
|
||||
if (is_int($val)) {
|
||||
// Integer: thousands separator, no decimals
|
||||
$fmt = '#,##0';
|
||||
} elseif (is_float($val)) {
|
||||
// Float: show decimals only if fractional part exists
|
||||
$fmt = (floor($val) != $val) ? '#,##0.00' : '#,##0';
|
||||
}
|
||||
if ($fmt) {
|
||||
$sheet->getStyle($coord)->getNumberFormat()->setFormatCode($fmt);
|
||||
}
|
||||
}
|
||||
$rowIndex++;
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private function colLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
};
|
||||
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($export, $filename.'.xlsx');
|
||||
}
|
||||
|
||||
// Default CSV export
|
||||
$keys = array_map(fn ($c) => $c['key'], $columns);
|
||||
$headings = array_map(fn ($c) => $c['label'] ?? $c['key'], $columns);
|
||||
|
||||
$csv = fopen('php://temp', 'r+');
|
||||
fputcsv($csv, $headings);
|
||||
foreach ($rows as $r) {
|
||||
$line = collect($keys)->map(fn ($k) => data_get($r, $k))->toArray();
|
||||
fputcsv($csv, $line);
|
||||
}
|
||||
rewind($csv);
|
||||
$content = stream_get_contents($csv) ?: '';
|
||||
fclose($csv);
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'.csv"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight users lookup for filters: id + name, optional search and limit.
|
||||
*/
|
||||
public function users(Request $request)
|
||||
{
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
$limit = (int) ($request->integer('limit') ?: 10);
|
||||
|
||||
$q = \App\Models\User::query()->orderBy('name');
|
||||
if ($search !== '') {
|
||||
$like = '%'.mb_strtolower($search).'%';
|
||||
$q->where(function ($qq) use ($like) {
|
||||
$qq->whereRaw('LOWER(name) LIKE ?', [$like])
|
||||
->orWhereRaw('LOWER(email) LIKE ?', [$like]);
|
||||
});
|
||||
}
|
||||
|
||||
$users = $q->limit(max(1, min(50, $limit)))->get(['id', 'name']);
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight clients lookup for filters: uuid + name (person full_name), optional search and limit.
|
||||
*/
|
||||
public function clients(Request $request)
|
||||
{
|
||||
$clients = \App\Models\Client::query()
|
||||
->with('person:id,full_name')
|
||||
->get()
|
||||
->map(fn($c) => [
|
||||
'id' => $c->uuid,
|
||||
'name' => $c->person->full_name ?? 'Unknown'
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
return response()->json($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules based on inputs descriptor and validate.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $inputs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validateFilters(array $inputs, Request $request): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach ($inputs as $inp) {
|
||||
$key = $inp['key'];
|
||||
$type = $inp['type'] ?? 'string';
|
||||
$nullable = ($inp['nullable'] ?? true) ? 'nullable' : 'required';
|
||||
$rules[$key] = match ($type) {
|
||||
'date' => [$nullable, 'date'],
|
||||
'integer' => [$nullable, 'integer'],
|
||||
'select:user' => [$nullable, 'integer', 'exists:users,id'],
|
||||
'select:client' => [$nullable, 'string', 'exists:clients,uuid'],
|
||||
default => [$nullable, 'string'],
|
||||
};
|
||||
}
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inputs array from report filters.
|
||||
*/
|
||||
protected function buildInputsArray(Report $report): array
|
||||
{
|
||||
return $report->filters->map(fn($filter) => [
|
||||
'key' => $filter->key,
|
||||
'type' => $filter->type,
|
||||
'label' => $filter->label,
|
||||
'nullable' => $filter->nullable,
|
||||
'default' => $filter->default_value,
|
||||
'options' => $filter->options,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build columns array from report columns.
|
||||
*/
|
||||
protected function buildColumnsArray(Report $report): array
|
||||
{
|
||||
return $report->columns
|
||||
->where('visible', true)
|
||||
->map(fn($col) => [
|
||||
'key' => $col->key,
|
||||
'label' => $col->label,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure derived export/display fields exist on row objects.
|
||||
*/
|
||||
protected function normalizeRow(object $row): object
|
||||
{
|
||||
if (isset($row->contract) && ! isset($row->contract_reference)) {
|
||||
$row->contract_reference = $row->contract->reference ?? null;
|
||||
}
|
||||
if (isset($row->assignedUser) && ! isset($row->assigned_user_name)) {
|
||||
$row->assigned_user_name = $row->assignedUser->name ?? null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rows for XLSX export: dates to Excel serial numbers, numbers to numeric, contract refs to text.
|
||||
*
|
||||
* @param iterable<int, object|array> $rows
|
||||
* @param array<int, string> $keys
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
protected function prepareXlsxArray(iterable $rows, array $keys): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$line = [];
|
||||
foreach ($keys as $k) {
|
||||
$v = data_get($r, $k);
|
||||
if ($k === 'contract_reference') {
|
||||
$line[] = (string) $v;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($k, '_at')) {
|
||||
if (empty($v)) {
|
||||
$line[] = null;
|
||||
} else {
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($v);
|
||||
$line[] = \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($dt);
|
||||
} catch (\Throwable $e) {
|
||||
$line[] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_int($v) || is_float($v)) {
|
||||
$line[] = $v;
|
||||
} elseif (is_numeric($v) && is_string($v)) {
|
||||
// cast numeric-like strings unless they are identifiers that we want as text
|
||||
$line[] = (strpos($k, 'id') !== false) ? (int) $v : ($v + 0);
|
||||
} else {
|
||||
$line[] = $v;
|
||||
}
|
||||
}
|
||||
$out[] = $line;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 1-based index to Excel column letter.
|
||||
*/
|
||||
protected function excelColumnLetter(int $index): string
|
||||
{
|
||||
$letter = '';
|
||||
while ($index > 0) {
|
||||
$mod = ($index - 1) % 26;
|
||||
$letter = chr(65 + $mod).$letter;
|
||||
$index = intdiv($index - $mod, 26) - 1;
|
||||
}
|
||||
|
||||
return $letter;
|
||||
}
|
||||
}
|
||||
293
app/Http/Controllers/Settings/ReportSettingsController.php
Normal file
293
app/Http/Controllers/Settings/ReportSettingsController.php
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Report;
|
||||
use App\Models\ReportEntity;
|
||||
use App\Models\ReportColumn;
|
||||
use App\Models\ReportFilter;
|
||||
use App\Models\ReportCondition;
|
||||
use App\Models\ReportOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ReportSettingsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$reports = Report::orderBy('order')->orderBy('name')->get();
|
||||
|
||||
return Inertia::render('Settings/Reports/Index', [
|
||||
'reports' => $reports,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Report $report)
|
||||
{
|
||||
$report->load(['entities', 'columns', 'filters', 'conditions', 'orders']);
|
||||
|
||||
return Inertia::render('Settings/Reports/Edit', [
|
||||
'report' => $report,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slug' => 'required|string|unique:reports,slug|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'enabled' => 'boolean',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$report = Report::create($validated);
|
||||
|
||||
return redirect()->route('settings.reports.index')
|
||||
->with('success', 'Report created successfully.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'slug' => 'required|string|unique:reports,slug,' . $report->id . '|max:255',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'enabled' => 'boolean',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$report->update($validated);
|
||||
|
||||
return redirect()->route('settings.reports.index')
|
||||
->with('success', 'Report updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Report $report)
|
||||
{
|
||||
$report->delete();
|
||||
|
||||
return redirect()->route('settings.reports.index')
|
||||
->with('success', 'Report deleted successfully.');
|
||||
}
|
||||
|
||||
public function toggleEnabled(Report $report)
|
||||
{
|
||||
$report->update(['enabled' => !$report->enabled]);
|
||||
|
||||
return back()->with('success', 'Report status updated.');
|
||||
}
|
||||
|
||||
// Entity CRUD
|
||||
public function storeEntity(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'model_class' => 'required|string|max:255',
|
||||
'alias' => 'nullable|string|max:50',
|
||||
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
|
||||
'join_first' => 'nullable|string|max:100',
|
||||
'join_operator' => 'nullable|string|max:10',
|
||||
'join_second' => 'nullable|string|max:100',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$report->entities()->create($validated);
|
||||
|
||||
return back()->with('success', 'Entity added successfully.');
|
||||
}
|
||||
|
||||
public function updateEntity(Request $request, ReportEntity $entity)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'model_class' => 'required|string|max:255',
|
||||
'alias' => 'nullable|string|max:50',
|
||||
'join_type' => 'required|in:base,join,leftJoin,rightJoin',
|
||||
'join_first' => 'nullable|string|max:100',
|
||||
'join_operator' => 'nullable|string|max:10',
|
||||
'join_second' => 'nullable|string|max:100',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$entity->update($validated);
|
||||
|
||||
return back()->with('success', 'Entity updated successfully.');
|
||||
}
|
||||
|
||||
public function destroyEntity(ReportEntity $entity)
|
||||
{
|
||||
$entity->delete();
|
||||
|
||||
return back()->with('success', 'Entity deleted successfully.');
|
||||
}
|
||||
|
||||
// Column CRUD
|
||||
public function storeColumn(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => 'required|string|max:100',
|
||||
'label' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:50',
|
||||
'expression' => 'required|string',
|
||||
'sortable' => 'boolean',
|
||||
'visible' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'format_options' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$report->columns()->create($validated);
|
||||
|
||||
return back()->with('success', 'Column added successfully.');
|
||||
}
|
||||
|
||||
public function updateColumn(Request $request, ReportColumn $column)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => 'required|string|max:100',
|
||||
'label' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:50',
|
||||
'expression' => 'required|string',
|
||||
'sortable' => 'boolean',
|
||||
'visible' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'format_options' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$column->update($validated);
|
||||
|
||||
return back()->with('success', 'Column updated successfully.');
|
||||
}
|
||||
|
||||
public function destroyColumn(ReportColumn $column)
|
||||
{
|
||||
$column->delete();
|
||||
|
||||
return back()->with('success', 'Column deleted successfully.');
|
||||
}
|
||||
|
||||
// Filter CRUD
|
||||
public function storeFilter(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => 'required|string|max:100',
|
||||
'label' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:50',
|
||||
'nullable' => 'boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
'data_source' => 'nullable|string|max:255',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$report->filters()->create($validated);
|
||||
|
||||
return back()->with('success', 'Filter added successfully.');
|
||||
}
|
||||
|
||||
public function updateFilter(Request $request, ReportFilter $filter)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'key' => 'required|string|max:100',
|
||||
'label' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:50',
|
||||
'nullable' => 'boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
'data_source' => 'nullable|string|max:255',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$filter->update($validated);
|
||||
|
||||
return back()->with('success', 'Filter updated successfully.');
|
||||
}
|
||||
|
||||
public function destroyFilter(ReportFilter $filter)
|
||||
{
|
||||
$filter->delete();
|
||||
|
||||
return back()->with('success', 'Filter deleted successfully.');
|
||||
}
|
||||
|
||||
// Condition CRUD
|
||||
public function storeCondition(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'column' => 'required|string|max:255',
|
||||
'operator' => 'required|string|max:50',
|
||||
'value_type' => 'required|in:static,filter,expression',
|
||||
'value' => 'nullable|string',
|
||||
'filter_key' => 'nullable|string|max:100',
|
||||
'logical_operator' => 'required|in:AND,OR',
|
||||
'group_id' => 'nullable|integer',
|
||||
'order' => 'integer',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$report->conditions()->create($validated);
|
||||
|
||||
return back()->with('success', 'Condition added successfully.');
|
||||
}
|
||||
|
||||
public function updateCondition(Request $request, ReportCondition $condition)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'column' => 'required|string|max:255',
|
||||
'operator' => 'required|string|max:50',
|
||||
'value_type' => 'required|in:static,filter,expression',
|
||||
'value' => 'nullable|string',
|
||||
'filter_key' => 'nullable|string|max:100',
|
||||
'logical_operator' => 'required|in:AND,OR',
|
||||
'group_id' => 'nullable|integer',
|
||||
'order' => 'integer',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$condition->update($validated);
|
||||
|
||||
return back()->with('success', 'Condition updated successfully.');
|
||||
}
|
||||
|
||||
public function destroyCondition(ReportCondition $condition)
|
||||
{
|
||||
$condition->delete();
|
||||
|
||||
return back()->with('success', 'Condition deleted successfully.');
|
||||
}
|
||||
|
||||
// Order CRUD
|
||||
public function storeOrder(Request $request, Report $report)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'column' => 'required|string|max:255',
|
||||
'direction' => 'required|in:ASC,DESC',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$report->orders()->create($validated);
|
||||
|
||||
return back()->with('success', 'Order clause added successfully.');
|
||||
}
|
||||
|
||||
public function updateOrder(Request $request, ReportOrder $order)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'column' => 'required|string|max:255',
|
||||
'direction' => 'required|in:ASC,DESC',
|
||||
'order' => 'integer',
|
||||
]);
|
||||
|
||||
$order->update($validated);
|
||||
|
||||
return back()->with('success', 'Order clause updated successfully.');
|
||||
}
|
||||
|
||||
public function destroyOrder(ReportOrder $order)
|
||||
{
|
||||
$order->delete();
|
||||
|
||||
return back()->with('success', 'Order clause deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ public function share(Request $request): array
|
|||
'error' => fn () => $request->session()->get('error'),
|
||||
'warning' => fn () => $request->session()->get('warning'),
|
||||
'info' => fn () => $request->session()->get('info'),
|
||||
'method' => fn () => $request->session()->get('flash_method'), // HTTP method for toast styling
|
||||
],
|
||||
'notifications' => function () use ($request) {
|
||||
try {
|
||||
|
|
|
|||
26
app/Http/Resources/ActivityCollection.php
Normal file
26
app/Http/Resources/ActivityCollection.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ActivityCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
// Transform data to add user_name attribute
|
||||
$this->collection->transform(function ($activity) {
|
||||
$activity->setAttribute('user_name', optional($activity->user)->name);
|
||||
|
||||
return $activity;
|
||||
});
|
||||
|
||||
return $this->resource->toArray();
|
||||
}
|
||||
}
|
||||
19
app/Http/Resources/ContractCollection.php
Normal file
19
app/Http/Resources/ContractCollection.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ContractCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->resource->toArray();
|
||||
}
|
||||
}
|
||||
21
app/Http/Resources/DocumentCollection.php
Normal file
21
app/Http/Resources/DocumentCollection.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class DocumentCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Jobs/ProcessLargeImportJob.php
Normal file
107
app/Jobs/ProcessLargeImportJob.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessLargeImportJob implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 3600; // 1 hour
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Import $import,
|
||||
public ?int $userId = null
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Log::info('ProcessLargeImportJob started', [
|
||||
'import_id' => $this->import->id,
|
||||
'user_id' => $this->userId,
|
||||
]);
|
||||
|
||||
try {
|
||||
$user = $this->userId ? \App\Models\User::find($this->userId) : null;
|
||||
|
||||
$service = app(ImportServiceV2::class);
|
||||
$results = $service->process($this->import, $user);
|
||||
|
||||
Log::info('ProcessLargeImportJob completed', [
|
||||
'import_id' => $this->import->id,
|
||||
'results' => $results,
|
||||
]);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $this->import->id,
|
||||
'user_id' => $this->userId,
|
||||
'event' => 'queue_job_completed',
|
||||
'level' => 'info',
|
||||
'message' => sprintf(
|
||||
'Queued import completed: %d imported, %d skipped, %d invalid',
|
||||
$results['imported'],
|
||||
$results['skipped'],
|
||||
$results['invalid']
|
||||
),
|
||||
'context' => $results,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProcessLargeImportJob failed', [
|
||||
'import_id' => $this->import->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->import->update(['status' => 'failed']);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $this->import->id,
|
||||
'user_id' => $this->userId,
|
||||
'event' => 'queue_job_failed',
|
||||
'level' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('ProcessLargeImportJob permanently failed', [
|
||||
'import_id' => $this->import->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$this->import->update(['status' => 'failed']);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $this->import->id,
|
||||
'user_id' => $this->userId,
|
||||
'event' => 'queue_job_permanently_failed',
|
||||
'level' => 'error',
|
||||
'message' => 'Import job failed after maximum retries: '.$exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -57,6 +59,69 @@ protected static function booted()
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope activities to those linked to contracts within a specific segment.
|
||||
*/
|
||||
#[Scope]
|
||||
public function scopeForSegment(Builder $query, int $segmentId, array $contractIds): Builder
|
||||
{
|
||||
return $query->where(function ($q) use ($contractIds) {
|
||||
$q->whereNull('contract_id');
|
||||
if (! empty($contractIds)) {
|
||||
$q->orWhereIn('contract_id', $contractIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope activities with decoded base64 filters.
|
||||
*/
|
||||
#[Scope]
|
||||
public function scopeWithFilters(Builder $query, ?string $encodedFilters, \App\Models\ClientCase $clientCase): Builder
|
||||
{
|
||||
if (empty($encodedFilters)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
try {
|
||||
$decompressed = base64_decode($encodedFilters);
|
||||
$filters = json_decode($decompressed, true);
|
||||
|
||||
if (! is_array($filters)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if (! empty($filters['action_id'])) {
|
||||
$query->where('action_id', $filters['action_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['contract_uuid'])) {
|
||||
$contract = $clientCase->contracts()->where('uuid', $filters['contract_uuid'])->first(['id']);
|
||||
if ($contract) {
|
||||
$query->where('contract_id', $contract->id);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['user_id'])) {
|
||||
$query->where('user_id', $filters['user_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from'])) {
|
||||
$query->whereDate('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_to'])) {
|
||||
$query->whereDate('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Invalid activity filter format', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function action(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Action::class);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\Uuid;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -27,6 +29,7 @@ class Contract extends Model
|
|||
'end_date',
|
||||
'client_case_id',
|
||||
'type_id',
|
||||
'active',
|
||||
'description',
|
||||
'meta',
|
||||
];
|
||||
|
|
@ -78,6 +81,20 @@ protected function endDate(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope contracts to those in a specific segment with active pivot.
|
||||
*/
|
||||
#[Scope]
|
||||
public function scopeForSegment(Builder $query, int $segmentId): Builder
|
||||
{
|
||||
return $query->whereExists(function ($q) use ($segmentId) {
|
||||
$q->from('contract_segment')
|
||||
->whereColumn('contract_segment.contract_id', 'contracts.id')
|
||||
->where('contract_segment.segment_id', $segmentId)
|
||||
->where('contract_segment.active', true);
|
||||
});
|
||||
}
|
||||
|
||||
public function type(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\ContractType::class, 'type_id');
|
||||
|
|
@ -124,6 +141,10 @@ public function fieldJobs(): HasMany
|
|||
return $this->hasMany(\App\Models\FieldJob::class);
|
||||
}
|
||||
|
||||
public function lastFieldJobs(): HasOne {
|
||||
return $this->hasOne(\App\Models\FieldJob::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function latestObject(): HasOne
|
||||
{
|
||||
return $this->hasOne(\App\Models\CaseObject::class)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ class ImportEntity extends Model
|
|||
'meta',
|
||||
'rules',
|
||||
'ui',
|
||||
'handler_class',
|
||||
'validation_rules',
|
||||
'processing_options',
|
||||
'is_active',
|
||||
'priority',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -27,5 +32,9 @@ class ImportEntity extends Model
|
|||
'meta' => 'boolean',
|
||||
'rules' => 'array',
|
||||
'ui' => 'array',
|
||||
'validation_rules' => 'array',
|
||||
'processing_options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'priority' => 'integer',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ class ImportTemplate extends Model
|
|||
'reactivate' => 'boolean',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
|
|
|||
48
app/Models/Report.php
Normal file
48
app/Models/Report.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Report extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'enabled',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function entities(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportEntity::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function columns(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportColumn::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function filters(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportFilter::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function conditions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportCondition::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReportOrder::class)->orderBy('order');
|
||||
}
|
||||
}
|
||||
33
app/Models/ReportColumn.php
Normal file
33
app/Models/ReportColumn.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportColumn extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'report_id',
|
||||
'key',
|
||||
'label',
|
||||
'type',
|
||||
'expression',
|
||||
'sortable',
|
||||
'visible',
|
||||
'order',
|
||||
'format_options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sortable' => 'boolean',
|
||||
'visible' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'format_options' => 'array',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class);
|
||||
}
|
||||
}
|
||||
33
app/Models/ReportCondition.php
Normal file
33
app/Models/ReportCondition.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportCondition extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'report_id',
|
||||
'column',
|
||||
'operator',
|
||||
'value_type',
|
||||
'value',
|
||||
'filter_key',
|
||||
'logical_operator',
|
||||
'group_id',
|
||||
'order',
|
||||
'enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'group_id' => 'integer',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/ReportEntity.php
Normal file
29
app/Models/ReportEntity.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportEntity extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'report_id',
|
||||
'model_class',
|
||||
'alias',
|
||||
'join_type',
|
||||
'join_first',
|
||||
'join_operator',
|
||||
'join_second',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class);
|
||||
}
|
||||
}
|
||||
32
app/Models/ReportFilter.php
Normal file
32
app/Models/ReportFilter.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportFilter extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'report_id',
|
||||
'key',
|
||||
'label',
|
||||
'type',
|
||||
'nullable',
|
||||
'default_value',
|
||||
'options',
|
||||
'data_source',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'nullable' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class);
|
||||
}
|
||||
}
|
||||
25
app/Models/ReportOrder.php
Normal file
25
app/Models/ReportOrder.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportOrder extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'report_id',
|
||||
'column',
|
||||
'direction',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order' => 'integer',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Report::class);
|
||||
}
|
||||
}
|
||||
181
app/Services/ClientCaseDataService.php
Normal file
181
app/Services/ClientCaseDataService.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Document;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ClientCaseDataService
|
||||
{
|
||||
/**
|
||||
* Get paginated contracts for a client case with optional segment filtering.
|
||||
*/
|
||||
public function getContracts(ClientCase $clientCase, ?int $segmentId = null, int $perPage = 50): LengthAwarePaginator
|
||||
{
|
||||
$query = $clientCase->contracts()
|
||||
->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at'])
|
||||
->with([
|
||||
'type:id,name',
|
||||
'account' => function ($q) {
|
||||
$q->select([
|
||||
'accounts.id',
|
||||
'accounts.contract_id',
|
||||
'accounts.type_id',
|
||||
'accounts.initial_amount',
|
||||
'accounts.balance_amount',
|
||||
'accounts.promise_date',
|
||||
'accounts.created_at',
|
||||
'accounts.updated_at',
|
||||
])->orderByDesc('accounts.id');
|
||||
},
|
||||
'segments:id,name',
|
||||
'objects:id,contract_id,reference,name,description,type,created_at',
|
||||
])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
$query->forSegment($segmentId);
|
||||
}
|
||||
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'contracts_page')->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated activities for a client case with optional segment and filter constraints.
|
||||
*/
|
||||
public function getActivities(
|
||||
ClientCase $clientCase,
|
||||
?int $segmentId = null,
|
||||
?string $encodedFilters = null,
|
||||
array $contractIds = [],
|
||||
int $perPage = 20
|
||||
): LengthAwarePaginator {
|
||||
$query = $clientCase->activities()
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
$query->forSegment($segmentId, $contractIds);
|
||||
}
|
||||
|
||||
if (! empty($encodedFilters)) {
|
||||
$query->withFilters($encodedFilters, $clientCase);
|
||||
}
|
||||
|
||||
$perPage = max(1, min(100, $perPage));
|
||||
|
||||
return $query->paginate($perPage, ['*'], 'activities_page')->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged documents from case and its contracts.
|
||||
*/
|
||||
public function getDocuments(ClientCase $clientCase, array $contractIds = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = null;
|
||||
$caseDocsQuery = Document::query()
|
||||
->select([
|
||||
'documents.id',
|
||||
'documents.uuid',
|
||||
'documents.documentable_id',
|
||||
'documents.documentable_type',
|
||||
'documents.name',
|
||||
'documents.file_name',
|
||||
'documents.original_name',
|
||||
'documents.extension',
|
||||
'documents.mime_type',
|
||||
'documents.size',
|
||||
'documents.created_at',
|
||||
'documents.is_public',
|
||||
\DB::raw('NULL as contract_reference'),
|
||||
\DB::raw('NULL as contract_uuid'),
|
||||
\DB::raw("'{$clientCase->uuid}' as client_case_uuid"),
|
||||
\DB::raw('users.name as created_by'),
|
||||
])
|
||||
->join('users', 'documents.user_id', '=', 'users.id')
|
||||
->where('documents.documentable_type', ClientCase::class)
|
||||
->where('documents.documentable_id', $clientCase->id);
|
||||
|
||||
if (! empty($contractIds)) {
|
||||
// Get contract references for mapping
|
||||
$contracts = Contract::query()
|
||||
->whereIn('id', $contractIds)
|
||||
->get(['id', 'uuid', 'reference'])
|
||||
->keyBy('id');
|
||||
|
||||
$contractDocsQuery = Document::query()
|
||||
->select([
|
||||
'documents.id',
|
||||
'documents.uuid',
|
||||
'documents.documentable_id',
|
||||
'documents.documentable_type',
|
||||
'documents.name',
|
||||
'documents.file_name',
|
||||
'documents.original_name',
|
||||
'documents.extension',
|
||||
'documents.mime_type',
|
||||
'documents.size',
|
||||
'documents.created_at',
|
||||
'documents.is_public',
|
||||
'contracts.reference as contract_reference',
|
||||
'contracts.uuid as contract_uuid',
|
||||
\DB::raw('NULL as client_case_uuid'),
|
||||
\DB::raw('users.name as created_by'),
|
||||
])
|
||||
->join('users', 'documents.user_id', '=', 'users.id')
|
||||
->join('contracts', 'documents.documentable_id', '=', 'contracts.id')
|
||||
->where('documents.documentable_type', Contract::class)
|
||||
->whereIn('documents.documentable_id', $contractIds);
|
||||
|
||||
// Union the queries
|
||||
$query = $caseDocsQuery->union($contractDocsQuery);
|
||||
} else {
|
||||
$query = $caseDocsQuery;
|
||||
}
|
||||
|
||||
return \DB::table(\DB::raw("({$query->toSql()}) as documents"))
|
||||
->mergeBindings($query->getQuery())
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'documentsPage')
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archive metadata from latest non-reactivate archive setting.
|
||||
*/
|
||||
public function getArchiveMeta(): array
|
||||
{
|
||||
$latestArchiveSetting = \App\Models\ArchiveSetting::query()
|
||||
->where('enabled', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('reactivate')->orWhere('reactivate', false);
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$archiveSegmentId = optional($latestArchiveSetting)->segment_id;
|
||||
$relatedArchiveTables = [];
|
||||
|
||||
if ($latestArchiveSetting) {
|
||||
$entities = (array) $latestArchiveSetting->entities;
|
||||
foreach ($entities as $edef) {
|
||||
if (isset($edef['related']) && is_array($edef['related'])) {
|
||||
foreach ($edef['related'] as $rel) {
|
||||
$relatedArchiveTables[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
$relatedArchiveTables = array_values(array_unique($relatedArchiveTables));
|
||||
}
|
||||
|
||||
return [
|
||||
'archive_segment_id' => $archiveSegmentId,
|
||||
'related_tables' => $relatedArchiveTables,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,57 +2,11 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
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
|
||||
/**
|
||||
* Backward compatibility alias for DateNormalizer.
|
||||
* Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
class DateNormalizer extends \App\Services\Import\DateNormalizer
|
||||
{
|
||||
// This class extends the actual DateNormalizer for backward compatibility
|
||||
}
|
||||
|
|
|
|||
221
app/Services/Documents/DocumentStreamService.php
Normal file
221
app/Services/Documents/DocumentStreamService.php
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Documents;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class DocumentStreamService
|
||||
{
|
||||
/**
|
||||
* Stream a document either inline or as attachment with all Windows/public fallbacks.
|
||||
*/
|
||||
public function stream(Document $document, bool $inline = true): StreamedResponse|Response
|
||||
{
|
||||
$disk = $document->disk ?: 'public';
|
||||
$relPath = $this->normalizePath($document->path ?? '');
|
||||
|
||||
// Handle DOC/DOCX previews for inline viewing
|
||||
if ($inline) {
|
||||
$previewResponse = $this->tryPreview($document);
|
||||
if ($previewResponse) {
|
||||
return $previewResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the file using multiple path candidates
|
||||
$found = $this->findFile($disk, $relPath);
|
||||
|
||||
if (! $found) {
|
||||
// Try public/ fallback
|
||||
$found = $this->tryPublicFallback($relPath);
|
||||
if (! $found) {
|
||||
abort(404, 'Document file not found');
|
||||
}
|
||||
}
|
||||
|
||||
$headers = $this->buildHeaders($document, $inline);
|
||||
|
||||
// Try streaming first
|
||||
$stream = Storage::disk($disk)->readStream($found);
|
||||
if ($stream !== false) {
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
// Fallbacks on readStream failure
|
||||
return $this->fallbackStream($disk, $found, $document, $relPath, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for Windows and legacy prefixes.
|
||||
*/
|
||||
protected function normalizePath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = ltrim($path, '/');
|
||||
if (str_starts_with($path, 'public/')) {
|
||||
$path = substr($path, 7);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build path candidates to try.
|
||||
*/
|
||||
protected function buildPathCandidates(string $relPath, ?string $documentPath): array
|
||||
{
|
||||
$candidates = [$relPath];
|
||||
$raw = $documentPath ? ltrim(str_replace('\\', '/', $documentPath), '/') : null;
|
||||
|
||||
if ($raw && $raw !== $relPath) {
|
||||
$candidates[] = $raw;
|
||||
}
|
||||
if (str_starts_with($relPath, 'storage/')) {
|
||||
$candidates[] = substr($relPath, 8);
|
||||
}
|
||||
if ($raw && str_starts_with($raw, 'storage/')) {
|
||||
$candidates[] = substr($raw, 8);
|
||||
}
|
||||
|
||||
return array_unique($candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find file using path candidates.
|
||||
*/
|
||||
protected function findFile(string $disk, string $relPath, ?string $documentPath = null): ?string
|
||||
{
|
||||
$candidates = $this->buildPathCandidates($relPath, $documentPath);
|
||||
|
||||
foreach ($candidates as $cand) {
|
||||
if (Storage::disk($disk)->exists($cand)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try public/ fallback path.
|
||||
*/
|
||||
protected function tryPublicFallback(string $relPath): ?string
|
||||
{
|
||||
$publicFull = public_path($relPath);
|
||||
$real = @realpath($publicFull);
|
||||
$publicRoot = @realpath(public_path());
|
||||
$realN = $real ? str_replace('\\\\', '/', $real) : null;
|
||||
$rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null;
|
||||
|
||||
if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) {
|
||||
return $real;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to stream preview for DOC/DOCX files.
|
||||
*/
|
||||
protected function tryPreview(Document $document): StreamedResponse|Response|null
|
||||
{
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
if (! in_array($ext, ['doc', 'docx'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previewDisk = config('files.preview_disk', 'public');
|
||||
if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) {
|
||||
$stream = Storage::disk($previewDisk)->readStream($document->preview_path);
|
||||
if ($stream !== false) {
|
||||
$previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $document->preview_mime ?: 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Queue preview generation if not available
|
||||
\App\Jobs\GenerateDocumentPreview::dispatch($document->id);
|
||||
|
||||
return response('Preview is being generated. Please try again shortly.', 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build response headers.
|
||||
*/
|
||||
protected function buildHeaders(Document $document, bool $inline): array
|
||||
{
|
||||
$nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME);
|
||||
$ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION));
|
||||
$name = $ext ? ($nameBase.'.'.$ext) : $nameBase;
|
||||
|
||||
return [
|
||||
'Content-Type' => $document->mime_type ?: 'application/octet-stream',
|
||||
'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($name).'"',
|
||||
'Cache-Control' => 'private, max-age=0, no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback streaming methods when readStream fails.
|
||||
*/
|
||||
protected function fallbackStream(string $disk, string $found, Document $document, string $relPath, array $headers): StreamedResponse|Response
|
||||
{
|
||||
// Fallback 1: get() the bytes directly
|
||||
try {
|
||||
$bytes = Storage::disk($disk)->get($found);
|
||||
if (! is_null($bytes) && $bytes !== false) {
|
||||
return response($bytes, 200, $headers);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Continue to next fallback
|
||||
}
|
||||
|
||||
// Fallback 2: open via absolute storage path
|
||||
$abs = null;
|
||||
try {
|
||||
if (method_exists(Storage::disk($disk), 'path')) {
|
||||
$abs = Storage::disk($disk)->path($found);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$abs = null;
|
||||
}
|
||||
|
||||
if ($abs && is_file($abs)) {
|
||||
$fp = @fopen($abs, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 3: serve from public path if available
|
||||
$publicFull = public_path($found);
|
||||
$real = @realpath($publicFull);
|
||||
if ($real && is_file($real)) {
|
||||
$fp = @fopen($real, 'rb');
|
||||
if ($fp !== false) {
|
||||
return response()->stream(function () use ($fp) {
|
||||
fpassthru($fp);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
abort(404, 'Document file could not be streamed');
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Services/Import/BaseEntityHandler.php
Normal file
86
app/Services/Import/BaseEntityHandler.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\ImportEntity;
|
||||
use App\Services\Import\Contracts\EntityHandlerInterface;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
abstract class BaseEntityHandler implements EntityHandlerInterface
|
||||
{
|
||||
protected ?ImportEntity $entityConfig;
|
||||
|
||||
public function __construct(?ImportEntity $entityConfig = null)
|
||||
{
|
||||
$this->entityConfig = $entityConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mapped data using configuration rules.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$rules = $this->entityConfig?->validation_rules ?? [];
|
||||
|
||||
if (empty($rules)) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
$validator = Validator::make($mapped, $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => $validator->errors()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing options from config.
|
||||
*/
|
||||
protected function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->entityConfig?->processing_options[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a field has changed.
|
||||
*/
|
||||
protected function hasChanged($model, string $field, mixed $newValue): bool
|
||||
{
|
||||
$current = $model->{$field};
|
||||
|
||||
if (is_null($newValue) && is_null($current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $current != $newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track which fields were applied/changed.
|
||||
*/
|
||||
protected function trackAppliedFields($model, array $payload): array
|
||||
{
|
||||
$applied = [];
|
||||
|
||||
foreach ($payload as $field => $value) {
|
||||
if ($this->hasChanged($model, $field, $value)) {
|
||||
$applied[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation returns null - override in specific handlers.
|
||||
*/
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
43
app/Services/Import/Contracts/EntityHandlerInterface.php
Normal file
43
app/Services/Import/Contracts/EntityHandlerInterface.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Contracts;
|
||||
|
||||
use App\Models\Import;
|
||||
|
||||
interface EntityHandlerInterface
|
||||
{
|
||||
/**
|
||||
* Process a single row for this entity.
|
||||
*
|
||||
* @param Import $import The import instance
|
||||
* @param array $mapped Mapped data for this entity
|
||||
* @param array $raw Raw row data
|
||||
* @param array $context Additional context (previous entity results, etc.)
|
||||
* @return array Result with action, entity instance, applied_fields, etc.
|
||||
*/
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
|
||||
|
||||
/**
|
||||
* Validate mapped data before processing.
|
||||
*
|
||||
* @param array $mapped Mapped data for this entity
|
||||
* @return array Validation result ['valid' => bool, 'errors' => array]
|
||||
*/
|
||||
public function validate(array $mapped): array;
|
||||
|
||||
/**
|
||||
* Get the entity class name this handler manages.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getEntityClass(): string;
|
||||
|
||||
/**
|
||||
* Resolve existing entity by key/reference.
|
||||
*
|
||||
* @param array $mapped Mapped data for this entity
|
||||
* @param array $context Additional context
|
||||
* @return mixed|null Existing entity instance or null
|
||||
*/
|
||||
public function resolve(array $mapped, array $context = []): mixed;
|
||||
}
|
||||
58
app/Services/Import/DateNormalizer.php
Normal file
58
app/Services/Import/DateNormalizer.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
class DateNormalizer
|
||||
{
|
||||
/**
|
||||
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
|
||||
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
|
||||
*/
|
||||
public static function toDate(?string $raw): ?string
|
||||
{
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Common European and ISO formats first (day-first, then ISO)
|
||||
$candidates = [
|
||||
'd.m.Y', 'd.m.y',
|
||||
'd/m/Y', 'd/m/y',
|
||||
'd-m-Y', 'd-m-y',
|
||||
'Y-m-d', 'Y/m/d', 'Y.m.d',
|
||||
];
|
||||
|
||||
foreach ($candidates as $fmt) {
|
||||
$dt = \DateTime::createFromFormat($fmt, $raw);
|
||||
if ($dt instanceof \DateTime) {
|
||||
$errors = \DateTime::getLastErrors();
|
||||
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
|
||||
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
|
||||
$year = (int) $dt->format('Y');
|
||||
if ($year < 100) {
|
||||
$year += ($year <= 69) ? 2000 : 1900;
|
||||
// Rebuild date with corrected year
|
||||
$month = (int) $dt->format('m');
|
||||
$day = (int) $dt->format('d');
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
return $dt->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: strtotime (permissive). If fails, return null.
|
||||
$ts = @strtotime($raw);
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d', $ts);
|
||||
}
|
||||
}
|
||||
83
app/Services/Import/DecimalNormalizer.php
Normal file
83
app/Services/Import/DecimalNormalizer.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
class DecimalNormalizer
|
||||
{
|
||||
/**
|
||||
* Normalize a raw decimal string to a standard format (period as decimal separator).
|
||||
* Handles European format (comma as decimal) and American format (period as decimal).
|
||||
*
|
||||
* Examples:
|
||||
* - "958,31" => "958.31"
|
||||
* - "1.234,56" => "1234.56"
|
||||
* - "1,234.56" => "1234.56"
|
||||
* - "1234" => "1234"
|
||||
*
|
||||
* Based on ImportProcessor::normalizeDecimal()
|
||||
*/
|
||||
public static function normalize(?string $raw): ?string
|
||||
{
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep digits, comma, dot, and minus to detect separators
|
||||
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
|
||||
$s = trim($s);
|
||||
|
||||
if ($s === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastComma = strrpos($s, ',');
|
||||
$lastDot = strrpos($s, '.');
|
||||
|
||||
// Determine decimal separator by last occurrence
|
||||
$decimalSep = null;
|
||||
if ($lastComma !== false || $lastDot !== false) {
|
||||
if ($lastComma === false) {
|
||||
$decimalSep = '.';
|
||||
} elseif ($lastDot === false) {
|
||||
$decimalSep = ',';
|
||||
} else {
|
||||
$decimalSep = $lastComma > $lastDot ? ',' : '.';
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all thousand separators and unify decimal to '.'
|
||||
if ($decimalSep === ',') {
|
||||
// Remove all dots (thousand separators)
|
||||
$s = str_replace('.', '', $s);
|
||||
// Replace last comma with dot
|
||||
$pos = strrpos($s, ',');
|
||||
if ($pos !== false) {
|
||||
$s[$pos] = '.';
|
||||
}
|
||||
// Remove any remaining commas (unlikely)
|
||||
$s = str_replace(',', '', $s);
|
||||
} elseif ($decimalSep === '.') {
|
||||
// Remove all commas (thousand separators)
|
||||
$s = str_replace(',', '', $s);
|
||||
// Dot is already decimal separator
|
||||
} else {
|
||||
// No decimal separator: remove commas/dots entirely
|
||||
$s = str_replace([',', '.'], '', $s);
|
||||
}
|
||||
|
||||
// Handle negative numbers
|
||||
$s = ltrim($s, '+');
|
||||
$neg = false;
|
||||
if (str_starts_with($s, '-')) {
|
||||
$neg = true;
|
||||
$s = ltrim($s, '-');
|
||||
}
|
||||
// Remove any stray minus signs
|
||||
$s = str_replace('-', '', $s);
|
||||
if ($neg) {
|
||||
$s = '-' . $s;
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
399
app/Services/Import/EntityResolutionService.php
Normal file
399
app/Services/Import/EntityResolutionService.php
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Email;
|
||||
use App\Models\Import;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Models\Person\PersonPhone;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* EntityResolutionService - Resolves existing entities to prevent duplication.
|
||||
*
|
||||
* This service checks for existing entities before creating new ones,
|
||||
* following the V1 deduplication hierarchy:
|
||||
* 1. Contract reference → ClientCase → Person
|
||||
* 2. ClientCase client_ref → Person
|
||||
* 3. Contact values (email/phone/address) → Person
|
||||
* 4. Person identifiers (tax_number/ssn) → Person
|
||||
*/
|
||||
class EntityResolutionService
|
||||
{
|
||||
/**
|
||||
* Resolve Person ID from import context (existing entities).
|
||||
* Returns Person ID if found, null otherwise.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param array $mapped Mapped data from CSV row
|
||||
* @param array $context Processing context with previously processed entities
|
||||
* @return int|null Person ID if found, null if should create new
|
||||
*/
|
||||
public function resolvePersonFromContext(Import $import, array $mapped, array $context): ?int
|
||||
{
|
||||
// 1. Check if Contract already processed in this row
|
||||
if ($contract = $context['contract']['entity'] ?? null) {
|
||||
$personId = $this->getPersonFromContract($contract);
|
||||
if ($personId) {
|
||||
Log::info('EntityResolutionService: Found Person from processed Contract', [
|
||||
'person_id' => $personId,
|
||||
'contract_id' => $contract->id,
|
||||
]);
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if ClientCase already processed in this row
|
||||
if ($clientCase = $context['client_case']['entity'] ?? null) {
|
||||
if ($clientCase->person_id) {
|
||||
Log::info('EntityResolutionService: Found Person from processed ClientCase', [
|
||||
'person_id' => $clientCase->person_id,
|
||||
'client_case_id' => $clientCase->id,
|
||||
]);
|
||||
return $clientCase->person_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for existing Contract by reference (before it's processed)
|
||||
if ($contractRef = $mapped['contract']['reference'] ?? null) {
|
||||
$personId = $this->getPersonFromContractReference($import->client_id, $contractRef);
|
||||
if ($personId) {
|
||||
Log::info('EntityResolutionService: Found Person from existing Contract reference', [
|
||||
'person_id' => $personId,
|
||||
'contract_reference' => $contractRef,
|
||||
]);
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for existing ClientCase by client_ref (before it's processed)
|
||||
if ($clientRef = $mapped['client_case']['client_ref'] ?? null) {
|
||||
$personId = $this->getPersonFromClientRef($import->client_id, $clientRef);
|
||||
if ($personId) {
|
||||
Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [
|
||||
'person_id' => $personId,
|
||||
'client_ref' => $clientRef,
|
||||
]);
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check for existing Person by contact values (email/phone/address)
|
||||
$personId = $this->resolvePersonByContacts($mapped);
|
||||
if ($personId) {
|
||||
Log::info('EntityResolutionService: Found Person from contact values', [
|
||||
'person_id' => $personId,
|
||||
]);
|
||||
return $personId;
|
||||
}
|
||||
|
||||
// No existing Person found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ClientCase exists for this client_ref.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $clientRef
|
||||
* @return bool
|
||||
*/
|
||||
public function clientCaseExists(?int $clientId, string $clientRef): bool
|
||||
{
|
||||
if (!$clientId || !$clientRef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Contract exists for this reference.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $reference
|
||||
* @return bool
|
||||
*/
|
||||
public function contractExists(?int $clientId, string $reference): bool
|
||||
{
|
||||
if (!$clientId || !$reference) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing ClientCase by client_ref.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $clientRef
|
||||
* @return ClientCase|null
|
||||
*/
|
||||
public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase
|
||||
{
|
||||
if (!$clientId || !$clientRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing Contract by reference for this client.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $reference
|
||||
* @return Contract|null
|
||||
*/
|
||||
public function getExistingContract(?int $clientId, string $reference): ?Contract
|
||||
{
|
||||
if (!$clientId || !$reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->select('contracts.*')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Person ID from a Contract entity.
|
||||
*
|
||||
* @param Contract $contract
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getPersonFromContract(Contract $contract): ?int
|
||||
{
|
||||
if ($contract->client_case_id) {
|
||||
return ClientCase::where('id', $contract->client_case_id)
|
||||
->value('person_id');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Person ID from existing Contract by reference.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $reference
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getPersonFromContractReference(?int $clientId, string $reference): ?int
|
||||
{
|
||||
if (!$clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientCaseId = Contract::query()
|
||||
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->where('contracts.reference', $reference)
|
||||
->value('contracts.client_case_id');
|
||||
|
||||
if ($clientCaseId) {
|
||||
return ClientCase::where('id', $clientCaseId)
|
||||
->value('person_id');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Person ID from existing ClientCase by client_ref.
|
||||
*
|
||||
* @param int|null $clientId
|
||||
* @param string $clientRef
|
||||
* @return int|null
|
||||
*/
|
||||
protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int
|
||||
{
|
||||
if (!$clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->value('person_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Person by contact values (email, phone, address).
|
||||
* Checks existing contact records and returns associated Person ID.
|
||||
*
|
||||
* @param array $mapped
|
||||
* @return int|null
|
||||
*/
|
||||
protected function resolvePersonByContacts(array $mapped): ?int
|
||||
{
|
||||
// Check email (support both single and array formats)
|
||||
$email = $this->extractContactValue($mapped, 'email', 'value', 'emails');
|
||||
if ($email) {
|
||||
$personId = Email::where('value', trim($email))->value('person_id');
|
||||
if ($personId) {
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
// Check phone (support both single and array formats)
|
||||
$phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones');
|
||||
if ($phone) {
|
||||
$personId = PersonPhone::where('nu', trim($phone))->value('person_id');
|
||||
if ($personId) {
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
// Check address (support both single and array formats)
|
||||
$address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses');
|
||||
if ($address) {
|
||||
$personId = PersonAddress::where('address', trim($address))->value('person_id');
|
||||
if ($personId) {
|
||||
return $personId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract contact value from mapped data, supporting multiple formats.
|
||||
*
|
||||
* @param array $mapped
|
||||
* @param string $singularKey e.g., 'email', 'phone', 'address'
|
||||
* @param string $field Field name within the contact data
|
||||
* @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses'
|
||||
* @return string|null
|
||||
*/
|
||||
protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string
|
||||
{
|
||||
// Try singular key first (e.g., 'email')
|
||||
if (isset($mapped[$singularKey][$field])) {
|
||||
return $mapped[$singularKey][$field];
|
||||
}
|
||||
|
||||
// Try plural key (e.g., 'emails')
|
||||
if (isset($mapped[$pluralKey])) {
|
||||
// If it's an array of contacts
|
||||
if (is_array($mapped[$pluralKey])) {
|
||||
// Try first element if it's an indexed array
|
||||
if (isset($mapped[$pluralKey][0][$field])) {
|
||||
return $mapped[$pluralKey][0][$field];
|
||||
}
|
||||
// Try direct field access if it's a single hash
|
||||
if (isset($mapped[$pluralKey][$field])) {
|
||||
return $mapped[$pluralKey][$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this row should skip Person creation based on existing entities.
|
||||
* Used by PersonHandler to determine if Person already exists via chain.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param array $mapped
|
||||
* @param array $context
|
||||
* @return bool True if Person should be skipped (already exists)
|
||||
*/
|
||||
public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool
|
||||
{
|
||||
// If we can resolve existing Person, we should skip creation
|
||||
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
|
||||
|
||||
return $personId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create ClientCase for Contract creation.
|
||||
* Reuses existing ClientCase if found by client_ref.
|
||||
*
|
||||
* @param Import $import
|
||||
* @param array $mapped
|
||||
* @param array $context
|
||||
* @return int|null ClientCase ID
|
||||
*/
|
||||
public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int
|
||||
{
|
||||
$clientId = $import->client_id;
|
||||
|
||||
if (!$clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If ClientCase already processed in this row, use it
|
||||
if ($clientCaseId = $context['client_case']['entity']?->id ?? null) {
|
||||
return $clientCaseId;
|
||||
}
|
||||
|
||||
// Try to find by client_ref
|
||||
$clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null;
|
||||
|
||||
if ($clientRef) {
|
||||
$existing = $this->getExistingClientCase($clientId, $clientRef);
|
||||
|
||||
if ($existing) {
|
||||
Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [
|
||||
'client_case_id' => $existing->id,
|
||||
'client_ref' => $clientRef,
|
||||
]);
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create new ClientCase
|
||||
// Get Person from context (should be processed before Contract now)
|
||||
$personId = $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (!$personId) {
|
||||
// Person wasn't in import or wasn't found, try to resolve
|
||||
$personId = $this->resolvePersonFromContext($import, $mapped, $context);
|
||||
|
||||
if (!$personId) {
|
||||
// Create minimal Person as last resort
|
||||
$defaultGroupId = (int) (\App\Models\Person\PersonGroup::min('id') ?? 1);
|
||||
$personId = Person::create([
|
||||
'type_id' => 1,
|
||||
'group_id' => $defaultGroupId,
|
||||
])->id;
|
||||
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
|
||||
'person_id' => $personId,
|
||||
'group_id' => $defaultGroupId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$clientCase = ClientCase::create([
|
||||
'client_id' => $clientId,
|
||||
'person_id' => $personId,
|
||||
'client_ref' => $clientRef,
|
||||
]);
|
||||
|
||||
Log::info('EntityResolutionService: Created new ClientCase', [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'person_id' => $personId,
|
||||
'client_ref' => $clientRef,
|
||||
]);
|
||||
|
||||
return $clientCase->id;
|
||||
}
|
||||
}
|
||||
216
app/Services/Import/Handlers/AccountHandler.php
Normal file
216
app/Services/Import/Handlers/AccountHandler.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
use App\Services\Import\DecimalNormalizer;
|
||||
|
||||
class AccountHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Account::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to handle contract_id and reference from context.
|
||||
* Both contract_id and reference are populated in process() (reference defaults to contract reference).
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
// Remove contract_id and reference from validation - both will be populated in process()
|
||||
// Reference defaults to contract.reference if not set (matching v1 behavior)
|
||||
$rules = $this->entityConfig?->validation_rules ?? [];
|
||||
|
||||
unset($rules['contract_id'], $rules['reference']);
|
||||
|
||||
if (empty($rules)) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
$validator = \Illuminate\Support\Facades\Validator::make($mapped, $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => $validator->errors()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']->id ?? null;
|
||||
|
||||
if (! $reference || ! $contractId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Account::where('contract_id', $contractId)
|
||||
->where('reference', $reference)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Ensure contract context
|
||||
if (! isset($context['contract'])) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Account requires contract context',
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback: if account.reference is empty, use contract.reference (matching v1 behavior)
|
||||
if (empty($mapped['reference'])) {
|
||||
$contractReference = $context['contract']['entity']->reference ?? null;
|
||||
if ($contractReference) {
|
||||
$mapped['reference'] = preg_replace('/\s+/', '', trim((string) $contractReference));
|
||||
}
|
||||
}
|
||||
|
||||
$contractId = $context['contract']['entity']->id;
|
||||
$mapped['contract_id'] = $contractId;
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Track old balance for activity creation
|
||||
$oldBalance = (float) ($existing->balance_amount ?? 0);
|
||||
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
// Create activity if balance changed and tracking is enabled
|
||||
if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) {
|
||||
$this->createBalanceChangeActivity($existing, $oldBalance, $import, $context);
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$account = new Account;
|
||||
$payload = $this->buildPayload($mapped, $account);
|
||||
|
||||
// Ensure required defaults for new accounts
|
||||
if (!isset($payload['type_id'])) {
|
||||
$payload['type_id'] = $this->getDefaultAccountTypeId();
|
||||
}
|
||||
|
||||
$account->fill($payload);
|
||||
$account->save();
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $account,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
$fieldMap = [
|
||||
'contract_id' => 'contract_id',
|
||||
'reference' => 'reference',
|
||||
'title' => 'title',
|
||||
'description' => 'description',
|
||||
'balance_amount' => 'balance_amount',
|
||||
'currency' => 'currency',
|
||||
];
|
||||
|
||||
foreach ($fieldMap as $source => $target) {
|
||||
if (array_key_exists($source, $mapped)) {
|
||||
$value = $mapped[$source];
|
||||
|
||||
// Normalize decimal fields (convert comma to period)
|
||||
if (in_array($source, ['balance_amount', 'initial_amount']) && is_string($value)) {
|
||||
$value = DecimalNormalizer::normalize($value);
|
||||
}
|
||||
|
||||
$payload[$target] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create activity when account balance changes.
|
||||
*/
|
||||
protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void
|
||||
{
|
||||
if (! $this->getOption('create_activity_on_balance_change', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$newBalance = (float) ($account->balance_amount ?? 0);
|
||||
|
||||
// Skip if balance didn't actually change
|
||||
if ($newBalance === $oldBalance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR';
|
||||
$beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency;
|
||||
$afterStr = number_format($newBalance, 2, ',', '.').' '.$currency;
|
||||
$note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)';
|
||||
|
||||
// Get client_case_id
|
||||
$clientCaseId = $account->contract?->client_case_id;
|
||||
|
||||
if ($clientCaseId) {
|
||||
// Use action_id from import meta if available
|
||||
$metaActionId = (int) ($import->meta['action_id'] ?? 0);
|
||||
|
||||
if ($metaActionId > 0) {
|
||||
\App\Models\Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => null,
|
||||
'note' => $note,
|
||||
'action_id' => $metaActionId,
|
||||
'decision_id' => $import->meta['decision_id'] ?? null,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Failed to create balance change activity', [
|
||||
'account_id' => $account->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default account type ID.
|
||||
*/
|
||||
protected function getDefaultAccountTypeId(): int
|
||||
{
|
||||
return (int) (\App\Models\AccountType::min('id') ?? 1);
|
||||
}
|
||||
}
|
||||
171
app/Services/Import/Handlers/ActivityHandler.php
Normal file
171
app/Services/Import/Handlers/ActivityHandler.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\DateNormalizer;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class ActivityHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Activity::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to skip validation if note is empty.
|
||||
* Handles both single values and arrays.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$note = $mapped['note'] ?? null;
|
||||
|
||||
// If array, check if all values are empty
|
||||
if (is_array($note)) {
|
||||
$hasValue = false;
|
||||
foreach ($note as $n) {
|
||||
if (!empty($n) && trim((string)$n) !== '') {
|
||||
$hasValue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasValue) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
// Skip parent validation for arrays - we'll validate in process()
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
// Single value - check if empty
|
||||
if (empty($note) || trim((string)$note) === '') {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
return parent::validate($mapped);
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
// Activities typically don't have a unique reference for deduplication
|
||||
// Override this method if you have specific deduplication logic
|
||||
return null;
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Handle multiple activities if note is an array
|
||||
$notes = $mapped['note'] ?? null;
|
||||
|
||||
// If single value, convert to array for uniform processing
|
||||
if (!is_array($notes)) {
|
||||
$notes = [$notes];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$insertedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Get context IDs once
|
||||
$clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null;
|
||||
$contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null;
|
||||
|
||||
foreach ($notes as $note) {
|
||||
// Skip if note is empty
|
||||
if (empty($note) || trim((string)$note) === '') {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Require at least client_case_id or contract_id based on options
|
||||
$requireCase = $this->getOption('require_client_case', false);
|
||||
$requireContract = $this->getOption('require_contract', false);
|
||||
|
||||
if ($requireCase && ! $clientCaseId) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($requireContract && ! $contractId) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build activity payload for this note
|
||||
$payload = ['note' => $note];
|
||||
$payload['client_case_id'] = $clientCaseId;
|
||||
$payload['contract_id'] = $contractId;
|
||||
|
||||
// Set action_id and decision_id from template meta if not in mapped data
|
||||
if (!isset($mapped['action_id'])) {
|
||||
$payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId();
|
||||
} else {
|
||||
$payload['action_id'] = $mapped['action_id'];
|
||||
}
|
||||
|
||||
if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) {
|
||||
$payload['decision_id'] = $import->template->meta['activity_decision_id'];
|
||||
}
|
||||
|
||||
// Create activity
|
||||
$activity = new \App\Models\Activity;
|
||||
$activity->fill($payload);
|
||||
$activity->save();
|
||||
|
||||
$results[] = $activity;
|
||||
$insertedCount++;
|
||||
}
|
||||
|
||||
if ($insertedCount === 0 && $skippedCount > 0) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'All activities empty or missing requirements',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $results[0] ?? null,
|
||||
'entities' => $results,
|
||||
'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'],
|
||||
'count' => $insertedCount,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
// Map activity fields
|
||||
if (isset($mapped['due_date'])) {
|
||||
$payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']);
|
||||
}
|
||||
|
||||
if (isset($mapped['amount'])) {
|
||||
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
|
||||
}
|
||||
|
||||
if (isset($mapped['note'])) {
|
||||
$payload['note'] = $mapped['note'];
|
||||
}
|
||||
|
||||
if (isset($mapped['action_id'])) {
|
||||
$payload['action_id'] = (int) $mapped['action_id'];
|
||||
}
|
||||
|
||||
if (isset($mapped['decision_id'])) {
|
||||
$payload['decision_id'] = (int) $mapped['decision_id'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default action ID (use minimum ID from actions table).
|
||||
*/
|
||||
private function getDefaultActionId(): int
|
||||
{
|
||||
return (int) (\App\Models\Action::min('id') ?? 1);
|
||||
}
|
||||
}
|
||||
144
app/Services/Import/Handlers/AddressHandler.php
Normal file
144
app/Services/Import/Handlers/AddressHandler.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\Person\PersonAddress;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class AddressHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return PersonAddress::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to skip validation if address is empty.
|
||||
* Handles both single values and arrays.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$address = $mapped['address'] ?? null;
|
||||
|
||||
// If array, check if all values are empty
|
||||
if (is_array($address)) {
|
||||
$hasValue = false;
|
||||
foreach ($address as $addr) {
|
||||
if (!empty($addr) && trim((string)$addr) !== '') {
|
||||
$hasValue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasValue) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
// Skip parent validation for arrays - we'll validate in process()
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
// Single value - check if empty
|
||||
if (empty($address) || trim((string)$address) === '') {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
return parent::validate($mapped);
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$address = $mapped['address'] ?? null;
|
||||
$personId = $mapped['person_id']
|
||||
?? ($context['person']['entity']->id ?? null)
|
||||
?? ($context['person']?->entity?->id ?? null);
|
||||
|
||||
if (! $address || ! $personId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find existing address by exact match for this person
|
||||
return PersonAddress::where('person_id', $personId)
|
||||
->where('address', $address)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Handle multiple addresses if address is an array
|
||||
$addresses = $mapped['address'] ?? null;
|
||||
|
||||
// If single value, convert to array for uniform processing
|
||||
if (!is_array($addresses)) {
|
||||
$addresses = [$addresses];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$insertedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($addresses as $address) {
|
||||
// Skip if address is empty or blank
|
||||
if (empty($address) || trim((string)$address) === '') {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve person_id from context
|
||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (! $personId) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->resolveAddress($address, $personId);
|
||||
|
||||
// Check for duplicates if configured
|
||||
if ($this->getOption('deduplicate', true) && $existing) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create new address
|
||||
$payload = $this->buildPayloadForAddress($address);
|
||||
$payload['person_id'] = $personId;
|
||||
|
||||
$addressEntity = new PersonAddress;
|
||||
$addressEntity->fill($payload);
|
||||
$addressEntity->save();
|
||||
|
||||
$results[] = $addressEntity;
|
||||
$insertedCount++;
|
||||
}
|
||||
|
||||
if ($insertedCount === 0 && $skippedCount > 0) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'All addresses empty or duplicates',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $results[0] ?? null,
|
||||
'entities' => $results,
|
||||
'applied_fields' => ['address', 'person_id'],
|
||||
'count' => $insertedCount,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveAddress(string $address, int $personId): mixed
|
||||
{
|
||||
return PersonAddress::where('person_id', $personId)
|
||||
->where('address', $address)
|
||||
->first();
|
||||
}
|
||||
|
||||
protected function buildPayloadForAddress(string $address): array
|
||||
{
|
||||
return [
|
||||
'address' => $address,
|
||||
'type_id' => 1, // Default to permanent address
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Services/Import/Handlers/CaseObjectHandler.php
Normal file
96
app/Services/Import/Handlers/CaseObjectHandler.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\CaseObject;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class CaseObjectHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return CaseObject::class;
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
$name = $mapped['name'] ?? null;
|
||||
|
||||
if (! $reference && ! $name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find by reference first
|
||||
if ($reference) {
|
||||
$object = CaseObject::where('reference', $reference)->first();
|
||||
if ($object) {
|
||||
return $object;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to name if reference not found
|
||||
if ($name) {
|
||||
return CaseObject::where('name', $name)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing object
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new case object
|
||||
$payload = $this->buildPayload($mapped, new CaseObject);
|
||||
|
||||
$caseObject = new CaseObject;
|
||||
$caseObject->fill($payload);
|
||||
$caseObject->save();
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $caseObject,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
$fields = ['reference', 'name', 'description', 'type', 'contract_id'];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $mapped)) {
|
||||
$payload[$field] = $mapped[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
163
app/Services/Import/Handlers/ClientCaseHandler.php
Normal file
163
app/Services/Import/Handlers/ClientCaseHandler.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
use App\Services\Import\EntityResolutionService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClientCaseHandler extends BaseEntityHandler
|
||||
{
|
||||
protected EntityResolutionService $resolutionService;
|
||||
|
||||
public function __construct($entityConfig = null)
|
||||
{
|
||||
parent::__construct($entityConfig);
|
||||
$this->resolutionService = new EntityResolutionService();
|
||||
}
|
||||
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return ClientCase::class;
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$clientRef = $mapped['client_ref'] ?? null;
|
||||
$clientId = $context['import']?->client_id ?? null;
|
||||
|
||||
if (! $clientRef || ! $clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find existing case by client_ref for this client
|
||||
return ClientCase::where('client_id', $clientId)
|
||||
->where('client_ref', $clientRef)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
$clientId = $import->client_id ?? null;
|
||||
|
||||
if (! $clientId) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'ClientCase requires client_id',
|
||||
];
|
||||
}
|
||||
|
||||
// PHASE 5: Use Person from context (already processed due to reversed priorities)
|
||||
// Priority order: explicit person_id > context person > resolved person
|
||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||
|
||||
// If no Person in context, try to resolve using EntityResolutionService
|
||||
if (!$personId) {
|
||||
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
|
||||
|
||||
if ($personId) {
|
||||
Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [
|
||||
'person_id' => $personId,
|
||||
]);
|
||||
} else {
|
||||
Log::warning('ClientCaseHandler: No Person found in context or via resolution', [
|
||||
'has_person_context' => isset($context['person']),
|
||||
'has_mapped_person_id' => isset($mapped['person_id']),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
Log::info('ClientCaseHandler: Using Person from context/mapping', [
|
||||
'person_id' => $personId,
|
||||
'source' => $mapped['person_id'] ? 'mapped' : 'context',
|
||||
]);
|
||||
}
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Update if configured
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'ClientCase already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
|
||||
// Update person_id if provided and different
|
||||
if ($personId && $existing->person_id !== $personId) {
|
||||
$payload['person_id'] = $personId;
|
||||
}
|
||||
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
Log::info('ClientCaseHandler: Updated existing ClientCase', [
|
||||
'client_case_id' => $existing->id,
|
||||
'person_id' => $existing->person_id,
|
||||
'applied_fields' => $appliedFields,
|
||||
]);
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new client case
|
||||
$payload = $this->buildPayload($mapped, new ClientCase);
|
||||
$payload['client_id'] = $clientId;
|
||||
|
||||
if ($personId) {
|
||||
$payload['person_id'] = $personId;
|
||||
}
|
||||
|
||||
$clientCase = new ClientCase;
|
||||
$clientCase->fill($payload);
|
||||
$clientCase->save();
|
||||
|
||||
Log::info('ClientCaseHandler: Created new ClientCase', [
|
||||
'client_case_id' => $clientCase->id,
|
||||
'person_id' => $clientCase->person_id,
|
||||
'client_ref' => $clientCase->client_ref,
|
||||
]);
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $clientCase,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
$fields = ['client_ref'];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $mapped)) {
|
||||
$payload[$field] = $mapped[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
316
app/Services/Import/Handlers/ContractHandler.php
Normal file
316
app/Services/Import/Handlers/ContractHandler.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\ClientCase;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
use App\Services\Import\EntityResolutionService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ContractHandler extends BaseEntityHandler
|
||||
{
|
||||
protected EntityResolutionService $resolutionService;
|
||||
|
||||
public function __construct($entityConfig = null)
|
||||
{
|
||||
parent::__construct($entityConfig);
|
||||
$this->resolutionService = new EntityResolutionService();
|
||||
}
|
||||
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Contract::class;
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
|
||||
if (! $reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = Contract::query();
|
||||
|
||||
// Scope by client if available
|
||||
if ($clientId = $context['import']->client_id) {
|
||||
$query->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
|
||||
->where('client_cases.client_id', $clientId)
|
||||
->select('contracts.*');
|
||||
}
|
||||
|
||||
return $query->where('contracts.reference', $reference)->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Check for existing contract (using resolve method which handles client scoping)
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
|
||||
|
||||
// Check for reactivation FIRST (before update_mode check)
|
||||
$reactivate = $this->shouldReactivate($context);
|
||||
|
||||
Log::info('ContractHandler: Found existing Contract', [
|
||||
'contract_id' => $existing->id,
|
||||
'reference' => $mapped['reference'] ?? null,
|
||||
'context' => $context['import']
|
||||
]);
|
||||
|
||||
if ($reactivate && $this->needsReactivation($existing)) {
|
||||
$reactivated = $this->attemptReactivation($existing, $context);
|
||||
Log::info('ContractHandler: Reactivate', ['reactivated' => $reactivated]);
|
||||
if ($reactivated) {
|
||||
return [
|
||||
'action' => 'reactivated',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract reactivated',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Check update mode
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
// Update existing contract
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
$payload = $this->mergeJsonFields($payload, $existing);
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new contract
|
||||
$contract = new Contract;
|
||||
$payload = $this->buildPayload($mapped, $contract);
|
||||
|
||||
// Get client_case_id from context or mapped data
|
||||
$clientCaseId = $mapped['client_case_id']
|
||||
?? $context['client_case']?->entity?->id
|
||||
?? null;
|
||||
|
||||
// If no client_case_id, try to create/find one automatically (using EntityResolutionService)
|
||||
if (!$clientCaseId) {
|
||||
// Add mapped data to context for EntityResolutionService
|
||||
$context['mapped'] = $mapped;
|
||||
$clientCaseId = $this->findOrCreateClientCaseId($context);
|
||||
}
|
||||
|
||||
if (!$clientCaseId) {
|
||||
return [
|
||||
'action' => 'invalid',
|
||||
'message' => 'Contract requires client_case_id (import must have client_id)',
|
||||
];
|
||||
}
|
||||
|
||||
$payload['client_case_id'] = $clientCaseId;
|
||||
|
||||
// Ensure required defaults
|
||||
if (!isset($payload['type_id'])) {
|
||||
$payload['type_id'] = $this->getDefaultContractTypeId();
|
||||
}
|
||||
if (!isset($payload['start_date'])) {
|
||||
$payload['start_date'] = now()->toDateString();
|
||||
}
|
||||
|
||||
$contract->fill($payload);
|
||||
$contract->save();
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $contract,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
// Map fields according to contract schema
|
||||
$fieldMap = [
|
||||
'reference' => 'reference',
|
||||
'description' => 'description',
|
||||
'amount' => 'amount',
|
||||
'currency' => 'currency',
|
||||
'start_date' => 'start_date',
|
||||
'end_date' => 'end_date',
|
||||
'active' => 'active',
|
||||
'type_id' => 'type_id',
|
||||
'client_case_id' => 'client_case_id',
|
||||
];
|
||||
|
||||
foreach ($fieldMap as $source => $target) {
|
||||
if (array_key_exists($source, $mapped)) {
|
||||
$payload[$target] = $mapped[$source];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle meta field - merge grouped meta into flat structure
|
||||
if (!empty($mapped['meta']) && is_array($mapped['meta'])) {
|
||||
$metaData = [];
|
||||
foreach ($mapped['meta'] as $grp => $entries) {
|
||||
if (!is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $k => $v) {
|
||||
$metaData[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (!empty($metaData)) {
|
||||
$payload['meta'] = $metaData;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function getDefaultContractTypeId(): int
|
||||
{
|
||||
return (int) (\App\Models\ContractType::min('id') ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reactivation should be attempted.
|
||||
*/
|
||||
protected function shouldReactivate(array $context): bool
|
||||
{
|
||||
// Row-level reactivate column takes precedence
|
||||
if (isset($context['raw']['reactivate'])) {
|
||||
return filter_var($context['raw']['reactivate'], FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
// Then import-level
|
||||
if (isset($context['import']->reactivate)) {
|
||||
return (bool) $context['import']->reactivate;
|
||||
}
|
||||
|
||||
// Finally template-level
|
||||
if (isset($context['import']->template?->reactivate)) {
|
||||
return (bool) $context['import']->template->reactivate;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity needs reactivation.
|
||||
*/
|
||||
protected function needsReactivation($entity): bool
|
||||
{
|
||||
return $entity->active == 0 || $entity->deleted_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reactivate soft-deleted or inactive contract.
|
||||
*/
|
||||
protected function attemptReactivation(Contract $contract, array $context): bool
|
||||
{
|
||||
if (! $this->getOption('supports_reactivation', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($contract->trashed()) {
|
||||
$contract->restore();
|
||||
}
|
||||
|
||||
$contract->active = 1;
|
||||
|
||||
$contract->save();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Contract reactivation failed', [
|
||||
'contract_id' => $contract->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge JSON fields instead of overwriting.
|
||||
*/
|
||||
protected function mergeJsonFields(array $payload, $existing): array
|
||||
{
|
||||
$mergeFields = $this->getOption('merge_json_fields', []);
|
||||
|
||||
foreach ($mergeFields as $field) {
|
||||
if (isset($payload[$field]) && isset($existing->{$field})) {
|
||||
$existingData = is_array($existing->{$field}) ? $existing->{$field} : [];
|
||||
$newData = is_array($payload[$field]) ? $payload[$field] : [];
|
||||
$payload[$field] = array_merge($existingData, $newData);
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a ClientCase for this contract (using EntityResolutionService).
|
||||
*/
|
||||
protected function findOrCreateClientCaseId(array $context): ?int
|
||||
{
|
||||
$import = $context['import'] ?? null;
|
||||
$mapped = $context['mapped'] ?? [];
|
||||
$clientId = $import?->client_id ?? null;
|
||||
|
||||
if (!$clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// PHASE 4: Use EntityResolutionService to resolve or create ClientCase
|
||||
// This will reuse existing ClientCase when possible
|
||||
$clientCaseId = $this->resolutionService->resolveOrCreateClientCaseForContract(
|
||||
$import,
|
||||
$mapped,
|
||||
$context
|
||||
);
|
||||
|
||||
if ($clientCaseId) {
|
||||
Log::info('ContractHandler: Resolved/Created ClientCase for Contract', [
|
||||
'client_case_id' => $clientCaseId,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clientCaseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique client_ref.
|
||||
*/
|
||||
protected function generateClientRef(int $clientId): string
|
||||
{
|
||||
$timestamp = now()->format('ymdHis');
|
||||
$random = substr(md5(uniqid()), 0, 4);
|
||||
return "C{$clientId}-{$timestamp}-{$random}";
|
||||
}
|
||||
}
|
||||
123
app/Services/Import/Handlers/EmailHandler.php
Normal file
123
app/Services/Import/Handlers/EmailHandler.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class EmailHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Email::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to skip validation if email is empty or invalid.
|
||||
* Invalid emails should be skipped, not cause transaction rollback.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$email = $mapped['value'] ?? null;
|
||||
if (empty($email) || trim((string)$email) === '') {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
// Validate email format - if invalid, mark as valid to skip instead of failing
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
// Return valid=true but we'll skip it in process()
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
return parent::validate($mapped);
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$value = $mapped['value'] ?? null;
|
||||
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Email::where('value', strtolower(trim($value)))->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Skip if email is empty or blank
|
||||
$email = $mapped['value'] ?? null;
|
||||
if (empty($email) || trim((string)$email) === '') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Email is empty',
|
||||
];
|
||||
}
|
||||
|
||||
// Skip if email format is invalid
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Invalid email format',
|
||||
];
|
||||
}
|
||||
|
||||
// Resolve person_id from context
|
||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (! $personId) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Email requires person_id',
|
||||
];
|
||||
}
|
||||
|
||||
// Check if this email already exists for THIS person
|
||||
$existing = Email::where('person_id', $personId)
|
||||
->where('value', strtolower(trim($email)))
|
||||
->first();
|
||||
|
||||
// If email already exists for this person, skip
|
||||
if ($existing) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Email already exists for this person',
|
||||
];
|
||||
}
|
||||
|
||||
// Create new email for this person
|
||||
$payload = $this->buildPayload($mapped, new Email);
|
||||
$payload['person_id'] = $personId;
|
||||
|
||||
$email = new Email;
|
||||
$email->fill($payload);
|
||||
$email->save();
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $email,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
if (isset($mapped['value'])) {
|
||||
$payload['value'] = strtolower(trim($mapped['value']));
|
||||
}
|
||||
|
||||
if (isset($mapped['is_primary'])) {
|
||||
$payload['is_primary'] = (bool) $mapped['is_primary'];
|
||||
}
|
||||
|
||||
if (isset($mapped['label'])) {
|
||||
$payload['label'] = $mapped['label'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
224
app/Services/Import/Handlers/PaymentHandler.php
Normal file
224
app/Services/Import/Handlers/PaymentHandler.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Booking;
|
||||
use App\Models\Import;
|
||||
use App\Models\Payment;
|
||||
use App\Models\PaymentSetting;
|
||||
use App\Services\Import\DateNormalizer;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
class PaymentHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Payment::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to skip validation if amount is empty.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$amount = $mapped['amount'] ?? null;
|
||||
if (empty($amount) || !is_numeric($amount)) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
return parent::validate($mapped);
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
|
||||
if (! $accountId || ! $reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Payment::where('account_id', $accountId)
|
||||
->where('reference', $reference)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Skip if amount is empty or invalid
|
||||
$amount = $mapped['amount'] ?? null;
|
||||
if (empty($amount) || !is_numeric($amount)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Payment amount is empty or invalid',
|
||||
];
|
||||
}
|
||||
|
||||
// Resolve account - either from mapped data or context
|
||||
$accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null;
|
||||
|
||||
if (! $accountId) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Payment requires an account',
|
||||
];
|
||||
}
|
||||
|
||||
// Check for duplicates if configured
|
||||
if ($this->getOption('deduplicate_by', [])) {
|
||||
$existing = $this->resolve($mapped, ['account' => (object) ['entity' => (object) ['id' => $accountId]]]);
|
||||
if ($existing) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Payment already exists (duplicate by reference)',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Build payment payload
|
||||
$payload = $this->buildPayload($mapped, new Payment);
|
||||
$payload['account_id'] = $accountId;
|
||||
$payload['created_by'] = $context['user']?->getAuthIdentifier();
|
||||
|
||||
// Get account balance before payment
|
||||
$account = Account::find($accountId);
|
||||
$balanceBefore = $account ? (float) ($account->balance_amount ?? 0) : 0;
|
||||
|
||||
// Create payment
|
||||
$payment = new Payment;
|
||||
$payment->fill($payload);
|
||||
$payment->balance_before = $balanceBefore;
|
||||
|
||||
try {
|
||||
$payment->save();
|
||||
} catch (\Throwable $e) {
|
||||
// Handle unique constraint violations gracefully
|
||||
if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Payment duplicate detected (database constraint)',
|
||||
];
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Create booking if configured
|
||||
if ($this->getOption('create_booking', true) && isset($payment->amount)) {
|
||||
try {
|
||||
Booking::create([
|
||||
'account_id' => $accountId,
|
||||
'payment_id' => $payment->id,
|
||||
'amount_cents' => (int) round(((float) $payment->amount) * 100),
|
||||
'type' => 'credit',
|
||||
'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo',
|
||||
'booked_at' => $payment->paid_at ?? now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to create booking for payment', [
|
||||
'payment_id' => $payment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create activity if configured
|
||||
if ($this->getOption('create_activity', false)) {
|
||||
$this->createPaymentActivity($payment, $account, $balanceBefore);
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $payment,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
// Map payment fields
|
||||
if (isset($mapped['reference'])) {
|
||||
$payload['reference'] = is_string($mapped['reference']) ? trim($mapped['reference']) : $mapped['reference'];
|
||||
}
|
||||
|
||||
// Handle amount - support both amount and amount_cents
|
||||
if (array_key_exists('amount', $mapped)) {
|
||||
$payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount'];
|
||||
} elseif (array_key_exists('amount_cents', $mapped)) {
|
||||
$payload['amount'] = ((int) $mapped['amount_cents']) / 100.0;
|
||||
}
|
||||
|
||||
// Payment date - support both paid_at and payment_date
|
||||
$dateValue = $mapped['paid_at'] ?? $mapped['payment_date'] ?? null;
|
||||
if ($dateValue) {
|
||||
$payload['paid_at'] = DateNormalizer::toDate((string) $dateValue);
|
||||
}
|
||||
|
||||
$payload['currency'] = $mapped['currency'] ?? 'EUR';
|
||||
|
||||
// Handle meta
|
||||
$meta = [];
|
||||
if (is_array($mapped['meta'] ?? null)) {
|
||||
$meta = $mapped['meta'];
|
||||
}
|
||||
if (! empty($mapped['payment_nu'])) {
|
||||
$meta['payment_nu'] = trim((string) $mapped['payment_nu']);
|
||||
}
|
||||
if (! empty($meta)) {
|
||||
$payload['meta'] = $meta;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function createPaymentActivity(Payment $payment, ?Account $account, float $balanceBefore): void
|
||||
{
|
||||
try {
|
||||
$settings = PaymentSetting::first();
|
||||
if (! $settings || ! ($settings->create_activity_on_payment ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) $payment->amount) * 100);
|
||||
$note = $settings->activity_note_template ?? 'Prejeto plačilo';
|
||||
$note = str_replace(
|
||||
['{amount}', '{currency}'],
|
||||
[number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'],
|
||||
$note
|
||||
);
|
||||
|
||||
// Get updated balance
|
||||
$account?->refresh();
|
||||
$balanceAfter = $account ? (float) ($account->balance_amount ?? 0) : 0;
|
||||
|
||||
$beforeStr = number_format($balanceBefore, 2, ',', '.').' '.($payment->currency ?? 'EUR');
|
||||
$afterStr = number_format($balanceAfter, 2, ',', '.').' '.($payment->currency ?? 'EUR');
|
||||
$note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)";
|
||||
|
||||
// Resolve client_case_id
|
||||
$account?->loadMissing('contract');
|
||||
$clientCaseId = $account?->contract?->client_case_id;
|
||||
|
||||
if ($clientCaseId) {
|
||||
$activity = \App\Models\Activity::create([
|
||||
'due_date' => null,
|
||||
'amount' => $amountCents / 100,
|
||||
'note' => $note,
|
||||
'action_id' => $settings->default_action_id,
|
||||
'decision_id' => $settings->default_decision_id,
|
||||
'client_case_id' => $clientCaseId,
|
||||
'contract_id' => $account->contract_id,
|
||||
]);
|
||||
$payment->update(['activity_id' => $activity->id]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to create activity for payment', [
|
||||
'payment_id' => $payment->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
200
app/Services/Import/Handlers/PersonHandler.php
Normal file
200
app/Services/Import/Handlers/PersonHandler.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\Person\Person;
|
||||
use App\Models\Person\PersonGroup;
|
||||
use App\Models\Person\PersonType;
|
||||
use App\Services\Import\DateNormalizer;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
use App\Services\Import\EntityResolutionService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PersonHandler extends BaseEntityHandler
|
||||
{
|
||||
protected EntityResolutionService $resolutionService;
|
||||
|
||||
public function __construct($entityConfig = null)
|
||||
{
|
||||
parent::__construct($entityConfig);
|
||||
$this->resolutionService = new EntityResolutionService();
|
||||
}
|
||||
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Person::class;
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
// PHASE 3: Use EntityResolutionService to check chain-based deduplication
|
||||
// This prevents creating duplicate Persons when Contract/ClientCase already exists
|
||||
$import = $context['import'] ?? null;
|
||||
|
||||
if ($import) {
|
||||
$personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context);
|
||||
|
||||
if ($personId) {
|
||||
$person = Person::find($personId);
|
||||
|
||||
if ($person) {
|
||||
Log::info('PersonHandler: Resolved existing Person via chain', [
|
||||
'person_id' => $personId,
|
||||
'resolution_method' => 'EntityResolutionService',
|
||||
]);
|
||||
|
||||
return $person;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to configured deduplication fields (tax_number, SSN)
|
||||
$dedupeBy = $this->getOption('deduplicate_by', ['tax_number', 'social_security_number']);
|
||||
|
||||
foreach ($dedupeBy as $field) {
|
||||
if (! empty($mapped[$field])) {
|
||||
$person = Person::where($field, $mapped[$field])->first();
|
||||
if ($person) {
|
||||
Log::info('PersonHandler: Resolved existing Person by identifier', [
|
||||
'person_id' => $person->id,
|
||||
'field' => $field,
|
||||
]);
|
||||
|
||||
return $person;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Add import to context for EntityResolutionService
|
||||
$context['import'] = $import;
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Update if configured
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Person already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create new person
|
||||
Log::info('PersonHandler: Creating new Person (no existing entity found)', [
|
||||
'has_tax_number' => !empty($mapped['tax_number']),
|
||||
'has_ssn' => !empty($mapped['social_security_number']),
|
||||
'has_contract' => isset($context['contract']),
|
||||
'has_client_case' => isset($context['client_case']),
|
||||
]);
|
||||
|
||||
$person = new Person;
|
||||
$payload = $this->buildPayload($mapped, $person);
|
||||
|
||||
// Ensure required foreign keys have defaults
|
||||
if (!isset($payload['group_id'])) {
|
||||
$payload['group_id'] = $this->getDefaultPersonGroupId();
|
||||
}
|
||||
if (!isset($payload['type_id'])) {
|
||||
$payload['type_id'] = $this->getDefaultPersonTypeId();
|
||||
}
|
||||
|
||||
Log::debug('PersonHandler: Payload before fill', [
|
||||
'payload' => $payload,
|
||||
'has_group_id' => isset($payload['group_id']),
|
||||
'group_id_value' => $payload['group_id'] ?? null,
|
||||
]);
|
||||
|
||||
$person->fill($payload);
|
||||
|
||||
Log::debug('PersonHandler: Person attributes after fill', [
|
||||
'attributes' => $person->getAttributes(),
|
||||
'has_group_id' => isset($person->group_id),
|
||||
'group_id_value' => $person->group_id ?? null,
|
||||
]);
|
||||
|
||||
$person->save();
|
||||
|
||||
Log::info('PersonHandler: Created new Person', [
|
||||
'person_id' => $person->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $person,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
$fieldMap = [
|
||||
'first_name' => 'first_name',
|
||||
'last_name' => 'last_name',
|
||||
'full_name' => 'full_name',
|
||||
'gender' => 'gender',
|
||||
'birthday' => 'birthday',
|
||||
'tax_number' => 'tax_number',
|
||||
'social_security_number' => 'social_security_number',
|
||||
'description' => 'description',
|
||||
'group_id' => 'group_id',
|
||||
'type_id' => 'type_id',
|
||||
];
|
||||
|
||||
foreach ($fieldMap as $source => $target) {
|
||||
if (array_key_exists($source, $mapped)) {
|
||||
$value = $mapped[$source];
|
||||
|
||||
// Normalize date fields
|
||||
if ($source === 'birthday' && $value) {
|
||||
$value = DateNormalizer::toDate((string) $value);
|
||||
}
|
||||
|
||||
$payload[$target] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function getDefaultPersonGroupId(): int
|
||||
{
|
||||
return (int) (PersonGroup::min('id') ?? 1);
|
||||
}
|
||||
|
||||
private function getDefaultPersonTypeId(): int
|
||||
{
|
||||
return (int) (PersonType::min('id') ?? 1);
|
||||
}
|
||||
}
|
||||
153
app/Services/Import/Handlers/PhoneHandler.php
Normal file
153
app/Services/Import/Handlers/PhoneHandler.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\Person\PersonPhone;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class PhoneHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return PersonPhone::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override validate to skip validation if phone is empty.
|
||||
* Handles both single values and arrays.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$phone = $mapped['nu'] ?? null;
|
||||
|
||||
// If array, check if all values are empty/invalid
|
||||
if (is_array($phone)) {
|
||||
$hasValue = false;
|
||||
foreach ($phone as $ph) {
|
||||
if (!empty($ph) && trim((string)$ph) !== '' && $ph !== '0') {
|
||||
$hasValue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasValue) {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
// Skip parent validation for arrays - we'll validate in process()
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
// Single value - check if empty or invalid
|
||||
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
|
||||
return ['valid' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
return parent::validate($mapped);
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
$nu = $mapped['nu'] ?? null;
|
||||
$personId = $mapped['person_id']
|
||||
?? ($context['person']['entity']->id ?? null)
|
||||
?? ($context['person']?->entity?->id ?? null);
|
||||
|
||||
if (! $nu || ! $personId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize phone number for comparison
|
||||
$normalizedNu = $this->normalizePhoneNumber($nu);
|
||||
|
||||
// Find existing phone by normalized number for this person
|
||||
return PersonPhone::where('person_id', $personId)
|
||||
->where('nu', $normalizedNu)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
// Handle multiple phones if nu is an array
|
||||
$phones = $mapped['nu'] ?? null;
|
||||
|
||||
// If single value, convert to array for uniform processing
|
||||
if (!is_array($phones)) {
|
||||
$phones = [$phones];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$insertedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($phones as $phone) {
|
||||
// Skip if phone number is empty or blank or '0'
|
||||
if (empty($phone) || trim((string)$phone) === '' || $phone === '0') {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve person_id from context
|
||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (! $personId) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize phone number
|
||||
$normalizedPhone = $this->normalizePhoneNumber($phone);
|
||||
|
||||
$existing = $this->resolvePhone($normalizedPhone, $personId);
|
||||
|
||||
// Check for duplicates if configured
|
||||
if ($this->getOption('deduplicate', true) && $existing) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create new phone
|
||||
$payload = [
|
||||
'nu' => $normalizedPhone,
|
||||
'person_id' => $personId,
|
||||
'type_id' => 1, // Default to mobile
|
||||
];
|
||||
|
||||
$phoneEntity = new PersonPhone;
|
||||
$phoneEntity->fill($payload);
|
||||
$phoneEntity->save();
|
||||
|
||||
$results[] = $phoneEntity;
|
||||
$insertedCount++;
|
||||
}
|
||||
|
||||
if ($insertedCount === 0 && $skippedCount > 0) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'All phones empty, invalid or duplicates',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $results[0] ?? null,
|
||||
'entities' => $results,
|
||||
'applied_fields' => ['nu', 'person_id'],
|
||||
'count' => $insertedCount,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolvePhone(string $normalizedPhone, int $personId): mixed
|
||||
{
|
||||
return PersonPhone::where('person_id', $personId)
|
||||
->where('nu', $normalizedPhone)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize phone number by removing spaces, dashes, and parentheses.
|
||||
*/
|
||||
protected function normalizePhoneNumber(string $phone): string
|
||||
{
|
||||
return preg_replace('/[\s\-\(\)]/', '', $phone);
|
||||
}
|
||||
}
|
||||
1017
app/Services/Import/ImportServiceV2.php
Normal file
1017
app/Services/Import/ImportServiceV2.php
Normal file
File diff suppressed because it is too large
Load Diff
832
app/Services/Import/ImportSimulationServiceV2.php
Normal file
832
app/Services/Import/ImportSimulationServiceV2.php
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportEntity;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* ImportSimulationServiceV2 - Simulates imports using V2 handler architecture.
|
||||
*
|
||||
* Processes rows using entity handlers without persisting any data to the database.
|
||||
* Returns preview data showing what would be created/updated for each row.
|
||||
*
|
||||
* Deduplication: Uses EntityResolutionService through handlers to accurately simulate
|
||||
* Person resolution from Contract/ClientCase chains, matching production behavior.
|
||||
*/
|
||||
class ImportSimulationServiceV2
|
||||
{
|
||||
protected array $handlers = [];
|
||||
|
||||
protected array $entityConfigs = [];
|
||||
|
||||
/**
|
||||
* Simulate an import and return preview data.
|
||||
*
|
||||
* @param Import $import Import record with mappings
|
||||
* @param int $limit Maximum number of rows to simulate (default: 100)
|
||||
* @param bool $verbose Include detailed information (default: false)
|
||||
* @return array Simulation results with row previews and statistics
|
||||
*/
|
||||
public function simulate(Import $import, int $limit = 100, bool $verbose = false): array
|
||||
{
|
||||
try {
|
||||
// Load entity configurations and handlers
|
||||
$this->loadEntityConfigurations();
|
||||
|
||||
// Only CSV/TXT supported
|
||||
if (! in_array($import->source_type, ['csv', 'txt'])) {
|
||||
return $this->errorPayload('Podprti so samo CSV/TXT formati.');
|
||||
}
|
||||
|
||||
$filePath = $import->path;
|
||||
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
|
||||
return $this->errorPayload("Datoteka ni najdena: {$filePath}");
|
||||
}
|
||||
|
||||
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
|
||||
$fh = fopen($fullPath, 'r');
|
||||
|
||||
if (! $fh) {
|
||||
return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}");
|
||||
}
|
||||
|
||||
$meta = $import->meta ?? [];
|
||||
$hasHeader = (bool) ($meta['has_header'] ?? true);
|
||||
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
|
||||
|
||||
$mappings = $this->loadMappings($import);
|
||||
if (empty($mappings)) {
|
||||
fclose($fh);
|
||||
|
||||
return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.');
|
||||
}
|
||||
|
||||
$header = null;
|
||||
$rowNum = 0;
|
||||
|
||||
// Read header if present
|
||||
if ($hasHeader) {
|
||||
$header = fgetcsv($fh, 0, $delimiter);
|
||||
$rowNum++;
|
||||
}
|
||||
|
||||
$simRows = [];
|
||||
$summaries = $this->initSummaries();
|
||||
$rowCount = 0;
|
||||
|
||||
while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) {
|
||||
$rowNum++;
|
||||
$rowCount++;
|
||||
|
||||
try {
|
||||
$rawAssoc = $this->buildRowAssoc($row, $header);
|
||||
|
||||
// Skip empty rows
|
||||
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped = $this->applyMappings($rawAssoc, $mappings);
|
||||
|
||||
// Group mapped data by entity (from "entity.field" to nested structure)
|
||||
$groupedMapped = $this->groupMappedDataByEntity($mapped);
|
||||
|
||||
\Log::info('ImportSimulation: Grouped entities', [
|
||||
'row' => $rowNum,
|
||||
'entity_keys' => array_keys($groupedMapped),
|
||||
'config_roots' => array_keys($this->entityConfigs),
|
||||
]);
|
||||
|
||||
// Simulate processing for this row
|
||||
// Context must include 'import' for EntityResolutionService to work
|
||||
$context = [
|
||||
'import' => $import,
|
||||
'simulation' => true,
|
||||
];
|
||||
|
||||
$rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose);
|
||||
|
||||
// Update summaries - handle both single and array results
|
||||
foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) {
|
||||
// Extract entity root from key (e.g., 'person', 'contract', etc.)
|
||||
$root = explode('.', $entityKey)[0];
|
||||
|
||||
// Handle array of results (grouped entities)
|
||||
if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) {
|
||||
foreach ($entityDataOrArray as $entityData) {
|
||||
$action = $entityData['action'] ?? 'skip';
|
||||
if (!isset($summaries[$root])) {
|
||||
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
|
||||
}
|
||||
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
|
||||
}
|
||||
} else {
|
||||
// Single result
|
||||
$action = $entityDataOrArray['action'] ?? 'skip';
|
||||
if (!isset($summaries[$root])) {
|
||||
$summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0];
|
||||
}
|
||||
$summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$simRows[] = [
|
||||
'row_number' => $rowNum,
|
||||
'raw_data' => $verbose ? $rawAssoc : null,
|
||||
'entities' => $rowResult['entities'],
|
||||
'warnings' => $rowResult['warnings'] ?? [],
|
||||
'errors' => $rowResult['errors'] ?? [],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$simRows[] = [
|
||||
'row_number' => $rowNum,
|
||||
'raw_data' => $verbose ? ($rawAssoc ?? null) : null,
|
||||
'entities' => [],
|
||||
'warnings' => [],
|
||||
'errors' => [$e->getMessage()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fh);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'total_simulated' => $rowCount,
|
||||
'limit' => $limit,
|
||||
'summaries' => $summaries,
|
||||
'rows' => $simRows,
|
||||
'meta' => [
|
||||
'has_header' => $hasHeader,
|
||||
'delimiter' => $delimiter,
|
||||
'mappings_count' => count($mappings),
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate processing a single row without database writes.
|
||||
*
|
||||
* Updated to match ImportServiceV2 logic:
|
||||
* - Process entities in priority order from entity configs
|
||||
* - Accumulate entity results in context for chain resolution
|
||||
* - Pass proper context to handlers for EntityResolutionService
|
||||
*/
|
||||
protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array
|
||||
{
|
||||
$entities = [];
|
||||
$warnings = [];
|
||||
$errors = [];
|
||||
$entityResults = [];
|
||||
|
||||
// Process entities in configured priority order (like ImportServiceV2)
|
||||
foreach ($this->entityConfigs as $root => $config) {
|
||||
// Check if this entity exists in mapped data
|
||||
$mappedKey = $this->findMappedKey($mapped, $root, $config);
|
||||
|
||||
if (!$mappedKey || !isset($mapped[$mappedKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$handler = $this->handlers[$root] ?? null;
|
||||
|
||||
if (!$handler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is an array of entities (grouped)
|
||||
$entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0])
|
||||
? $mapped[$mappedKey]
|
||||
: [$mapped[$mappedKey]];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($entityDataArray as $entityData) {
|
||||
// Validate
|
||||
$validation = $handler->validate($entityData);
|
||||
if (!$validation['valid']) {
|
||||
$results[] = [
|
||||
'action' => 'invalid',
|
||||
'data' => $entityData,
|
||||
'errors' => $validation['errors'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty/invalid data that handlers would skip during real import
|
||||
// Phone: skip if nu is 0, empty, or #N/A
|
||||
if ($root === 'phone') {
|
||||
$nu = $entityData['nu'] ?? null;
|
||||
if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') {
|
||||
continue; // Skip this phone entirely
|
||||
}
|
||||
}
|
||||
|
||||
// Address: skip if address is empty or #N/A
|
||||
if ($root === 'address') {
|
||||
$address = $entityData['address'] ?? null;
|
||||
if (empty($address) || $address === '#N/A' || trim((string)$address) === '') {
|
||||
continue; // Skip this address entirely
|
||||
}
|
||||
}
|
||||
|
||||
// Email: skip if value is 0, empty, or #N/A
|
||||
if ($root === 'email') {
|
||||
$email = $entityData['value'] ?? null;
|
||||
if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') {
|
||||
continue; // Skip this email entirely
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG: Log context for grouped entities
|
||||
if (in_array($root, ['phone', 'address'])) {
|
||||
Log::info("ImportSimulation: Resolving grouped entity", [
|
||||
'entity' => $root,
|
||||
'data' => $entityData,
|
||||
'has_person_in_context' => isset($entityResults['person']),
|
||||
'person_id' => $entityResults['person']['entity']->id ?? null,
|
||||
'context_keys' => array_keys(array_merge($context, $entityResults)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Resolve existing entity (uses EntityResolutionService internally)
|
||||
// Pass accumulated entityResults as context for chain resolution
|
||||
try {
|
||||
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
|
||||
} catch (\Throwable $resolutionError) {
|
||||
// In simulation mode, resolution may fail due to simulated entities
|
||||
// Just treat as new entity
|
||||
\Log::debug("ImportSimulation: Resolution failed (treating as new)", [
|
||||
'entity' => $root,
|
||||
'error' => $resolutionError->getMessage(),
|
||||
]);
|
||||
$existingEntity = null;
|
||||
}
|
||||
|
||||
if ($existingEntity) {
|
||||
// Would update existing
|
||||
$results[] = [
|
||||
'action' => 'update',
|
||||
'reference' => $this->getEntityReference($existingEntity, $root),
|
||||
'existing_id' => $existingEntity->id ?? null,
|
||||
'data' => $entityData,
|
||||
'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null,
|
||||
'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null,
|
||||
];
|
||||
|
||||
// Add to entityResults for subsequent handlers
|
||||
$entityResults[$root] = [
|
||||
'entity' => $existingEntity,
|
||||
'action' => 'updated',
|
||||
];
|
||||
} else {
|
||||
// Would create new
|
||||
$results[] = [
|
||||
'action' => 'create',
|
||||
'data' => $entityData,
|
||||
];
|
||||
|
||||
// Simulate entity creation for context (no actual ID)
|
||||
// Mark as simulated so resolution service knows not to use model methods
|
||||
$simulatedEntity = (object) $entityData;
|
||||
$simulatedEntity->_simulated = true;
|
||||
|
||||
$entityResults[$root] = [
|
||||
'entity' => $simulatedEntity,
|
||||
'action' => 'inserted',
|
||||
'_simulated' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Store results (single or array)
|
||||
$entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results;
|
||||
} catch (\Throwable $e) {
|
||||
$entities[$mappedKey] = [
|
||||
'action' => 'error',
|
||||
'errors' => [$e->getMessage()],
|
||||
];
|
||||
$errors[] = "{$root}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
return compact('entities', 'warnings', 'errors');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the mapped key for an entity (supports aliases and common variations).
|
||||
*/
|
||||
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
|
||||
{
|
||||
// Check canonical root exactly
|
||||
if (isset($mapped[$canonicalRoot])) {
|
||||
return $canonicalRoot;
|
||||
}
|
||||
|
||||
// Build comprehensive list of variations
|
||||
$variations = [$canonicalRoot];
|
||||
|
||||
// Generate plural variations (handle -y endings correctly)
|
||||
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
|
||||
// activity -> activities
|
||||
$variations[] = substr($canonicalRoot, 0, -1) . 'ies';
|
||||
} else {
|
||||
// address -> addresses
|
||||
$variations[] = $canonicalRoot . 's';
|
||||
}
|
||||
|
||||
// Add singular form (remove trailing s or ies)
|
||||
if (str_ends_with($canonicalRoot, 'ies')) {
|
||||
$variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity
|
||||
} else {
|
||||
$variations[] = rtrim($canonicalRoot, 's'); // addresses -> address
|
||||
}
|
||||
|
||||
// Add person_ prefixed versions
|
||||
$variations[] = 'person_' . $canonicalRoot;
|
||||
|
||||
// person_activity -> person_activities
|
||||
if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) {
|
||||
$variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies';
|
||||
} else {
|
||||
$variations[] = 'person_' . $canonicalRoot . 's';
|
||||
}
|
||||
|
||||
// Special handling: if canonical is 'address', also check 'person_addresses'
|
||||
if ($canonicalRoot === 'address') {
|
||||
$variations[] = 'person_addresses';
|
||||
}
|
||||
// Special handling: if canonical is 'phone', also check 'person_phones'
|
||||
if ($canonicalRoot === 'phone') {
|
||||
$variations[] = 'person_phones';
|
||||
}
|
||||
// Reverse: if canonical has 'person_', also check without it
|
||||
if (str_starts_with($canonicalRoot, 'person_')) {
|
||||
$withoutPerson = str_replace('person_', '', $canonicalRoot);
|
||||
$variations[] = $withoutPerson;
|
||||
// Handle plural variations
|
||||
if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) {
|
||||
$variations[] = substr($withoutPerson, 0, -1) . 'ies';
|
||||
} else {
|
||||
$variations[] = rtrim($withoutPerson, 's');
|
||||
$variations[] = $withoutPerson . 's';
|
||||
}
|
||||
}
|
||||
|
||||
$variations = array_unique($variations);
|
||||
|
||||
foreach ($variations as $variation) {
|
||||
if (isset($mapped[$variation])) {
|
||||
\Log::debug("ImportSimulation: Matched entity", [
|
||||
'canonical_root' => $canonicalRoot,
|
||||
'matched_key' => $variation,
|
||||
]);
|
||||
return $variation;
|
||||
}
|
||||
}
|
||||
|
||||
// Check aliases if configured
|
||||
if (isset($config->options['aliases'])) {
|
||||
$aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : [];
|
||||
foreach ($aliases as $alias) {
|
||||
if (isset($mapped[$alias])) {
|
||||
return $alias;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
\Log::debug("ImportSimulation: No match found for entity", [
|
||||
'canonical_root' => $canonicalRoot,
|
||||
'tried_variations' => array_slice($variations, 0, 5),
|
||||
'available_keys' => array_keys($mapped),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group mapped data by entity from "entity.field" format to nested structure.
|
||||
* Handles both single values and arrays (for grouped entities like multiple addresses).
|
||||
*
|
||||
* Special handling:
|
||||
* - activity.note arrays are kept together (single activity with multiple notes)
|
||||
* - Other array values create separate entity instances (e.g., multiple addresses)
|
||||
*
|
||||
* Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']]
|
||||
* Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]]
|
||||
*/
|
||||
protected function groupMappedDataByEntity(array $mapped): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($mapped as $key => $value) {
|
||||
if (!str_contains($key, '.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$entity, $field] = explode('.', $key, 2);
|
||||
|
||||
// Handle array values
|
||||
if (is_array($value)) {
|
||||
// Special case: activity.note should be kept as array in single instance
|
||||
if ($entity === 'activity' || $entity === 'activities') {
|
||||
if (!isset($grouped[$entity])) {
|
||||
$grouped[$entity] = [];
|
||||
}
|
||||
$grouped[$entity][$field] = $value; // Keep as array
|
||||
} else {
|
||||
// For other entities, only create multiple instances if:
|
||||
// 1. Entity doesn't exist yet, OR
|
||||
// 2. Entity has no other fields yet (is empty array)
|
||||
if (!isset($grouped[$entity])) {
|
||||
$grouped[$entity] = [];
|
||||
}
|
||||
|
||||
// If entity already has string-keyed fields, just set the array as field value
|
||||
// Otherwise, create separate instances
|
||||
$hasStringKeys = !empty($grouped[$entity]) && isset(array_keys($grouped[$entity])[0]) && is_string(array_keys($grouped[$entity])[0]);
|
||||
|
||||
if ($hasStringKeys) {
|
||||
// Entity has fields already - don't split, keep array as-is
|
||||
$grouped[$entity][$field] = $value;
|
||||
} else {
|
||||
// Create separate entity instances for each array value
|
||||
foreach ($value as $idx => $val) {
|
||||
if (!isset($grouped[$entity][$idx])) {
|
||||
$grouped[$entity][$idx] = [];
|
||||
}
|
||||
$grouped[$entity][$idx][$field] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single value
|
||||
if (!isset($grouped[$entity])) {
|
||||
$grouped[$entity] = [];
|
||||
}
|
||||
|
||||
// Check if entity is already an array of instances (from previous grouped field)
|
||||
if (!empty($grouped[$entity]) && is_int(array_key_first($grouped[$entity]))) {
|
||||
// Entity has multiple instances - add field to all instances
|
||||
foreach ($grouped[$entity] as &$instance) {
|
||||
if (is_array($instance)) {
|
||||
$instance[$field] = $value;
|
||||
}
|
||||
}
|
||||
unset($instance);
|
||||
} else {
|
||||
// Simple associative array - add field
|
||||
$grouped[$entity][$field] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if entity data is grouped (array of instances).
|
||||
*/
|
||||
protected function isGroupedEntity($data): bool
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if numeric array (multiple instances)
|
||||
$keys = array_keys($data);
|
||||
return isset($keys[0]) && is_int($keys[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract existing entity data as array.
|
||||
*/
|
||||
protected function extractExistingData($entity): array
|
||||
{
|
||||
if (method_exists($entity, 'toArray')) {
|
||||
return $entity->toArray();
|
||||
}
|
||||
|
||||
return (array) $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect changes between existing entity and new data.
|
||||
*/
|
||||
protected function detectChanges($existingEntity, array $newData): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($newData as $key => $newValue) {
|
||||
$oldValue = $existingEntity->{$key} ?? null;
|
||||
|
||||
// Convert to comparable formats
|
||||
if ($oldValue instanceof \Carbon\Carbon) {
|
||||
$oldValue = $oldValue->format('Y-m-d');
|
||||
}
|
||||
|
||||
if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) {
|
||||
$changes[$key] = [
|
||||
'old' => $oldValue,
|
||||
'new' => $newValue,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference string for an entity.
|
||||
*/
|
||||
protected function getEntityReference($entity, string $root): string
|
||||
{
|
||||
if (isset($entity->reference)) {
|
||||
return (string) $entity->reference;
|
||||
}
|
||||
if (isset($entity->value)) {
|
||||
return (string) $entity->value;
|
||||
}
|
||||
if (isset($entity->title)) {
|
||||
return (string) $entity->title;
|
||||
}
|
||||
if (isset($entity->id)) {
|
||||
return "{$root}#{$entity->id}";
|
||||
}
|
||||
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entity configurations from database.
|
||||
*/
|
||||
protected function loadEntityConfigurations(): void
|
||||
{
|
||||
$entities = ImportEntity::where('is_active', true)
|
||||
->orderBy('priority', 'desc')
|
||||
->get();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$this->entityConfigs[$entity->canonical_root] = $entity;
|
||||
|
||||
// Instantiate handler if configured
|
||||
if ($entity->handler_class && class_exists($entity->handler_class)) {
|
||||
$this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler for entity root.
|
||||
*/
|
||||
protected function getHandler(string $root)
|
||||
{
|
||||
return $this->handlers[$root] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from import_mappings table.
|
||||
* Uses target_field in "entity.field" format.
|
||||
* Supports multiple sources mapping to same target (for groups).
|
||||
*/
|
||||
protected function loadMappings(Import $import): array
|
||||
{
|
||||
$rows = \DB::table('import_mappings')
|
||||
->where('import_id', $import->id)
|
||||
->orderBy('position')
|
||||
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
|
||||
|
||||
$mappings = [];
|
||||
foreach ($rows as $row) {
|
||||
$source = trim((string) $row->source_column);
|
||||
$target = trim((string) $row->target_field);
|
||||
|
||||
if ($source === '' || $target === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use unique key combining source and target to avoid overwriting
|
||||
$key = $source . '→' . $target;
|
||||
|
||||
// target_field is in "entity.field" format
|
||||
$mappings[$key] = [
|
||||
'source' => $source,
|
||||
'target' => $target,
|
||||
'transform' => $row->transform ?? null,
|
||||
'apply_mode' => $row->apply_mode ?? 'both',
|
||||
'options' => $row->options ? json_decode($row->options, true) : [],
|
||||
];
|
||||
}
|
||||
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build associative array from row data.
|
||||
*/
|
||||
protected function buildRowAssoc(array $row, ?array $header): array
|
||||
{
|
||||
if ($header) {
|
||||
return array_combine($header, array_pad($row, count($header), null));
|
||||
}
|
||||
|
||||
// Use numeric indices if no header
|
||||
return array_combine(
|
||||
array_map(fn ($i) => "col_{$i}", array_keys($row)),
|
||||
$row
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if row is effectively empty.
|
||||
*/
|
||||
protected function rowIsEffectivelyEmpty(array $assoc): bool
|
||||
{
|
||||
foreach ($assoc as $value) {
|
||||
if ($value !== null && $value !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply mappings to raw row data.
|
||||
* Returns array keyed by "entity.field".
|
||||
*
|
||||
* Updated to match ImportServiceV2:
|
||||
* - Supports group option for concatenating multiple sources
|
||||
* - Returns flat array with "entity.field" keys (no nesting)
|
||||
*/
|
||||
protected function applyMappings(array $raw, array $mappings): array
|
||||
{
|
||||
$mapped = [];
|
||||
|
||||
// Group mappings by target field to handle concatenation (same as ImportServiceV2)
|
||||
$groupedMappings = [];
|
||||
foreach ($mappings as $mapping) {
|
||||
$target = $mapping['target'];
|
||||
if (!isset($groupedMappings[$target])) {
|
||||
$groupedMappings[$target] = [];
|
||||
}
|
||||
$groupedMappings[$target][] = $mapping;
|
||||
}
|
||||
|
||||
foreach ($groupedMappings as $targetField => $fieldMappings) {
|
||||
// Group by group number from options
|
||||
$valuesByGroup = [];
|
||||
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$source = $mapping['source'];
|
||||
|
||||
if (!isset($raw[$source])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $raw[$source];
|
||||
|
||||
// Apply transform if specified
|
||||
if (!empty($mapping['transform'])) {
|
||||
$value = $this->applyTransform($value, $mapping['transform']);
|
||||
}
|
||||
|
||||
// Get group from options
|
||||
$options = $mapping['options'] ?? [];
|
||||
$group = $options['group'] ?? null;
|
||||
|
||||
// Group values by their group number (same logic as ImportServiceV2)
|
||||
if ($group !== null) {
|
||||
// Same group = concatenate
|
||||
if (!isset($valuesByGroup[$group])) {
|
||||
$valuesByGroup[$group] = [];
|
||||
}
|
||||
$valuesByGroup[$group][] = $value;
|
||||
} else {
|
||||
// No group = each gets its own group
|
||||
$valuesByGroup[] = [$value];
|
||||
}
|
||||
}
|
||||
|
||||
// Now set the values - KEEP FLAT, DON'T NEST
|
||||
foreach ($valuesByGroup as $values) {
|
||||
if (count($values) === 1) {
|
||||
// Single value - add to array if key exists, otherwise set directly
|
||||
if (isset($mapped[$targetField])) {
|
||||
// Convert to array and append
|
||||
if (!is_array($mapped[$targetField])) {
|
||||
$mapped[$targetField] = [$mapped[$targetField]];
|
||||
}
|
||||
$mapped[$targetField][] = $values[0];
|
||||
} else {
|
||||
$mapped[$targetField] = $values[0];
|
||||
}
|
||||
} else {
|
||||
// Multiple values in same group - concatenate with newline
|
||||
$concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== ''));
|
||||
if (!empty($concatenated)) {
|
||||
if (isset($mapped[$targetField])) {
|
||||
// Convert to array and append
|
||||
if (!is_array($mapped[$targetField])) {
|
||||
$mapped[$targetField] = [$mapped[$targetField]];
|
||||
}
|
||||
$mapped[$targetField][] = $concatenated;
|
||||
} else {
|
||||
$mapped[$targetField] = $concatenated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set nested value in array using dot notation.
|
||||
* If the key already exists, convert to array and append the new value.
|
||||
*
|
||||
* Same logic as ImportServiceV2.
|
||||
*/
|
||||
protected function setNestedValue(array &$array, string $key, mixed $value): void
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$current = &$array;
|
||||
|
||||
foreach ($keys as $i => $k) {
|
||||
if ($i === count($keys) - 1) {
|
||||
// If key already exists, convert to array and append
|
||||
if (isset($current[$k])) {
|
||||
// Convert existing single value to array if needed
|
||||
if (!is_array($current[$k])) {
|
||||
$current[$k] = [$current[$k]];
|
||||
}
|
||||
// Append new value
|
||||
$current[$k][] = $value;
|
||||
} else {
|
||||
// Set as single value
|
||||
$current[$k] = $value;
|
||||
}
|
||||
} else {
|
||||
if (!isset($current[$k]) || !is_array($current[$k])) {
|
||||
$current[$k] = [];
|
||||
}
|
||||
$current = &$current[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply transform to a value.
|
||||
*/
|
||||
protected function applyTransform(mixed $value, string $transform): mixed
|
||||
{
|
||||
return match ($transform) {
|
||||
'trim' => trim((string) $value),
|
||||
'upper' => strtoupper((string) $value),
|
||||
'lower' => strtolower((string) $value),
|
||||
'decimal' => (float) str_replace(',', '.', (string) $value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize summary counters.
|
||||
*/
|
||||
protected function initSummaries(): array
|
||||
{
|
||||
$summaries = [];
|
||||
|
||||
foreach (array_keys($this->entityConfigs) as $root) {
|
||||
$summaries[$root] = [
|
||||
'create' => 0,
|
||||
'update' => 0,
|
||||
'skip' => 0,
|
||||
'invalid' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error payload.
|
||||
*/
|
||||
protected function errorPayload(string $message): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'total_simulated' => 0,
|
||||
'summaries' => [],
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
347
app/Services/Import/README.md
Normal file
347
app/Services/Import/README.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Import System V2 Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
ImportServiceV2 is a refactored, database-driven import processing system that replaces the monolithic ImportProcessor.php with a modular, maintainable architecture.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Database-driven configuration**: Entity processing rules, validation, and handlers configured in `import_entities` table
|
||||
- **Pluggable handlers**: Each entity type has its own handler class implementing `EntityHandlerInterface`
|
||||
- **Queue support**: Large imports can be processed asynchronously via `ProcessLargeImportJob`
|
||||
- **Validation**: Entity-level validation rules stored in database
|
||||
- **Priority-based processing**: Entities processed in configured priority order
|
||||
- **Extensible**: Easy to add new entity types without modifying core service
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/Services/Import/
|
||||
├── Contracts/
|
||||
│ └── EntityHandlerInterface.php # Handler contract
|
||||
├── Handlers/
|
||||
│ ├── ContractHandler.php # Contract entity handler
|
||||
│ ├── AccountHandler.php # Account entity handler
|
||||
│ ├── PaymentHandler.php # Payment handler (to be implemented)
|
||||
│ ├── ActivityHandler.php # Activity handler (to be implemented)
|
||||
│ └── ... # Additional handlers
|
||||
├── BaseEntityHandler.php # Base handler with common logic
|
||||
└── ImportServiceV2.php # Main import service
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### import_entities Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | bigint | Primary key |
|
||||
| key | string | UI key (plural, e.g., "contracts") |
|
||||
| canonical_root | string | Canonical root for processor (singular, e.g., "contract") |
|
||||
| label | string | Human-readable label |
|
||||
| fields | json | Array of field names |
|
||||
| field_aliases | json | Field alias mappings |
|
||||
| aliases | json | Root aliases |
|
||||
| supports_multiple | boolean | Whether entity supports multiple items per row |
|
||||
| meta | boolean | Whether entity is metadata |
|
||||
| rules | json | Suggestion rules |
|
||||
| ui | json | UI configuration |
|
||||
| handler_class | string | Fully qualified handler class name |
|
||||
| validation_rules | json | Laravel validation rules |
|
||||
| processing_options | json | Handler-specific options |
|
||||
| is_active | boolean | Whether entity is enabled |
|
||||
| priority | integer | Processing priority (higher = first) |
|
||||
| created_at | timestamp | Creation timestamp |
|
||||
| updated_at | timestamp | Update timestamp |
|
||||
|
||||
## Handler Interface
|
||||
|
||||
All entity handlers must implement `EntityHandlerInterface`:
|
||||
|
||||
```php
|
||||
interface EntityHandlerInterface
|
||||
{
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
|
||||
public function validate(array $mapped): array;
|
||||
public function getEntityClass(): string;
|
||||
public function resolve(array $mapped, array $context = []): mixed;
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Methods
|
||||
|
||||
- **process()**: Main processing method, returns result with action (inserted/updated/skipped) and entity
|
||||
- **validate()**: Validates mapped data before processing
|
||||
- **getEntityClass()**: Returns the model class name this handler manages
|
||||
- **resolve()**: Resolves existing entity by key/reference
|
||||
|
||||
## Creating a New Handler
|
||||
|
||||
1. Create handler class extending `BaseEntityHandler`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\YourEntity;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class YourEntityHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return YourEntity::class;
|
||||
}
|
||||
|
||||
public function resolve(array $mapped, array $context = []): mixed
|
||||
{
|
||||
// Implement entity resolution logic
|
||||
return YourEntity::where('key', $mapped['key'])->first();
|
||||
}
|
||||
|
||||
public function process(Import $import, array $mapped, array $raw, array $context = []): array
|
||||
{
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
if ($existing) {
|
||||
// Update logic
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
$appliedFields = $this->trackAppliedFields($existing, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existing->fill($payload);
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
|
||||
// Create logic
|
||||
$entity = new YourEntity;
|
||||
$payload = $this->buildPayload($mapped, $entity);
|
||||
$entity->fill($payload);
|
||||
$entity->save();
|
||||
|
||||
return [
|
||||
'action' => 'inserted',
|
||||
'entity' => $entity,
|
||||
'applied_fields' => array_keys($payload),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildPayload(array $mapped, $model): array
|
||||
{
|
||||
// Map fields to model attributes
|
||||
return [
|
||||
'field1' => $mapped['field1'] ?? null,
|
||||
'field2' => $mapped['field2'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Add configuration to `import_entities` table:
|
||||
|
||||
```php
|
||||
ImportEntity::create([
|
||||
'key' => 'your_entities',
|
||||
'canonical_root' => 'your_entity',
|
||||
'label' => 'Your Entities',
|
||||
'fields' => ['field1', 'field2'],
|
||||
'handler_class' => \App\Services\Import\Handlers\YourEntityHandler::class,
|
||||
'validation_rules' => [
|
||||
'field1' => 'required|string',
|
||||
'field2' => 'nullable|integer',
|
||||
],
|
||||
'processing_options' => [
|
||||
'update_mode' => 'update',
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 100,
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Synchronous Processing
|
||||
|
||||
```php
|
||||
use App\Services\Import\ImportServiceV2;
|
||||
|
||||
$service = app(ImportServiceV2::class);
|
||||
$results = $service->process($import, $user);
|
||||
```
|
||||
|
||||
### Queue Processing (Large Imports)
|
||||
|
||||
```php
|
||||
use App\Jobs\ProcessLargeImportJob;
|
||||
|
||||
ProcessLargeImportJob::dispatch($import, $user->id);
|
||||
```
|
||||
|
||||
## Processing Options
|
||||
|
||||
Handler-specific options stored in `processing_options` JSON column:
|
||||
|
||||
### Contract Handler
|
||||
- `update_mode`: 'update' | 'skip' | 'error'
|
||||
- `create_missing`: boolean
|
||||
|
||||
### Account Handler
|
||||
- `update_mode`: 'update' | 'skip'
|
||||
- `require_contract`: boolean
|
||||
|
||||
### Payment Handler (planned)
|
||||
- `deduplicate_by`: array of fields
|
||||
- `create_booking`: boolean
|
||||
- `create_activity`: boolean
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Setup (Current)
|
||||
- ✅ Create directory structure
|
||||
- ✅ Add v2 columns to import_entities
|
||||
- ✅ Create base interfaces and classes
|
||||
- ✅ Implement ContractHandler and AccountHandler
|
||||
- ✅ Create ProcessLargeImportJob
|
||||
- ✅ Create seeder for entity configurations
|
||||
|
||||
### Phase 2: Implementation
|
||||
- [ ] Implement remaining handlers (Payment, Activity, Person, Contacts)
|
||||
- [ ] Add comprehensive tests
|
||||
- [ ] Update controllers to use ImportServiceV2
|
||||
- [ ] Add feature flag to toggle between v1 and v2
|
||||
|
||||
### Phase 3: Migration
|
||||
- [ ] Run both systems in parallel
|
||||
- [ ] Compare results and fix discrepancies
|
||||
- [ ] Migrate all imports to v2
|
||||
- [ ] Remove ImportProcessor.php (v1)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
php artisan migrate
|
||||
|
||||
# Seed entity configurations
|
||||
php artisan db:seed --class=ImportEntitiesV2Seeder
|
||||
|
||||
# Run tests
|
||||
php artisan test --filter=ImportServiceV2
|
||||
```
|
||||
|
||||
## Benefits Over V1
|
||||
|
||||
1. **Maintainability**: Each entity has its own handler, easier to understand and modify
|
||||
2. **Testability**: Handlers can be tested independently
|
||||
3. **Extensibility**: New entities added without touching core service
|
||||
4. **Configuration**: Business rules in database, no code deployment needed
|
||||
5. **Queue Support**: Built-in queue support for large imports
|
||||
6. **Validation**: Entity-level validation separate from processing logic
|
||||
7. **Priority Control**: Process entities in configurable order
|
||||
8. **Reusability**: Handlers can be reused across different import scenarios
|
||||
|
||||
## Simulation Service
|
||||
|
||||
ImportSimulationServiceV2 provides a way to preview what an import would do without persisting any data to the database. This is useful for:
|
||||
- Validating mappings before processing
|
||||
- Previewing create/update actions
|
||||
- Detecting errors before running actual import
|
||||
- Testing handler logic
|
||||
|
||||
### Usage
|
||||
|
||||
```php
|
||||
use App\Services\Import\ImportSimulationServiceV2;
|
||||
|
||||
$service = app(ImportSimulationServiceV2::class);
|
||||
|
||||
// Simulate first 100 rows (default)
|
||||
$result = $service->simulate($import);
|
||||
|
||||
// Simulate 50 rows with verbose output
|
||||
$result = $service->simulate($import, limit: 50, verbose: true);
|
||||
|
||||
// Result structure:
|
||||
// [
|
||||
// 'success' => true,
|
||||
// 'total_simulated' => 50,
|
||||
// 'limit' => 50,
|
||||
// 'summaries' => [
|
||||
// 'contract' => ['create' => 10, 'update' => 5, 'skip' => 0, 'invalid' => 1],
|
||||
// 'account' => ['create' => 20, 'update' => 3, 'skip' => 0, 'invalid' => 0],
|
||||
// ],
|
||||
// 'rows' => [
|
||||
// [
|
||||
// 'row_number' => 2,
|
||||
// 'entities' => [
|
||||
// 'contract' => [
|
||||
// 'action' => 'update',
|
||||
// 'reference' => 'CNT-001',
|
||||
// 'existing_id' => 123,
|
||||
// 'data' => ['reference', 'title', 'amount'],
|
||||
// 'changes' => ['title' => ['old' => 'Old', 'new' => 'New']],
|
||||
// ],
|
||||
// ],
|
||||
// 'warnings' => [],
|
||||
// 'errors' => [],
|
||||
// ],
|
||||
// ],
|
||||
// 'meta' => [
|
||||
// 'has_header' => true,
|
||||
// 'delimiter' => ',',
|
||||
// 'mappings_count' => 8,
|
||||
// ],
|
||||
// ]
|
||||
```
|
||||
|
||||
### CLI Command
|
||||
|
||||
```bash
|
||||
# Simulate import with ID 123
|
||||
php artisan import:simulate-v2 123
|
||||
|
||||
# Simulate with custom limit
|
||||
php artisan import:simulate-v2 123 --limit=50
|
||||
|
||||
# Verbose mode shows field-level changes
|
||||
php artisan import:simulate-v2 123 --verbose
|
||||
```
|
||||
|
||||
### Action Types
|
||||
|
||||
- **create**: Entity doesn't exist, would be created
|
||||
- **update**: Entity exists, would be updated
|
||||
- **skip**: Entity exists but update_mode is 'skip'
|
||||
- **invalid**: Validation failed
|
||||
- **error**: Processing error occurred
|
||||
|
||||
### Comparison with V1 Simulation
|
||||
|
||||
| Feature | ImportSimulationService (V1) | ImportSimulationServiceV2 |
|
||||
|---------|------------------------------|---------------------------|
|
||||
| Handler-based | ❌ Hardcoded logic | ✅ Uses V2 handlers |
|
||||
| Configuration | ❌ In code | ✅ From database |
|
||||
| Validation | ❌ Manual | ✅ Handler validation |
|
||||
| Extensibility | ❌ Modify service | ✅ Add handlers |
|
||||
| Change detection | ✅ Yes | ✅ Yes |
|
||||
| Priority ordering | ❌ Fixed | ✅ Configurable |
|
||||
| Error handling | ✅ Basic | ✅ Comprehensive |
|
||||
|
||||
## Original ImportProcessor.php
|
||||
|
||||
The original file remains at `app/Services/ImportProcessor.php` and can be used as reference for implementing remaining handlers.
|
||||
68
app/Services/ReferenceDataCache.php
Normal file
68
app/Services/ReferenceDataCache.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AccountType;
|
||||
use App\Models\ContractType;
|
||||
use App\Models\Person\AddressType;
|
||||
use App\Models\Person\PhoneType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ReferenceDataCache
|
||||
{
|
||||
private const TTL = 3600; // 1 hour
|
||||
|
||||
public function getAddressTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:address_types', self::TTL, fn () => AddressType::all());
|
||||
}
|
||||
|
||||
public function getPhoneTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:phone_types', self::TTL, fn () => PhoneType::all());
|
||||
}
|
||||
|
||||
public function getAccountTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:account_types', self::TTL, fn () => AccountType::all());
|
||||
}
|
||||
|
||||
public function getContractTypes()
|
||||
{
|
||||
return Cache::remember('reference_data:contract_types', self::TTL, fn () => ContractType::whereNull('deleted_at')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all reference data cache.
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
Cache::forget('reference_data:address_types');
|
||||
Cache::forget('reference_data:phone_types');
|
||||
Cache::forget('reference_data:account_types');
|
||||
Cache::forget('reference_data:contract_types');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific reference data cache.
|
||||
*/
|
||||
public function clear(string $type): void
|
||||
{
|
||||
Cache::forget("reference_data:{$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all types as an array for convenience.
|
||||
*/
|
||||
public function getAllTypes(): array
|
||||
{
|
||||
return [
|
||||
'address_types' => $this->getAddressTypes(),
|
||||
'phone_types' => $this->getPhoneTypes(),
|
||||
'account_types' => $this->getAccountTypes(),
|
||||
'contract_types' => $this->getContractTypes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
248
app/Services/ReportQueryBuilder.php
Normal file
248
app/Services/ReportQueryBuilder.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Report;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReportQueryBuilder
|
||||
{
|
||||
/**
|
||||
* Build a query from a database-driven report configuration.
|
||||
*/
|
||||
public function build(Report $report, array $filters = []): Builder
|
||||
{
|
||||
// Load all required relationships
|
||||
$report->load(['entities', 'columns', 'conditions', 'orders']);
|
||||
|
||||
// 1. Start with base model query
|
||||
$query = $this->buildBaseQuery($report);
|
||||
|
||||
// 2. Apply joins from report_entities
|
||||
$this->applyJoins($query, $report);
|
||||
|
||||
// 3. Select columns from report_columns
|
||||
$this->applySelects($query, $report);
|
||||
|
||||
// 4. Apply GROUP BY if aggregate functions are used
|
||||
$this->applyGroupBy($query, $report);
|
||||
|
||||
// 5. Apply conditions from report_conditions
|
||||
$this->applyConditions($query, $report, $filters);
|
||||
|
||||
// 6. Apply ORDER BY from report_orders
|
||||
$this->applyOrders($query, $report);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the base query from the first entity.
|
||||
*/
|
||||
protected function buildBaseQuery(Report $report): Builder
|
||||
{
|
||||
$baseEntity = $report->entities->firstWhere('join_type', 'base');
|
||||
|
||||
if (!$baseEntity) {
|
||||
throw new \RuntimeException("Report {$report->slug} has no base entity defined.");
|
||||
}
|
||||
|
||||
$modelClass = $baseEntity->model_class;
|
||||
|
||||
if (!class_exists($modelClass)) {
|
||||
throw new \RuntimeException("Model class {$modelClass} does not exist.");
|
||||
}
|
||||
|
||||
return $modelClass::query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply joins from report entities.
|
||||
*/
|
||||
protected function applyJoins(Builder $query, Report $report): void
|
||||
{
|
||||
$entities = $report->entities->where('join_type', '!=', 'base');
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$table = $this->getTableFromModel($entity->model_class);
|
||||
|
||||
// Use alias if provided
|
||||
if ($entity->alias) {
|
||||
$table = "{$table} as {$entity->alias}";
|
||||
}
|
||||
|
||||
$joinMethod = $entity->join_type;
|
||||
|
||||
$query->{$joinMethod}(
|
||||
$table,
|
||||
$entity->join_first,
|
||||
$entity->join_operator ?? '=',
|
||||
$entity->join_second
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply column selections.
|
||||
*/
|
||||
protected function applySelects(Builder $query, Report $report): void
|
||||
{
|
||||
$columns = $report->columns
|
||||
->where('visible', true)
|
||||
->map(fn($col) => DB::raw("{$col->expression} as {$col->key}"))
|
||||
->toArray();
|
||||
|
||||
if (!empty($columns)) {
|
||||
$query->select($columns);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply GROUP BY clause if aggregate functions are detected.
|
||||
*/
|
||||
protected function applyGroupBy(Builder $query, Report $report): void
|
||||
{
|
||||
$visibleColumns = $report->columns->where('visible', true);
|
||||
|
||||
// Check if any column uses aggregate functions
|
||||
$hasAggregates = $visibleColumns->contains(function ($col) {
|
||||
return preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
|
||||
});
|
||||
|
||||
if (!$hasAggregates) {
|
||||
return; // No aggregates, no grouping needed
|
||||
}
|
||||
|
||||
// Find non-aggregate columns that need to be in GROUP BY
|
||||
$groupByColumns = $visibleColumns
|
||||
->filter(function ($col) {
|
||||
// Check if this column does NOT use an aggregate function
|
||||
return !preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
|
||||
})
|
||||
->map(function ($col) {
|
||||
// Extract the actual column expression (before any COALESCE, CAST, etc.)
|
||||
// For COALESCE(segments.name, 'default'), we need segments.name
|
||||
if (preg_match('/COALESCE\s*\(\s*([^,]+)\s*,/i', $col->expression, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
// For simple columns, use as-is
|
||||
return $col->expression;
|
||||
})
|
||||
->filter() // Remove empty values
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (!empty($groupByColumns)) {
|
||||
foreach ($groupByColumns as $column) {
|
||||
$query->groupBy(DB::raw($column));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply conditions (WHERE clauses).
|
||||
*/
|
||||
protected function applyConditions(Builder $query, Report $report, array $filters): void
|
||||
{
|
||||
$conditions = $report->conditions->where('enabled', true);
|
||||
|
||||
// Group conditions by group_id
|
||||
$groups = $conditions->groupBy('group_id');
|
||||
|
||||
foreach ($groups as $groupId => $groupConditions) {
|
||||
$query->where(function ($subQuery) use ($groupConditions, $filters) {
|
||||
foreach ($groupConditions as $condition) {
|
||||
$value = $this->resolveConditionValue($condition, $filters);
|
||||
|
||||
// Skip if filter-based and no value provided
|
||||
if ($condition->value_type === 'filter' && $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$method = $condition->logical_operator === 'OR' ? 'orWhere' : 'where';
|
||||
|
||||
$this->applyCondition($subQuery, $condition, $value, $method);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single condition to the query.
|
||||
*/
|
||||
protected function applyCondition(Builder $query, $condition, $value, string $method): void
|
||||
{
|
||||
$column = $condition->column;
|
||||
$operator = strtoupper($condition->operator);
|
||||
|
||||
switch ($operator) {
|
||||
case 'IS NULL':
|
||||
$query->{$method . 'Null'}($column);
|
||||
break;
|
||||
|
||||
case 'IS NOT NULL':
|
||||
$query->{$method . 'NotNull'}($column);
|
||||
break;
|
||||
|
||||
case 'IN':
|
||||
$values = is_array($value) ? $value : explode(',', $value);
|
||||
$query->{$method . 'In'}($column, $values);
|
||||
break;
|
||||
|
||||
case 'NOT IN':
|
||||
$values = is_array($value) ? $value : explode(',', $value);
|
||||
$query->{$method . 'NotIn'}($column, $values);
|
||||
break;
|
||||
|
||||
case 'BETWEEN':
|
||||
if (is_array($value) && count($value) === 2) {
|
||||
$query->{$method . 'Between'}($column, $value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'LIKE':
|
||||
$query->{$method}($column, 'LIKE', $value);
|
||||
break;
|
||||
|
||||
default:
|
||||
$query->{$method}($column, $operator, $value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve condition value based on value_type.
|
||||
*/
|
||||
protected function resolveConditionValue($condition, array $filters)
|
||||
{
|
||||
return match ($condition->value_type) {
|
||||
'static' => $condition->value,
|
||||
'filter' => $filters[$condition->filter_key] ?? null,
|
||||
'expression' => DB::raw($condition->value),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply ORDER BY clauses.
|
||||
*/
|
||||
protected function applyOrders(Builder $query, Report $report): void
|
||||
{
|
||||
foreach ($report->orders as $order) {
|
||||
$query->orderBy($order->column, $order->direction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name from model class.
|
||||
*/
|
||||
protected function getTableFromModel(string $modelClass): string
|
||||
{
|
||||
if (!class_exists($modelClass)) {
|
||||
throw new \RuntimeException("Model class {$modelClass} does not exist.");
|
||||
}
|
||||
|
||||
return (new $modelClass)->getTable();
|
||||
}
|
||||
}
|
||||
147
clean-duplicates.php
Normal file
147
clean-duplicates.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
echo "=== Checking for duplicates ===\n\n";
|
||||
|
||||
// Check Actions table
|
||||
echo "ACTIONS TABLE:\n";
|
||||
echo "-------------\n";
|
||||
$actionDuplicates = DB::table('actions')
|
||||
->select('name', DB::raw('COUNT(*) as total_count'))
|
||||
->groupBy('name')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($actionDuplicates->count() > 0) {
|
||||
echo "Found duplicate actions:\n";
|
||||
foreach ($actionDuplicates as $dup) {
|
||||
echo " - '{$dup->name}' appears {$dup->total_count} times\n";
|
||||
|
||||
// Get all IDs for this name
|
||||
$records = DB::table('actions')
|
||||
->where('name', $dup->name)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'created_at']);
|
||||
|
||||
echo " IDs: ";
|
||||
foreach ($records as $record) {
|
||||
echo $record->id . " ";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
} else {
|
||||
echo "No duplicates found.\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Check Decisions table
|
||||
echo "DECISIONS TABLE:\n";
|
||||
echo "---------------\n";
|
||||
$decisionDuplicates = DB::table('decisions')
|
||||
->select('name', DB::raw('COUNT(*) as total_count'))
|
||||
->groupBy('name')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($decisionDuplicates->count() > 0) {
|
||||
echo "Found duplicate decisions:\n";
|
||||
foreach ($decisionDuplicates as $dup) {
|
||||
echo " - '{$dup->name}' appears {$dup->total_count} times\n";
|
||||
|
||||
// Get all IDs for this name
|
||||
$records = DB::table('decisions')
|
||||
->where('name', $dup->name)
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'created_at']);
|
||||
|
||||
echo " IDs: ";
|
||||
foreach ($records as $record) {
|
||||
echo $record->id . " ";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
} else {
|
||||
echo "No duplicates found.\n";
|
||||
}
|
||||
|
||||
echo "\n=== Removing duplicates ===\n\n";
|
||||
|
||||
// Remove duplicate actions (keep the first one)
|
||||
if ($actionDuplicates->count() > 0) {
|
||||
foreach ($actionDuplicates as $dup) {
|
||||
$records = DB::table('actions')
|
||||
->where('name', $dup->name)
|
||||
->orderBy('id')
|
||||
->get(['id']);
|
||||
|
||||
// Keep the first, delete the rest
|
||||
$toDelete = $records->skip(1)->pluck('id')->toArray();
|
||||
|
||||
if (count($toDelete) > 0) {
|
||||
DB::table('actions')->whereIn('id', $toDelete)->delete();
|
||||
echo "Deleted duplicate actions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate decisions (keep the first one)
|
||||
if ($decisionDuplicates->count() > 0) {
|
||||
foreach ($decisionDuplicates as $dup) {
|
||||
$records = DB::table('decisions')
|
||||
->where('name', $dup->name)
|
||||
->orderBy('id')
|
||||
->get(['id']);
|
||||
|
||||
// Keep the first, delete the rest
|
||||
$toDelete = $records->skip(1)->pluck('id')->toArray();
|
||||
|
||||
if (count($toDelete) > 0) {
|
||||
DB::table('decisions')->whereIn('id', $toDelete)->delete();
|
||||
echo "Deleted duplicate decisions for '{$dup->name}': IDs " . implode(', ', $toDelete) . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Check and clean action_decision pivot table
|
||||
echo "ACTION_DECISION PIVOT TABLE:\n";
|
||||
echo "---------------------------\n";
|
||||
|
||||
// Find duplicates in pivot table
|
||||
$pivotDuplicates = DB::table('action_decision')
|
||||
->select('action_id', 'decision_id', DB::raw('COUNT(*) as total_count'))
|
||||
->groupBy('action_id', 'decision_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($pivotDuplicates->count() > 0) {
|
||||
echo "Found duplicate pivot entries:\n";
|
||||
foreach ($pivotDuplicates as $dup) {
|
||||
echo " - action_id: {$dup->action_id}, decision_id: {$dup->decision_id} appears {$dup->total_count} times\n";
|
||||
|
||||
// Get all IDs for this combination
|
||||
$records = DB::table('action_decision')
|
||||
->where('action_id', $dup->action_id)
|
||||
->where('decision_id', $dup->decision_id)
|
||||
->orderBy('id')
|
||||
->get(['id']);
|
||||
|
||||
// Keep the first, delete the rest
|
||||
$toDelete = $records->skip(1)->pluck('id')->toArray();
|
||||
|
||||
if (count($toDelete) > 0) {
|
||||
DB::table('action_decision')->whereIn('id', $toDelete)->delete();
|
||||
echo " Deleted duplicate pivot entries: IDs " . implode(', ', $toDelete) . "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "No duplicates found.\n";
|
||||
}
|
||||
|
||||
echo "\n=== Cleanup complete ===\n";
|
||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "resources/css/app.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/Components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/Components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"arielmejiadev/larapex-charts": "^2.1",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
|
|
|
|||
381
composer.lock
generated
381
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d29c47a4d6813ee8e80a7c8112c2f17e",
|
||||
"content-hash": "d28e6760b713feea1c4ad6058f96287a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "arielmejiadev/larapex-charts",
|
||||
|
|
@ -113,6 +113,83 @@
|
|||
},
|
||||
"time": "2024-10-01T13:55:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9|^10",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-13T15:07:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.12.3",
|
||||
|
|
@ -761,6 +838,161 @@
|
|||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
|
||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
|
||||
},
|
||||
"time": "2025-10-29T12:43:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
|
||||
"reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
|
||||
},
|
||||
"time": "2024-12-02T14:37:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
|
||||
"reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
|
||||
},
|
||||
"time": "2024-04-29T13:26:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.4.0",
|
||||
|
|
@ -2995,16 +3227,16 @@
|
|||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.1",
|
||||
"version": "3.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -3015,7 +3247,7 @@
|
|||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"friendsofphp/php-cs-fixer": "^3.16",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
|
|
@ -3061,7 +3293,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3069,7 +3301,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-10T09:58:31+00:00"
|
||||
"time": "2025-07-17T11:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
|
|
@ -3178,6 +3410,73 @@
|
|||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "meilisearch/meilisearch-php",
|
||||
"version": "v1.13.0",
|
||||
|
|
@ -5031,6 +5330,72 @@
|
|||
],
|
||||
"time": "2025-02-28T15:16:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v8.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
|
||||
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
|
||||
"rawr/cross-data-providers": "^2.0.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
|
||||
},
|
||||
"time": "2025-07-11T13:20:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.92.0",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
'features' => [
|
||||
// Features::termsAndPrivacyPolicy(),
|
||||
// Features::profilePhotos(),
|
||||
Features::api(),
|
||||
// Features::api(),
|
||||
// Features::teams(['invitations' => true]),
|
||||
Features::accountDeletion(),
|
||||
],
|
||||
|
|
|
|||
10
config/reports.php
Normal file
10
config/reports.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Optionally list Postgres materialized view names to refresh on schedule
|
||||
'materialized_views' => [
|
||||
// e.g., 'mv_activities_daily', 'mv_segment_activity_counts'
|
||||
],
|
||||
// Time for scheduled refresh (24h format HH:MM)
|
||||
'refresh_time' => '03:00',
|
||||
];
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Contracts table indexes
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
if (! $this->indexExists('contracts', 'contracts_client_case_id_active_deleted_at_index')) {
|
||||
$table->index(['client_case_id', 'active', 'deleted_at'], 'contracts_client_case_id_active_deleted_at_index');
|
||||
}
|
||||
if (! $this->indexExists('contracts', 'contracts_start_date_end_date_index')) {
|
||||
$table->index(['start_date', 'end_date'], 'contracts_start_date_end_date_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Contract segment pivot table indexes
|
||||
Schema::table('contract_segment', function (Blueprint $table) {
|
||||
if (! $this->indexExists('contract_segment', 'contract_segment_contract_id_active_index')) {
|
||||
$table->index(['contract_id', 'active'], 'contract_segment_contract_id_active_index');
|
||||
}
|
||||
if (! $this->indexExists('contract_segment', 'contract_segment_segment_id_active_index')) {
|
||||
$table->index(['segment_id', 'active'], 'contract_segment_segment_id_active_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Activities table indexes
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
if (! $this->indexExists('activities', 'activities_client_case_id_created_at_index')) {
|
||||
$table->index(['client_case_id', 'created_at'], 'activities_client_case_id_created_at_index');
|
||||
}
|
||||
if (! $this->indexExists('activities', 'activities_contract_id_created_at_index')) {
|
||||
$table->index(['contract_id', 'created_at'], 'activities_contract_id_created_at_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Client cases table indexes
|
||||
Schema::table('client_cases', function (Blueprint $table) {
|
||||
if (! $this->indexExists('client_cases', 'client_cases_client_id_active_index')) {
|
||||
$table->index(['client_id', 'active'], 'client_cases_client_id_active_index');
|
||||
}
|
||||
if (! $this->indexExists('client_cases', 'client_cases_person_id_active_index')) {
|
||||
$table->index(['person_id', 'active'], 'client_cases_person_id_active_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Documents table indexes for polymorphic relations
|
||||
Schema::table('documents', function (Blueprint $table) {
|
||||
if (! $this->indexExists('documents', 'documents_documentable_type_documentable_id_index')) {
|
||||
$table->index(['documentable_type', 'documentable_id'], 'documents_documentable_type_documentable_id_index');
|
||||
}
|
||||
if (! $this->indexExists('documents', 'documents_created_at_index')) {
|
||||
$table->index(['created_at'], 'documents_created_at_index');
|
||||
}
|
||||
});
|
||||
|
||||
// Field jobs indexes
|
||||
Schema::table('field_jobs', function (Blueprint $table) {
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_assigned_user_id_index')) {
|
||||
$table->index(['assigned_user_id'], 'field_jobs_assigned_user_id_index');
|
||||
}
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_contract_id_index')) {
|
||||
$table->index(['contract_id'], 'field_jobs_contract_id_index');
|
||||
}
|
||||
if (! $this->indexExists('field_jobs', 'field_jobs_completed_at_index')) {
|
||||
$table->index(['completed_at'], 'field_jobs_completed_at_index');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
$table->dropIndex('contracts_client_case_id_active_deleted_at_index');
|
||||
$table->dropIndex('contracts_start_date_end_date_index');
|
||||
});
|
||||
|
||||
Schema::table('contract_segment', function (Blueprint $table) {
|
||||
$table->dropIndex('contract_segment_contract_id_active_index');
|
||||
$table->dropIndex('contract_segment_segment_id_active_index');
|
||||
});
|
||||
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropIndex('activities_client_case_id_created_at_index');
|
||||
$table->dropIndex('activities_contract_id_created_at_index');
|
||||
});
|
||||
|
||||
Schema::table('client_cases', function (Blueprint $table) {
|
||||
$table->dropIndex('client_cases_client_id_active_index');
|
||||
$table->dropIndex('client_cases_person_id_active_index');
|
||||
});
|
||||
|
||||
Schema::table('documents', function (Blueprint $table) {
|
||||
$table->dropIndex('documents_documentable_type_documentable_id_index');
|
||||
$table->dropIndex('documents_created_at_index');
|
||||
});
|
||||
|
||||
Schema::table('field_jobs', function (Blueprint $table) {
|
||||
$table->dropIndex('field_jobs_assigned_user_id_index');
|
||||
$table->dropIndex('field_jobs_contract_id_index');
|
||||
$table->dropIndex('field_jobs_completed_at_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an index exists on a table.
|
||||
*/
|
||||
protected function indexExists(string $table, string $index): bool
|
||||
{
|
||||
$connection = Schema::getConnection();
|
||||
$driver = $connection->getDriverName();
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
// PostgreSQL uses pg_indexes system catalog
|
||||
$result = $connection->select(
|
||||
"SELECT COUNT(*) as count FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = ? AND indexname = ?",
|
||||
[$table, $index]
|
||||
);
|
||||
} else {
|
||||
// MySQL/MariaDB uses information_schema.statistics
|
||||
$databaseName = $connection->getDatabaseName();
|
||||
$result = $connection->select(
|
||||
"SELECT COUNT(*) as count FROM information_schema.statistics
|
||||
WHERE table_schema = ? AND table_name = ? AND index_name = ?",
|
||||
[$databaseName, $table, $index]
|
||||
);
|
||||
}
|
||||
|
||||
return $result[0]->count > 0;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
306
database/seeders/ImportEntitiesV2Seeder.php
Normal file
306
database/seeders/ImportEntitiesV2Seeder.php
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ImportEntity;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ImportEntitiesV2Seeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed import_entities with v2 handler configurations.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$entities = [
|
||||
[
|
||||
'key' => 'contracts',
|
||||
'canonical_root' => 'contract',
|
||||
'label' => 'Pogodbe',
|
||||
'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id', 'meta'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['contract', 'contracts'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => true,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'reference', 'order' => 1],
|
||||
'handler_class' => \App\Services\Import\Handlers\ContractHandler::class,
|
||||
'validation_rules' => [
|
||||
'reference' => 'required|string|max:255',
|
||||
],
|
||||
'processing_options' => [
|
||||
'update_mode' => 'update', // update, skip, error
|
||||
'create_missing' => true,
|
||||
'supports_reactivation' => true,
|
||||
'merge_json_fields' => ['meta'],
|
||||
'post_actions' => [
|
||||
'attach_segment_id' => null,
|
||||
'create_activity' => false,
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 100, // Highest - process first to establish chain
|
||||
],
|
||||
[
|
||||
'key' => 'accounts',
|
||||
'canonical_root' => 'account',
|
||||
'label' => 'Računi',
|
||||
'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['account', 'accounts'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'reference', 'order' => 2],
|
||||
'handler_class' => \App\Services\Import\Handlers\AccountHandler::class,
|
||||
'validation_rules' => [
|
||||
'reference' => 'required|string|max:255',
|
||||
'contract_id' => 'required|integer|exists:contracts,id',
|
||||
],
|
||||
'processing_options' => [
|
||||
'update_mode' => 'update',
|
||||
'require_contract' => true,
|
||||
'track_balance_changes' => true,
|
||||
'create_activity_on_balance_change' => true,
|
||||
'history_import' => [
|
||||
'skip_updates' => true,
|
||||
'force_zero_balances' => true,
|
||||
'auto_create_for_contract' => true,
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 50, // After Person and contacts
|
||||
],
|
||||
[
|
||||
'key' => 'payments',
|
||||
'canonical_root' => 'payment',
|
||||
'label' => 'Plačila',
|
||||
'fields' => [
|
||||
'reference',
|
||||
'payment_nu',
|
||||
'payment_date',
|
||||
'amount',
|
||||
'type_id',
|
||||
'active',
|
||||
// optional helpers for mapping by related records
|
||||
'debt_id',
|
||||
'account_id',
|
||||
'account_reference',
|
||||
'contract_reference'
|
||||
],
|
||||
'field_aliases' => [
|
||||
'datum' => 'payment_date',
|
||||
'paid_at' => 'payment_date',
|
||||
'number' => 'payment_nu',
|
||||
'znesek' => 'amount',
|
||||
'value' => 'amount'
|
||||
],
|
||||
'aliases' => ['payment', 'payments'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'reference', 'order' => 3],
|
||||
'handler_class' => \App\Services\Import\Handlers\PaymentHandler::class,
|
||||
'validation_rules' => [
|
||||
'amount' => 'required|numeric',
|
||||
],
|
||||
'processing_options' => [
|
||||
'deduplicate_by' => ['account_id', 'reference'],
|
||||
'create_booking' => true,
|
||||
'create_activity' => false, // Based on PaymentSetting
|
||||
'track_balance' => true,
|
||||
'activity_note_template' => 'Prejeto plačilo [amount] [currency]',
|
||||
'payments_import' => [
|
||||
'require_fields' => ['contract.reference', 'payment.amount', 'payment.payment_date'],
|
||||
'contract_key_mode' => 'reference',
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 40, // After Account
|
||||
],
|
||||
[
|
||||
'key' => 'activities',
|
||||
'canonical_root' => 'activity',
|
||||
'label' => 'Aktivnosti',
|
||||
'fields' => ['client_case_id', 'contract_id', 'due_date', 'amount', 'note', 'action_id', 'decision_id'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['activity', 'activities'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'note', 'order' => 4],
|
||||
'handler_class' => \App\Services\Import\Handlers\ActivityHandler::class,
|
||||
'validation_rules' => [],
|
||||
'processing_options' => [
|
||||
'require_contract' => false,
|
||||
'require_client_case' => false,
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 30, // After all primary entities
|
||||
],
|
||||
[
|
||||
'key' => 'person',
|
||||
'canonical_root' => 'person',
|
||||
'label' => 'Osebe',
|
||||
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['person'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'full_name', 'order' => 5],
|
||||
'handler_class' => \App\Services\Import\Handlers\PersonHandler::class,
|
||||
'validation_rules' => [],
|
||||
'processing_options' => [
|
||||
'deduplicate_by' => ['tax_number', 'social_security_number'],
|
||||
'update_mode' => 'update',
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 90, // Third - derive from Contract/ClientCase chain if exists
|
||||
],
|
||||
[
|
||||
'key' => 'emails',
|
||||
'canonical_root' => 'email',
|
||||
'label' => 'Email naslovi',
|
||||
'fields' => ['value', 'is_primary', 'label'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['email', 'emails'],
|
||||
'supports_multiple' => true,
|
||||
'meta' => false,
|
||||
'rules' => [],
|
||||
'ui' => ['default_field' => 'value', 'order' => 6],
|
||||
'handler_class' => \App\Services\Import\Handlers\EmailHandler::class,
|
||||
'validation_rules' => [
|
||||
'value' => 'required|email',
|
||||
],
|
||||
'processing_options' => [
|
||||
'deduplicate' => true,
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 80, // After Person
|
||||
],
|
||||
[
|
||||
'key' => 'person_addresses',
|
||||
'canonical_root' => 'address',
|
||||
'label' => 'Naslovi oseb',
|
||||
'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'],
|
||||
'field_aliases' => [
|
||||
'ulica' => 'address',
|
||||
'naslov' => 'address',
|
||||
'mesto' => 'city',
|
||||
'posta' => 'postal_code',
|
||||
'pošta' => 'postal_code',
|
||||
'zip' => 'postal_code',
|
||||
'drzava' => 'country',
|
||||
'država' => 'country',
|
||||
'opis' => 'description',
|
||||
],
|
||||
'aliases' => ['person_addresses', 'address', 'addresses'],
|
||||
'supports_multiple' => true,
|
||||
'meta' => false,
|
||||
'rules' => [
|
||||
['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'],
|
||||
['pattern' => '/^(mesto|city|kraj)\b/i', 'field' => 'city'],
|
||||
['pattern' => '/^(posta|pošta|zip|postal)\b/i', 'field' => 'postal_code'],
|
||||
['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'],
|
||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||
],
|
||||
'ui' => ['default_field' => 'address', 'order' => 7],
|
||||
'handler_class' => \App\Services\Import\Handlers\AddressHandler::class,
|
||||
'validation_rules' => [
|
||||
'address' => 'required|string|max:255',
|
||||
],
|
||||
'processing_options' => [
|
||||
'deduplicate' => true,
|
||||
'parent_entity' => 'person',
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 70, // After Person
|
||||
],
|
||||
[
|
||||
'key' => 'person_phones',
|
||||
'canonical_root' => 'phone',
|
||||
'label' => 'Telefoni oseb',
|
||||
'fields' => ['nu', 'country_code', 'type_id', 'description'],
|
||||
'field_aliases' => ['number' => 'nu'],
|
||||
'aliases' => ['phone', 'person_phones'],
|
||||
'supports_multiple' => true,
|
||||
'meta' => false,
|
||||
'rules' => [
|
||||
['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'],
|
||||
],
|
||||
'ui' => ['default_field' => 'nu', 'order' => 8],
|
||||
'handler_class' => \App\Services\Import\Handlers\PhoneHandler::class,
|
||||
'validation_rules' => [
|
||||
'nu' => 'required|string|max:50',
|
||||
],
|
||||
'processing_options' => [
|
||||
'deduplicate' => true,
|
||||
'parent_entity' => 'person',
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 60, // After Person
|
||||
],
|
||||
[
|
||||
'key' => 'client_cases',
|
||||
'canonical_root' => 'client_case',
|
||||
'label' => 'Primeri',
|
||||
'fields' => ['client_ref'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [
|
||||
['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'],
|
||||
],
|
||||
'ui' => ['default_field' => 'client_ref', 'order' => 9],
|
||||
'handler_class' => \App\Services\Import\Handlers\ClientCaseHandler::class,
|
||||
'validation_rules' => [
|
||||
'client_ref' => 'required|string|max:255',
|
||||
],
|
||||
'processing_options' => [
|
||||
'deduplicate_by' => ['client_ref'],
|
||||
'update_mode' => 'update',
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 95, // Second - process after Contract to establish chain
|
||||
],
|
||||
[
|
||||
'key' => 'case_objects',
|
||||
'canonical_root' => 'case_object',
|
||||
'label' => 'Predmeti',
|
||||
'fields' => ['reference', 'name', 'description', 'type', 'contract_id'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['case_object', 'case_objects', 'object', 'objects', 'predmet', 'predmeti'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'rules' => [
|
||||
['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'],
|
||||
['pattern' => '/^(ime|naziv|name|title)\b/i', 'field' => 'name'],
|
||||
['pattern' => '/^(tip|vrsta|type|kind)\b/i', 'field' => 'type'],
|
||||
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
|
||||
['pattern' => '/^(contract\s*id|contract_id|pogodba\s*id|pogodba_id)\b/i', 'field' => 'contract_id'],
|
||||
],
|
||||
'ui' => ['default_field' => 'name', 'order' => 10],
|
||||
'handler_class' => \App\Services\Import\Handlers\CaseObjectHandler::class,
|
||||
'validation_rules' => [
|
||||
'name' => 'required|string|max:255',
|
||||
],
|
||||
'processing_options' => [
|
||||
'require_contract' => false,
|
||||
],
|
||||
'is_active' => true,
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
ImportEntity::updateOrCreate(
|
||||
['key' => $entity['key']],
|
||||
$entity
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info('Import entities v2 seeded successfully.');
|
||||
}
|
||||
}
|
||||
786
database/seeders/ReportsSeeder.php
Normal file
786
database/seeders/ReportsSeeder.php
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Report;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ReportsSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Clear existing reports (cascade will delete all related records)
|
||||
Report::truncate();
|
||||
|
||||
$this->seedActiveContractsReport();
|
||||
$this->seedFieldJobsCompletedReport();
|
||||
$this->seedDecisionsCountReport();
|
||||
$this->seedSegmentActivityCountsReport();
|
||||
$this->seedActionsDecisionsCountReport();
|
||||
$this->seedActivitiesPerPeriodReport();
|
||||
}
|
||||
|
||||
protected function seedActiveContractsReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'active-contracts',
|
||||
'name' => 'Aktivne pogodbe',
|
||||
'description' => 'Pogodbe, ki so aktivne na izbrani dan, z možnostjo filtriranja po stranki.',
|
||||
'category' => 'contracts',
|
||||
'enabled' => true,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Entities (joins)
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Contract',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\ClientCase',
|
||||
'join_type' => 'join',
|
||||
'join_first' => 'contracts.client_case_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'client_cases.id',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Client',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'client_cases.client_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'clients.id',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$report->entities()->createMany([
|
||||
[
|
||||
'model_class' => 'App\\Models\\Person\\Person',
|
||||
'alias' => 'client_people',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'clients.person_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'client_people.id',
|
||||
'order' => 3,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Person\\Person',
|
||||
'alias' => 'subject_people',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'client_cases.person_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'subject_people.id',
|
||||
'order' => 4,
|
||||
],
|
||||
]);
|
||||
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Account',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'contracts.id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'accounts.contract_id',
|
||||
'order' => 5,
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'contract_reference',
|
||||
'label' => 'Pogodba',
|
||||
'type' => 'string',
|
||||
'expression' => 'contracts.reference',
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'client_name',
|
||||
'label' => 'Stranka',
|
||||
'type' => 'string',
|
||||
'expression' => 'client_people.full_name',
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'person_name',
|
||||
'label' => 'Zadeva (oseba)',
|
||||
'type' => 'string',
|
||||
'expression' => 'subject_people.full_name',
|
||||
'order' => 2,
|
||||
],
|
||||
[
|
||||
'key' => 'start_date',
|
||||
'label' => 'Začetek',
|
||||
'type' => 'date',
|
||||
'expression' => 'contracts.start_date',
|
||||
'order' => 3,
|
||||
],
|
||||
[
|
||||
'key' => 'end_date',
|
||||
'label' => 'Konec',
|
||||
'type' => 'date',
|
||||
'expression' => 'contracts.end_date',
|
||||
'order' => 4,
|
||||
],
|
||||
[
|
||||
'key' => 'balance_amount',
|
||||
'label' => 'Saldo',
|
||||
'type' => 'currency',
|
||||
'expression' => 'CAST(accounts.balance_amount AS FLOAT)',
|
||||
'order' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->create([
|
||||
'key' => 'client_uuid',
|
||||
'label' => 'Stranka',
|
||||
'type' => 'select:client',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Conditions - Active as of today
|
||||
$asOf = 'CURRENT_DATE';
|
||||
|
||||
// start_date <= as_of (or null)
|
||||
$report->conditions()->create([
|
||||
'column' => 'contracts.start_date',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'expression',
|
||||
'value' => $asOf,
|
||||
'logical_operator' => 'OR',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$report->conditions()->create([
|
||||
'column' => 'contracts.start_date',
|
||||
'operator' => 'IS NULL',
|
||||
'value_type' => 'static',
|
||||
'logical_operator' => 'OR',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// end_date >= as_of (or null)
|
||||
$report->conditions()->create([
|
||||
'column' => 'contracts.end_date',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'expression',
|
||||
'value' => $asOf,
|
||||
'logical_operator' => 'OR',
|
||||
'group_id' => 2,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$report->conditions()->create([
|
||||
'column' => 'contracts.end_date',
|
||||
'operator' => 'IS NULL',
|
||||
'value_type' => 'static',
|
||||
'logical_operator' => 'OR',
|
||||
'group_id' => 2,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// client_uuid filter condition
|
||||
$report->conditions()->create([
|
||||
'column' => 'clients.uuid',
|
||||
'operator' => '=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'client_uuid',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 3,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Orders
|
||||
$report->orders()->create([
|
||||
'column' => 'contracts.start_date',
|
||||
'direction' => 'ASC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedFieldJobsCompletedReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'field-jobs-completed',
|
||||
'name' => 'Zaključeni tereni',
|
||||
'description' => 'Pregled zaključenih terenov po datumu in uporabniku.',
|
||||
'category' => 'field',
|
||||
'enabled' => true,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
// Base entity
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\FieldJob',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Join contracts table
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Contract',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'field_jobs.contract_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'contracts.id',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Join users table
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\User',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'field_jobs.assigned_user_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'users.id',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'id',
|
||||
'label' => '#',
|
||||
'type' => 'number',
|
||||
'expression' => 'field_jobs.id',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'contract_reference',
|
||||
'label' => 'Pogodba',
|
||||
'type' => 'string',
|
||||
'expression' => 'contracts.reference',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'assigned_user_name',
|
||||
'label' => 'Terenski',
|
||||
'type' => 'string',
|
||||
'expression' => 'users.name',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 2,
|
||||
],
|
||||
[
|
||||
'key' => 'completed_at',
|
||||
'label' => 'Zaključeno',
|
||||
'type' => 'date',
|
||||
'expression' => 'field_jobs.completed_at',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 3,
|
||||
],
|
||||
[
|
||||
'key' => 'notes',
|
||||
'label' => 'Opombe',
|
||||
'type' => 'string',
|
||||
'expression' => 'field_jobs.notes',
|
||||
'sortable' => false,
|
||||
'visible' => true,
|
||||
'order' => 4,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Od',
|
||||
'type' => 'date',
|
||||
'nullable' => false,
|
||||
'default_value' => now()->startOfMonth()->toDateString(),
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Do',
|
||||
'type' => 'date',
|
||||
'nullable' => false,
|
||||
'default_value' => now()->toDateString(),
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'user_id',
|
||||
'label' => 'Uporabnik',
|
||||
'type' => 'select:user',
|
||||
'nullable' => true,
|
||||
'order' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'field_jobs.cancelled_at',
|
||||
'operator' => 'IS NULL',
|
||||
'value_type' => 'static',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'field_jobs.completed_at',
|
||||
'operator' => 'BETWEEN',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from,to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'field_jobs.assigned_user_id',
|
||||
'operator' => '=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'user_id',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 2,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'field_jobs.completed_at',
|
||||
'direction' => 'DESC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedDecisionsCountReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'decisions-counts',
|
||||
'name' => 'Odločitve – štetje',
|
||||
'description' => 'Število aktivnosti po odločitvah v izbranem obdobju.',
|
||||
'category' => 'activities',
|
||||
'enabled' => true,
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
// Entities
|
||||
$report->entities()->createMany([
|
||||
[
|
||||
'model_class' => 'App\\Models\\Activity',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Decision',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.decision_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'decisions.id',
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'decision_name',
|
||||
'label' => 'Odločitev',
|
||||
'type' => 'string',
|
||||
'expression' => "COALESCE(decisions.name, '—')",
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'activities_count',
|
||||
'label' => 'Št. aktivnosti',
|
||||
'type' => 'number',
|
||||
'expression' => 'COUNT(*)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Od',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Do',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'activities_count',
|
||||
'direction' => 'DESC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedSegmentActivityCountsReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'segment-activity-counts',
|
||||
'name' => 'Aktivnosti po segmentih',
|
||||
'description' => 'Število aktivnosti po segmentih v izbranem obdobju (glede na segment dejanja).',
|
||||
'category' => 'activities',
|
||||
'enabled' => true,
|
||||
'order' => 4,
|
||||
]);
|
||||
|
||||
// Entities
|
||||
$report->entities()->createMany([
|
||||
[
|
||||
'model_class' => 'App\\Models\\Activity',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Action',
|
||||
'join_type' => 'join',
|
||||
'join_first' => 'activities.action_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'actions.id',
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Segment',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'actions.segment_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'segments.id',
|
||||
'order' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'segment_name',
|
||||
'label' => 'Segment',
|
||||
'type' => 'string',
|
||||
'expression' => "COALESCE(segments.name, 'Brez segmenta')",
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'activities_count',
|
||||
'label' => 'Št. aktivnosti',
|
||||
'type' => 'number',
|
||||
'expression' => 'COUNT(*)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Od',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Do',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'activities_count',
|
||||
'direction' => 'DESC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedActionsDecisionsCountReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'actions-decisions-counts',
|
||||
'name' => 'Dejanja / Odločitve – štetje',
|
||||
'description' => 'Število aktivnosti po dejanjih in odločitvah v obdobju.',
|
||||
'category' => 'activities',
|
||||
'enabled' => true,
|
||||
'order' => 5,
|
||||
]);
|
||||
|
||||
// Entities
|
||||
$report->entities()->createMany([
|
||||
[
|
||||
'model_class' => 'App\\Models\\Activity',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Action',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.action_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'actions.id',
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'model_class' => 'App\\Models\\Decision',
|
||||
'join_type' => 'leftJoin',
|
||||
'join_first' => 'activities.decision_id',
|
||||
'join_operator' => '=',
|
||||
'join_second' => 'decisions.id',
|
||||
'order' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Columns
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'action_name',
|
||||
'label' => 'Dejanje',
|
||||
'type' => 'string',
|
||||
'expression' => "COALESCE(actions.name, '—')",
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'decision_name',
|
||||
'label' => 'Odločitev',
|
||||
'type' => 'string',
|
||||
'expression' => "COALESCE(decisions.name, '—')",
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'activities_count',
|
||||
'label' => 'Št. aktivnosti',
|
||||
'type' => 'number',
|
||||
'expression' => 'COUNT(*)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Od',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Do',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'activities_count',
|
||||
'direction' => 'DESC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function seedActivitiesPerPeriodReport(): void
|
||||
{
|
||||
$report = Report::create([
|
||||
'slug' => 'activities-per-period',
|
||||
'name' => 'Aktivnosti po obdobjih',
|
||||
'description' => 'Seštevek aktivnosti po dneh/tednih/mesecih v obdobju.',
|
||||
'category' => 'activities',
|
||||
'enabled' => true,
|
||||
'order' => 6,
|
||||
]);
|
||||
|
||||
// Base entity
|
||||
$report->entities()->create([
|
||||
'model_class' => 'App\\Models\\Activity',
|
||||
'join_type' => 'base',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
// Columns (simplified - period grouping handled in ReportQueryBuilder or controller)
|
||||
$report->columns()->createMany([
|
||||
[
|
||||
'key' => 'period',
|
||||
'label' => 'Obdobje',
|
||||
'type' => 'string',
|
||||
'expression' => 'DATE(activities.created_at)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'activities_count',
|
||||
'label' => 'Št. aktivnosti',
|
||||
'type' => 'number',
|
||||
'expression' => 'COUNT(*)',
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Filters
|
||||
$report->filters()->createMany([
|
||||
[
|
||||
'key' => 'from',
|
||||
'label' => 'Od',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 0,
|
||||
],
|
||||
[
|
||||
'key' => 'to',
|
||||
'label' => 'Do',
|
||||
'type' => 'date',
|
||||
'nullable' => true,
|
||||
'order' => 1,
|
||||
],
|
||||
[
|
||||
'key' => 'period',
|
||||
'label' => 'Obdobje (day/week/month)',
|
||||
'type' => 'string',
|
||||
'nullable' => false,
|
||||
'default_value' => 'day',
|
||||
'order' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
// Conditions
|
||||
$report->conditions()->createMany([
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '>=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'from',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 0,
|
||||
'enabled' => true,
|
||||
],
|
||||
[
|
||||
'column' => 'activities.created_at',
|
||||
'operator' => '<=',
|
||||
'value_type' => 'filter',
|
||||
'filter_key' => 'to',
|
||||
'logical_operator' => 'AND',
|
||||
'group_id' => 1,
|
||||
'order' => 1,
|
||||
'enabled' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
// Order
|
||||
$report->orders()->create([
|
||||
'column' => 'period',
|
||||
'direction' => 'ASC',
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
deploy.sh
Normal file
74
deploy.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Teren App Deployment Script
|
||||
# This script handles automated deployment from Gitea
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🚀 Starting deployment..."
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/var/www/Teren-app"
|
||||
BRANCH="main" # Change to your production branch
|
||||
GITEA_REPO="git@your-gitea-server.com:username/Teren-app.git"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Change to project directory
|
||||
cd $PROJECT_DIR
|
||||
|
||||
echo "📥 Pulling latest changes from $BRANCH..."
|
||||
git fetch origin $BRANCH
|
||||
git reset --hard origin/$BRANCH
|
||||
|
||||
echo "🔧 Copying production environment file..."
|
||||
if [ ! -f .env ]; then
|
||||
echo "${RED}❌ .env file not found! Please create it from .env.production.example${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🐳 Building and starting Docker containers..."
|
||||
docker-compose down
|
||||
docker-compose build --no-cache app
|
||||
docker-compose up -d
|
||||
|
||||
echo "⏳ Waiting for containers to be healthy..."
|
||||
sleep 10
|
||||
|
||||
echo "📦 Installing/updating Composer dependencies..."
|
||||
docker-compose exec -T app composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
echo "🎨 Building frontend assets..."
|
||||
# If you build assets locally or in CI/CD, uncomment:
|
||||
# npm ci
|
||||
# npm run build
|
||||
|
||||
echo "🔑 Optimizing Laravel..."
|
||||
docker-compose exec -T app php artisan config:cache
|
||||
docker-compose exec -T app php artisan route:cache
|
||||
docker-compose exec -T app php artisan view:cache
|
||||
docker-compose exec -T app php artisan event:cache
|
||||
|
||||
echo "📊 Running database migrations..."
|
||||
docker-compose exec -T app php artisan migrate --force
|
||||
|
||||
echo "🗄️ Clearing old caches..."
|
||||
docker-compose exec -T app php artisan cache:clear
|
||||
docker-compose exec -T app php artisan queue:restart
|
||||
|
||||
echo "🔄 Restarting queue workers..."
|
||||
docker-compose restart app
|
||||
|
||||
echo "${GREEN}✅ Deployment completed successfully!${NC}"
|
||||
|
||||
# Optional: Send notification (Slack, Discord, etc.)
|
||||
# curl -X POST -H 'Content-type: application/json' \
|
||||
# --data '{"text":"🚀 Teren App deployed successfully!"}' \
|
||||
# YOUR_WEBHOOK_URL
|
||||
|
||||
# Show running containers
|
||||
echo "📋 Running containers:"
|
||||
docker-compose ps
|
||||
189
docker-compose.yaml.example
Normal file
189
docker-compose.yaml.example
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Laravel Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- PHP_VERSION=8.4
|
||||
container_name: teren-app
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./storage:/var/www/storage
|
||||
- ./bootstrap/cache:/var/www/bootstrap/cache
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- APP_DEBUG=${APP_DEBUG:-false}
|
||||
- DB_CONNECTION=pgsql
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_DATABASE=${DB_DATABASE}
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- QUEUE_CONNECTION=redis
|
||||
- LIBREOFFICE_BIN=/usr/bin/soffice
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- teren-network
|
||||
# Supervisor runs inside the container (defined in Dockerfile)
|
||||
# Includes PHP-FPM, Laravel queue workers, and queue-sms workers
|
||||
|
||||
# Nginx Web Server (VPN-only access)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: teren-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.13.13.1:80:80" # Only accessible via WireGuard VPN
|
||||
- "10.13.13.1:443:443" # Only accessible via WireGuard VPN
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl
|
||||
- ./docker/certbot/conf:/etc/letsencrypt
|
||||
- ./docker/certbot/www:/var/www/certbot
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- teren-network
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
# Certbot for SSL certificates
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: teren-certbot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/certbot/conf:/etc/letsencrypt
|
||||
- ./docker/certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: teren-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # Only accessible via localhost (or VPN)
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_DATABASE}
|
||||
- POSTGRES_USER=${DB_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# pgAdmin - PostgreSQL UI
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: teren-pgadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5050:80" # Only accessible via localhost (or VPN)
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@admin.com}
|
||||
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-admin}
|
||||
- PGADMIN_CONFIG_SERVER_MODE=True
|
||||
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=True
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# Redis for caching and queues
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: teren-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# WireGuard VPN with Web UI Dashboard
|
||||
wireguard:
|
||||
image: weejewel/wg-easy:latest
|
||||
container_name: teren-wireguard
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
environment:
|
||||
- WG_HOST=${WG_SERVERURL} # Your VPS public IP or domain
|
||||
- PASSWORD=${WG_UI_PASSWORD} # Password for WireGuard UI
|
||||
- WG_PORT=51820
|
||||
- WG_DEFAULT_ADDRESS=10.13.13.x
|
||||
- WG_DEFAULT_DNS=1.1.1.1,1.0.0.1
|
||||
- WG_MTU=1420
|
||||
- WG_PERSISTENT_KEEPALIVE=25
|
||||
- WG_ALLOWED_IPS=10.13.13.0/24
|
||||
volumes:
|
||||
- wireguard-data:/etc/wireguard
|
||||
ports:
|
||||
- "51820:51820/udp" # WireGuard VPN port (public)
|
||||
- "51821:51821/tcp" # WireGuard Web UI (public for initial setup, then VPN-only)
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
- net.ipv4.ip_forward=1
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
# Portainer - Docker Management UI (VPN-only access)
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: teren-portainer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10.13.13.1:9000:9000" # Portainer UI (VPN-only)
|
||||
- "10.13.13.1:9443:9443" # Portainer HTTPS (VPN-only)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- portainer-data:/data
|
||||
networks:
|
||||
- teren-network
|
||||
|
||||
networks:
|
||||
teren-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
pgadmin-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
wireguard-data:
|
||||
driver: local
|
||||
portainer-data:
|
||||
driver: local
|
||||
86
docker/nginx/conf.d/app.conf
Normal file
86
docker/nginx/conf.d/app.conf
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name example.com www.example.com; # Change this to your domain
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name example.com www.example.com; # Change this to your domain
|
||||
|
||||
root /var/www/public;
|
||||
index index.php index.html;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # Change this
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Change this
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Laravel location configuration
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
|
||||
# Increase timeouts for long-running requests
|
||||
fastcgi_read_timeout 300;
|
||||
fastcgi_send_timeout 300;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
gzip_disable "msie6";
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
53
docker/nginx/conf.d/app.local.conf
Normal file
53
docker/nginx/conf.d/app.local.conf
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /var/www/public;
|
||||
index index.php index.html;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Laravel location configuration
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
|
||||
# Increase timeouts for debugging
|
||||
fastcgi_read_timeout 3600;
|
||||
fastcgi_send_timeout 3600;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny access to sensitive files
|
||||
location ~ /\.env {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
gzip_disable "msie6";
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
23
docker/php/custom.ini
Normal file
23
docker/php/custom.ini
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
; PHP Custom Configuration for Production
|
||||
|
||||
upload_max_filesize = 100M
|
||||
post_max_size = 100M
|
||||
memory_limit = 512M
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
|
||||
; OPcache settings
|
||||
opcache.enable = 1
|
||||
opcache.memory_consumption = 256
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.max_accelerated_files = 20000
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.save_comments = 1
|
||||
opcache.fast_shutdown = 1
|
||||
|
||||
; Production settings
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
error_log = /var/log/php_errors.log
|
||||
25
docker/supervisor/conf.d/laravel-queue.conf
Normal file
25
docker/supervisor/conf.d/laravel-queue.conf
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[program:laravel-queue]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --timeout=300 --verbose
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www
|
||||
numprocs=2
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/www/storage/logs/worker.log
|
||||
stdout_logfile_maxbytes=20MB
|
||||
stdout_logfile_backups=10
|
||||
stopwaitsecs=360
|
||||
|
||||
[program:laravel-queue-sms]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=/usr/local/bin/php /var/www/artisan queue:work --queue=sms --sleep=3 --tries=3 --timeout=90 --verbose
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www
|
||||
numprocs=1
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/www/storage/logs/worker-sms.log
|
||||
stdout_logfile_maxbytes=20MB
|
||||
stdout_logfile_backups=10
|
||||
stopwaitsecs=360
|
||||
11
docker/supervisor/conf.d/php-fpm.conf
Normal file
11
docker/supervisor/conf.d/php-fpm.conf
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[program:php-fpm]
|
||||
command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
19
docker/supervisor/supervisord.conf
Normal file
19
docker/supervisor/supervisord.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
chmod=0700
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
childlogdir=/var/log/supervisor
|
||||
user=root
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[include]
|
||||
files = /etc/supervisor/conf.d/*.conf
|
||||
18
mark-import-failed.php
Normal file
18
mark-import-failed.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__ . '/bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$uuid = '68c7b8f8-fdf0-4575-9cbc-3ab2b3544d8f';
|
||||
$import = \App\Models\Import::where('uuid', $uuid)->first();
|
||||
|
||||
if ($import) {
|
||||
$import->status = 'failed';
|
||||
$import->save();
|
||||
echo "Import {$uuid} marked as failed\n";
|
||||
echo "Current status: {$import->status}\n";
|
||||
} else {
|
||||
echo "Import not found\n";
|
||||
}
|
||||
4978
package-lock.json
generated
4978
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
|
|
@ -3,46 +3,66 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "2.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.7.4",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.3",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.13.2",
|
||||
"laravel-vite-plugin": "^2.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^7.1.7",
|
||||
"vue": "^3.3.13"
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vue": "^3.3.13",
|
||||
"vue-tsc": "^3.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"quill": "^1.3.7",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@guolao/vue-monaco-editor": "^1.6.0",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@internationalized/date": "^3.9.0",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"apexcharts": "^4.0.0",
|
||||
"flowbite": "^2.5.2",
|
||||
"flowbite-vue": "^0.1.6",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unovis/ts": "^1.6.2",
|
||||
"@unovis/vue": "^1.6.2",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vuepic/vue-datepicker": "^11.0.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"apexcharts": "^4.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clean": "^4.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"material-design-icons-iconfont": "^6.7.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"preline": "^2.7.0",
|
||||
"reka-ui": "^2.5.1",
|
||||
"quill": "^1.3.7",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-inner-border": "^0.2.0",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
"vue-search-input": "^1.1.16",
|
||||
"vue3-apexcharts": "^1.7.0",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue-currency-input": "^3.2.1",
|
||||
"vue-multiselect": "^3.4.0",
|
||||
"vue-search-input": "^1.1.19",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue3-apexcharts": "^1.10.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vue-currency-input": "^3.2.1"
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import tailwindcss from '@tailwindcss/postcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
autoprefixer(),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user