Mass changes
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user