2025-11-18 10:27:49 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
|
|
|
|
|
|
use App\Models\LineItem;
|
2025-12-08 13:20:52 +01:00
|
|
|
use App\Models\Unit;
|
2025-11-18 10:27:49 +01:00
|
|
|
use App\Support\ApiDataTransformer;
|
2025-12-08 13:20:52 +01:00
|
|
|
use Illuminate\Http\Request;
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
|
|
|
class LineItemController extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function index($invoiceId)
|
|
|
|
|
{
|
2025-11-26 10:05:43 +01:00
|
|
|
$items = LineItem::with('unit')
|
|
|
|
|
->select('line_items.*')
|
2025-11-18 10:27:49 +01:00
|
|
|
->where('invoice_id', $invoiceId)
|
2026-02-24 16:15:21 +01:00
|
|
|
->orderBy('position', 'asc')
|
2025-11-18 10:27:49 +01:00
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
return $items->map(function ($item) {
|
2025-11-26 10:05:43 +01:00
|
|
|
$itemArray = $item->toArray();
|
|
|
|
|
|
|
|
|
|
if ($item->unit) {
|
|
|
|
|
$itemArray['unit_name'] = $item->unit->name;
|
|
|
|
|
$itemArray['unit_symbol'] = $item->unit->symbol;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ApiDataTransformer::snakeToCamel($itemArray);
|
2025-11-18 10:27:49 +01:00
|
|
|
});
|
|
|
|
|
}
|
2025-12-08 13:20:52 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2026-01-20 15:25:06 +01:00
|
|
|
public function importFromCsv(Request $request)
|
2025-12-08 13:20:52 +01:00
|
|
|
{
|
|
|
|
|
$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[] = [
|
2026-01-20 15:25:06 +01:00
|
|
|
'id' => 0,
|
|
|
|
|
'invoice_id' => 0,
|
2025-12-08 13:20:52 +01:00
|
|
|
'position' => $position,
|
|
|
|
|
'is_section' => false,
|
|
|
|
|
'title' => $itemData['title'],
|
|
|
|
|
'description' => $itemData['description'] ?? '',
|
|
|
|
|
'quantity' => (float)$quantity,
|
|
|
|
|
'unit_id' => $unit->id,
|
2026-01-20 15:25:06 +01:00
|
|
|
'unit' => [
|
|
|
|
|
'id' => $unit->id,
|
|
|
|
|
'name' => $unit->name,
|
|
|
|
|
'symbol' => $unit->symbol,
|
|
|
|
|
'created_at' => $unit->created_at,
|
|
|
|
|
'updated_at' => $unit->updated_at,
|
|
|
|
|
],
|
2025-12-08 13:20:52 +01:00
|
|
|
'price' => (float)$price,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($lineItems)) {
|
|
|
|
|
return response()->json(['message' => 'No valid items found in the CSV file'], 400);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:25:06 +01:00
|
|
|
return response()->json(
|
|
|
|
|
ApiDataTransformer::snakeToCamel($lineItems)
|
|
|
|
|
);
|
2025-12-08 13:20:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
2025-11-18 10:27:49 +01:00
|
|
|
}
|