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>
286 lines
9.7 KiB
PHP
286 lines
9.7 KiB
PHP
<?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;
|
||
}
|
||
}
|