changes
This commit is contained in:
parent
f8623a6071
commit
dea7432deb
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -19,4 +19,13 @@ yarn-error.log
|
|||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/shadcn-vue
|
||||
/shadcn-vue
|
||||
|
||||
# Development/Testing Scripts
|
||||
check-*.php
|
||||
test-*.php
|
||||
fix-*.php
|
||||
|
||||
# Development Documentation
|
||||
IMPORT_*.md
|
||||
V2_*.md
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,56 +25,71 @@ 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');
|
||||
})
|
||||
$search = $request->input('search');
|
||||
$assignedUserId = $request->input('assigned_user_id');
|
||||
|
||||
$unassignedContracts = Contract::query()
|
||||
->with(['clientCase.person.addresses', 'clientCase.client.person', '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}")
|
||||
)
|
||||
)
|
||||
)
|
||||
->whereDoesntHave('fieldJobs', fn ($q) =>
|
||||
$q->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
)
|
||||
->latest('id')
|
||||
->limit(50)
|
||||
->get();
|
||||
->paginate(
|
||||
$request->input('per_page_contracts', 10),
|
||||
['*'],
|
||||
'page_contracts',
|
||||
$request->input('page_contracts', 1)
|
||||
);
|
||||
|
||||
// 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')
|
||||
->whereNull('cancelled_at')
|
||||
->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid'])
|
||||
->get();
|
||||
|
||||
$assignments = $activeJobs->mapWithKeys(function (FieldJob $job) {
|
||||
return [
|
||||
optional($job->contract)->uuid => [
|
||||
'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null,
|
||||
'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null,
|
||||
'assigned_at' => $job->assigned_at,
|
||||
],
|
||||
];
|
||||
})->filter();
|
||||
}
|
||||
$assignedContracts = Contract::query()
|
||||
->with(['clientCase.person.addresses', 'clientCase.client.person', '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')
|
||||
)
|
||||
->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')
|
||||
->paginate(
|
||||
$request->input('per_page_assignments', 10),
|
||||
['*'],
|
||||
'page_assignments',
|
||||
$request->input('page_assignments', 1)
|
||||
);
|
||||
|
||||
$users = User::query()->orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return Inertia::render('FieldJob/Index', [
|
||||
'setting' => $setting,
|
||||
'contracts' => $contracts,
|
||||
'unassignedContracts' => $unassignedContracts,
|
||||
'assignedContracts' => $assignedContracts,
|
||||
'users' => $users,
|
||||
'assignments' => $assignments,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'assigned_user_id' => $assignedUserId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -182,12 +184,27 @@ public function store(Request $request)
|
|||
}
|
||||
|
||||
// Kick off processing of an import - simple synchronous step for now
|
||||
public function process(Import $import, Request $request, ImportProcessor $processor)
|
||||
public function process(Import $import, Request $request, ImportServiceV2 $processor)
|
||||
{
|
||||
$import->update(['status' => 'validating', 'started_at' => now()]);
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
|
||||
try {
|
||||
$result = $processor->process($import, user: $request->user());
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Import processing failed', [
|
||||
'import_id' => $import->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$import->update(['status' => 'failed']);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Import processing failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze the uploaded file and return column headers or positional indices
|
||||
|
|
@ -426,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) {
|
||||
|
|
@ -514,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')
|
||||
|
|
@ -694,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)
|
||||
{
|
||||
|
|
@ -704,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);
|
||||
|
|
|
|||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +140,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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,57 +2,11 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
class DateNormalizer
|
||||
/**
|
||||
* Backward compatibility alias for DateNormalizer.
|
||||
* Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer.
|
||||
*/
|
||||
class DateNormalizer extends \App\Services\Import\DateNormalizer
|
||||
{
|
||||
/**
|
||||
* Normalize a raw date string to Y-m-d (ISO) or return null if unparseable.
|
||||
* Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30
|
||||
*/
|
||||
public static function toDate(?string $raw): ?string
|
||||
{
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Common European and ISO formats first (day-first, then ISO)
|
||||
$candidates = [
|
||||
'd.m.Y', 'd.m.y',
|
||||
'd/m/Y', 'd/m/y',
|
||||
'd-m-Y', 'd-m-y',
|
||||
'Y-m-d', 'Y/m/d', 'Y.m.d',
|
||||
];
|
||||
|
||||
foreach ($candidates as $fmt) {
|
||||
$dt = \DateTime::createFromFormat($fmt, $raw);
|
||||
if ($dt instanceof \DateTime) {
|
||||
$errors = \DateTime::getLastErrors();
|
||||
if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) {
|
||||
// Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999)
|
||||
$year = (int) $dt->format('Y');
|
||||
if ($year < 100) {
|
||||
$year += ($year <= 69) ? 2000 : 1900;
|
||||
// Rebuild date with corrected year
|
||||
$month = (int) $dt->format('m');
|
||||
$day = (int) $dt->format('d');
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
return $dt->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: strtotime (permissive). If fails, return null.
|
||||
$ts = @strtotime($raw);
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date('Y-m-d', $ts);
|
||||
}
|
||||
// This class extends the actual DateNormalizer for backward compatibility
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
394
app/Services/Import/EntityResolutionService.php
Normal file
394
app/Services/Import/EntityResolutionService.php
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<?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
|
||||
$personId = Person::create(['type_id' => 1])->id;
|
||||
Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [
|
||||
'person_id' => $personId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
158
app/Services/Import/Handlers/AccountHandler.php
Normal file
158
app/Services/Import/Handlers/AccountHandler.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import\Handlers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Import;
|
||||
use App\Services\Import\BaseEntityHandler;
|
||||
|
||||
class AccountHandler extends BaseEntityHandler
|
||||
{
|
||||
public function getEntityClass(): string
|
||||
{
|
||||
return Account::class;
|
||||
}
|
||||
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
$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);
|
||||
$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)) {
|
||||
$payload[$target] = $mapped[$source];
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 \App\Models\Person\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 \App\Models\Person\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;
|
||||
}
|
||||
}
|
||||
343
app/Services/Import/Handlers/ContractHandler.php
Normal file
343
app/Services/Import/Handlers/ContractHandler.php
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
<?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
|
||||
{
|
||||
// PHASE 4: Check for existing Contract early to prevent duplicate creation
|
||||
$reference = $mapped['reference'] ?? null;
|
||||
|
||||
if ($reference) {
|
||||
$existingContract = $this->resolutionService->getExistingContract(
|
||||
$import->client_id,
|
||||
$reference
|
||||
);
|
||||
|
||||
if ($existingContract) {
|
||||
Log::info('ContractHandler: Found existing Contract by reference', [
|
||||
'contract_id' => $existingContract->id,
|
||||
'reference' => $reference,
|
||||
]);
|
||||
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existingContract,
|
||||
'message' => 'Contract already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
// Update existing contract
|
||||
$payload = $this->buildPayload($mapped, $existingContract);
|
||||
$payload = $this->mergeJsonFields($payload, $existingContract);
|
||||
$appliedFields = $this->trackAppliedFields($existingContract, $payload);
|
||||
|
||||
if (empty($appliedFields)) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existingContract,
|
||||
'message' => 'No changes detected',
|
||||
];
|
||||
}
|
||||
|
||||
$existingContract->fill($payload);
|
||||
$existingContract->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existingContract,
|
||||
'applied_fields' => $appliedFields,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
// Check for reactivation request
|
||||
$reactivate = $this->shouldReactivate($context);
|
||||
|
||||
// Handle reactivation if entity is soft-deleted or inactive
|
||||
if ($existing && $reactivate && $this->needsReactivation($existing)) {
|
||||
$reactivated = $this->attemptReactivation($existing, $context);
|
||||
if ($reactivated) {
|
||||
return [
|
||||
'action' => 'reactivated',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract reactivated',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should update or skip based on mode
|
||||
$mode = $this->getOption('update_mode', 'update');
|
||||
|
||||
if ($existing) {
|
||||
if ($mode === 'skip') {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Contract already exists (skip mode)',
|
||||
];
|
||||
}
|
||||
|
||||
// Update
|
||||
$payload = $this->buildPayload($mapped, $existing);
|
||||
|
||||
// Merge JSON fields instead of overwriting
|
||||
$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',
|
||||
'title' => 'title',
|
||||
'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];
|
||||
}
|
||||
}
|
||||
|
||||
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->update(['active' => 1]);
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
117
app/Services/Import/Handlers/EmailHandler.php
Normal file
117
app/Services/Import/Handlers/EmailHandler.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?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.
|
||||
*/
|
||||
public function validate(array $mapped): array
|
||||
{
|
||||
$email = $mapped['value'] ?? null;
|
||||
if (empty($email) || trim((string)$email) === '') {
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
// Resolve person_id from context
|
||||
$personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null;
|
||||
|
||||
if (! $personId) {
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'message' => 'Email requires person_id',
|
||||
];
|
||||
}
|
||||
|
||||
$existing = $this->resolve($mapped, $context);
|
||||
|
||||
// Check for duplicates if configured
|
||||
if ($this->getOption('deduplicate', true) && $existing) {
|
||||
// Update person_id if different
|
||||
if ($existing->person_id !== $personId) {
|
||||
$existing->person_id = $personId;
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'action' => 'updated',
|
||||
'entity' => $existing,
|
||||
'applied_fields' => ['person_id'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => 'skipped',
|
||||
'entity' => $existing,
|
||||
'message' => 'Email already exists',
|
||||
];
|
||||
}
|
||||
|
||||
// Create new email
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/Services/Import/Handlers/PersonHandler.php
Normal file
187
app/Services/Import/Handlers/PersonHandler.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?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();
|
||||
}
|
||||
|
||||
$person->fill($payload);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
759
app/Services/Import/ImportServiceV2.php
Normal file
759
app/Services/Import/ImportServiceV2.php
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Import;
|
||||
|
||||
use App\Models\Import;
|
||||
use App\Models\ImportEntity;
|
||||
use App\Models\ImportEvent;
|
||||
use App\Models\ImportRow;
|
||||
use App\Services\Import\Contracts\EntityHandlerInterface;
|
||||
use App\Services\Import\DateNormalizer;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* ImportServiceV2 - Generic, database-driven import processor.
|
||||
*
|
||||
* Refactored from ImportProcessor to use entity handlers and config from import_entities table.
|
||||
*
|
||||
* PHASE 6: EntityResolutionService is integrated via handler constructors.
|
||||
* Each handler (PersonHandler, ContractHandler, ClientCaseHandler) instantiates
|
||||
* the service and uses it to prevent duplicate Person creation.
|
||||
*/
|
||||
class ImportServiceV2
|
||||
{
|
||||
protected array $handlers = [];
|
||||
|
||||
protected array $entityConfigs = [];
|
||||
|
||||
protected array $templateMeta = [];
|
||||
|
||||
protected bool $paymentsImport = false;
|
||||
|
||||
protected bool $historyImport = false;
|
||||
|
||||
protected ?string $contractKeyMode = null;
|
||||
|
||||
/**
|
||||
* Process an import using v2 architecture.
|
||||
*/
|
||||
public function process(Import $import, ?Authenticatable $user = null): array
|
||||
{
|
||||
$started = now();
|
||||
$total = 0;
|
||||
$skipped = 0;
|
||||
$imported = 0;
|
||||
$invalid = 0;
|
||||
|
||||
try {
|
||||
// Load template meta flags
|
||||
$this->loadTemplateMeta($import);
|
||||
|
||||
// Load entity configurations and handlers
|
||||
$this->loadEntityConfigurations();
|
||||
|
||||
// Only CSV/TSV supported for now
|
||||
if (! in_array($import->source_type, ['csv', 'txt'])) {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'processing_skipped',
|
||||
'level' => 'warning',
|
||||
'message' => 'Only CSV/TXT supported in v2 processor.',
|
||||
]);
|
||||
$import->update(['status' => 'completed', 'finished_at' => now()]);
|
||||
|
||||
return compact('total', 'imported', 'skipped', 'invalid');
|
||||
}
|
||||
|
||||
$import->update(['status' => 'processing', 'started_at' => $started]);
|
||||
|
||||
$filePath = $import->path;
|
||||
if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) {
|
||||
throw new \RuntimeException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
$fullPath = Storage::disk($import->disk ?? 'local')->path($filePath);
|
||||
$fh = fopen($fullPath, 'r');
|
||||
|
||||
if (! $fh) {
|
||||
throw new \RuntimeException("Could not open file: {$filePath}");
|
||||
}
|
||||
|
||||
$meta = $import->meta ?? [];
|
||||
$hasHeader = (bool) ($meta['has_header'] ?? true);
|
||||
$delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ',';
|
||||
|
||||
$mappings = $this->loadMappings($import);
|
||||
$header = null;
|
||||
$rowNum = 0;
|
||||
|
||||
// Read header if present
|
||||
if ($hasHeader) {
|
||||
$header = fgetcsv($fh, 0, $delimiter);
|
||||
$rowNum++;
|
||||
}
|
||||
|
||||
$isPg = DB::connection()->getDriverName() === 'pgsql';
|
||||
|
||||
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
|
||||
$rowNum++;
|
||||
$total++;
|
||||
|
||||
try {
|
||||
$rawAssoc = $this->buildRowAssoc($row, $header);
|
||||
|
||||
// Skip empty rows
|
||||
if ($this->rowIsEffectivelyEmpty($rawAssoc)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped = $this->applyMappings($rawAssoc, $mappings);
|
||||
|
||||
$rawSha1 = sha1(json_encode($rawAssoc));
|
||||
$importRow = ImportRow::create([
|
||||
'import_id' => $import->id,
|
||||
'row_number' => $rowNum,
|
||||
'record_type' => $this->determineRecordType($mapped),
|
||||
'raw_data' => $rawAssoc,
|
||||
'mapped_data' => $mapped,
|
||||
'status' => 'valid',
|
||||
'raw_sha1' => $rawSha1,
|
||||
]);
|
||||
|
||||
// Process entities in priority order within a transaction
|
||||
$context = ['import' => $import, 'user' => $user, 'import_row' => $importRow];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$results = $this->processRow($import, $mapped, $rawAssoc, $context);
|
||||
|
||||
// If processing succeeded, commit the transaction
|
||||
if ($results['status'] === 'imported' || $results['status'] === 'skipped') {
|
||||
DB::commit();
|
||||
} else {
|
||||
DB::rollBack();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Collect entity details from results
|
||||
$entityData = $this->collectEntityDetails($results);
|
||||
$entityDetails = $entityData['details'];
|
||||
$hasErrors = $entityData['hasErrors'];
|
||||
$hasWarnings = $entityData['hasWarnings'];
|
||||
|
||||
// Handle different result statuses
|
||||
if ($results['status'] === 'imported') {
|
||||
$imported++;
|
||||
$importRow->update([
|
||||
'status' => 'imported',
|
||||
'entity_type' => $results['entity_type'] ?? null,
|
||||
'entity_id' => $results['entity_id'] ?? null,
|
||||
]);
|
||||
$this->createRowProcessedEvent($import, $user, $rowNum, $entityDetails, $hasWarnings, $rawAssoc);
|
||||
} elseif ($results['status'] === 'skipped') {
|
||||
$skipped++;
|
||||
$importRow->update(['status' => 'skipped']);
|
||||
$this->createRowSkippedEvent($import, $user, $rowNum, $entityDetails, $rawAssoc);
|
||||
} else {
|
||||
$invalid++;
|
||||
$importRow->update([
|
||||
'status' => 'invalid',
|
||||
'errors' => $results['errors'] ?? ['Processing failed'],
|
||||
]);
|
||||
$this->createRowFailedEvent(
|
||||
$import,
|
||||
$user,
|
||||
$rowNum,
|
||||
$results['errors'] ?? ['Processing failed'],
|
||||
$entityDetails,
|
||||
$rawAssoc
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$invalid++;
|
||||
$this->handleRowException($import, $user, $rowNum, $e);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fh);
|
||||
|
||||
$this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid);
|
||||
} catch (\Throwable $e) {
|
||||
$this->handleFatalException($import, $user, $e);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return compact('total', 'imported', 'skipped', 'invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 specified
|
||||
if ($entity->handler_class && class_exists($entity->handler_class)) {
|
||||
$this->handlers[$entity->canonical_root] = new $entity->handler_class($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings for import.
|
||||
*/
|
||||
protected function loadMappings(Import $import)
|
||||
{
|
||||
return DB::table('import_mappings')
|
||||
->where('import_id', $import->id)
|
||||
->orderBy('position')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build associative array from row.
|
||||
*/
|
||||
protected function buildRowAssoc(array $row, ?array $header): array
|
||||
{
|
||||
if ($header) {
|
||||
$result = [];
|
||||
foreach ($header as $idx => $col) {
|
||||
$result[$col] = $row[$idx] ?? null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return array_combine(range(0, count($row) - 1), $row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if row is effectively empty.
|
||||
*/
|
||||
protected function rowIsEffectivelyEmpty(array $raw): bool
|
||||
{
|
||||
foreach ($raw as $val) {
|
||||
if (! is_null($val) && trim((string) $val) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply mappings to raw data.
|
||||
*/
|
||||
protected function applyMappings(array $raw, $mappings): array
|
||||
{
|
||||
$mapped = [];
|
||||
|
||||
// Group mappings by target field to handle concatenation
|
||||
$groupedMappings = [];
|
||||
foreach ($mappings as $mapping) {
|
||||
$targetField = $mapping->target_field;
|
||||
if (!isset($groupedMappings[$targetField])) {
|
||||
$groupedMappings[$targetField] = [];
|
||||
}
|
||||
$groupedMappings[$targetField][] = $mapping;
|
||||
}
|
||||
|
||||
foreach ($groupedMappings as $targetField => $fieldMappings) {
|
||||
// Group by group number from options
|
||||
$valuesByGroup = [];
|
||||
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$sourceCol = $mapping->source_column;
|
||||
|
||||
if (!isset($raw[$sourceCol])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $raw[$sourceCol];
|
||||
|
||||
// Apply transform
|
||||
if ($mapping->transform) {
|
||||
$value = $this->applyTransform($value, $mapping->transform);
|
||||
}
|
||||
|
||||
// Get group from options
|
||||
$options = $mapping->options ? json_decode($mapping->options, true) : [];
|
||||
$group = $options['group'] ?? null;
|
||||
|
||||
// Group values by their group number
|
||||
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
|
||||
foreach ($valuesByGroup as $values) {
|
||||
if (count($values) === 1) {
|
||||
// Single value - set directly
|
||||
$this->setNestedValue($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)) {
|
||||
$this->setNestedValue($mapped, $targetField, $concatenated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply transform to value.
|
||||
*/
|
||||
protected function applyTransform(mixed $value, string $transform): mixed
|
||||
{
|
||||
return match (strtolower($transform)) {
|
||||
'trim' => is_string($value) ? trim($value) : $value,
|
||||
'upper' => is_string($value) ? strtoupper($value) : $value,
|
||||
'lower' => is_string($value) ? strtolower($value) : $value,
|
||||
'date' => $this->normalizeDate($value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize date value.
|
||||
*/
|
||||
protected function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return DateNormalizer::toDate((string) $value);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set nested value in array using dot notation.
|
||||
* If the key already exists, convert to array and append the new value.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine record type from mapped data.
|
||||
*/
|
||||
protected function determineRecordType(array $mapped): string
|
||||
{
|
||||
if (isset($mapped['payment'])) {
|
||||
return 'payment';
|
||||
}
|
||||
if (isset($mapped['activity'])) {
|
||||
return 'activity';
|
||||
}
|
||||
if (isset($mapped['contract'])) {
|
||||
return 'contract';
|
||||
}
|
||||
if (isset($mapped['account'])) {
|
||||
return 'account';
|
||||
}
|
||||
|
||||
return 'contact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single row through all entity handlers.
|
||||
*/
|
||||
protected function processRow(Import $import, array $mapped, array $raw, array $context): array
|
||||
{
|
||||
$entityResults = [];
|
||||
$lastEntityType = null;
|
||||
$lastEntityId = null;
|
||||
$hasErrors = false;
|
||||
|
||||
// Process entities in configured priority order
|
||||
foreach ($this->entityConfigs as $root => $config) {
|
||||
// Check if this entity exists in mapped data (support aliases)
|
||||
$mappedKey = $this->findMappedKey($mapped, $root, $config);
|
||||
|
||||
if (!$mappedKey || !isset($mapped[$mappedKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$handler = $this->handlers[$root] ?? null;
|
||||
|
||||
if (! $handler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate before processing
|
||||
$validation = $handler->validate($mapped[$mappedKey]);
|
||||
if (! $validation['valid']) {
|
||||
$entityResults[$root] = [
|
||||
'action' => 'invalid',
|
||||
'errors' => $validation['errors'],
|
||||
'level' => 'error',
|
||||
];
|
||||
$hasErrors = true;
|
||||
|
||||
// Don't stop processing, continue to other entities to collect all errors
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pass previous results as context
|
||||
$result = $handler->process($import, $mapped[$mappedKey], $raw, array_merge($context, $entityResults));
|
||||
|
||||
$entityResults[$root] = $result;
|
||||
|
||||
// Track last successful entity for row status
|
||||
if (in_array($result['action'] ?? null, ['inserted', 'updated'])) {
|
||||
$lastEntityType = $handler->getEntityClass();
|
||||
$lastEntityId = $result['entity']?->id ?? null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$hasErrors = true;
|
||||
|
||||
Log::error("Handler failed for entity {$root}", [
|
||||
'import_id' => $import->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$entityResults[$root] = [
|
||||
'action' => 'failed',
|
||||
'level' => 'error',
|
||||
'errors' => [$e->getMessage()],
|
||||
'exception' => [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => basename($e->getFile()),
|
||||
'line' => $e->getLine(),
|
||||
'class' => get_class($e),
|
||||
],
|
||||
];
|
||||
|
||||
// Continue to process other entities to collect all errors
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we had errors, return invalid status
|
||||
if ($hasErrors) {
|
||||
$allErrors = [];
|
||||
foreach ($entityResults as $root => $result) {
|
||||
if (isset($result['errors'])) {
|
||||
$allErrors[] = "{$root}: " . implode(', ', $result['errors']);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'invalid',
|
||||
'errors' => $allErrors,
|
||||
'results' => $entityResults,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $lastEntityId ? 'imported' : 'skipped',
|
||||
'entity_type' => $lastEntityType,
|
||||
'entity_id' => $lastEntityId,
|
||||
'results' => $entityResults,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the key in mapped data that corresponds to this canonical root.
|
||||
*/
|
||||
protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string
|
||||
{
|
||||
// First check canonical_root itself
|
||||
if (isset($mapped[$canonicalRoot])) {
|
||||
return $canonicalRoot;
|
||||
}
|
||||
|
||||
// Then check key (e.g., 'contracts', 'person_addresses')
|
||||
if (isset($mapped[$config->key])) {
|
||||
return $config->key;
|
||||
}
|
||||
|
||||
// Then check aliases
|
||||
$aliases = $config->aliases ?? [];
|
||||
foreach ($aliases as $alias) {
|
||||
if (isset($mapped[$alias])) {
|
||||
return $alias;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Load template meta flags for special processing modes.
|
||||
*/
|
||||
protected function loadTemplateMeta(Import $import): void
|
||||
{
|
||||
$this->templateMeta = optional($import->template)->meta ?? [];
|
||||
$this->paymentsImport = (bool) ($this->templateMeta['payments_import'] ?? false);
|
||||
$this->historyImport = (bool) ($this->templateMeta['history_import'] ?? false);
|
||||
$this->contractKeyMode = $this->templateMeta['contract_key_mode'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect entity details from processing results.
|
||||
*/
|
||||
protected function collectEntityDetails(array $results): array
|
||||
{
|
||||
$entityDetails = [];
|
||||
$hasErrors = false;
|
||||
$hasWarnings = false;
|
||||
|
||||
if (isset($results['results']) && is_array($results['results'])) {
|
||||
foreach ($results['results'] as $entityKey => $result) {
|
||||
$action = $result['action'] ?? 'unknown';
|
||||
$message = $result['message'] ?? null;
|
||||
$count = $result['count'] ?? 1;
|
||||
|
||||
$detail = [
|
||||
'entity' => $entityKey,
|
||||
'action' => $action,
|
||||
'count' => $count,
|
||||
];
|
||||
|
||||
if ($message) {
|
||||
$detail['message'] = $message;
|
||||
}
|
||||
|
||||
if ($action === 'invalid' || isset($result['errors'])) {
|
||||
$detail['level'] = 'error';
|
||||
$detail['errors'] = $result['errors'] ?? [];
|
||||
$hasErrors = true;
|
||||
} elseif ($action === 'skipped') {
|
||||
$detail['level'] = 'warning';
|
||||
$hasWarnings = true;
|
||||
} else {
|
||||
$detail['level'] = 'info';
|
||||
}
|
||||
|
||||
if (isset($result['exception'])) {
|
||||
$detail['exception'] = $result['exception'];
|
||||
$hasErrors = true;
|
||||
}
|
||||
|
||||
$entityDetails[] = $detail;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'details' => $entityDetails,
|
||||
'hasErrors' => $hasErrors,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success event for a processed row.
|
||||
*/
|
||||
protected function createRowProcessedEvent(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
int $rowNum,
|
||||
array $entityDetails,
|
||||
bool $hasWarnings,
|
||||
array $rawData = []
|
||||
): void {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'row_processed',
|
||||
'level' => $hasWarnings ? 'warning' : 'info',
|
||||
'message' => "Row {$rowNum} processed successfully",
|
||||
'context' => [
|
||||
'row' => $rowNum,
|
||||
'entity_details' => $entityDetails,
|
||||
'raw_data' => $rawData,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a skip event for a skipped row.
|
||||
*/
|
||||
protected function createRowSkippedEvent(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
int $rowNum,
|
||||
array $entityDetails,
|
||||
array $rawData = []
|
||||
): void {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'row_skipped',
|
||||
'level' => 'warning',
|
||||
'message' => "Row {$rowNum} skipped",
|
||||
'context' => [
|
||||
'row' => $rowNum,
|
||||
'entity_details' => $entityDetails,
|
||||
'raw_data' => $rawData,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failure event for a failed row.
|
||||
*/
|
||||
protected function createRowFailedEvent(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
int $rowNum,
|
||||
array $errors,
|
||||
array $entityDetails,
|
||||
array $rawData = []
|
||||
): void {
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'row_failed',
|
||||
'level' => 'error',
|
||||
'message' => "Row {$rowNum} failed: " . implode(', ', $errors),
|
||||
'context' => [
|
||||
'row' => $rowNum,
|
||||
'errors' => $errors,
|
||||
'entity_details' => $entityDetails,
|
||||
'raw_data' => $rawData,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle row processing exception.
|
||||
*/
|
||||
protected function handleRowException(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
int $rowNum,
|
||||
\Throwable $e
|
||||
): void {
|
||||
Log::error('ImportServiceV2 row processing failed', [
|
||||
'import_id' => $import->id,
|
||||
'row' => $rowNum,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'row_failed',
|
||||
'level' => 'error',
|
||||
'message' => "Row {$rowNum} exception: {$e->getMessage()}",
|
||||
'context' => [
|
||||
'row' => $rowNum,
|
||||
'exception' => [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize import with completion event.
|
||||
*/
|
||||
protected function finalizeImport(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
int $total,
|
||||
int $imported,
|
||||
int $skipped,
|
||||
int $invalid
|
||||
): void {
|
||||
$import->update([
|
||||
'status' => 'completed',
|
||||
'finished_at' => now(),
|
||||
'total_rows' => $total,
|
||||
'imported_rows' => $imported,
|
||||
'valid_rows' => $imported,
|
||||
'invalid_rows' => $invalid,
|
||||
]);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'processing_completed',
|
||||
'level' => 'info',
|
||||
'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal processing exception.
|
||||
*/
|
||||
protected function handleFatalException(
|
||||
Import $import,
|
||||
?Authenticatable $user,
|
||||
\Throwable $e
|
||||
): void {
|
||||
Log::error('ImportServiceV2 processing failed', [
|
||||
'import_id' => $import->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$import->update(['status' => 'failed', 'finished_at' => now()]);
|
||||
|
||||
ImportEvent::create([
|
||||
'import_id' => $import->id,
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
'event' => 'processing_failed',
|
||||
'level' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
786
app/Services/Import/ImportSimulationServiceV2.php
Normal file
786
app/Services/Import/ImportSimulationServiceV2.php
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
<?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
|
||||
$existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults));
|
||||
|
||||
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)
|
||||
$entityResults[$root] = [
|
||||
'entity' => (object) $entityData,
|
||||
'action' => 'inserted',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (!isset($grouped[$entity])) {
|
||||
$grouped[$entity] = [];
|
||||
}
|
||||
|
||||
// Special case: activity.note should be kept as array in single instance
|
||||
if ($entity === 'activity' || $entity === 'activities') {
|
||||
if (!isset($grouped[$entity][0])) {
|
||||
$grouped[$entity][0] = [];
|
||||
}
|
||||
$grouped[$entity][0][$field] = $value; // Keep as array
|
||||
} 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] = [];
|
||||
}
|
||||
|
||||
// If entity is already an array (from previous grouped field), add to all instances
|
||||
if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) {
|
||||
foreach ($grouped[$entity] as &$instance) {
|
||||
$instance[$field] = $value;
|
||||
}
|
||||
unset($instance);
|
||||
} else {
|
||||
// Simple associative array
|
||||
$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
|
||||
* - Uses setNestedValue for proper array handling
|
||||
*/
|
||||
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 (same logic as ImportServiceV2)
|
||||
foreach ($valuesByGroup as $values) {
|
||||
if (count($values) === 1) {
|
||||
// Single value - set directly
|
||||
$this->setNestedValue($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)) {
|
||||
$this->setNestedValue($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.
|
||||
|
|
@ -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);');
|
||||
}
|
||||
};
|
||||
288
database/seeders/ImportEntitiesV2Seeder.php
Normal file
288
database/seeders/ImportEntitiesV2Seeder.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?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', 'title', 'description', 'amount', 'currency', 'start_date', 'end_date', 'active'],
|
||||
'field_aliases' => [],
|
||||
'aliases' => ['contract', 'contracts'],
|
||||
'supports_multiple' => false,
|
||||
'meta' => false,
|
||||
'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' => ['contract_id', 'reference', 'title', 'description', 'balance_amount', 'currency'],
|
||||
'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' => ['account_id', 'reference', 'amount', 'currency', 'paid_at', 'payment_date'],
|
||||
'field_aliases' => ['payment_date' => 'paid_at'],
|
||||
'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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showPerPageSelector: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
|
|
@ -27,13 +31,13 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
|||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="showPerPageSelector" class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
:model-value="`${table.getState().pagination.pageSize}`"
|
||||
@update:model-value="(value) => table.setPageSize(Number(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectTrigger class="h-8 w-17.5">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
|
|
@ -47,7 +51,7 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
<div class="flex w-25 items-center justify-center text-sm font-medium">
|
||||
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
|
|
@ -92,4 +96,3 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
|
|||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
|
|
@ -523,15 +530,16 @@ const submitAttachSegment = () => {
|
|||
>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Segment</label>
|
||||
<select
|
||||
v-model="attachForm.segment_id"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>-- izberi segment --</option>
|
||||
<option v-for="s in availableSegments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="attachForm.segment_id">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="-- izberi segment --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in availableSegments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">
|
||||
{{ attachForm.errors.segment_id }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ function applySearch() {
|
|||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po primeru, davčni, osebi..."
|
||||
class="w-[260px]"
|
||||
class="w-65"
|
||||
@keydown.enter="applySearch"
|
||||
/>
|
||||
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, useForm } from "@inertiajs/vue3";
|
||||
import { Link, useForm, router } from "@inertiajs/vue3";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { AlertCircle } from "lucide-vue-next";
|
||||
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
contracts: Array,
|
||||
unassignedContracts: Object,
|
||||
assignedContracts: Object,
|
||||
users: Array,
|
||||
assignments: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
|
|
@ -24,10 +41,113 @@ const bulkForm = useForm({
|
|||
assigned_user_id: null,
|
||||
});
|
||||
|
||||
// Global search (applies to both tables)
|
||||
const search = ref("");
|
||||
// Separate reactive state for selected UUIDs (for UI reactivity)
|
||||
const selectedContractUuids = ref([]);
|
||||
|
||||
// Select all state for unassigned table (current page only)
|
||||
const isAllUnassignedSelected = computed({
|
||||
get: () => {
|
||||
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
|
||||
return (
|
||||
pageUuids.length > 0 &&
|
||||
pageUuids.every((uuid) => selectedContractUuids.value.includes(uuid))
|
||||
);
|
||||
},
|
||||
set: (value) => {
|
||||
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
|
||||
if (value) {
|
||||
// Add all page items to selection
|
||||
selectedContractUuids.value = [
|
||||
...new Set([...selectedContractUuids.value, ...pageUuids]),
|
||||
];
|
||||
} else {
|
||||
// Remove all page items from selection
|
||||
selectedContractUuids.value = selectedContractUuids.value.filter(
|
||||
(uuid) => !pageUuids.includes(uuid)
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to toggle contract selection
|
||||
function toggleContractSelection(uuid, checked) {
|
||||
if (checked) {
|
||||
if (!selectedContractUuids.value.includes(uuid)) {
|
||||
selectedContractUuids.value = [...selectedContractUuids.value, uuid];
|
||||
}
|
||||
} else {
|
||||
selectedContractUuids.value = selectedContractUuids.value.filter((id) => id !== uuid);
|
||||
}
|
||||
console.log(selectedContractUuids.value);
|
||||
}
|
||||
|
||||
// Format helpers (Slovenian formatting)
|
||||
|
||||
// Initialize search and filter from URL params
|
||||
const search = ref(props.filters?.search || "");
|
||||
const assignedFilterUserId = ref(props.filters?.assigned_user_id || "all");
|
||||
|
||||
// Navigation helpers
|
||||
function navigateWithParams(params) {
|
||||
router.visit(route("fieldjobs.index"), {
|
||||
data: params,
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
only: ["unassignedContracts", "assignedContracts", "filters"],
|
||||
});
|
||||
}
|
||||
|
||||
const applySearch = async function () {
|
||||
const params = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const term = (search.value || "").trim();
|
||||
if (term) {
|
||||
params.search = term;
|
||||
} else {
|
||||
delete params.search;
|
||||
}
|
||||
delete params.page;
|
||||
router.get(route("fieldjobs.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ["unassignedContracts"],
|
||||
});
|
||||
};
|
||||
|
||||
watchDebounced(
|
||||
() => search.value,
|
||||
(val) => {
|
||||
applySearch();
|
||||
},
|
||||
{
|
||||
debounce: 200,
|
||||
maxWait: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Watch search and filter changes
|
||||
/*watch(search, (value) => {
|
||||
navigateWithParams({
|
||||
search: value || undefined,
|
||||
assigned_user_id:
|
||||
assignedFilterUserId.value !== "all" ? assignedFilterUserId.value : undefined,
|
||||
page_contracts: 1, // Reset to first page on search
|
||||
page_assignments: 1,
|
||||
});
|
||||
});*/
|
||||
|
||||
watch(assignedFilterUserId, (value) => {
|
||||
navigateWithParams({
|
||||
search: search.value || undefined,
|
||||
assigned_user_id: value !== "all" ? value : undefined,
|
||||
page_contracts: props.unassignedContracts?.current_page,
|
||||
page_assignments: 1, // Reset to first page on filter change
|
||||
});
|
||||
});
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
|
|
@ -77,8 +197,10 @@ function assign(contract) {
|
|||
function assignSelected() {
|
||||
// Use the same selected user as in the single-assign dropdown
|
||||
bulkForm.assigned_user_id = form.assigned_user_id;
|
||||
bulkForm.contract_uuids = selectedContractUuids.value;
|
||||
bulkForm.post(route("fieldjobs.assign-bulk"), {
|
||||
onSuccess: () => {
|
||||
selectedContractUuids.value = [];
|
||||
bulkForm.contract_uuids = [];
|
||||
},
|
||||
});
|
||||
|
|
@ -89,185 +211,50 @@ function cancelAssignment(contract) {
|
|||
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
||||
}
|
||||
|
||||
function isAssigned(contract) {
|
||||
return !!(props.assignments && props.assignments[contract.uuid]);
|
||||
}
|
||||
|
||||
function assignedTo(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_to?.name || null;
|
||||
}
|
||||
|
||||
function assignedBy(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_by?.name || null;
|
||||
}
|
||||
|
||||
// removed window.open behavior; default SPA navigation via Inertia Link
|
||||
|
||||
// Derived lists
|
||||
const unassignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter((c) => !isAssigned(c));
|
||||
});
|
||||
|
||||
const assignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter((c) => isAssigned(c));
|
||||
});
|
||||
|
||||
// Apply search to lists
|
||||
function matchesSearch(c) {
|
||||
if (!search.value) {
|
||||
return true;
|
||||
}
|
||||
const q = String(search.value).toLowerCase();
|
||||
const ref = String(c.reference || "").toLowerCase();
|
||||
const casePerson = String(c.client_case?.person?.full_name || "").toLowerCase();
|
||||
// Optionally include client person in search as well for convenience
|
||||
const clientPerson = String(c.client?.person?.full_name || "").toLowerCase();
|
||||
// Include address fields
|
||||
const primaryAddr = String(primaryCaseAddress(c) || "").toLowerCase();
|
||||
const allAddrs = String(
|
||||
(c.client_case?.person?.addresses || [])
|
||||
.map((a) => `${a?.address || ""} ${a?.country || ""}`.trim())
|
||||
.join(" ")
|
||||
).toLowerCase();
|
||||
return (
|
||||
ref.includes(q) ||
|
||||
casePerson.includes(q) ||
|
||||
clientPerson.includes(q) ||
|
||||
primaryAddr.includes(q) ||
|
||||
allAddrs.includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
const unassignedFiltered = computed(() =>
|
||||
unassignedContracts.value.filter(matchesSearch)
|
||||
);
|
||||
|
||||
// Filter for assigned table
|
||||
const assignedFilterUserId = ref("");
|
||||
const assignedContractsFiltered = computed(() => {
|
||||
let list = assignedContracts.value;
|
||||
if (assignedFilterUserId.value) {
|
||||
list = list.filter((c) => {
|
||||
const uid = props.assignments?.[c.uuid]?.assigned_to?.id;
|
||||
return String(uid) === String(assignedFilterUserId.value);
|
||||
});
|
||||
}
|
||||
return list.filter(matchesSearch);
|
||||
});
|
||||
|
||||
// DataTableClient state per table
|
||||
const unassignedSort = ref({ key: null, direction: null });
|
||||
const unassignedPage = ref(1);
|
||||
const unassignedPageSize = ref(10);
|
||||
const assignedSort = ref({ key: null, direction: null });
|
||||
const assignedPage = ref(1);
|
||||
const assignedPageSize = ref(10);
|
||||
|
||||
watch([search, assignedFilterUserId], () => {
|
||||
unassignedPage.value = 1;
|
||||
assignedPage.value = 1;
|
||||
});
|
||||
|
||||
// Column definitions for DataTableClient
|
||||
// Column definitions for DataTableNew2
|
||||
const unassignedColumns = [
|
||||
{ key: "_select", label: "", class: "w-8" },
|
||||
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
||||
{
|
||||
key: "case_person",
|
||||
label: "Primer",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client_case?.person?.full_name || "-",
|
||||
key: "_select",
|
||||
label: "",
|
||||
sortable: false,
|
||||
class: "w-8",
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
label: "Naslov",
|
||||
sortable: true,
|
||||
formatter: (c) => primaryCaseAddress(c),
|
||||
},
|
||||
{
|
||||
key: "client_person",
|
||||
label: "Stranka",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
label: "Začetek",
|
||||
sortable: true,
|
||||
formatter: (c) => formatDate(c.start_date),
|
||||
},
|
||||
{
|
||||
key: "balance_amount",
|
||||
label: "Stanje",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
|
||||
},
|
||||
{ key: "_actions", label: "Dejanje", class: "w-32" },
|
||||
{ key: "reference", label: "Pogodba", sortable: false },
|
||||
{ key: "case_person", label: "Primer", sortable: false },
|
||||
{ key: "address", label: "Naslov", sortable: false },
|
||||
{ key: "client_person", label: "Stranka", sortable: false },
|
||||
{ key: "start_date", label: "Začetek", sortable: false },
|
||||
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "_actions", label: "Dejanje", sortable: false },
|
||||
];
|
||||
|
||||
const assignedColumns = [
|
||||
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
||||
{
|
||||
key: "case_person",
|
||||
label: "Primer",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client_case?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
label: "Naslov",
|
||||
sortable: true,
|
||||
formatter: (c) => primaryCaseAddress(c),
|
||||
},
|
||||
{
|
||||
key: "client_person",
|
||||
label: "Stranka",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "assigned_at",
|
||||
label: "Dodeljeno dne",
|
||||
sortable: true,
|
||||
formatter: (c) => formatDate(props.assignments?.[c.uuid]?.assigned_at),
|
||||
},
|
||||
{
|
||||
key: "assigned_to",
|
||||
label: "Dodeljeno komu",
|
||||
sortable: true,
|
||||
formatter: (c) => assignedTo(c) || "-",
|
||||
},
|
||||
{
|
||||
key: "balance_amount",
|
||||
label: "Stanje",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
|
||||
},
|
||||
{ key: "_actions", label: "Dejanje", class: "w-32" },
|
||||
{ key: "reference", label: "Pogodba", sortable: false },
|
||||
{ key: "case_person", label: "Primer", sortable: false },
|
||||
{ key: "address", label: "Naslov", sortable: false },
|
||||
{ key: "client_person", label: "Stranka", sortable: false },
|
||||
{ key: "assigned_at", label: "Dodeljeno dne", sortable: false },
|
||||
{ key: "assigned_to", label: "Dodeljeno komu", sortable: false },
|
||||
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "_actions", label: "Dejanje", sortable: false },
|
||||
];
|
||||
|
||||
// Provide derived row arrays for DataTable (already filtered)
|
||||
// Add a flat numeric property `balance_amount` so the generic table sorter can sort by value
|
||||
// (original data nests it under account.balance_amount which the sorter cannot reach).
|
||||
// Prepare rows with flattened fields for display
|
||||
const unassignedRows = computed(() =>
|
||||
unassignedFiltered.value.map((c) => ({
|
||||
(props.unassignedContracts?.data || []).map((c) => ({
|
||||
...c,
|
||||
// Ensure numeric so sorter treats it as number (server often returns string)
|
||||
balance_amount:
|
||||
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
|
||||
? null
|
||||
: Number(c.account.balance_amount),
|
||||
// Flatten derived text fields so DataTable sorting/searching works
|
||||
case_person: c.client_case?.person?.full_name || null,
|
||||
client_person: c.client?.person?.full_name || null,
|
||||
address: primaryCaseAddress(c) || null,
|
||||
assigned_to: null, // not assigned yet
|
||||
}))
|
||||
);
|
||||
|
||||
const assignedRows = computed(() =>
|
||||
assignedContractsFiltered.value.map((c) => ({
|
||||
(props.assignedContracts?.data || []).map((c) => ({
|
||||
...c,
|
||||
balance_amount:
|
||||
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
|
||||
|
|
@ -276,7 +263,8 @@ const assignedRows = computed(() =>
|
|||
case_person: c.client_case?.person?.full_name || null,
|
||||
client_person: c.client?.person?.full_name || null,
|
||||
address: primaryCaseAddress(c) || null,
|
||||
assigned_to: assignedTo(c) || null,
|
||||
assigned_to: c.last_field_jobs.assigned_user.name || null,
|
||||
assigned_at_formatted: formatDate(c.last_field_jobs.assigned_at),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
|
@ -288,155 +276,199 @@ const assignedRows = computed(() =>
|
|||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
v-if="!setting"
|
||||
class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6"
|
||||
class="mb-6 flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve →
|
||||
Nastavitve terenskih opravil.
|
||||
<AlertCircle class="h-5 w-5 text-yellow-600 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-yellow-800">
|
||||
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve
|
||||
→ Nastavitve terenskih opravil.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Global search -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Iskanje (št. pogodbe, nazivu ali naslovu)</label
|
||||
>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po številki pogodbe, nazivu ali naslovu"
|
||||
class="border rounded px-3 py-2 w-full max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unassigned (Assignable) Contracts via DataTableClient -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Dodeli uporabniku</label
|
||||
>
|
||||
<select
|
||||
v-model="form.assigned_user_id"
|
||||
class="border rounded px-3 py-2 w-full max-w-xs"
|
||||
>
|
||||
<option :value="null" disabled>Izberite uporabnika</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
|
||||
{{ form.errors.assigned_user_id }}
|
||||
|
||||
<!-- Unassigned (Assignable) Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg mb-8">
|
||||
<div class="p-4 border-b">
|
||||
<h2 class="text-xl font-semibold">Pogodbe (nedodeljene)</h2>
|
||||
</div>
|
||||
<div class="p-4 border-b space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="assign-user">Dodeli uporabniku</Label>
|
||||
<Select v-model="form.assigned_user_id">
|
||||
<SelectTrigger id="assign-user" class="max-w-xs">
|
||||
<SelectValue placeholder="Izberite uporabnika" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm">
|
||||
{{ form.errors.assigned_user_id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||
:disabled="!bulkForm.contract_uuids.length || !form.assigned_user_id"
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:disabled="!selectedContractUuids.length || !form.assigned_user_id"
|
||||
@click="assignSelected"
|
||||
>
|
||||
Dodeli izbrane ({{ bulkForm.contract_uuids.length }})
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50"
|
||||
:disabled="!bulkForm.contract_uuids.length"
|
||||
@click="bulkForm.contract_uuids = []"
|
||||
Dodeli izbrane
|
||||
<Badge
|
||||
v-if="selectedContractUuids.length"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ selectedContractUuids.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="!selectedContractUuids.length"
|
||||
@click="selectedContractUuids = []"
|
||||
>
|
||||
Počisti izbor
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableClient
|
||||
<DataTable
|
||||
:columns="unassignedColumns"
|
||||
:rows="unassignedRows"
|
||||
:search-keys="['reference', 'case_person', 'client_person', 'address']"
|
||||
v-model:sort="unassignedSort"
|
||||
v-model:search="search"
|
||||
v-model:page="unassignedPage"
|
||||
v-model:pageSize="unassignedPageSize"
|
||||
:data="unassignedRows"
|
||||
:meta="{
|
||||
current_page: unassignedContracts.current_page,
|
||||
per_page: unassignedContracts.per_page,
|
||||
total: unassignedContracts.total,
|
||||
last_page: unassignedContracts.last_page,
|
||||
from: unassignedContracts.from,
|
||||
to: unassignedContracts.to,
|
||||
links: unassignedContracts.links,
|
||||
}"
|
||||
row-key="uuid"
|
||||
:page-size="props.unassignedContracts?.per_page || 10"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
route-name="fieldjobs.index"
|
||||
page-param-name="page_contracts"
|
||||
per-page-param-name="per_page_contracts"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po pogodbi, primeru, stranki, naslovu..."
|
||||
class="w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-_select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:value="row.uuid"
|
||||
v-model="bulkForm.contract_uuids"
|
||||
<Checkbox
|
||||
@update:model-value="
|
||||
(checked) => toggleContractSelection(row.uuid, checked)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-case_person="{ row }">
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "Primer stranke" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||
</template>
|
||||
<template #cell-_actions="{ row }">
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded bg-indigo-600 text-white"
|
||||
@click="assign(row)"
|
||||
>
|
||||
Dodeli
|
||||
</button>
|
||||
<template #cell-start_date="{ row }">
|
||||
{{ formatDate(row.start_date) }}
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-sm text-gray-500 py-4 text-center">
|
||||
Ni najdenih pogodb.
|
||||
<template #cell-balance_amount="{ row }">
|
||||
<div class="text-right">
|
||||
{{ formatCurrencyEUR(row.account?.balance_amount) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
<template #cell-_actions="{ row }">
|
||||
<Button size="sm" @click="assign(row)">Dodeli</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Contracts via DataTableClient -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Assigned Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="p-4 border-b">
|
||||
<h2 class="text-xl font-semibold">Dodeljene pogodbe</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700">Filter po uporabniku</label>
|
||||
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
|
||||
<option value="">Vsi</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableClient
|
||||
<DataTable
|
||||
:columns="assignedColumns"
|
||||
:rows="assignedRows"
|
||||
:search-keys="[
|
||||
'reference',
|
||||
'case_person',
|
||||
'client_person',
|
||||
'address',
|
||||
'assigned_to',
|
||||
]"
|
||||
v-model:sort="assignedSort"
|
||||
v-model:search="search"
|
||||
v-model:page="assignedPage"
|
||||
v-model:pageSize="assignedPageSize"
|
||||
:data="assignedRows"
|
||||
:meta="{
|
||||
current_page: assignedContracts.current_page,
|
||||
per_page: assignedContracts.per_page,
|
||||
total: assignedContracts.total,
|
||||
last_page: assignedContracts.last_page,
|
||||
from: assignedContracts.from,
|
||||
to: assignedContracts.to,
|
||||
links: assignedContracts.links,
|
||||
}"
|
||||
row-key="uuid"
|
||||
:page-size="props.assignedContracts?.per_page || 10"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
route-name="fieldjobs.index"
|
||||
page-param-name="page_assignments"
|
||||
per-page-param-name="per_page_assignments"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search_contract"
|
||||
placeholder="Išči po pogodbi, primeru, stranki..."
|
||||
class="w-[320px]"
|
||||
/>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<Label for="filter-user" class="text-sm whitespace-nowrap"
|
||||
>Filter po uporabniku</Label
|
||||
>
|
||||
<Select v-model="assignedFilterUserId">
|
||||
<SelectTrigger id="filter-user" class="w-48">
|
||||
<SelectValue placeholder="Vsi" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Vsi</SelectItem>
|
||||
<SelectItem
|
||||
v-for="u in users || []"
|
||||
:key="u.id"
|
||||
:value="String(u.id)"
|
||||
>
|
||||
{{ u.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-case_person="{ row }">
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "Primer stranke" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||
</template>
|
||||
<template #cell-_actions="{ row }">
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded bg-red-600 text-white"
|
||||
@click="cancelAssignment(row)"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<template #cell-assigned_at="{ row }">
|
||||
{{ row.assigned_at_formatted }}
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-sm text-gray-500 py-4 text-center">
|
||||
Ni dodeljenih pogodb za izbran filter.
|
||||
<template #cell-balance_amount="{ row }">
|
||||
<div class="text-right">
|
||||
{{ formatCurrencyEUR(row.account?.balance_amount) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
<template #cell-_actions="{ row }">
|
||||
<Button variant="destructive" size="sm" @click="cancelAssignment(row)">
|
||||
Prekliči
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@
|
|||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
// Props: provided by controller (clients + templates collections)
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
// Basic create form (rest of workflow handled on the Continue page)
|
||||
const form = useForm({
|
||||
client_uuid: null,
|
||||
import_template_id: null,
|
||||
|
|
@ -19,36 +27,12 @@ const form = useForm({
|
|||
file: null,
|
||||
});
|
||||
|
||||
// Multiselect bridge: client
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
if (!form.client_uuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Multiselect bridge: template
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
if (form.import_template_id == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.import_template_id = val ? val.id : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates
|
||||
const filteredTemplates = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter((t) => t.client_id == null);
|
||||
if (!form.client_uuid) {
|
||||
return props.templates.filter((t) => !t.client_id);
|
||||
}
|
||||
return list.filter((t) => t.client_uuid === cuuid || t.client_id == null);
|
||||
return props.templates.filter((t) => t.client_uuid === form.client_uuid || !t.client_id);
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
|
|
@ -57,7 +41,7 @@ const uploadError = ref(null);
|
|||
|
||||
function onFileChange(e) {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
if (files?.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
|
|
@ -65,50 +49,51 @@ function onFileChange(e) {
|
|||
|
||||
function onFileDrop(e) {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length) {
|
||||
if (files?.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
dragActive.value = false;
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
form.file = null;
|
||||
uploadError.value = null;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
uploadError.value = null;
|
||||
if (!form.file) {
|
||||
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
|
||||
uploadError.value = "Najprej izberite datoteko.";
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
if (form.import_template_id != null) {
|
||||
if (form.import_template_id) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", form.client_uuid);
|
||||
}
|
||||
fd.append("has_header", form.has_header ? "1" : "0");
|
||||
|
||||
const { data } = await axios.post(route("imports.store"), fd, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (data?.uuid) {
|
||||
router.visit(route("imports.continue", { import: data.uuid }));
|
||||
return;
|
||||
}
|
||||
if (data?.id) {
|
||||
// Fallback if only numeric id returned
|
||||
} else if (data?.id) {
|
||||
router.visit(route("imports.continue", { import: data.id }));
|
||||
return;
|
||||
}
|
||||
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
uploadError.value = e.response.data.message;
|
||||
} else {
|
||||
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
|
||||
uploadError.value = "Nepričakovan odgovor strežnika.";
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e.response?.data?.message || "Nalaganje ni uspelo.";
|
||||
console.error("Import upload failed", e.response?.status, e.response?.data || e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
|
|
@ -119,13 +104,14 @@ async function startImport() {
|
|||
<template>
|
||||
<AppLayout title="Nov uvoz">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
|
||||
<h2 class="text-xl font-semibold leading-tight text-gray-800">Nov uvoz</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
|
||||
<!-- Intro / guidance -->
|
||||
<div class="text-sm text-gray-600 leading-relaxed">
|
||||
<div class="mx-auto max-w-4xl sm:px-6 lg:px-8">
|
||||
<div class="space-y-8 rounded-lg bg-white p-6 shadow sm:rounded-lg">
|
||||
<!-- Intro -->
|
||||
<div class="text-sm leading-relaxed text-gray-600">
|
||||
<p class="mb-2">
|
||||
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
|
||||
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
|
||||
|
|
@ -137,130 +123,138 @@ async function startImport() {
|
|||
</div>
|
||||
|
||||
<!-- Client & Template selection -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Poišči stranko..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Poišči predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Client Select -->
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<Select v-model="form.client_uuid">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberite stranko..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="client in clients"
|
||||
:key="client.uuid"
|
||||
:value="client.uuid"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||
{{ client.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-gray-500">
|
||||
Če stranka ni izbrana, bo uvoz globalen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Select -->
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.import_template_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberite predlogo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<span class="truncate">{{ template.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
|
||||
>
|
||||
{{ template.client_id ? "Client" : "Global" }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!form.client_uuid" class="mt-1 text-xs text-gray-500">
|
||||
Prikazane so samo globalne predloge dokler ne izberete stranke.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File + Header -->
|
||||
<div class="grid grid-cols-1 gap-6 items-start">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
id="import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
|
||||
>Zamenjaj</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" v-model="form.has_header" class="rounded" />
|
||||
<span>Prva vrstica je glava</span>
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-4">
|
||||
<Label>Datoteka</Label>
|
||||
<div
|
||||
class="cursor-pointer rounded-md border-2 border-dashed p-6 text-center transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 text-sm text-gray-800">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ (form.file.size / 1024).toFixed(1) }} kB
|
||||
</span>
|
||||
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px]">
|
||||
Zamenjaj
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="text-xs text-gray-500 leading-relaxed">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Has Header Checkbox -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="has-header" v-model:checked="form.has_header" />
|
||||
<Label
|
||||
for="has-header"
|
||||
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Prva vrstica je glava
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-gray-500">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
<div v-if="uploadError" class="text-sm text-red-600">
|
||||
<!-- Error Message -->
|
||||
<div v-if="uploadError" class="rounded-md bg-red-50 p-3 text-sm text-red-600">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
() => {
|
||||
form.file = null;
|
||||
uploadError = null;
|
||||
}
|
||||
"
|
||||
<div class="flex flex-wrap justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="uploading || !form.file"
|
||||
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
|
||||
@click="clearFile"
|
||||
>
|
||||
Počisti
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="startImport"
|
||||
:disabled="uploading"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
|
||||
>
|
||||
</Button>
|
||||
<Button :disabled="uploading" @click="startImport">
|
||||
<span
|
||||
v-if="uploading"
|
||||
class="h-4 w-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
|
||||
</button>
|
||||
class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
|
||||
/>
|
||||
{{ uploading ? "Nalagam..." : "Začni uvoz" }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 pt-4 border-t">
|
||||
<div class="border-t pt-4 text-xs text-gray-400">
|
||||
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
|
||||
preslikave, simulacijo in končno obdelavo.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@ import LogsTable from "./Partials/LogsTable.vue";
|
|||
import ProcessResult from "./Partials/ProcessResult.vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
|
||||
import Modal from "@/Components/Modal.vue";
|
||||
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
|
||||
import SimulationModal from "./Partials/SimulationModal.vue";
|
||||
import MissingContractsModal from "./Partials/MissingContractsModal.vue";
|
||||
import FoundContractsModal from "./Partials/FoundContractsModal.vue";
|
||||
import UnresolvedRowsModal from "./Partials/UnresolvedRowsModal.vue";
|
||||
import { useCurrencyFormat } from "./useCurrencyFormat.js";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
|
||||
// Reintroduce props definition lost during earlier edits
|
||||
const props = defineProps({
|
||||
|
|
@ -180,11 +186,6 @@ async function openUnresolved() {
|
|||
unresolvedLoading.value = false;
|
||||
}
|
||||
}
|
||||
function downloadUnresolvedCsv() {
|
||||
if (!importId.value) return;
|
||||
// Direct download
|
||||
window.location.href = route("imports.missing-keyref-csv", { import: importId.value });
|
||||
}
|
||||
|
||||
// History import: list of contracts that already existed in DB and were matched
|
||||
const isHistoryImport = computed(() => {
|
||||
|
|
@ -592,32 +593,39 @@ const statusInfo = computed(() => {
|
|||
completed: {
|
||||
label: "Zaključeno",
|
||||
classes: "bg-emerald-100 text-emerald-700 border border-emerald-300",
|
||||
variant: "default",
|
||||
},
|
||||
processing: {
|
||||
label: "Obdelava",
|
||||
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||
variant: "default",
|
||||
},
|
||||
validating: {
|
||||
label: "Preverjanje",
|
||||
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||
variant: "default",
|
||||
},
|
||||
failed: {
|
||||
label: "Neuspešno",
|
||||
classes: "bg-red-100 text-red-700 border border-red-300",
|
||||
variant: "destructive",
|
||||
},
|
||||
parsed: {
|
||||
label: "Razčlenjeno",
|
||||
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
variant: "secondary",
|
||||
},
|
||||
uploaded: {
|
||||
label: "Naloženo",
|
||||
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
return (
|
||||
map[raw] || {
|
||||
label: raw || "Status",
|
||||
classes: "bg-gray-100 text-gray-700 border border-gray-300",
|
||||
variant: "outline",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -1117,11 +1125,19 @@ async function fetchSimulation() {
|
|||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// V2 format
|
||||
paymentSimRows.value = Array.isArray(data?.rows) ? data.rows : [];
|
||||
paymentSimEntities.value = Array.isArray(data?.entities) ? data.entities : [];
|
||||
// Summaries keys vary (payment, contract, account, etc.). Keep existing behaviour for payment summary exposure.
|
||||
paymentSimSummary.value = data?.summaries?.payment || null;
|
||||
paymentSimSummarySl.value = data?.povzetki?.payment || null;
|
||||
paymentSimSummary.value = data?.summaries || null;
|
||||
|
||||
// Extract unique entity types from rows for SimulationModal
|
||||
const entitySet = new Set();
|
||||
for (const row of data?.rows || []) {
|
||||
if (row.entities && typeof row.entities === 'object') {
|
||||
Object.keys(row.entities).forEach(key => entitySet.add(key));
|
||||
}
|
||||
}
|
||||
paymentSimEntities.value = Array.from(entitySet);
|
||||
} catch (e) {
|
||||
console.error("Simulation failed", e.response?.status || "", e.response?.data || e);
|
||||
} finally {
|
||||
|
|
@ -1142,20 +1158,20 @@ async function fetchSimulation() {
|
|||
selectedClientOption?.name || selectedClientOption?.uuid || "—"
|
||||
}}</strong>
|
||||
</span>
|
||||
<span
|
||||
v-if="templateApplied"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
|
||||
>uporabljena</span
|
||||
<Badge v-if="templateApplied" variant="secondary" class="text-[10px]"
|
||||
>uporabljena</Badge
|
||||
>
|
||||
<span
|
||||
<Badge
|
||||
v-if="props.import?.status"
|
||||
:class="['px-2 py-0.5 rounded-full text-xs font-medium', statusInfo.classes]"
|
||||
>{{ statusInfo.label }}</span
|
||||
:variant="statusInfo.variant || 'default'"
|
||||
class="text-xs"
|
||||
>{{ statusInfo.label }}</Badge
|
||||
>
|
||||
<span
|
||||
<Badge
|
||||
v-if="showMissingEnabled"
|
||||
class="text-[10px] px-1 py-0.5 rounded bg-amber-100 text-amber-700 align-middle"
|
||||
>seznam manjkajočih</span
|
||||
variant="outline"
|
||||
class="text-[10px] bg-amber-50 text-amber-700 border-amber-200"
|
||||
>seznam manjkajočih</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1167,13 +1183,15 @@ async function fetchSimulation() {
|
|||
v-if="isHistoryImport || historyFoundContracts.length"
|
||||
class="flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-emerald-700 text-white text-xs rounded"
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-emerald-700 hover:bg-emerald-800 text-xs"
|
||||
@click.prevent="showFoundContracts = true"
|
||||
title="Prikaži pogodbe, ki so bile najdene in že obstajajo v bazi"
|
||||
>
|
||||
Najdene pogodbe
|
||||
</button>
|
||||
</Button>
|
||||
<span v-if="historyFoundContracts.length" class="text-xs text-gray-600">
|
||||
{{ historyFoundContracts.length }} že obstoječih
|
||||
</span>
|
||||
|
|
@ -1210,28 +1228,34 @@ async function fetchSimulation() {
|
|||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
@click.prevent="openPreview"
|
||||
>
|
||||
Ogled CSV
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowMissingButton"
|
||||
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-xs"
|
||||
@click.prevent="openMissingContracts"
|
||||
title="Prikaži aktivne pogodbe, ki niso bile prisotne v uvozu (samo keyref)"
|
||||
>
|
||||
Ogled manjkajoče
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCompleted && contractRefIsKeyref"
|
||||
class="px-3 py-1.5 bg-amber-600 text-white text-xs rounded"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-amber-600 hover:bg-amber-700 text-xs"
|
||||
@click.prevent="openUnresolved"
|
||||
title="Prikaži vrstice, kjer pogodba (keyref) ni bila najdena"
|
||||
>
|
||||
Neobstoječi
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
|
@ -1265,22 +1289,34 @@ async function fetchSimulation() {
|
|||
@apply-template="applyTemplateToImport"
|
||||
/>
|
||||
<!-- Import options -->
|
||||
<div v-if="!isCompleted" class="mt-2 p-3 rounded border bg-gray-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="inline-flex items-center text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="rounded mr-2"
|
||||
v-model="showMissingEnabled"
|
||||
@change="saveImportOptions"
|
||||
/>
|
||||
<span>Seznam manjkajočih (po končanem uvozu)</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="!isCompleted"
|
||||
class="mt-2 p-4 rounded-lg border bg-linear-to-br from-gray-50 to-gray-100"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
:id="'show-missing-checkbox'"
|
||||
:checked="showMissingEnabled"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
showMissingEnabled = val;
|
||||
saveImportOptions();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<Label
|
||||
:for="'show-missing-checkbox'"
|
||||
class="text-sm font-medium text-gray-700 cursor-pointer"
|
||||
>
|
||||
Seznam manjkajočih (po končanem uvozu)
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
|
||||
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
|
||||
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
|
||||
</p>
|
||||
</div>
|
||||
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
|
||||
</div>
|
||||
|
|
@ -1356,160 +1392,37 @@ async function fetchSimulation() {
|
|||
:truncated="previewTruncated"
|
||||
:has-header="detected.has_header"
|
||||
@close="showPreview = false"
|
||||
@change-limit="(val) => (previewLimit = val)"
|
||||
@change-limit="
|
||||
async (val) => {
|
||||
previewLimit = val;
|
||||
await fetchPreview();
|
||||
}
|
||||
"
|
||||
@refresh="fetchPreview"
|
||||
/>
|
||||
<!-- Missing contracts modal -->
|
||||
<Modal
|
||||
|
||||
<MissingContractsModal
|
||||
:show="showMissingContracts"
|
||||
max-width="2xl"
|
||||
:loading="missingContractsLoading"
|
||||
:contracts="missingContracts"
|
||||
:format-money="formatMoney"
|
||||
@close="showMissingContracts = false"
|
||||
>
|
||||
<div class="p-4 max-h-[70vh] overflow-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg">Manjkajoče pogodbe (aktivne, ne-arhivirane)</h3>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click.prevent="showMissingContracts = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="missingContractsLoading" class="py-8 text-center text-sm text-gray-500">
|
||||
Nalagam …
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="!missingContracts.length" class="py-6 text-sm text-gray-600">
|
||||
Ni zadetkov.
|
||||
</div>
|
||||
<ul v-else class="divide-y divide-gray-200">
|
||||
<li
|
||||
v-for="row in missingContracts"
|
||||
:key="row.uuid"
|
||||
class="py-2 text-sm flex items-center justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-gray-800">{{ row.reference }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
<span class="font-medium text-gray-600">Primer: </span>
|
||||
<span>{{ row.full_name || "—" }}</span>
|
||||
<span v-if="row.balance_amount != null" class="ml-2"
|
||||
>• {{ formatMoney(row.balance_amount) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<a
|
||||
:href="route('clientCase.show', { client_case: row.case_uuid })"
|
||||
class="text-blue-600 hover:underline text-xs"
|
||||
>Odpri primer</a
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
/>
|
||||
|
||||
<!-- History import: existing contracts found -->
|
||||
<DialogModal :show="showFoundContracts" max-width="3xl" @close="showFoundContracts = false">
|
||||
<template #title>Obstoječe pogodbe najdene v zgodovinskem uvozu</template>
|
||||
<template #content>
|
||||
<div v-if="!historyFoundContracts.length" class="text-sm text-gray-600">Ni zadetkov.</div>
|
||||
<ul v-else class="divide-y divide-gray-200 max-h-[70vh] overflow-auto">
|
||||
<li
|
||||
v-for="item in historyFoundContracts"
|
||||
:key="item.contract_uuid || item.reference"
|
||||
class="py-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-sm text-gray-900">{{ item.reference }}</div>
|
||||
<div class="text-xs text-gray-600 truncate">
|
||||
<span>{{ item.full_name || "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<a
|
||||
v-if="item.case_uuid"
|
||||
:href="route('clientCase.show', { client_case: item.case_uuid })"
|
||||
class="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Odpri primer
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
|
||||
@click.prevent="showFoundContracts = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<FoundContractsModal
|
||||
:show="showFoundContracts"
|
||||
:contracts="historyFoundContracts"
|
||||
@close="showFoundContracts = false"
|
||||
/>
|
||||
|
||||
<!-- Unresolved keyref rows modal -->
|
||||
<Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false">
|
||||
<div class="p-4 max-h-[75vh] overflow-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg">
|
||||
Vrstice z neobstoječim contract.reference (KEYREF)
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded"
|
||||
@click.prevent="downloadUnresolvedCsv"
|
||||
>
|
||||
Prenesi CSV
|
||||
</button>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click.prevent="showUnresolved = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="unresolvedLoading" class="py-8 text-center text-sm text-gray-500">
|
||||
Nalagam …
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="!unresolvedRows.length" class="py-6 text-sm text-gray-600">
|
||||
Ni zadetkov.
|
||||
</div>
|
||||
<div v-else class="overflow-auto border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-24"># vrstica</th>
|
||||
<th
|
||||
v-for="(c, i) in unresolvedColumns"
|
||||
:key="i"
|
||||
class="px-3 py-2 text-left"
|
||||
>
|
||||
{{ c }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in unresolvedRows" :key="r.id" class="border-t">
|
||||
<td class="px-3 py-2 text-gray-500">{{ r.row_number }}</td>
|
||||
<td
|
||||
v-for="(c, i) in unresolvedColumns"
|
||||
:key="i"
|
||||
class="px-3 py-2 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{{ r.values?.[i] ?? "" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<UnresolvedRowsModal
|
||||
:show="showUnresolved"
|
||||
:loading="unresolvedLoading"
|
||||
:columns="unresolvedColumns"
|
||||
:rows="unresolvedRows"
|
||||
:import-id="importId"
|
||||
@close="showUnresolved = false"
|
||||
/>
|
||||
<SimulationModal
|
||||
:show="showPaymentSim"
|
||||
:rows="paymentSimRows"
|
||||
|
|
@ -1522,8 +1435,9 @@ async function fetchSimulation() {
|
|||
:money-formatter="formatMoney"
|
||||
@close="showPaymentSim = false"
|
||||
@change-limit="
|
||||
(val) => {
|
||||
async (val) => {
|
||||
paymentSimLimit = val;
|
||||
await fetchSimulation();
|
||||
}
|
||||
"
|
||||
@toggle-verbose="
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import {
|
|||
BeakerIcon,
|
||||
ArrowDownOnSquareIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
const props = defineProps({
|
||||
importId: [Number, String],
|
||||
isCompleted: Boolean,
|
||||
|
|
@ -17,47 +20,50 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
|
|||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click.prevent="$emit('preview')"
|
||||
:disabled="!importId"
|
||||
class="px-4 py-2 bg-gray-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<EyeIcon class="h-4 w-4" />
|
||||
<EyeIcon class="h-4 w-4 mr-2" />
|
||||
Predogled vrstic
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-orange-600 hover:bg-orange-700"
|
||||
@click.prevent="$emit('save-mappings')"
|
||||
:disabled="!importId || processing || savingMappings || isCompleted"
|
||||
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
title="Shrani preslikave za ta uvoz"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<ArrowPathIcon v-else class="h-4 w-4" />
|
||||
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
||||
<span>Shrani preslikave</span>
|
||||
<span
|
||||
<Badge
|
||||
v-if="selectedMappingsCount"
|
||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
||||
>{{ selectedMappingsCount }}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
variant="secondary"
|
||||
class="ml-2 text-xs"
|
||||
>{{ selectedMappingsCount }}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-purple-600 hover:bg-purple-700"
|
||||
@click.prevent="$emit('process-import')"
|
||||
:disabled="!canProcess"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<BeakerIcon class="h-4 w-4" />
|
||||
<BeakerIcon class="h-4 w-4 mr-2" />
|
||||
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
@click.prevent="$emit('simulate')"
|
||||
:disabled="!importId || processing"
|
||||
class="px-4 py-2 bg-blue-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4" />
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
|
||||
Simulacija vnosa
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
<script setup>
|
||||
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||
import { Badge } from '@/Components/ui/badge'
|
||||
const props = defineProps({ steps: Array, missingCritical: Array })
|
||||
</script>
|
||||
<template>
|
||||
<div class="bg-gray-50 border rounded p-3 text-xs flex flex-col gap-1 h-fit">
|
||||
<div class="font-semibold text-gray-700 mb-1">Kontrolni seznam</div>
|
||||
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-gray-500'">
|
||||
<div class="bg-muted/50 border rounded-lg p-4 text-xs flex flex-col gap-2 h-fit">
|
||||
<div class="font-semibold text-foreground mb-1">Kontrolni seznam</div>
|
||||
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-muted-foreground'">
|
||||
<CheckCircleIcon v-if="s.done" class="h-4 w-4 text-emerald-600" />
|
||||
<span v-else class="h-4 w-4 rounded-full border border-gray-300 inline-block"></span>
|
||||
<span v-else class="h-4 w-4 rounded-full border-2 border-muted-foreground/30 inline-block"></span>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2 text-red-600 font-medium">Manjkajo kritične: {{ missingCritical.join(', ') }}</div>
|
||||
<div v-else class="mt-2 text-emerald-600">Kritične preslikave prisotne</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2">
|
||||
<Badge variant="destructive" class="text-[10px]">Manjkajo kritične: {{ missingCritical.join(', ') }}</Badge>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<Badge variant="default" class="text-[10px] bg-emerald-600">Kritične preslikave prisotne</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<script setup>
|
||||
import Modal from '@/Components/Modal.vue'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
limit: Number,
|
||||
|
|
@ -13,49 +18,69 @@ const emits = defineEmits(['close','change-limit','refresh'])
|
|||
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
||||
</script>
|
||||
<template>
|
||||
<Modal :show="show" max-width="wide" @close="$emit('close')">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg">CSV Preview ({{ rows.length }} / {{ limit }})</h3>
|
||||
<button class="text-sm px-2 py-1 rounded border" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center gap-3 text-sm">
|
||||
<div>
|
||||
<label class="mr-1 text-gray-600">Limit:</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="300">300</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
|
||||
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-3 pb-3 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
||||
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
|
||||
<SelectTrigger id="limit-select" class="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button @click="$emit('refresh')" class="px-2 py-1 border rounded" :disabled="loading">{{ loading ? 'Loading…' : 'Refresh' }}</button>
|
||||
<span v-if="truncated" class="text-xs text-amber-600">Truncated at limit</span>
|
||||
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
||||
Truncated at limit
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[60vh] border rounded">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="p-2 border bg-white">#</th>
|
||||
<th v-for="col in columns" :key="col" class="p-2 border text-left">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">Loading…</td>
|
||||
</tr>
|
||||
<tr v-for="(r, idx) in rows" :key="idx" class="border-t hover:bg-gray-50">
|
||||
<td class="p-2 border text-gray-500">{{ idx + 1 }}</td>
|
||||
<td v-for="col in columns" :key="col" class="p-2 border whitespace-pre-wrap">{{ r[col] }}</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !rows.length">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">No rows</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex-1 overflow-auto border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 bg-white z-10">
|
||||
<TableRow>
|
||||
<TableHead class="w-16">#</TableHead>
|
||||
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="(r, idx) in rows" :key="idx">
|
||||
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
|
||||
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
|
||||
{{ r[col] }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loading && !rows.length">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
No rows
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Showing up to {{ limit }} rows from source file. Header detection: {{ hasHeader ? 'header present' : 'no header' }}.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="text-xs text-gray-500 pt-3 border-t">
|
||||
Showing up to {{ limit }} rows from source file.
|
||||
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
63
resources/js/Pages/Imports/Partials/FoundContractsModal.vue
Normal file
63
resources/js/Pages/Imports/Partials/FoundContractsModal.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Obstoječe pogodbe najdene v zgodovinskem uvozu</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="!contracts.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="item in contracts"
|
||||
:key="item.contract_uuid || item.reference"
|
||||
class="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<code class="text-sm font-medium text-gray-900">{{ item.reference }}</code>
|
||||
<Badge variant="outline" class="text-[10px]">Najdena</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<span>{{ item.full_name || "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="item.case_uuid"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as="a"
|
||||
:href="route('clientCase.show', { client_case: item.case_uuid })"
|
||||
class="shrink-0"
|
||||
>
|
||||
Odpri primer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/Components/ui/accordion';
|
||||
|
||||
const props = defineProps({
|
||||
events: Array,
|
||||
|
|
@ -8,8 +14,8 @@ const props = defineProps({
|
|||
limit: Number,
|
||||
});
|
||||
const emits = defineEmits(["update:limit", "refresh"]);
|
||||
function onLimit(e) {
|
||||
emits("update:limit", Number(e.target.value));
|
||||
function onLimit(val) {
|
||||
emits("update:limit", Number(val));
|
||||
emits("refresh");
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +52,32 @@ function toggleExpand(id) {
|
|||
expanded.value = new Set(expanded.value);
|
||||
}
|
||||
|
||||
// Entity details dialog
|
||||
const detailsDialog = ref(false);
|
||||
const selectedEvent = ref(null);
|
||||
|
||||
function hasEntityDetails(ev) {
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx && Array.isArray(ctx.entity_details) && ctx.entity_details.length > 0;
|
||||
}
|
||||
|
||||
function showEntityDetails(ev) {
|
||||
selectedEvent.value = ev;
|
||||
detailsDialog.value = true;
|
||||
}
|
||||
|
||||
function getEntityDetails(ev) {
|
||||
if (!ev) return [];
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx?.entity_details || [];
|
||||
}
|
||||
|
||||
function getRawData(ev) {
|
||||
if (!ev) return {};
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx?.raw_data || {};
|
||||
}
|
||||
|
||||
function isLong(msg) {
|
||||
return msg && String(msg).length > 160;
|
||||
}
|
||||
|
|
@ -138,68 +170,72 @@ function formattedContext(ctx) {
|
|||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Logs</h3>
|
||||
<div class="flex items-center flex-wrap gap-2 text-sm">
|
||||
<label class="text-gray-600">Show</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<label class="text-gray-600 ml-2">Level</label>
|
||||
<select v-model="levelFilter" class="border rounded p-1">
|
||||
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
<span class="text-muted-foreground">Show</span>
|
||||
<Select :model-value="limit.toString()" @update:model-value="onLimit">
|
||||
<SelectTrigger class="w-20 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-muted-foreground ml-2">Level</span>
|
||||
<Select v-model="levelFilter">
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="$emit('refresh')"
|
||||
class="px-2 py-1 border rounded text-sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? "Refreshing…" : "Refresh" }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
|
||||
<table class="min-w-full bg-white text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
<col class="w-40" />
|
||||
<col />
|
||||
<col class="w-16" />
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
|
||||
<tr class="text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Time</th>
|
||||
<th class="p-2 border">Level</th>
|
||||
<th class="p-2 border">Event</th>
|
||||
<th class="p-2 border">Message</th>
|
||||
<th class="p-2 border">Row</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ev in filteredEvents" :key="ev.id" class="border-t align-top">
|
||||
<td class="p-2 border whitespace-nowrap">
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead class="w-[160px]">Time</TableHead>
|
||||
<TableHead class="w-[80px]">Level</TableHead>
|
||||
<TableHead class="w-[160px]">Event</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead class="w-[64px]">Row</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="ev in filteredEvents" :key="ev.id">
|
||||
<TableCell class="whitespace-nowrap">
|
||||
{{ new Date(ev.created_at).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<span
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
:variant="ev.level === 'error' ? 'destructive' : ev.level === 'warning' ? 'default' : 'secondary'"
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-xs',
|
||||
ev.level === 'error'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: ev.level === 'warning'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-gray-100 text-gray-700',
|
||||
'text-xs',
|
||||
ev.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : ''
|
||||
]"
|
||||
>{{ ev.level }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="p-2 border break-words max-w-[9rem]">
|
||||
>{{ ev.level }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="max-w-[9rem]">
|
||||
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
|
||||
</td>
|
||||
<td class="p-2 border align-top max-w-[28rem]">
|
||||
</TableCell>
|
||||
<TableCell class="max-w-[28rem]">
|
||||
<div class="space-y-1 break-words">
|
||||
<div class="leading-snug whitespace-pre-wrap">
|
||||
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
|
||||
|
|
@ -215,7 +251,15 @@ function formattedContext(ctx) {
|
|||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="ev.context" class="text-xs text-gray-600">
|
||||
<div v-if="ev.context" class="text-xs text-gray-600 flex items-center gap-2">
|
||||
<button
|
||||
v-if="hasEntityDetails(ev)"
|
||||
type="button"
|
||||
class="px-2 py-1 rounded border border-indigo-300 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 transition text-[11px] font-medium"
|
||||
@click="showEntityDetails(ev)"
|
||||
>
|
||||
📋 Entity Details
|
||||
</button>
|
||||
<Dropdown
|
||||
align="left"
|
||||
width="wide"
|
||||
|
|
@ -255,14 +299,91 @@ function formattedContext(ctx) {
|
|||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEvents.length">
|
||||
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</TableCell>
|
||||
<TableCell>{{ ev.import_row_id ?? "—" }}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!filteredEvents.length">
|
||||
<TableCell colspan="5" class="text-center text-muted-foreground">No events yet</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Entity Details Dialog -->
|
||||
<Dialog v-model:open="detailsDialog">
|
||||
<DialogContent class="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Entity Processing Details</DialogTitle>
|
||||
<DialogDescription v-if="selectedEvent">
|
||||
Row {{ tryJson(selectedEvent.context)?.row || '—' }} - {{ selectedEvent.event }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedEvent" class="space-y-3 mt-4">
|
||||
<div
|
||||
v-for="(detail, idx) in getEntityDetails(selectedEvent)"
|
||||
:key="idx"
|
||||
class="p-3 rounded-lg border"
|
||||
:class="{
|
||||
'bg-red-50 border-red-200': detail.level === 'error',
|
||||
'bg-amber-50 border-amber-200': detail.level === 'warning',
|
||||
'bg-green-50 border-green-200': detail.level === 'info' && detail.action === 'inserted',
|
||||
'bg-blue-50 border-blue-200': detail.level === 'info' && detail.action === 'updated',
|
||||
'bg-gray-50 border-gray-200': detail.level === 'info' && detail.action === 'skipped'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="font-medium text-sm capitalize">{{ detail.entity }}</div>
|
||||
<Badge
|
||||
:variant="detail.level === 'error' ? 'destructive' : detail.level === 'warning' ? 'default' : 'secondary'"
|
||||
:class="[
|
||||
'text-xs',
|
||||
detail.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : '',
|
||||
detail.action === 'inserted' ? 'bg-green-100 text-green-800 hover:bg-green-100' : '',
|
||||
detail.action === 'updated' ? 'bg-blue-100 text-blue-800 hover:bg-blue-100' : '',
|
||||
detail.action === 'skipped' ? 'bg-gray-200 text-gray-700 hover:bg-gray-200' : ''
|
||||
]"
|
||||
>
|
||||
{{ detail.action }}{{ detail.count > 1 ? ` (${detail.count})` : '' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="detail.message" class="text-sm text-gray-700 mb-1">
|
||||
{{ detail.message }}
|
||||
</div>
|
||||
<div v-if="detail.errors && detail.errors.length" class="mt-2 space-y-1">
|
||||
<div class="text-xs font-medium text-red-700">Errors:</div>
|
||||
<div
|
||||
v-for="(err, errIdx) in detail.errors"
|
||||
:key="errIdx"
|
||||
class="text-xs text-red-600 pl-3"
|
||||
>
|
||||
• {{ err }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.exception" class="mt-2 p-2 bg-red-100 rounded border border-red-200">
|
||||
<div class="text-xs font-semibold text-red-800 mb-1">Exception:</div>
|
||||
<div class="text-xs text-red-700">{{ detail.exception.message }}</div>
|
||||
<div v-if="detail.exception.file" class="text-xs text-red-600 mt-1">
|
||||
{{ detail.exception.file }}:{{ detail.exception.line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getEntityDetails(selectedEvent).length === 0" class="text-center text-muted-foreground py-4">
|
||||
No entity details available
|
||||
</div>
|
||||
|
||||
<!-- Raw Row Data Accordion -->
|
||||
<Accordion type="single" collapsible class="mt-4 border-t pt-4">
|
||||
<AccordionItem value="raw-data" class="border-b-0">
|
||||
<AccordionTrigger class="text-sm font-medium hover:no-underline py-2">
|
||||
📄 Raw Row Data (JSON)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto mt-2">{{ JSON.stringify(getRawData(selectedEvent), null, 2) }}</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
|
||||
const props = defineProps({
|
||||
rows: Array,
|
||||
entityOptions: Array,
|
||||
|
|
@ -17,97 +24,145 @@ const emits = defineEmits(['update:rows','save'])
|
|||
|
||||
function duplicateTarget(row){
|
||||
if(!row || !row.entity || !row.field) return false
|
||||
// parent already marks duplicates in duplicateTargets set keyed as record.field
|
||||
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="show && rows?.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">
|
||||
Detected Columns ({{ detected?.has_header ? 'header' : 'positional' }})
|
||||
<span class="ml-2 text-xs text-gray-500">detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}</span>
|
||||
</h3>
|
||||
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
|
||||
<div class="relative border rounded overflow-auto max-h-[420px]">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="bg-gray-50/95 backdrop-blur text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Source column</th>
|
||||
<th class="p-2 border">Entity</th>
|
||||
<th class="p-2 border">Field</th>
|
||||
<th class="p-2 border">Meta key</th>
|
||||
<th class="p-2 border">Meta type</th>
|
||||
<th class="p-2 border">Transform</th>
|
||||
<th class="p-2 border">Apply mode</th>
|
||||
<th class="p-2 border">Skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in rows" :key="idx" class="border-t" :class="duplicateTarget(row) ? 'bg-red-50' : ''">
|
||||
<td class="p-2 border text-sm">{{ row.source_column }}</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.entity" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||
<option value="">—</option>
|
||||
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.field" :class="['border rounded p-1 w-full', duplicateTarget(row) ? 'border-red-500 bg-red-50' : '']" :disabled="isCompleted">
|
||||
<option value="">—</option>
|
||||
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<input
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">
|
||||
Detected Columns
|
||||
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
|
||||
<div class="relative border rounded-lg">
|
||||
<ScrollArea class="h-[420px]">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow class="hover:bg-transparent">
|
||||
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
|
||||
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
|
||||
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
|
||||
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
|
||||
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue placeholder="Select entity..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.field || ''"
|
||||
@update:model-value="(val) => row.field = val || ''"
|
||||
:disabled="isCompleted"
|
||||
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).key"
|
||||
type="text"
|
||||
class="border rounded p-1 w-full"
|
||||
class="h-8 text-xs"
|
||||
placeholder="e.g. monthly_rent"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-xs">—</span>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).type"
|
||||
class="border rounded p-1 w-full"
|
||||
:model-value="(row.options ||= {}).type || 'string'"
|
||||
@update:model-value="(val) => (row.options ||= {}).type = val"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<option :value="null">Default (string)</option>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="date">date</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
<span v-else class="text-gray-400 text-xs">—</span>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||
<option value="">None</option>
|
||||
<option value="trim">Trim</option>
|
||||
<option value="upper">Uppercase</option>
|
||||
<option value="lower">Lowercase</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select v-model="row.apply_mode" class="border rounded p-1 w-full" :disabled="isCompleted">
|
||||
<option value="keyref">Keyref</option>
|
||||
<option value="both">Both</option>
|
||||
<option value="insert">Insert only</option>
|
||||
<option value="update">Update only</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border text-center">
|
||||
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="date">date</SelectItem>
|
||||
<SelectItem value="boolean">boolean</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="trim">Trim</SelectItem>
|
||||
<SelectItem value="upper">Uppercase</SelectItem>
|
||||
<SelectItem value="lower">Lowercase</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="keyref">Keyref</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="insert">Insert only</SelectItem>
|
||||
<SelectItem value="update">Update only</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
|
||||
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
|
||||
<span>{{ mappingSavedCount }} mappings saved</span>
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2">
|
||||
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
|
||||
<div v-if="missingCritical?.length" class="text-xs text-amber-600 mt-1">Missing critical: {{ missingCritical.join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Skeleton } from "@/Components/ui/skeleton";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
formatMoney: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manjkajoče pogodbe (aktivne, ne-arhivirane)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="loading" class="space-y-3 p-4">
|
||||
<Skeleton v-for="i in 5" :key="i" class="h-16 w-full" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!contracts.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="row in contracts"
|
||||
:key="row.uuid"
|
||||
class="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<code class="text-sm font-medium text-gray-900">{{
|
||||
row.reference
|
||||
}}</code>
|
||||
<Badge variant="secondary" class="text-[10px]">Aktivna</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 space-y-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Primer:</span>
|
||||
<span class="truncate">{{ row.full_name || "—" }}</span>
|
||||
</div>
|
||||
<div v-if="row.balance_amount != null" class="flex items-center gap-2">
|
||||
<span class="font-medium">Stanje:</span>
|
||||
<span class="font-mono">{{ formatMoney(row.balance_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as="a"
|
||||
:href="route('clientCase.show', { client_case: row.case_uuid })"
|
||||
class="shrink-0"
|
||||
>
|
||||
Odpri primer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
<script setup>
|
||||
const props = defineProps({ result: [String, Object] })
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({ result: [String, Object] });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="result" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Import Result</h3>
|
||||
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ result }}</pre>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="font-semibold">Import Result</h3>
|
||||
<Badge variant="default" class="bg-emerald-600">Complete</Badge>
|
||||
</div>
|
||||
<pre class="bg-muted border rounded-lg p-4 text-sm overflow-x-auto">{{ result }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,53 @@
|
|||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
const props = defineProps({ mappings: Array });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="mappings?.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full border bg-white text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Source column</th>
|
||||
<th class="p-2 border">Target field</th>
|
||||
<th class="p-2 border">Transform</th>
|
||||
<th class="p-2 border">Mode</th>
|
||||
<th class="p-2 border">Options</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
<div class="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Source column</TableHead>
|
||||
<TableHead>Target field</TableHead>
|
||||
<TableHead>Transform</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Options</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="m in mappings"
|
||||
:key="m.id || m.source_column + m.target_field"
|
||||
class="border-t"
|
||||
>
|
||||
<td class="p-2 border">{{ m.source_column }}</td>
|
||||
<td class="p-2 border">{{ m.target_field }}</td>
|
||||
<td class="p-2 border">{{ m.transform || "—" }}</td>
|
||||
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
|
||||
<td class="p-2 border">
|
||||
<TableCell class="font-medium">{{ m.source_column }}</TableCell>
|
||||
<TableCell>{{ m.target_field }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="m.transform" variant="outline" class="text-xs">{{ m.transform }}</Badge>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" class="text-xs">{{ m.apply_mode || "both" }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<template v-if="m.options">
|
||||
<span v-if="m.options.key" class="inline-block mr-2"
|
||||
>key: <strong>{{ m.options.key }}</strong></span
|
||||
>
|
||||
<span v-if="m.options.type" class="inline-block"
|
||||
>type: <strong>{{ m.options.type }}</strong></span
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-if="m.options.key" variant="outline" class="text-[10px]">
|
||||
key: {{ m.options.key }}
|
||||
</Badge>
|
||||
<Badge v-if="m.options.type" variant="outline" class="text-[10px]">
|
||||
type: {{ m.options.type }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,10 @@
|
|||
<script setup>
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { computed } from "vue";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
isCompleted: Boolean,
|
||||
|
|
@ -19,11 +23,11 @@ const emits = defineEmits([
|
|||
"preview",
|
||||
]);
|
||||
|
||||
function onHeaderChange(e) {
|
||||
emits("update:hasHeader", e.target.value === "true");
|
||||
function onHeaderChange(val) {
|
||||
emits("update:hasHeader", val === "true");
|
||||
}
|
||||
function onDelimiterMode(e) {
|
||||
emits("update:delimiterMode", e.target.value);
|
||||
function onDelimiterMode(val) {
|
||||
emits("update:delimiterMode", val);
|
||||
}
|
||||
function onDelimiterCustom(e) {
|
||||
emits("update:delimiterCustom", e.target.value);
|
||||
|
|
@ -44,116 +48,119 @@ const selectedTemplateProxy = computed({
|
|||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateProxy"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Izberi predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
:custom-label="(o) => o.name"
|
||||
:disabled="filteredTemplates?.length === 0"
|
||||
:show-no-results="true"
|
||||
:clear-on-select="false"
|
||||
<Label class="text-sm font-medium">Template</Label>
|
||||
<Select
|
||||
:model-value="selectedTemplateProxy?.id?.toString()"
|
||||
@update:model-value="(val) => {
|
||||
const tpl = filteredTemplates.find(t => t.id.toString() === val);
|
||||
selectedTemplateProxy = tpl || null;
|
||||
}"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span v-if="option.source_type" class="ml-2 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue placeholder="Izberi predlogo...">
|
||||
<div v-if="selectedTemplateProxy" class="flex items-center gap-2">
|
||||
<span>{{ selectedTemplateProxy.name }}</span>
|
||||
<span v-if="selectedTemplateProxy.source_type" class="text-xs text-muted-foreground">({{ selectedTemplateProxy.source_type }})</span>
|
||||
<Badge variant="outline" class="text-[10px]">{{ selectedTemplateProxy.client_id ? 'Client' : 'Global' }}</Badge>
|
||||
</div>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
|
||||
option.client_id ? "Client" : "Global"
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #singleLabel="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span v-if="option.source_type" class="ml-1 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
|
||||
option.client_id ? "Client" : "Global"
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #noResult>
|
||||
<div class="px-2 py-1 text-xs text-gray-500">Ni predlog.</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="option in filteredTemplates" :key="option.id" :value="option.id.toString()">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span v-if="option.source_type" class="text-xs text-muted-foreground">({{ option.source_type }})</span>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-[10px]">{{
|
||||
option.client_id ? "Client" : "Global"
|
||||
}}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="isCompleted" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto"
|
||||
@click="$emit('preview')"
|
||||
class="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-500 w-full sm:w-auto"
|
||||
>
|
||||
Ogled CSV
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isCompleted" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Header row</label>
|
||||
<select
|
||||
:value="hasHeader"
|
||||
@change="onHeaderChange"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
<Label class="text-xs font-medium">Header row</Label>
|
||||
<Select
|
||||
:model-value="hasHeader.toString()"
|
||||
@update:model-value="onHeaderChange"
|
||||
>
|
||||
<option value="true">Has header</option>
|
||||
<option value="false">No header (positional)</option>
|
||||
</select>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="true">Has header</SelectItem>
|
||||
<SelectItem value="false">No header (positional)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Delimiter</label>
|
||||
<select
|
||||
:value="delimiterState.mode"
|
||||
@change="onDelimiterMode"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
<Label class="text-xs font-medium">Delimiter</Label>
|
||||
<Select
|
||||
:model-value="delimiterState.mode"
|
||||
@update:model-value="onDelimiterMode"
|
||||
>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="comma">Comma ,</option>
|
||||
<option value="semicolon">Semicolon ;</option>
|
||||
<option value="tab">Tab \t</option>
|
||||
<option value="pipe">Pipe |</option>
|
||||
<option value="space">Space ␠</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||
<SelectItem value="comma">Comma ,</SelectItem>
|
||||
<SelectItem value="semicolon">Semicolon ;</SelectItem>
|
||||
<SelectItem value="tab">Tab \t</SelectItem>
|
||||
<SelectItem value="pipe">Pipe |</SelectItem>
|
||||
<SelectItem value="space">Space ␠</SelectItem>
|
||||
<SelectItem value="custom">Custom…</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="delimiterState.mode === 'custom'" class="flex items-end gap-3">
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-medium text-gray-600">Custom delimiter</label>
|
||||
<input
|
||||
:value="delimiterState.custom"
|
||||
<Label class="text-xs font-medium">Custom delimiter</Label>
|
||||
<Input
|
||||
:model-value="delimiterState.custom"
|
||||
@input="onDelimiterCustom"
|
||||
maxlength="4"
|
||||
placeholder=","
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCompleted"
|
||||
class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm"
|
||||
:disabled="!form.import_template_id"
|
||||
<Button
|
||||
v-if="!isCompleted && form.import_template_id"
|
||||
variant="default"
|
||||
@click="$emit('apply-template')"
|
||||
class="w-full"
|
||||
>
|
||||
{{ templateApplied ? "Ponovno uporabi predlogo" : "Uporabi predlogo" }}
|
||||
</button>
|
||||
{{ templateApplied ? 'Re-apply Template' : 'Apply Template' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
80
resources/js/Pages/Imports/Partials/UnresolvedRowsModal.vue
Normal file
80
resources/js/Pages/Imports/Partials/UnresolvedRowsModal.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Skeleton } from "@/Components/ui/skeleton";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
columns: { type: Array, default: () => [] },
|
||||
rows: { type: Array, default: () => [] },
|
||||
importId: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
function downloadCsv() {
|
||||
if (!props.importId) return;
|
||||
window.location.href = route("imports.missing-keyref-csv", { import: props.importId });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-6xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle>Vrstice z neobstoječim contract.reference (KEYREF)</DialogTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="downloadCsv"
|
||||
class="gap-2"
|
||||
>
|
||||
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||
Prenesi CSV
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="loading" class="space-y-3 p-4">
|
||||
<Skeleton v-for="i in 10" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!rows.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-24"># vrstica</TableHead>
|
||||
<TableHead v-for="(c, i) in columns" :key="i">{{ c }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="r in rows" :key="r.id">
|
||||
<TableCell class="font-medium text-gray-500">{{ r.row_number }}</TableCell>
|
||||
<TableCell
|
||||
v-for="(c, i) in columns"
|
||||
:key="i"
|
||||
class="whitespace-pre-wrap wrap-break-word"
|
||||
>
|
||||
{{ r.values?.[i] ?? "" }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -331,7 +331,12 @@ watch(
|
|||
/>
|
||||
|
||||
<!-- Import Mode Settings -->
|
||||
<ImportModeSettings :form="form" :entities="entities" />
|
||||
<ImportModeSettings
|
||||
:form="form"
|
||||
:entities="entities"
|
||||
:actions="props.actions"
|
||||
:decisions="props.decisions"
|
||||
/>
|
||||
|
||||
<!-- Unassigned Mappings -->
|
||||
<UnassignedMappings
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ function getEntityMappings(entity) {
|
|||
:key="m.id"
|
||||
class="p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-center">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Izvor</Label>
|
||||
<Input v-model="m.source_column" class="text-sm" />
|
||||
|
|
@ -196,6 +196,18 @@ function getEntityMappings(entity) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Group</Label>
|
||||
<Input
|
||||
:value="m.options?.group ?? ''"
|
||||
@input="e => {
|
||||
if (!m.options) m.options = {};
|
||||
m.options.group = e.target.value || null;
|
||||
}"
|
||||
class="text-sm"
|
||||
placeholder="1, 2, ..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Button
|
||||
|
|
@ -233,7 +245,7 @@ function getEntityMappings(entity) {
|
|||
<div class="p-3 bg-muted/50 rounded-lg border">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium">Dodaj novo preslikavo</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Izvorno polje</Label>
|
||||
<Input
|
||||
|
|
@ -289,6 +301,13 @@ function getEntityMappings(entity) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Group</Label>
|
||||
<Input
|
||||
v-model="(newRows[entity] ||= {}).group"
|
||||
placeholder="1, 2, ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addRow(entity)" size="sm">Dodaj preslikavo</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
|
|
@ -8,6 +9,17 @@ import { Badge } from "@/Components/ui/badge";
|
|||
const props = defineProps({
|
||||
form: { type: Object, required: true },
|
||||
entities: { type: Array, default: () => [] },
|
||||
actions: { type: Array, default: () => [] },
|
||||
decisions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const hasActivities = computed(() => {
|
||||
return Array.isArray(props.entities) && props.entities.includes('activities');
|
||||
});
|
||||
|
||||
const decisionsForActivitiesAction = computed(() => {
|
||||
const act = (props.actions || []).find((a) => a.id === props.form.meta.activity_action_id);
|
||||
return act?.decisions || [];
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -68,6 +80,47 @@ const props = defineProps({
|
|||
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Plačila</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activities Settings -->
|
||||
<div v-if="hasActivities" class="space-y-4 pt-4 border-t">
|
||||
<div class="text-sm font-medium">Nastavitve aktivnosti</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="activity_action">Dejanje za aktivnosti</Label>
|
||||
<Select v-model="form.meta.activity_action_id">
|
||||
<SelectTrigger id="activity_action">
|
||||
<SelectValue placeholder="Izberi dejanje" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(brez)</SelectItem>
|
||||
<SelectItem v-for="a in actions || []" :key="a.id" :value="a.id">
|
||||
{{ a.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="activity_decision">Odločitev za aktivnosti</Label>
|
||||
<Select v-model="form.meta.activity_decision_id" :disabled="!form.meta.activity_action_id">
|
||||
<SelectTrigger id="activity_decision">
|
||||
<SelectValue placeholder="Izberi odločitev" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(brez)</SelectItem>
|
||||
<SelectItem v-for="d in decisionsForActivitiesAction" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!form.meta.activity_action_id" class="text-xs text-muted-foreground">
|
||||
Najprej izberi dejanje, nato odločitev.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Te nastavitve se uporabljajo za aktivnosti, ki so uvožene iz CSV (npr. opombe, zgodovinske aktivnosti).
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
Route::get('phone', [PhoneViewController::class, 'index'])->name('phone.index');
|
||||
Route::get('phone/completed', [PhoneViewController::class, 'completedToday'])->name('phone.completed');
|
||||
Route::get('phone/case/{client_case:uuid}', [PhoneViewController::class, 'showCase'])->name('phone.case');
|
||||
Route::post('phone/case/{client_case:uuid}/complete', [\App\Http\Controllers\FieldJobController::class, 'complete'])->name('phone.case.complete');
|
||||
Route::post('phone/case/{client_case:uuid}/complete', [FieldJobController::class, 'complete'])->name('phone.case.complete');
|
||||
|
||||
Route::get('search', function (Request $request) {
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user