['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} */ 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; } }