From 9fcea246800b1711fd40230856780943807e13e7 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 26 Jun 2026 09:20:40 +0000 Subject: [PATCH] Add bulk CSV product import for POS (retail + restaurant) Customers with large catalogs can upload a CSV instead of adding products one by one. Mode-aware: restaurant imports into the local catalog (chunked INSERT, categories resolved/created by name); retail batches to the CRM products/bulk endpoint. Streamed parsing + batched writes handle thousands of rows in one request. Includes a downloadable template, an Import button on both product pages, and a skipped-rows report. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Pos/ProductController.php | 59 ++++ app/Services/Crm/CrmClient.php | 11 + .../Import/PosProductCsvImportService.php | 285 ++++++++++++++++++ .../pos/products/_import-errors.blade.php | 10 + resources/views/pos/products/import.blade.php | 55 ++++ resources/views/pos/products/index.blade.php | 9 +- .../views/pos/products/retail/index.blade.php | 7 +- routes/web.php | 3 + storage/framework/testing/events-import.json | 1 + tests/Feature/PosProductImportTest.php | 106 +++++++ 10 files changed, 543 insertions(+), 3 deletions(-) create mode 100644 app/Services/Import/PosProductCsvImportService.php create mode 100644 resources/views/pos/products/_import-errors.blade.php create mode 100644 resources/views/pos/products/import.blade.php create mode 100644 storage/framework/testing/events-import.json create mode 100644 tests/Feature/PosProductImportTest.php diff --git a/app/Http/Controllers/Pos/ProductController.php b/app/Http/Controllers/Pos/ProductController.php index 42e27f1..314a76d 100644 --- a/app/Http/Controllers/Pos/ProductController.php +++ b/app/Http/Controllers/Pos/ProductController.php @@ -11,9 +11,12 @@ use App\Models\PosProduct; use App\Models\PosSaleLine; use App\Models\PosStation; use App\Services\Crm\CrmClient; +use App\Services\Import\PosProductCsvImportService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use RuntimeException; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * Products are mode-aware: @@ -119,6 +122,62 @@ class ProductController extends Controller return redirect()->route('pos.products.index')->with('success', 'Product removed.'); } + /** Bulk CSV import — upload form (mode-aware target catalog). */ + public function importForm(Request $request): View + { + return view('pos.products.import', [ + 'restaurant' => $this->isRestaurant($request), + ]); + } + + /** Bulk CSV import — handle the upload. */ + public function import(Request $request, PosProductCsvImportService $import): RedirectResponse + { + $request->validate([ + 'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'], + ]); + + try { + $result = $import->import( + $this->ownerRef($request), + $request->file('file'), + $this->isRestaurant($request), + ); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + $message = "Imported {$result['imported']} product(s)."; + if ($result['skipped'] > 0) { + $message .= " Skipped {$result['skipped']}."; + } + + return redirect()->route('pos.products.index') + ->with('success', $message) + ->with('import_errors', array_slice($result['errors'], 0, 50)); + } + + /** Bulk CSV import — downloadable template with example rows. */ + public function importTemplate(Request $request): StreamedResponse + { + $restaurant = $this->isRestaurant($request); + $filename = 'pos-products-template.csv'; + + return response()->streamDownload(function () use ($restaurant) { + $out = fopen('php://output', 'wb'); + if ($restaurant) { + fputcsv($out, ['name', 'sku', 'price', 'currency', 'category', 'active']); + fputcsv($out, ['Coffee', 'CFE-01', '15.00', 'GHS', 'Drinks', 'yes']); + fputcsv($out, ['Croissant', 'CRS-02', '8.50', 'GHS', 'Pastry', 'yes']); + } else { + fputcsv($out, ['name', 'sku', 'price', 'currency', 'description', 'tax_rate', 'active']); + fputcsv($out, ['Mug', 'MUG-01', '25.00', 'GHS', 'Ceramic mug', '0', 'yes']); + fputcsv($out, ['T-Shirt', 'TSH-02', '60.00', 'GHS', 'Cotton tee', '0', 'yes']); + } + fclose($out); + }, $filename, ['Content-Type' => 'text/csv']); + } + private function isRestaurant(Request $request): bool { return PosLocation::owned($this->ownerRef($request)) diff --git a/app/Services/Crm/CrmClient.php b/app/Services/Crm/CrmClient.php index 1f1e32a..8e616b4 100644 --- a/app/Services/Crm/CrmClient.php +++ b/app/Services/Crm/CrmClient.php @@ -36,6 +36,17 @@ class CrmClient return $this->send('post', 'products', $data); } + /** + * Bulk-create products (one CRM INSERT per call). Caller batches large + * catalogs; the CRM caps each request at 500 rows. + * + * @param array> $products + */ + public function bulkCreateProducts(array $products): array + { + return $this->send('post', 'products/bulk', ['products' => array_values($products)]); + } + public function updateProduct(int|string $id, array $data): array { return $this->send('patch', "products/{$id}", $data); diff --git a/app/Services/Import/PosProductCsvImportService.php b/app/Services/Import/PosProductCsvImportService.php new file mode 100644 index 0000000..b1bb583 --- /dev/null +++ b/app/Services/Import/PosProductCsvImportService.php @@ -0,0 +1,285 @@ + ['name', 'product', 'product name', 'title', 'item'], + 'sku' => ['sku', 'code', 'barcode'], + 'price' => ['price', 'unit_price', 'unit price', 'amount', 'cost', 'selling price'], + 'currency' => ['currency', 'cur'], + 'category' => ['category', 'group', 'department'], + 'description' => ['description', 'desc', 'details'], + 'tax_rate' => ['tax_rate', 'tax rate', 'tax', 'vat'], + 'active' => ['active', 'is_active', 'status', 'enabled'], + ]; + + /** + * @return array{imported: int, skipped: int, errors: array} + */ + public function import(string $ownerRef, UploadedFile $file, bool $restaurant): array + { + $handle = fopen($file->getRealPath(), 'rb'); + if ($handle === false) { + throw new RuntimeException('Could not read the uploaded file.'); + } + + try { + $map = $this->headerMap($handle); + if (! isset($map['name'], $map['price'])) { + throw new RuntimeException('The file must have at least "name" and "price" columns.'); + } + + $currency = strtoupper((string) config('pos.default_currency', 'GHS')); + $errors = []; + $skipped = 0; + $seen = []; // de-dupe by lowercased name within the file + $valid = []; // normalized rows + $line = 1; // header was line 1 + + while (($raw = fgetcsv($handle)) !== false) { + $line++; + if ($this->isBlankRow($raw)) { + continue; + } + if (count($valid) >= self::MAX_ROWS) { + $errors[] = 'Stopped at '.self::MAX_ROWS.' rows — split the file and import the rest.'; + break; + } + + $name = trim((string) ($raw[$map['name']] ?? '')); + if ($name === '') { + $errors[] = "Row {$line}: missing product name — skipped."; + $skipped++; + continue; + } + + $price = $this->parsePrice($raw[$map['price']] ?? null); + if ($price === null) { + $errors[] = "Row {$line}: invalid price for \"{$name}\" — skipped."; + $skipped++; + continue; + } + + $key = mb_strtolower($name); + if (isset($seen[$key])) { + $skipped++; + continue; + } + $seen[$key] = true; + + $valid[] = [ + 'name' => mb_substr($name, 0, 200), + 'sku' => $this->cell($raw, $map, 'sku', 80), + 'price_minor' => (int) round($price * 100), + 'currency' => $this->cellCurrency($raw, $map, $currency), + 'category' => $this->cell($raw, $map, 'category', 120), + 'description' => $this->cell($raw, $map, 'description', 5000), + 'tax_rate' => $this->parseTaxRate($raw[$map['tax_rate'] ?? -1] ?? null), + 'active' => $this->parseActive($raw[$map['active'] ?? -1] ?? null), + ]; + } + + if ($valid === []) { + throw new RuntimeException('No valid rows found to import. Check the column headers and try again.'); + } + + $imported = $restaurant + ? $this->importLocal($ownerRef, $valid) + : $this->importCrm($ownerRef, $valid); + + return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors]; + } finally { + fclose($handle); + } + } + + /** Restaurant: chunked INSERT into pos_products, categories resolved by name. */ + private function importLocal(string $ownerRef, array $rows): int + { + $categories = $this->categoryMap($ownerRef, $rows); + $now = now(); + $imported = 0; + + foreach (array_chunk($rows, 500) as $chunk) { + $insert = array_map(fn (array $r): array => [ + 'owner_ref' => $ownerRef, + 'category_id' => $r['category'] !== null ? ($categories[mb_strtolower($r['category'])] ?? null) : null, + 'name' => $r['name'], + 'sku' => $r['sku'], + 'price_minor' => $r['price_minor'], + 'currency' => $r['currency'], + 'is_active' => $r['active'], + 'created_at' => $now, + 'updated_at' => $now, + ], $chunk); + + PosProduct::insert($insert); + $imported += count($insert); + } + + return $imported; + } + + /** Retail: batch to the CRM bulk-create endpoint. */ + private function importCrm(string $ownerRef, array $rows): int + { + $client = CrmClient::for($ownerRef); + $imported = 0; + + foreach (array_chunk($rows, self::CRM_BATCH) as $chunk) { + $payload = array_map(fn (array $r): array => [ + 'name' => $r['name'], + 'sku' => $r['sku'], + 'type' => 'product', + 'description' => $r['description'], + 'unit_price_minor' => $r['price_minor'], + 'currency' => $r['currency'], + 'tax_rate' => $r['tax_rate'], + 'active' => $r['active'], + ], $chunk); + + $result = $client->bulkCreateProducts($payload); + $imported += (int) ($result['imported'] ?? count($payload)); + } + + return $imported; + } + + /** Resolve every distinct category name to an id, creating missing ones. */ + private function categoryMap(string $ownerRef, array $rows): array + { + $names = []; + foreach ($rows as $r) { + if ($r['category'] !== null) { + $names[mb_strtolower($r['category'])] = $r['category']; + } + } + if ($names === []) { + return []; + } + + $existing = PosCategory::owned($ownerRef)->get(['id', 'name']) + ->keyBy(fn ($c) => mb_strtolower($c->name)); + + $map = []; + $position = (int) PosCategory::owned($ownerRef)->max('position'); + foreach ($names as $lower => $original) { + if ($existing->has($lower)) { + $map[$lower] = $existing->get($lower)->id; + continue; + } + $map[$lower] = PosCategory::create([ + 'owner_ref' => $ownerRef, + 'name' => $original, + 'position' => ++$position, + ])->id; + } + + return $map; + } + + /** Build canonical-column → CSV-index map from the header row. */ + private function headerMap($handle): array + { + $header = fgetcsv($handle); + if ($header === false || $header === null) { + throw new RuntimeException('The file appears to be empty.'); + } + + $map = []; + foreach ($header as $i => $label) { + $clean = mb_strtolower(trim($this->stripBom((string) $label))); + foreach (self::ALIASES as $canonical => $aliases) { + if (in_array($clean, $aliases, true)) { + $map[$canonical] ??= $i; + } + } + } + + return $map; + } + + private function cell(array $raw, array $map, string $key, int $max): ?string + { + if (! isset($map[$key])) { + return null; + } + $value = trim((string) ($raw[$map[$key]] ?? '')); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + private function cellCurrency(array $raw, array $map, string $default): string + { + $value = strtoupper(trim((string) ($raw[$map['currency'] ?? -1] ?? ''))); + + return strlen($value) === 3 ? $value : $default; + } + + private function parsePrice(mixed $value): ?float + { + $clean = preg_replace('/[^0-9.\-]/', '', (string) $value); + if ($clean === '' || ! is_numeric($clean)) { + return null; + } + $price = (float) $clean; + + return $price < 0 ? null : $price; + } + + private function parseTaxRate(mixed $value): float + { + $clean = preg_replace('/[^0-9.]/', '', (string) $value); + + return is_numeric($clean) ? min(100.0, max(0.0, (float) $clean)) : 0.0; + } + + private function parseActive(mixed $value): bool + { + $v = mb_strtolower(trim((string) $value)); + if ($v === '') { + return true; + } + + return ! in_array($v, ['0', 'no', 'n', 'false', 'inactive', 'disabled', 'off'], true); + } + + private function isBlankRow(array $raw): bool + { + foreach ($raw as $cell) { + if (trim((string) $cell) !== '') { + return false; + } + } + + return true; + } + + private function stripBom(string $value): string + { + return str_starts_with($value, "\xEF\xBB\xBF") ? substr($value, 3) : $value; + } +} diff --git a/resources/views/pos/products/_import-errors.blade.php b/resources/views/pos/products/_import-errors.blade.php new file mode 100644 index 0000000..db33aca --- /dev/null +++ b/resources/views/pos/products/_import-errors.blade.php @@ -0,0 +1,10 @@ +@if (!empty(session('import_errors'))) +
+ {{ count(session('import_errors')) }} row(s) were skipped — view details +
    + @foreach (session('import_errors') as $error) +
  • {{ $error }}
  • + @endforeach +
+
+@endif diff --git a/resources/views/pos/products/import.blade.php b/resources/views/pos/products/import.blade.php new file mode 100644 index 0000000..20e2226 --- /dev/null +++ b/resources/views/pos/products/import.blade.php @@ -0,0 +1,55 @@ + +
+
+ ← Products +

Import products

+

+ Upload a CSV to add many products at once + @if ($restaurant) + to your restaurant catalog. + @else + to your retail catalog (synced to Ladill CRM). + @endif +

+
+ + @if (session('error')) +
{{ session('error') }}
+ @endif + @error('file') +
{{ $message }}
+ @enderror + +
+

1. Start from the template

+

+ Required columns: name and + price. Optional: + sku, + currency, + @if ($restaurant) + category, + @else + description, + tax_rate, + @endif + active. +

+ + + Download CSV template + +
+ +
+ @csrf +

2. Upload your file

+ +

CSV up to 10 MB. Rows with a missing name or invalid price are skipped and reported.

+ +
+
+
diff --git a/resources/views/pos/products/index.blade.php b/resources/views/pos/products/index.blade.php index 208467c..00fb1b1 100644 --- a/resources/views/pos/products/index.blade.php +++ b/resources/views/pos/products/index.blade.php @@ -1,10 +1,15 @@
-
+

Products

- Add product +
+ @include('pos.products._import-errors') +
diff --git a/resources/views/pos/products/retail/index.blade.php b/resources/views/pos/products/retail/index.blade.php index df9d0ad..1680675 100644 --- a/resources/views/pos/products/retail/index.blade.php +++ b/resources/views/pos/products/retail/index.blade.php @@ -5,7 +5,10 @@

Products

Your retail catalog, synced live with Ladill CRM.

- New product + @if(session('success')) @@ -15,6 +18,8 @@
{{ session('error') }}
@endif + @include('pos.products._import-errors') + diff --git a/routes/web.php b/routes/web.php index e574803..1ae4961 100644 --- a/routes/web.php +++ b/routes/web.php @@ -86,6 +86,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::delete('/menu/modifiers/{modifier}', [MenuController::class, 'destroyModifier'])->name('pos.menu.modifiers.destroy'); Route::get('/products', [ProductController::class, 'index'])->name('pos.products.index'); + Route::get('/products/import', [ProductController::class, 'importForm'])->name('pos.products.import.form'); + Route::post('/products/import', [ProductController::class, 'import'])->name('pos.products.import'); + Route::get('/products/import/template', [ProductController::class, 'importTemplate'])->name('pos.products.import.template'); Route::get('/products/create', [ProductController::class, 'create'])->name('pos.products.create'); Route::post('/products', [ProductController::class, 'store'])->name('pos.products.store'); Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('pos.products.edit'); diff --git a/storage/framework/testing/events-import.json b/storage/framework/testing/events-import.json new file mode 100644 index 0000000..2d452f1 --- /dev/null +++ b/storage/framework/testing/events-import.json @@ -0,0 +1 @@ +{"qr_wallets":[],"qr_codes":[{"platform_id":10,"owner_public_id":"usr_events_owner","owner_email":"dejah85@example.org","short_code":"demo-programme","type":"itinerary","label":"Demo Programme","payload":"{\"content\":{\"title\":\"Demo Programme\"},\"style\":[]}","is_active":true,"scans_total":0,"created_at":"2026-06-24 00:00:25","updated_at":"2026-06-24 00:00:25","destination_updated_at":"2026-06-24 00:00:25"},{"platform_id":11,"owner_public_id":"usr_events_owner","owner_email":"dejah85@example.org","short_code":"demo-event","type":"event","label":"Demo Event","payload":"{\"content\":{\"name\":\"Demo Event\",\"programme_qr_id\":10},\"style\":[]}","is_active":true,"scans_total":0,"created_at":"2026-06-24 00:00:25","updated_at":"2026-06-24 00:00:25","destination_updated_at":"2026-06-24 00:00:25"}],"qr_event_registrations":[],"qr_scan_events":[],"qr_transactions":[]} \ No newline at end of file diff --git a/tests/Feature/PosProductImportTest.php b/tests/Feature/PosProductImportTest.php new file mode 100644 index 0000000..d919026 --- /dev/null +++ b/tests/Feature/PosProductImportTest.php @@ -0,0 +1,106 @@ + 'u-'.uniqid(), + 'name' => 'Owner', + 'email' => uniqid().'@example.com', + ]); + } + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + config(['crm.url' => 'https://crm.test/api', 'crm.key' => 'test-crm-key']); + } + + private function csv(string $content): UploadedFile + { + return UploadedFile::fake()->createWithContent('products.csv', $content); + } + + public function test_restaurant_import_creates_local_products_and_categories(): void + { + $user = $this->user(); + PosLocation::create(['owner_ref' => $user->public_id, 'name' => 'Main register', 'currency' => 'GHS', 'service_style' => 'restaurant']); + + $csv = "name,sku,price,category,active\n" + ."Coffee,CFE-01,15.00,Drinks,yes\n" + ."Latte,LAT-02,18.50,Drinks,yes\n" + ."Croissant,,8.00,Pastry,no\n" + .",NONAME,5.00,Drinks,yes\n" // skipped: no name + ."Bad,BAD-1,notaprice,Drinks,yes\n"; // skipped: bad price + + $this->actingAs($user) + ->post(route('pos.products.import'), ['file' => $this->csv($csv)]) + ->assertRedirect(route('pos.products.index')) + ->assertSessionHas('success'); + + $this->assertSame(3, PosProduct::where('owner_ref', $user->public_id)->count()); + $coffee = PosProduct::where('owner_ref', $user->public_id)->where('name', 'Coffee')->firstOrFail(); + $this->assertSame(1500, $coffee->price_minor); + $this->assertNotNull($coffee->category_id); + $this->assertFalse((bool) PosProduct::where('name', 'Croissant')->firstOrFail()->is_active); + // Drinks + Pastry created once each. + $this->assertSame(2, PosCategory::where('owner_ref', $user->public_id)->count()); + } + + public function test_retail_import_batches_to_crm_bulk_endpoint(): void + { + Http::fake([ + 'crm.test/api/products/bulk' => Http::response(['imported' => 2], 201), + 'crm.test/api/*' => Http::response(['data' => []], 200), + ]); + + $user = $this->user(); // retail by default (no restaurant location) + $csv = "name,sku,price,description,active\n" + ."Mug,MUG-01,25.00,Ceramic,yes\n" + ."Tee,TSH-02,60.00,Cotton,yes\n"; + + $this->actingAs($user) + ->post(route('pos.products.import'), ['file' => $this->csv($csv)]) + ->assertRedirect(route('pos.products.index')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/products/bulk') + && count($request['products']) === 2 + && $request['products'][0]['name'] === 'Mug' + && $request['products'][0]['unit_price_minor'] === 2500 + && $request['products'][0]['type'] === 'product'; + }); + + // No local products created in retail mode. + $this->assertSame(0, PosProduct::where('owner_ref', $user->public_id)->count()); + } + + public function test_template_download_is_csv(): void + { + $response = $this->actingAs($this->user()) + ->get(route('pos.products.import.template')) + ->assertOk() + ->assertHeader('content-disposition', 'attachment; filename=pos-products-template.csv'); + + $this->assertStringContainsString('text/csv', $response->headers->get('content-type')); + $this->assertStringContainsString('name', $response->streamedContent()); + } +}