select('line_items.*') ->where('invoice_id', $invoiceId) ->orderBy('position', 'asc') ->get(); return $items->map(function ($item) { $itemArray = $item->toArray(); if ($item->unit) { $itemArray['unit_name'] = $item->unit->name; $itemArray['unit_symbol'] = $item->unit->symbol; } return ApiDataTransformer::snakeToCamel($itemArray); }); } /** * Imports LineItems from a CSV file for a specific invoice * * CSV Format Requirements: * 1. The CSV file must be semicolon-separated (use ; as delimiter) to avoid conflicts with commas in numeric values * 2. The CSV file must contain the following columns (in any order): * - position (optional): The position of the item in the invoice (if not provided, items will be numbered sequentially starting from 1) * - title (required): The title of the item * - description (optional): A description of the item * - quantity (optional): The quantity (default: 1). Can use comma as decimal separator and dot as thousands separator (e.g., 1.500,50 for 1500.50) * - unit (required): The unit (must exist in the units table, either as name or symbol) * - price (optional): The price per unit (default: 0). Can use comma as decimal separator and dot as thousands separator (e.g., 1.500,50 for 1500.50) * * 3. The first row can optionally contain a header row with column names * 4. Each row must contain at least the 'title' and 'unit' columns * 5. Empty rows or rows with incomplete data will be skipped * 6. The CSV file must have the extension .csv or .txt * * Example CSV (with header): * position;title;description;quantity;unit;price * 1;"Webdesign";"Development of a new website";10;"Hours";93,75 * ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00 * * Example CSV (without header): * 1;"Webdesign";"Development of a new website";10;"Hours";93,75 * ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00 * * @param Request $request The HTTP request with the CSV file * @return \Illuminate\Http\JsonResponse A JSON response with the imported items or an error message */ public function importFromCsv(Request $request) { $request->validate([ 'csv' => 'required|file|mimes:csv,txt' ]); $file = $request->file('csv'); $csvData = array_map(function ($row) { // Use semicolon as delimiter return str_getcsv($row, ';'); }, file($file->getRealPath())); // Skip header row if it exists if (count($csvData) > 0 && count($csvData[0]) > 0) { $headers = array_map('trim', $csvData[0]); $data = array_slice($csvData, 1); } else { $headers = []; $data = $csvData; } // Default headers if none provided if (empty($headers)) { $headers = ['position', 'title', 'description', 'quantity', 'unit', 'price']; } $lineItems = []; $units = Unit::all(); $positionCounter = 1; // Create a NumberFormatter for German locale $formatter = new \NumberFormatter('de_DE', \NumberFormatter::DECIMAL); foreach ($data as $row) { if (count($row) < count($headers)) { continue; // Skip incomplete rows } $itemData = array_combine($headers, $row); $itemData = array_map('trim', $itemData); // Validate required fields if (empty($itemData['title']) || empty($itemData['unit'])) { continue; } // Find unit by name or symbol $unit = $units->first(function ($u) use ($itemData) { return strtolower($u->name) === strtolower($itemData['unit']) || strtolower($u->symbol) === strtolower($itemData['unit']); }); if (!$unit) { return response()->json([ 'message' => "Unit '{$itemData['unit']}' not found in database. Please add it first." ], 400); } // Determine position $position = !empty($itemData['position']) ? (float)$itemData['position'] : $positionCounter++; // Parse quantity using NumberFormatter $quantity = $this->parseNumber($formatter, $itemData['quantity'] ?? 1); // Parse price using NumberFormatter $price = $this->parseNumber($formatter, $itemData['price'] ?? 0); // Validate numeric values if (!is_numeric($quantity) || !is_numeric($price)) { continue; // Skip rows with invalid numeric values } $lineItems[] = [ 'id' => 0, 'invoice_id' => 0, 'position' => $position, 'is_section' => false, 'title' => $itemData['title'], 'description' => $itemData['description'] ?? '', 'quantity' => (float)$quantity, 'unit_id' => $unit->id, 'unit' => [ 'id' => $unit->id, 'name' => $unit->name, 'symbol' => $unit->symbol, 'created_at' => $unit->created_at, 'updated_at' => $unit->updated_at, ], 'price' => (float)$price, ]; } if (empty($lineItems)) { return response()->json(['message' => 'No valid items found in the CSV file'], 400); } return response()->json( ApiDataTransformer::snakeToCamel($lineItems) ); } /** * Parses a number using NumberFormatter for the specified locale * * @param \NumberFormatter $formatter The NumberFormatter instance * @param string|float $value The value to parse * @return float The parsed number */ protected function parseNumber(\NumberFormatter $formatter, $value) { if (is_numeric($value)) { return (float)$value; } if (!is_string($value)) { return 0.0; } // Parse the number using NumberFormatter $parsed = $formatter->parse($value); // Check if parsing was successful if ($parsed === false) { return 0.0; } return (float)$parsed; } }