Mass changes

This commit is contained in:
Simon Pocrnjič
2025-10-04 23:36:18 +02:00
parent ab50336e97
commit fe91c7e4bc
46 changed files with 5738 additions and 1873 deletions
@@ -1121,4 +1121,53 @@ public function destroy(string $id)
{
//
}
/**
* Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts.
*/
public function deleteDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Ownership check: direct case document?
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
// Or document of a contract that belongs to this case (include trashed contracts)
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
abort(404);
}
// (Optional future) $this->authorize('delete', $document);
$document->delete(); // soft delete
return $request->wantsJson()
? response()->json(['status' => 'ok'])
: back()->with('success', 'Document deleted.');
}
/**
* Delete a document accessed through a contract route binding.
*/
public function deleteContractDocument(Contract $contract, Document $document, Request $request)
{
$belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id;
if (! $belongs) {
abort(404);
}
// (Optional future) $this->authorize('delete', $document);
$document->delete();
return $request->wantsJson()
? response()->json(['status' => 'ok'])
: back()->with('success', 'Document deleted.');
}
}
+60 -63
View File
@@ -7,6 +7,8 @@
use App\Models\FieldJob;
use App\Models\FieldJobSetting;
use App\Models\User;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
@@ -83,48 +85,51 @@ public function assign(Request $request)
'assigned_user_id' => 'required|integer|exists:users,id',
]);
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
return back()->withErrors(['setting' => 'No Field Job Setting found. Create one in Settings → Field Job Settings.']);
try {
DB::transaction(function () use ($data) {
$setting = FieldJobSetting::query()->latest('id')->first();
if (! $setting) {
throw new Exception('No Field Job Setting found. Create one in Settings → Field Job Settings.');
}
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
$job = FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $data['assigned_user_id'],
'contract_id' => $contract->id,
'assigned_at' => now(),
]);
// Create an activity for the assignment
if ($setting->action_id && $setting->assign_decision_id) {
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $setting->action_id,
'decision_id' => $setting->assign_decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
// Move contract to the configured segment for field jobs
$job->moveContractToSegment($setting->segment_id);
} else {
throw new Exception('The current Field Job Setting is missing an action or assign decision. Please update it in Settings → Field Job Settings.');
}
});
return back()->with('success', 'Field job assigned.');
} catch (QueryException $e) {
return back()->withErrors(['database' => 'Database error: '.$e->getMessage()]);
} catch (Exception $e) {
return back()->withErrors(['error' => 'Error: '.$e->getMessage()]);
}
$contract = Contract::query()->where('uuid', $data['contract_uuid'])->firstOrFail();
$job = FieldJob::create([
'field_job_setting_id' => $setting->id,
'assigned_user_id' => $data['assigned_user_id'],
'contract_id' => $contract->id,
'assigned_at' => now(),
]);
// Create an activity for the assignment
// Find the first action linked to the assign decision via pivot; also prefer actions within the same segment as the setting
$decisionId = $setting->assign_decision_id;
$actionId = null;
if ($decisionId) {
// Strictly use the action_decision pivot: take the first action mapped to this decision
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
}
if ($actionId) {
$assigneeName = User::query()->where('id', $data['assigned_user_id'])->value('name');
// Localized note: "Terensko opravilo dodeljeno" + assignee when present
$note = 'Terensko opravilo dodeljeno'.($assigneeName ? ' uporabniku '.$assigneeName : '');
Activity::create([
'due_date' => null,
'amount' => null,
'note' => $note,
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
return back()->with('success', 'Field job assigned.');
}
public function cancel(Request $request)
@@ -149,24 +154,22 @@ public function cancel(Request $request)
// Create an activity for the cancellation, mirroring the assign flow
// Prefer the job's setting for a consistent decision
$job->loadMissing('setting');
$actionId = optional($job->setting)->action_id;
$decisionId = optional($job->setting)->cancel_decision_id;
if ($decisionId) {
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
if ($actionId) {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Terensko opravilo preklicano',
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
// If no decision configured, skip logging
if ($actionId && $decisionId) {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Terensko opravilo preklicano',
'action_id' => $actionId,
'decision_id' => $decisionId,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
]);
}
}
@@ -183,13 +186,7 @@ public function complete(Request $request, \App\Models\ClientCase $clientCase)
}
$decisionId = $setting->complete_decision_id;
$actionId = null;
if ($decisionId) {
$actionId = DB::table('action_decision')
->where('decision_id', $decisionId)
->orderBy('id')
->value('action_id');
}
$actionId = $setting->action_id;
// Find all active jobs for this case for the current user
$jobs = FieldJob::query()
@@ -15,13 +15,14 @@ class FieldJobSettingController extends Controller
public function index(Request $request)
{
$settings = FieldJobSetting::query()
->with(['segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
->with(['action', 'segment', 'initialDecision', 'assignDecision', 'completeDecision', 'cancelDecision', 'returnSegment', 'queueSegment'])
->get();
return Inertia::render('Settings/FieldJob/Index', [
'settings' => $settings,
'segments' => Segment::query()->get(),
'decisions' => Decision::query()->get(),
'actions' => \App\Models\Action::query()->with(['decisions:id'])->get(),
'decisions' => Decision::query()->with(['actions:id'])->get(),
]);
}
@@ -34,6 +35,7 @@ public function store(StoreFieldJobSettingRequest $request)
'initial_decision_id' => $attributes['initial_decision_id'],
'assign_decision_id' => $attributes['assign_decision_id'],
'complete_decision_id' => $attributes['complete_decision_id'],
'action_id' => $attributes['action_id'] ?? null,
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
'return_segment_id' => $attributes['return_segment_id'] ?? null,
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
@@ -54,6 +56,7 @@ public function update(FieldJobSetting $setting, UpdateFieldJobSettingRequest $r
'cancel_decision_id' => $attributes['cancel_decision_id'] ?? null,
'return_segment_id' => $attributes['return_segment_id'] ?? null,
'queue_segment_id' => $attributes['queue_segment_id'] ?? null,
'action_id' => $attributes['action_id'] ?? null,
]);
return to_route('settings.fieldjob.index')->with('success', 'Field job setting updated successfully!');
+154
View File
@@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Client;
use App\Models\Contract;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
@@ -366,6 +368,122 @@ public function getEvents(Import $import)
return response()->json(['events' => $events]);
}
// Preview (up to N) raw CSV rows for an import for mapping review
public function preview(Import $import, Request $request)
{
$validated = $request->validate([
'limit' => 'nullable|integer|min:1|max:500',
]);
$limit = (int) ($validated['limit'] ?? 200);
// Determine header/delimiter the same way as columns() stored them
$meta = $import->meta ?? [];
$hasHeader = (bool) ($meta['has_header'] ?? true);
// Forced delimiter overrides everything; else detected; fallback comma
$delimiter = $meta['forced_delimiter']
?? $meta['detected_delimiter']
?? ',';
$rows = [];
$columns = [];
$truncated = false;
$path = Storage::disk($import->disk)->path($import->path);
if (! is_readable($path)) {
return response()->json([
'error' => 'File not readable',
], 422);
}
$fh = @fopen($path, 'r');
if (! $fh) {
return response()->json([
'error' => 'Unable to open file',
], 422);
}
try {
if ($hasHeader) {
$header = fgetcsv($fh, 0, $delimiter) ?: [];
$columns = array_map(function ($h) {
return is_string($h) ? trim($h) : (string) $h;
}, $header);
} else {
// Use meta stored columns when available, else infer later from widest row
$columns = is_array($meta['columns'] ?? null) ? $meta['columns'] : [];
}
$count = 0;
$widest = count($columns);
while (($data = fgetcsv($fh, 0, $delimiter)) !== false) {
if ($count >= $limit) {
$truncated = true;
break;
}
// Track widest for non-header scenario
if (! $hasHeader) {
$widest = max($widest, count($data));
}
$rows[] = $data;
$count++;
}
if (! $hasHeader && $widest > count($columns)) {
// Generate positional column labels if missing
$columns = [];
for ($i = 0; $i < $widest; $i++) {
$columns[] = 'col_'.($i + 1);
}
}
} finally {
fclose($fh);
}
// Normalize each row into assoc keyed by columns (pad/truncate as needed)
$assocRows = [];
foreach ($rows as $r) {
$assoc = [];
foreach ($columns as $i => $colName) {
$assoc[$colName] = array_key_exists($i, $r) ? $r[$i] : null;
}
$assocRows[] = $assoc;
}
return response()->json([
'columns' => $columns,
'rows' => $assocRows,
'limit' => $limit,
'truncated' => $truncated,
'has_header' => $hasHeader,
]);
}
/**
* Simulate application of payment rows for a payments import without persisting changes.
* Returns per-row projected balance changes and resolution of contract / account references.
*/
public function simulatePayments(Import $import, Request $request)
{
// Delegate to the generic simulate method for backward compatibility.
return $this->simulate($import, $request);
}
/**
* Generic simulation endpoint: projects what would happen if the import were processed
* 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.
*/
public function simulate(Import $import, Request $request)
{
$validated = $request->validate([
'limit' => 'nullable|integer|min:1|max:500',
'verbose' => 'nullable|boolean',
]);
$limit = (int) ($validated['limit'] ?? 100);
$verbose = (bool) ($validated['verbose'] ?? false);
$service = app(\App\Services\ImportSimulationService::class);
$result = $service->simulate($import, $limit, $verbose);
return response()->json($result);
}
// Show an existing import by UUID to continue where left off
public function show(Import $import)
{
@@ -426,4 +544,40 @@ public function show(Import $import)
'client' => $client,
]);
}
// Delete an import if not finished (statuses allowed: uploaded, mapping, processing_failed etc.)
public function destroy(Request $request, Import $import)
{
// Only allow deletion if not completed or processing
if (in_array($import->status, ['completed', 'processing'])) {
return back()->with([
'ok' => false,
'message' => 'Import can not be deleted in its current status.',
], 422);
}
// Attempt to delete stored file
try {
if ($import->disk && $import->path && Storage::disk($import->disk)->exists($import->path)) {
Storage::disk($import->disk)->delete($import->path);
}
} catch (\Throwable $e) {
// Log event but proceed with deletion
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $request->user()?->getAuthIdentifier(),
'event' => 'file_delete_failed',
'level' => 'warning',
'message' => 'Failed to delete import file: '.$e->getMessage(),
]);
}
// Clean up related events/rows optionally (soft approach: rely on FKs if cascade configured)
// If not cascaded, we could manually delete; check quickly
// Assuming foreign key ON DELETE CASCADE for import_rows & import_events
$import->delete();
return back()->with(['ok' => true]);
}
}
@@ -18,6 +18,7 @@ public function rules(): array
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'initial_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'assign_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'complete_decision_id' => ['required', 'integer', 'exists:decisions,id'],
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
@@ -42,14 +43,26 @@ public function withValidator($validator): void
{
$validator->after(function ($validator): void {
// Validate that the assign_decision_id has a mapped action
$actionId = $this->input('action_id');
if (! empty($actionId)) {
$mapped = \App\Models\Action::query()
->where('id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.');
}
}
$assignDecisionId = $this->input('assign_decision_id');
if (! empty($assignDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $assignDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.');
}
}
@@ -58,10 +71,11 @@ public function withValidator($validator): void
if (! empty($completeDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $completeDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.');
}
}
@@ -69,6 +83,7 @@ public function withValidator($validator): void
if (! empty($cancelDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $cancelDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
@@ -22,6 +22,7 @@ public function rules(): array
'cancel_decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'return_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'queue_segment_id' => ['nullable', 'integer', 'exists:segments,id'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
];
}
@@ -38,13 +39,25 @@ public function messages(): array
public function withValidator($validator): void
{
$validator->after(function ($validator): void {
$actionId = $this->input('action_id');
if (! empty($actionId)) {
$mapped = \App\Models\Action::query()
->where('id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('action_id', 'The selected action does not exist. Please select a valid action.');
}
}
$assignDecisionId = $this->input('assign_decision_id');
if (! empty($assignDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $assignDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map an action to this decision first.');
$validator->errors()->add('assign_decision_id', 'The selected assign decision must have a mapped action. Please map the correct action to this decision first.');
}
}
@@ -52,9 +65,10 @@ public function withValidator($validator): void
if (! empty($completeDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $completeDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map an action to this decision first.');
$validator->errors()->add('complete_decision_id', 'The selected complete decision must have a mapped action. Please map the correct action to this decision first.');
}
}
@@ -62,9 +76,10 @@ public function withValidator($validator): void
if (! empty($cancelDecisionId)) {
$mapped = DB::table('action_decision')
->where('decision_id', $cancelDecisionId)
->where('action_id', $actionId)
->exists();
if (! $mapped) {
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map an action to this decision first.');
$validator->errors()->add('cancel_decision_id', 'The selected cancel decision must have a mapped action. Please map the correct action to this decision first.');
}
}
});
+4 -3
View File
@@ -6,7 +6,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Activity extends Model
{
@@ -43,9 +42,11 @@ protected static function booted()
// If an activity with a due date is added for a contract, update the related account's promise_date
if (! empty($activity->contract_id) && ! empty($activity->due_date)) {
DB::table('accounts')
\App\Models\Account::query()
->where('contract_id', $activity->contract_id)
->update(['promise_date' => $activity->due_date, 'updated_at' => now()]);
->update(
['promise_date' => $activity->due_date, 'updated_at' => now()],
);
}
});
}
+2 -1
View File
@@ -2,13 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Email extends Model
{
use SoftDeletes;
use HasFactory, SoftDeletes;
protected $fillable = [
'person_id',
+22 -4
View File
@@ -19,8 +19,14 @@ class FieldJobSetting extends Model
'cancel_decision_id',
'return_segment_id',
'queue_segment_id',
'action_id',
];
public function action(): BelongsTo
{
return $this->belongsTo(\App\Models\Action::class);
}
public function segment(): BelongsTo
{
return $this->belongsTo(Segment::class);
@@ -28,22 +34,34 @@ public function segment(): BelongsTo
public function assignDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'assign_decision_id');
return $this->belongsTo(Decision::class, 'assign_decision_id')
->with(['actions' => function ($query) {
$query->select('actions.id');
}]);
}
public function initialDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'initial_decision_id');
return $this->belongsTo(Decision::class, 'initial_decision_id')
->with(['actions' => function ($query) {
$query->select('actions.id');
}]);
}
public function completeDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'complete_decision_id');
return $this->belongsTo(Decision::class, 'complete_decision_id')
->with(['actions' => function ($query) {
$query->select('actions.id');
}]);
}
public function cancelDecision(): BelongsTo
{
return $this->belongsTo(Decision::class, 'cancel_decision_id');
return $this->belongsTo(Decision::class, 'cancel_decision_id')
->with(['actions' => function ($query) {
$query->select('actions.id');
}]);
}
public function returnSegment(): BelongsTo
+1 -1
View File
@@ -11,7 +11,7 @@ class ImportRow extends Model
use HasFactory;
protected $fillable = [
'import_id','row_number','sheet_name','record_type','raw_data','mapped_data','status','errors','warnings','entity_type','entity_id','fingerprint'
'import_id', 'row_number', 'sheet_name', 'record_type', 'raw_data', 'mapped_data', 'status', 'errors', 'warnings', 'entity_type', 'entity_id', 'fingerprint', 'raw_sha1',
];
protected $casts = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff