Add bulk CSV product import for POS (retail + restaurant)
Deploy Ladill POS / deploy (push) Successful in 1m39s
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:
co-authored by
Claude Opus 4.8
parent
62e396195b
commit
9fcea24680
@@ -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))
|
||||
|
||||
@@ -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 hundreds–thousands 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user