Files
ladill-pos/app/Services/Import/PosProductCsvImportService.php
T
isaaccladandClaude Opus 4.8 9fcea24680
Deploy Ladill POS / deploy (push) Successful in 1m39s
Add bulk CSV product import for POS (retail + restaurant)
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>
2026-06-26 09:20:40 +00:00

286 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}