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;
}
}