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))