Add bulk CSV product import for POS (retail + restaurant)
Deploy Ladill POS / deploy (push) Successful in 1m39s

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 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-26 09:20:40 +00:00
co-authored by Claude Opus 4.8
parent 62e396195b
commit 9fcea24680
10 changed files with 543 additions and 3 deletions
@@ -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))
+11
View File
@@ -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<int, array<string, mixed>> $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);
@@ -0,0 +1,285 @@
<?php
namespace App\Services\Import;
use App\Models\PosCategory;
use App\Models\PosProduct;
use App\Services\Crm\CrmClient;
use Illuminate\Http\UploadedFile;
use RuntimeException;
/**
* Bulk product import from a CSV spreadsheet, mode-aware:
* - Restaurant local pos_products (chunked INSERT; categories resolved/created by name).
* - Retail the CRM products API in batches via the bulk-create endpoint.
*
* Built for large catalogs: parsing is streamed row-by-row and writes are
* batched, so hundredsthousands of rows import in one request.
*/
class PosProductCsvImportService
{
/** Hard ceiling so a runaway file can't exhaust memory; split larger files. */
private const MAX_ROWS = 5000;
/** Batch size for CRM bulk-create calls (CRM caps each request at 500). */
private const CRM_BATCH = 200;
/** Canonical column → accepted header aliases (lowercased). */
private const ALIASES = [
'name' => ['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<int, string>}
*/
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;
}
}
@@ -0,0 +1,10 @@
@if (!empty(session('import_errors')))
<details class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<summary class="cursor-pointer font-semibold">{{ count(session('import_errors')) }} row(s) were skipped view details</summary>
<ul class="mt-2 list-disc space-y-0.5 pl-5 text-xs text-amber-700">
@foreach (session('import_errors') as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</details>
@endif
@@ -0,0 +1,55 @@
<x-app-layout title="Import products">
<div class="mx-auto max-w-xl space-y-5">
<div>
<a href="{{ route('pos.products.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> Products</a>
<h1 class="mt-2 text-lg font-semibold text-slate-900">Import products</h1>
<p class="mt-1 text-sm text-slate-500">
Upload a CSV to add many products at once
@if ($restaurant)
to your <strong>restaurant</strong> catalog.
@else
to your <strong>retail</strong> catalog (synced to Ladill CRM).
@endif
</p>
</div>
@if (session('error'))
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
@error('file')
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ $message }}</div>
@enderror
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">1. Start from the template</h2>
<p class="mt-1 text-sm text-slate-500">
Required columns: <code class="rounded bg-slate-100 px-1 py-0.5 text-xs">name</code> and
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">price</code>. Optional:
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">sku</code>,
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">currency</code>,
@if ($restaurant)
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">category</code>,
@else
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">description</code>,
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">tax_rate</code>,
@endif
<code class="rounded bg-slate-100 px-1 py-0.5 text-xs">active</code>.
</p>
<a href="{{ route('pos.products.import.template') }}"
class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-indigo-600 hover:text-indigo-800">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
Download CSV template
</a>
</div>
<form method="POST" action="{{ route('pos.products.import') }}" enctype="multipart/form-data"
class="rounded-2xl border border-slate-200 bg-white p-5">
@csrf
<h2 class="text-sm font-semibold text-slate-900">2. Upload your file</h2>
<input type="file" name="file" accept=".csv,text/csv" required
class="mt-3 block w-full text-sm text-slate-600 file:mr-3 file:rounded-xl file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200">
<p class="mt-2 text-xs text-slate-500">CSV up to 10 MB. Rows with a missing name or invalid price are skipped and reported.</p>
<button type="submit" class="btn-primary mt-4 w-full">Import products</button>
</form>
</div>
</x-app-layout>
+6 -1
View File
@@ -1,9 +1,14 @@
<x-app-layout title="Products">
<div class="space-y-5">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3">
<h1 class="text-lg font-semibold text-slate-900">Products</h1>
<div class="flex items-center gap-2">
<a href="{{ route('pos.products.import.form') }}" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Import</a>
<a href="{{ route('pos.products.create') }}" class="btn-primary">Add product</a>
</div>
</div>
@include('pos.products._import-errors')
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
@@ -5,8 +5,11 @@
<h1 class="text-lg font-semibold text-slate-900">Products</h1>
<p class="mt-1 text-sm text-slate-500">Your retail catalog, synced live with Ladill CRM.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ route('pos.products.import.form') }}" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">Import</a>
<a href="{{ route('pos.products.create') }}" class="btn-primary">New product</a>
</div>
</div>
@if(session('success'))
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ session('success') }}</div>
@@ -15,6 +18,8 @@
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
@include('pos.products._import-errors')
<form method="get" class="flex max-w-sm items-center gap-2">
<input type="search" name="search" value="{{ $search }}" placeholder="Search products…"
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
+3
View File
@@ -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');
@@ -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":[]}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\PosCategory;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PosProductImportTest extends TestCase
{
use RefreshDatabase;
private function user(): User
{
return User::create([
'public_id' => '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());
}
}