loadSheet($absPath); $highestColumn = $sheet->getHighestColumn(); $highestColIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn); $cols = []; for ($i = 1; $i <= min($highestColIndex, 20); $i++) { $cols[] = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i); } $rows = []; for ($r = 1; $r <= $maxRows; $r++) { $row = []; foreach ($cols as $col) { $row[$col] = (string) ($sheet->getCell("$col$r")->getValue() ?? ''); } $rows[] = $row; } return ['columns' => $cols, 'rows' => $rows]; } /** * Parse the file according to $mapping and classify each line. * * Mapping keys: * article_col, name_col, qty_col, price_col, brand_col (optional), * header_row (1-based row number to skip), sheet_name (optional). */ public function preview(string $absPath, array $mapping): array { $sheet = $this->loadSheet($absPath, $mapping['sheet_name'] ?? null); $headerRow = (int) ($mapping['header_row'] ?? 1); $highestRow = $sheet->getHighestRow(); $rows = []; $articlesInFile = []; for ($r = $headerRow + 1; $r <= $highestRow; $r++) { $article = $this->cellString($sheet, $mapping['article_col'] ?? null, $r); $name = $this->cellString($sheet, $mapping['name_col'] ?? null, $r); $brand = $this->cellString($sheet, $mapping['brand_col'] ?? null, $r); $qty = $this->cellDecimal($sheet, $mapping['qty_col'] ?? null, $r); $price = $this->cellDecimal($sheet, $mapping['price_col'] ?? null, $r); // Skip totally empty lines if ($article === '' && $name === '' && $qty <= 0) continue; $status = 'no_article'; $partId = null; if ($article !== '') { $articlesInFile[] = $article; } $rows[] = [ 'row' => $r, 'article' => $article, 'name' => $name, 'brand' => $brand, 'qty' => $qty, 'price' => $price, 'status' => $status, 'part_id' => $partId, ]; } // Single batch query for all articles → status if (! empty($articlesInFile)) { $existing = Part::whereIn('article', array_unique($articlesInFile))->get()->keyBy('article'); foreach ($rows as &$row) { if ($row['article'] === '') continue; if ($existing->has($row['article'])) { $row['status'] = 'found'; $row['part_id'] = $existing[$row['article']]->id; } else { $row['status'] = 'new'; } } unset($row); } return [ 'rows' => $rows, 'summary' => [ 'total' => count($rows), 'found' => count(array_filter($rows, fn ($r) => $r['status'] === 'found')), 'new' => count(array_filter($rows, fn ($r) => $r['status'] === 'new')), 'no_article' => count(array_filter($rows, fn ($r) => $r['status'] === 'no_article')), ], ]; } /** * Create a draft Purchase + items from the rows. Rows with status='new' * automatically get a Part created if $createNew is true. */ public function import(Supplier $supplier, array $rows, bool $createNew = true): Purchase { return DB::transaction(function () use ($supplier, $rows, $createNew) { $purchase = Purchase::create([ 'supplier_id' => $supplier->id, 'number' => $this->generatePurchaseNumber($supplier->company_id), 'order_date' => today(), 'status' => 'ordered', 'total' => 0, 'notes' => 'Auto-import Excel/CSV', ]); $total = 0; foreach ($rows as $row) { $partId = $row['part_id'] ?? null; if (! $partId && $row['status'] === 'new' && $createNew && $row['article'] !== '') { $part = Part::create([ 'name' => $row['name'] ?: $row['article'], 'article' => $row['article'], 'brand' => $row['brand'] ?? null, 'buy_price' => $row['price'], 'preferred_supplier_id' => $supplier->id, ]); $partId = $part->id; } PurchaseItem::create([ 'purchase_id' => $purchase->id, 'part_id' => $partId, 'name' => $row['name'] ?: $row['article'], 'article' => $row['article'], 'qty' => $row['qty'] ?: 1, 'qty_received' => 0, 'buy_price' => $row['price'], 'total' => round(($row['qty'] ?: 1) * $row['price'], 2), ]); $total += ($row['qty'] ?: 1) * $row['price']; } $purchase->update(['total' => round($total, 2)]); return $purchase; }); } /** * Save mapping config for a supplier so subsequent imports skip the wizard. */ public function rememberMapping(Supplier $supplier, array $mapping, ?string $sampleFileName = null): SupplierInvoiceMapping { return SupplierInvoiceMapping::updateOrCreate( ['supplier_id' => $supplier->id], [ 'mapping_config' => $mapping, 'sample_file_name' => $sampleFileName, 'last_used_at' => now(), ] ); } public function rememberedMappingFor(Supplier $supplier): ?array { $m = SupplierInvoiceMapping::where('supplier_id', $supplier->id)->first(); return $m?->mapping_config; } private function loadSheet(string $absPath, ?string $sheetName = null) { $reader = IOFactory::createReaderForFile($absPath); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($absPath); return $sheetName ? $spreadsheet->getSheetByName($sheetName) ?? $spreadsheet->getActiveSheet() : $spreadsheet->getActiveSheet(); } private function cellString($sheet, ?string $col, int $row): string { if (! $col) return ''; $value = $sheet->getCell("$col$row")->getValue(); return trim((string) ($value ?? '')); } private function cellDecimal($sheet, ?string $col, int $row): float { if (! $col) return 0.0; $value = $sheet->getCell("$col$row")->getValue(); if ($value === null || $value === '') return 0.0; // Normalize "1 234,56" / "1,234.56" → 1234.56 // PCRE doesn't support \u{XXXX} — use \x{00A0} (non-breaking space) instead $clean = preg_replace('/[\s\x{00A0}]/u', '', (string) $value); $clean = str_replace(',', '.', $clean); // remove duplicate dots if any $parts = explode('.', $clean); if (count($parts) > 2) { $clean = implode('', array_slice($parts, 0, -1)) . '.' . end($parts); } return (float) $clean; } private function generatePurchaseNumber(int $companyId): string { $year = date('Y'); $last = Purchase::where('company_id', $companyId) ->where('number', 'like', "P-$year-%") ->orderByDesc('id')->first(); $next = 1; if ($last && preg_match('/P-\d{4}-(\d+)$/', $last->number, $m)) { $next = (int) $m[1] + 1; } return sprintf('P-%s-%04d', $year, $next); } }