Skip to content
Merged

Dev #3608

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/Http/Controllers/BulkUserChangesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Controllers;

use App\Services\BulkUserChanges\BulkUserChangesPlanner;
use App\Services\BulkUserChanges\BulkUserChangesReadOptions;
use App\Services\BulkUserChanges\BulkUserChangesSheetReader;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -40,17 +41,19 @@ function ($attribute, $value, $fail) {
}
},
],
'ignore_through_row' => ['nullable', 'integer', 'min:1', 'max:10000'],
], [
'file.required' => 'Please select a file to upload.',
]);

$file = $validated['file'];
$readOptions = BulkUserChangesReadOptions::fromInput($validated['ignore_through_row'] ?? null);
$extension = strtolower($file->getClientOriginalExtension() ?: 'xlsx');
$tempDisk = config('filesystems.bulk_upload_temp_disk', 'local');
$path = $file->storeAs('temp', 'bulk_user_changes_'.time().'.'.$extension, $tempDisk);

try {
$parsed = $reader->read($path, $tempDisk);
$parsed = $reader->read($path, $tempDisk, $readOptions);
} catch (\Throwable $e) {
Storage::disk($tempDisk)->delete($path);

Expand All @@ -73,6 +76,7 @@ function ($attribute, $value, $fail) {
'disk' => $tempDisk,
'parsed' => $parsed,
'plan' => $plan,
'ignore_through_row' => $readOptions->ignoreThroughRow,
], now()->addHours(2));

$request->session()->put(self::SESSION_TOKEN, $token);
Expand Down Expand Up @@ -117,7 +121,8 @@ public function apply(
}

try {
$parsed = $reader->read($path, $disk);
$readOptions = BulkUserChangesReadOptions::fromInput($payload['ignore_through_row'] ?? null);
$parsed = $reader->read($path, $disk, $readOptions);
$result = $planner->apply($parsed['rows']);
} catch (\Throwable $e) {
return redirect()->route('admin.bulk-user-changes.preview')
Expand Down
29 changes: 29 additions & 0 deletions app/Services/BulkUserChanges/BulkUserChangesReadOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Services\BulkUserChanges;

final class BulkUserChangesReadOptions
{
public function __construct(
public readonly ?int $ignoreThroughRow = null,
) {
}

public static function fromInput(mixed $value): self
{
if ($value === null || $value === '') {
return new self;
}

$row = (int) $value;

return new self(
ignoreThroughRow: $row > 0 ? $row : null,
);
}

public function shouldIgnoreRow(int $rowNumber): bool
{
return $this->ignoreThroughRow !== null && $rowNumber <= $this->ignoreThroughRow;
}
}
130 changes: 112 additions & 18 deletions app/Services/BulkUserChanges/BulkUserChangesSheetReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,23 @@ public function __construct(
* sheet_name: string,
* header_row: int,
* rows: list<array<string, mixed>>,
* meta: array{total_sheet_rows: int, parsed_rows: int, skipped_blank_rows: int}
* meta: array{
* first_data_row: ?int,
* last_data_row: ?int,
* parsed_rows: int,
* skipped_blank_rows: int,
* skipped_no_email_rows: int,
* skipped_legacy_rows: int,
* skipped_ignored_range_rows: int,
* ignore_through_row: ?int,
* first_email: ?string,
* last_email: ?string,
* }
* }
*/
public function read(string $path, ?string $disk = null): array
public function read(string $path, ?string $disk = null, ?BulkUserChangesReadOptions $options = null): array
{
$options ??= new BulkUserChangesReadOptions;
$localPath = $path;
if ($disk !== null) {
$localPath = $this->materializeFromDisk($path, $disk);
Expand All @@ -31,35 +43,67 @@ public function read(string $path, ?string $disk = null): array
$sheet = $this->resolveChangesSheet($spreadsheet->getSheetNames(), $spreadsheet);
$headerRow = $this->detectHeaderRow($sheet);
$columnMap = $this->mapColumns($sheet, $headerRow);
$lastDataRow = $this->detectLastDataRow($sheet, $headerRow, $columnMap);

$rows = [];
$skippedBlank = 0;
$highest = (int) $sheet->getHighestRow();
$skippedNoEmail = 0;
$skippedLegacy = 0;
$skippedIgnoredRange = 0;

for ($rowNumber = $headerRow + 1; $rowNumber <= $lastDataRow; $rowNumber++) {
if ($options->shouldIgnoreRow($rowNumber)) {
$skippedIgnoredRange++;

continue;
}

for ($rowNumber = $headerRow + 1; $rowNumber <= $highest; $rowNumber++) {
$raw = $this->readRow($sheet, $rowNumber, $columnMap);
$normalized = $this->normalizer->normalize($raw);

if ($normalized['email'] === null && $normalized['operation'] === null) {
if ($this->isBlankDataRow($raw)) {
$skippedBlank++;

continue;
}

if ($this->isLegacyCountry($raw['country'] ?? null)) {
$skippedLegacy++;

continue;
}

$normalized = $this->normalizer->normalize($raw);

if ($normalized['email'] === null) {
$skippedNoEmail++;

continue;
}

$rows[] = [
'row_number' => $rowNumber,
...$normalized,
];
}

$firstRow = $rows[0]['row_number'] ?? null;
$lastRow = $rows !== [] ? $rows[array_key_last($rows)]['row_number'] : null;

return [
'sheet_name' => $sheet->getTitle(),
'header_row' => $headerRow,
'rows' => $rows,
'meta' => [
'total_sheet_rows' => max(0, $highest - $headerRow),
'first_data_row' => $firstRow,
'last_data_row' => $lastRow,
'parsed_rows' => count($rows),
'skipped_blank_rows' => $skippedBlank,
'skipped_no_email_rows' => $skippedNoEmail,
'skipped_legacy_rows' => $skippedLegacy,
'skipped_ignored_range_rows' => $skippedIgnoredRange,
'ignore_through_row' => $options->ignoreThroughRow,
'first_email' => $rows[0]['email'] ?? null,
'last_email' => $rows !== [] ? $rows[array_key_last($rows)]['email'] : null,
],
];
}
Expand Down Expand Up @@ -100,24 +144,74 @@ private function resolveChangesSheet(array $sheetNames, \PhpOffice\PhpSpreadshee

private function detectHeaderRow(Worksheet $sheet): int
{
$highest = min((int) $sheet->getHighestRow(), 300);
$highest = min((int) $sheet->getHighestRow(), 500);
$headerRow = 1;

for ($row = 1; $row <= $highest; $row++) {
$values = array_map(
fn ($v) => mb_strtolower(trim((string) $v)),
array_values($sheet->rangeToArray('A'.$row.':H'.$row, null, true, true, true)[$row] ?? []),
);
if (! $this->rowLooksLikeHeader($sheet, $row)) {
continue;
}

$headerRow = $row;
}

return $headerRow;
}

/**
* @param array<string, string> $columnMap
*/
private function detectLastDataRow(Worksheet $sheet, int $headerRow, array $columnMap): int
{
$scanLimit = min((int) $sheet->getHighestRow(), $headerRow + 2000);
$lastDataRow = $headerRow;

for ($rowNumber = $headerRow + 1; $rowNumber <= $scanLimit; $rowNumber++) {
$raw = $this->readRow($sheet, $rowNumber, $columnMap);

if ($this->isBlankDataRow($raw)) {
continue;
}

$lastDataRow = $rowNumber;
}

return $lastDataRow;
}

$hasCountry = $this->rowContains($values, 'country');
$hasEmail = $this->rowContains($values, 'email');
$hasAction = $this->rowContains($values, 'action');
private function rowLooksLikeHeader(Worksheet $sheet, int $row): bool
{
$values = array_map(
fn ($v) => mb_strtolower(trim((string) $v)),
array_values($sheet->rangeToArray('A'.$row.':H'.$row, null, true, true, true)[$row] ?? []),
);

return $this->rowContains($values, 'country')
&& $this->rowContains($values, 'email')
&& $this->rowContains($values, 'action');
}

if ($hasCountry && $hasEmail && $hasAction) {
return $row;
/**
* @param array<string, ?string> $raw
*/
private function isBlankDataRow(array $raw): bool
{
foreach (['country', 'full_name', 'email', 'action', 'role', 'comments'] as $field) {
if (($raw[$field] ?? null) !== null) {
return false;
}
}

return 1;
return true;
}

private function isLegacyCountry(?string $country): bool
{
if ($country === null) {
return false;
}

return str_starts_with($country, '#');
}

/**
Expand Down
10 changes: 9 additions & 1 deletion resources/views/admin/bulk-user-changes/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</section>

<section class="codeweek-content-wrapper">
<p class="mb-4">Upload the client Excel workbook. <strong>Only the sheet named <code>Changes</code> is read</strong> — all other tabs are ignored. The tool finds the header row on that sheet automatically; rows with no email/action are skipped. <strong>Missing users are never created.</strong></p>
<p class="mb-4">Upload the client Excel workbook. <strong>Only the sheet named <code>Changes</code> is read</strong> — all other tabs are ignored. If older batches are still on the sheet, set <strong>Ignore rows through</strong> so rows 2 up to that line are skipped (e.g. <code>149</code> to start at row 150). Only rows with an email address are listed. <strong>Missing users are never created.</strong></p>

@if ($errors->any())
<div class="mb-4 p-4 rounded bg-red-50 border border-red-200">
Expand All @@ -29,6 +29,14 @@
class="mt-2 text-sm file:mr-2 file:py-2 file:px-4 file:rounded-full file:border-0 file:font-semibold file:bg-primary file:text-white hover:file:opacity-90 file:cursor-pointer cursor-pointer">
<p class="text-sm text-gray-600 mt-2">Must include a tab named <code>Changes</code> (other tabs are not processed). Required columns on that tab: Country, Full name, Email address, ACTION, Role.</p>
</div>
<div class="mb-4">
<label for="bulk-user-changes-ignore-through-row" class="font-medium">Ignore rows through (optional)</label>
<input type="number" name="ignore_through_row" id="bulk-user-changes-ignore-through-row" min="1" max="10000" step="1"
value="{{ old('ignore_through_row') }}"
placeholder="e.g. 149"
class="mt-2 block w-full max-w-xs rounded border border-gray-300 px-3 py-2 text-sm">
<p class="text-sm text-gray-600 mt-2">Excel row number. Rows 2 through this line are skipped; processing starts on the next row. Leave blank to read from row 2.</p>
</div>
<button type="submit" class="bg-primary cursor-pointer px-6 py-3 rounded-full font-semibold text-[#20262C] hover:bg-hover-orange duration-300">Upload &amp; preview</button>
</div>
</form>
Expand Down
40 changes: 38 additions & 2 deletions resources/views/admin/bulk-user-changes/preview.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,44 @@
@endif

<div class="mb-6 p-4 rounded bg-blue-50 border border-blue-200 text-sm">
<p><strong>Sheet:</strong> {{ $parsed['sheet_name'] ?? 'Changes' }} · header row {{ $parsed['header_row'] ?? '?' }}</p>
<p><strong>Actionable rows:</strong> {{ $parsed['meta']['parsed_rows'] ?? 0 }} ({{ $parsed['meta']['skipped_blank_rows'] ?? 0 }} blank rows skipped)</p>
@php
$meta = $parsed['meta'] ?? [];
$firstRow = $meta['first_data_row'] ?? null;
$lastRow = $meta['last_data_row'] ?? null;
$headerRow = $parsed['header_row'] ?? 1;
@endphp
<p><strong>Sheet:</strong> {{ $parsed['sheet_name'] ?? 'Changes' }} · header row {{ $headerRow }}</p>
<p><strong>Rows in file:</strong>
@if ($firstRow && $lastRow)
{{ $firstRow }}–{{ $lastRow }}
({{ $meta['parsed_rows'] ?? 0 }} with email)
@else
none
@endif
</p>
@if (! empty($meta['first_email']) && ! empty($meta['last_email']))
<p><strong>First:</strong> {{ $meta['first_email'] }} · <strong>Last:</strong> {{ $meta['last_email'] }}</p>
@endif
@if (! empty($meta['ignore_through_row']))
<p><strong>Ignored rows:</strong> 2–{{ $meta['ignore_through_row'] }} ({{ $meta['skipped_ignored_range_rows'] ?? 0 }} rows skipped)</p>
@endif
@if (($meta['skipped_legacy_rows'] ?? 0) > 0 || ($meta['skipped_no_email_rows'] ?? 0) > 0 || (($meta['skipped_blank_rows'] ?? 0) > 0 && empty($meta['ignore_through_row'])))
<p class="text-gray-700">
Also ignored:
@if (($meta['skipped_legacy_rows'] ?? 0) > 0)
{{ $meta['skipped_legacy_rows'] }} legacy <code>#VALUE!</code> rows,
@endif
@if (($meta['skipped_no_email_rows'] ?? 0) > 0)
{{ $meta['skipped_no_email_rows'] }} rows without email,
@endif
@if (($meta['skipped_blank_rows'] ?? 0) > 0)
{{ $meta['skipped_blank_rows'] }} blank rows
@endif
</p>
@endif
@if ($firstRow && $firstRow > $headerRow + 1 && empty($meta['ignore_through_row']))
<p class="mt-2 text-amber-800"><strong>Note:</strong> The first row is {{ $firstRow }}, not row {{ $headerRow + 1 }}. Set <strong>Ignore rows through</strong> on upload, or delete earlier batches in Excel.</p>
@endif
<p class="mt-2"><strong>Ready to apply:</strong> {{ $summary['would_apply'] ?? 0 }} · <strong>Skipped:</strong> {{ collect($summary)->except(['would_apply', 'applied'])->sum() }}</p>
</div>

Expand Down
Loading
Loading