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
+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]);
}
}