From 810aff1b0e021ebdfac334e68ff89503809c8eb6 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Fri, 16 Jan 2026 17:37:39 -0600 Subject: [PATCH] WIP: Serials --- app/Http/Controllers/Admin/UserController.php | 10 +- app/Http/Controllers/App/ClientController.php | 21 +- .../Controllers/App/FacturaDataController.php | 185 ++++++++++++++++++ .../App/InventorySerialController.php | 171 ++++++++++++++++ app/Http/Controllers/App/SaleController.php | 4 +- app/Http/Requests/App/SaleStoreRequest.php | 4 +- app/Imports/ProductsImport.php | 37 +++- app/Models/Client.php | 9 + app/Models/Inventory.php | 30 +++ app/Models/InventorySerial.php | 64 ++++++ app/Models/Sale.php | 6 + app/Models/SaleDetail.php | 13 ++ app/Services/SaleService.php | 58 +++++- ...12_30_151510_create_sale_details_table.php | 1 + ..._162145_create_inventory_serials_table.php | 35 ++++ ...16_195146_add_client_id_to_sales_table.php | 46 +++++ database/seeders/RoleSeeder.php | 27 ++- database/seeders/UserSeeder.php | 53 ++--- routes/api.php | 13 +- 19 files changed, 738 insertions(+), 49 deletions(-) create mode 100644 app/Http/Controllers/App/FacturaDataController.php create mode 100644 app/Http/Controllers/App/InventorySerialController.php create mode 100644 app/Models/InventorySerial.php create mode 100644 database/migrations/2026_01_16_162145_create_inventory_serials_table.php create mode 100644 database/migrations/2026_01_16_195146_add_client_id_to_sales_table.php diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index c95d8b0..9a5eb70 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -15,11 +15,11 @@ /** * Controlador de usuarios - * + * * Permite la administración de los usuarios en general. - * + * * @author Moisés Cortés C - * + * * @version 1.0.0 */ class UserController extends Controller @@ -29,7 +29,7 @@ class UserController extends Controller */ public function index() { - $users = User::orderBy('name'); + $users = User::orderBy('name')->where('id', '!=', 1); QuerySupport::queryByKeys($users, ['name', 'email']); @@ -152,7 +152,7 @@ public function activity(UserActivityRequest $request, User $user) } return ApiResponse::OK->response([ - 'models' => + 'models' => $model->orderBy('created_at', 'desc') ->paginate(config('app.pagination')) ]); diff --git a/app/Http/Controllers/App/ClientController.php b/app/Http/Controllers/App/ClientController.php index 62da4f0..6a6effa 100644 --- a/app/Http/Controllers/App/ClientController.php +++ b/app/Http/Controllers/App/ClientController.php @@ -10,11 +10,24 @@ class ClientController extends Controller { public function index(Request $request) { - $clients = Client::where('name', 'LIKE', "%{$request->q}%") - ->orderBy('name') - ->paginate(config('app.pagination')); + $query = Client::query(); - return ApiResponse::OK->response(['clients' => $clients]); + if ($request->has('with')) { + $relations = explode(',', $request->with); + $query->with($relations); + } + + if ($request->has('q') && $request->q) { + $query->where(function($q) use ($request) { + $q->where('name', 'like', "%{$request->q}%") + ->orWhere('email', 'like', "%{$request->q}%") + ->orWhere('rfc', 'like', "%{$request->q}%"); + }); + } + + return ApiResponse::OK->response([ + 'clients' => $query->paginate(config('app.pagination')), + ]); } public function show(Client $client) diff --git a/app/Http/Controllers/App/FacturaDataController.php b/app/Http/Controllers/App/FacturaDataController.php new file mode 100644 index 0000000..5bee255 --- /dev/null +++ b/app/Http/Controllers/App/FacturaDataController.php @@ -0,0 +1,185 @@ +with([ + 'client', + 'details.inventory.category', + 'details.serials', + 'user:id,name,email' + ]) + ->first(); + + if (!$sale) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Venta no encontrada' + ]); + } + + // Si ya tiene datos de facturación + if ($sale->client_id) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Esta venta ya tiene datos de facturación registrados', + 'client' => $sale->client, + 'sale' => $this->formatSaleData($sale) + ]); + } + + return ApiResponse::OK->response([ + 'sale' => $this->formatSaleData($sale) + ]); + } + + /** + * Guarda los datos fiscales del cliente para la venta. + */ + public function store(Request $request, string $invoiceNumber) + { + $sale = Sale::where('invoice_number', $invoiceNumber) + ->with('details.serials') + ->first(); + + if (!$sale) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Venta no encontrada' + ]); + } + + if ($sale->client_id) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Esta venta ya tiene datos de facturación registrados' + ]); + } + + // Verificar que la venta esté completada + if ($sale->status !== 'completed') { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Solo se pueden facturar ventas completadas' + ]); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255', + 'phone' => 'nullable|string|max:20', + 'address' => 'nullable|string|max:500', + 'rfc' => 'required|string|size:13|regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i', + 'razon_social' => 'required|string|max:255', + 'regimen_fiscal' => 'required|string|max:100', + 'cp_fiscal' => 'required|string|size:5|regex:/^\d{5}$/', + 'uso_cfdi' => 'required|string|max:10', + ], [ + 'rfc.regex' => 'El RFC no tiene un formato válido', + 'rfc.size' => 'El RFC debe tener 13 caracteres', + 'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos', + 'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos', + 'name.required' => 'El nombre es obligatorio', + 'email.required' => 'El correo electrónico es obligatorio', + 'email.email' => 'El correo electrónico debe ser válido', + 'razon_social.required' => 'La razón social es obligatoria', + 'regimen_fiscal.required' => 'El régimen fiscal es obligatorio', + 'uso_cfdi.required' => 'El uso de CFDI es obligatorio', + ]); + + // Buscar si ya existe un cliente con ese RFC + $client = Client::where('rfc', strtoupper($validated['rfc']))->first(); + + if ($client) { + // Actualizar datos del cliente existente + $client->update([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'phone' => $validated['phone'] ?? $client->phone, + 'address' => $validated['address'] ?? $client->address, + 'razon_social' => $validated['razon_social'], + 'regimen_fiscal' => $validated['regimen_fiscal'], + 'cp_fiscal' => $validated['cp_fiscal'], + 'uso_cfdi' => $validated['uso_cfdi'], + ]); + } else { + // Crear nuevo cliente + $client = Client::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'phone' => $validated['phone'], + 'address' => $validated['address'], + 'rfc' => strtoupper($validated['rfc']), + 'razon_social' => $validated['razon_social'], + 'regimen_fiscal' => $validated['regimen_fiscal'], + 'cp_fiscal' => $validated['cp_fiscal'], + 'uso_cfdi' => $validated['uso_cfdi'], + ]); + } + + // Asociar cliente a la venta + $sale->update(['client_id' => $client->id]); + + // Recargar relaciones + $sale->load([ + 'client', + 'details.inventory.category', + 'details.serials', + 'user:id,name,email' + ]); + + return ApiResponse::OK->response([ + 'message' => 'Datos de facturación guardados correctamente', + 'client' => $client, + 'sale' => $this->formatSaleData($sale) + ]); + } + + /** + * Formatear datos de la venta incluyendo números de serie + */ + private function formatSaleData(Sale $sale): array + { + return [ + 'id' => $sale->id, + 'invoice_number' => $sale->invoice_number, + 'total' => $sale->total, + 'subtotal' => $sale->subtotal, + 'tax' => $sale->tax, + 'payment_method' => $sale->payment_method, + 'status' => $sale->status, + 'created_at' => $sale->created_at, + 'user' => $sale->user ? [ + 'id' => $sale->user->id, + 'name' => $sale->user->name, + 'email' => $sale->user->email, + ] : null, + 'items' => $sale->details->map(function ($detail) { + return [ + 'id' => $detail->id, + 'product_name' => $detail->product_name, + 'quantity' => $detail->quantity, + 'unit_price' => $detail->unit_price, + 'subtotal' => $detail->subtotal, + 'category' => $detail->inventory->category->name ?? null, + 'sku' => $detail->inventory->sku ?? null, + // Números de serie vendidos + 'serial_numbers' => $detail->serials->map(function ($serial) { + return [ + 'serial_number' => $serial->serial_number, + 'status' => $serial->status, + ]; + })->toArray(), + ]; + })->toArray(), + ]; + } +} diff --git a/app/Http/Controllers/App/InventorySerialController.php b/app/Http/Controllers/App/InventorySerialController.php new file mode 100644 index 0000000..f4ba8d4 --- /dev/null +++ b/app/Http/Controllers/App/InventorySerialController.php @@ -0,0 +1,171 @@ + + * @version 1.0.0 + */ +class InventorySerialController extends Controller +{ + /** + * Listar seriales de un producto + */ + public function index(Inventory $inventory, Request $request) + { + $query = $inventory->serials(); + + if ($request->has('status')) { + $query->where('status', $request->status); + } + + if ($request->has('q')) { + $query->where('serial_number', 'like', "%{$request->q}%"); + } + + $serials = $query->orderBy('created_at', 'desc') + ->paginate(config('app.pagination')); + + return ApiResponse::OK->response([ + 'serials' => $serials, + 'inventory' => $inventory->load('category'), + ]); + } + + /** + * Mostrar un serial específico + */ + public function show(Inventory $inventory, InventorySerial $serial) + { + // Verificar que el serial pertenece al inventario + if ($serial->inventory_id !== $inventory->id) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Serial no encontrado para este inventario' + ]); + } + + return ApiResponse::OK->response([ + 'serial' => $serial->load('saleDetail'), + 'inventory' => $inventory->load('category'), + ]); + } + + /** + * Crear un nuevo serial + */ + public function store(Inventory $inventory, Request $request) + { + $request->validate([ + 'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'], + 'notes' => ['nullable', 'string'], + ]); + + $serial = InventorySerial::create([ + 'inventory_id' => $inventory->id, + 'serial_number' => $request->serial_number, + 'status' => 'disponible', + 'notes' => $request->notes, + ]); + + // Sincronizar stock + $inventory->syncStock(); + + return ApiResponse::CREATED->response([ + 'serial' => $serial, + 'inventory' => $inventory->fresh(), + ]); + } + + /** + * Actualizar un serial + */ + public function update(Inventory $inventory, InventorySerial $serial, Request $request) + { + // Verificar que el serial pertenece al inventario + if ($serial->inventory_id !== $inventory->id) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Serial no encontrado para este inventario' + ]); + } + + $request->validate([ + 'serial_number' => ['sometimes', 'string', 'unique:inventory_serials,serial_number,' . $serial->id], + 'status' => ['sometimes', 'in:disponible,vendido,dañado,reservado'], + 'notes' => ['nullable', 'string'], + ]); + + $serial->update($request->only(['serial_number', 'status', 'notes'])); + + // Sincronizar stock del inventario + $inventory->syncStock(); + + return ApiResponse::OK->response([ + 'serial' => $serial->fresh(), + 'inventory' => $inventory->fresh(), + ]); + } + + /** + * Eliminar un serial + */ + public function destroy(Inventory $inventory, InventorySerial $serial) + { + // Verificar que el serial pertenece al inventario + if ($serial->inventory_id !== $inventory->id) { + return ApiResponse::NOT_FOUND->response([ + 'message' => 'Serial no encontrado para este inventario' + ]); + } + + $serial->delete(); + + // Sincronizar stock + $inventory->syncStock(); + + return ApiResponse::OK->response([ + 'message' => 'Serial eliminado exitosamente', + 'inventory' => $inventory->fresh(), + ]); + } + + /** + * Importar múltiples seriales + */ + public function bulkStore(Inventory $inventory, Request $request) + { + $request->validate([ + 'serial_numbers' => ['required', 'array', 'min:1'], + 'serial_numbers.*' => ['required', 'string', 'unique:inventory_serials,serial_number'], + ]); + + $created = []; + + foreach ($request->serial_numbers as $serialNumber) { + $serial = InventorySerial::create([ + 'inventory_id' => $inventory->id, + 'serial_number' => $serialNumber, + 'status' => 'disponible', + ]); + + $created[] = $serial; + } + + // Sincronizar stock + $inventory->syncStock(); + + return ApiResponse::CREATED->response([ + 'serials' => $created, + 'count' => count($created), + 'inventory' => $inventory->fresh(), + ]); + } +} diff --git a/app/Http/Controllers/App/SaleController.php b/app/Http/Controllers/App/SaleController.php index ec2aeba..5cbfa37 100644 --- a/app/Http/Controllers/App/SaleController.php +++ b/app/Http/Controllers/App/SaleController.php @@ -17,7 +17,7 @@ public function __construct( public function index(Request $request) { - $sales = Sale::with(['details.inventory', 'user']) + $sales = Sale::with(['details.inventory', 'user', 'client']) ->orderBy('created_at', 'desc'); if ($request->has('q') && $request->q) { @@ -43,7 +43,7 @@ public function index(Request $request) public function show(Sale $sale) { return ApiResponse::OK->response([ - 'model' => $sale->load(['details.inventory', 'user']) + 'model' => $sale->load(['details.inventory', 'user', 'client']) ]); } diff --git a/app/Http/Requests/App/SaleStoreRequest.php b/app/Http/Requests/App/SaleStoreRequest.php index 7ea6bf7..e79b826 100644 --- a/app/Http/Requests/App/SaleStoreRequest.php +++ b/app/Http/Requests/App/SaleStoreRequest.php @@ -37,7 +37,9 @@ public function rules(): array 'items.*.quantity' => ['required', 'integer', 'min:1'], 'items.*.unit_price' => ['required', 'numeric', 'min:0'], 'items.*.subtotal' => ['required', 'numeric', 'min:0'], - ]; + 'items.*.serial_numbers' => ['nullable', 'array'], + 'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'], + ]; } /** diff --git a/app/Imports/ProductsImport.php b/app/Imports/ProductsImport.php index e1db2e7..49fc27e 100644 --- a/app/Imports/ProductsImport.php +++ b/app/Imports/ProductsImport.php @@ -6,6 +6,7 @@ use App\Models\Price; use App\Models\Category; use App\Http\Requests\App\InventoryImportRequest; +use App\Models\InventorySerial; use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithValidation; @@ -48,6 +49,7 @@ public function map($row): array 'costo' => $row['costo'] ?? null, 'precio_venta' => $row['precio_venta'] ?? null, 'impuesto' => $row['impuesto'] ?? null, + 'numeros_serie' => $row['numeros_serie'] ?? null, // Nueva columna: separados por comas ]; } @@ -82,13 +84,13 @@ public function model(array $row) $categoryId = $category->id; } - // Crear el producto en inventario (solo con campos específicos) + // Crear el producto en inventario $inventory = new Inventory(); $inventory->name = trim($row['nombre']); $inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $inventory->category_id = $categoryId; - $inventory->stock = (int) $row['stock']; + $inventory->stock = 0; // Se calculará automáticamente $inventory->is_active = true; $inventory->save(); @@ -100,6 +102,37 @@ public function model(array $row) 'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, ]); + // Crear números de serie si se proporcionan + if (!empty($row['numeros_serie'])) { + $serials = explode(',', $row['numeros_serie']); + + foreach ($serials as $serial) { + $serial = trim($serial); + + if (!empty($serial)) { + InventorySerial::create([ + 'inventory_id' => $inventory->id, + 'serial_number' => $serial, + 'status' => 'disponible', + ]); + } + } + } else { + // Si no se proporcionan seriales, generar automáticamente + $stockQuantity = (int) $row['stock']; + + for ($i = 1; $i <= $stockQuantity; $i++) { + InventorySerial::create([ + 'inventory_id' => $inventory->id, + 'serial_number' => $inventory->sku . '-' . str_pad($i, 4, '0', STR_PAD_LEFT), + 'status' => 'disponible', + ]); + } + } + + // Sincronizar stock + $inventory->syncStock(); + $this->imported++; return $inventory; diff --git a/app/Models/Client.php b/app/Models/Client.php index 7651a07..cc029e7 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -10,5 +10,14 @@ class Client extends Model 'phone', 'address', 'rfc', + 'razon_social', + 'regimen_fiscal', + 'cp_fiscal', + 'uso_cfdi', ]; + + public function sales() + { + return $this->hasMany(Sale::class); + } } diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index b119568..58aac4e 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -37,4 +37,34 @@ public function price() { return $this->hasOne(Price::class); } + + public function serials() + { + return $this->hasMany(InventorySerial::class); + } + + /** + * Obtener seriales disponibles + */ + public function availableSerials() + { + return $this->hasMany(InventorySerial::class) + ->where('status', 'disponible'); + } + + /** + * Calcular stock basado en seriales disponibles + */ + public function getAvailableStockAttribute(): int + { + return $this->availableSerials()->count(); + } + + /** + * Sincronizar el campo stock con los seriales disponibles + */ + public function syncStock(): void + { + $this->update(['stock' => $this->getAvailableStockAttribute()]); + } } diff --git a/app/Models/InventorySerial.php b/app/Models/InventorySerial.php new file mode 100644 index 0000000..1410154 --- /dev/null +++ b/app/Models/InventorySerial.php @@ -0,0 +1,64 @@ + + * @version 1.0.0 + */ +class InventorySerial extends Model +{ + protected $fillable = [ + 'inventory_id', + 'serial_number', + 'status', + 'sale_detail_id', + 'notes', + ]; + + protected $casts = [ + 'status' => 'string', + ]; + + public function inventory() + { + return $this->belongsTo(Inventory::class); + } + + public function saleDetail() + { + return $this->belongsTo(SaleDetail::class); + } + + /** + * Verificar si el serial está disponible + */ + public function isAvailable(): bool + { + return $this->status === 'disponible'; + } + + /** + * Marcar como vendido + */ + public function markAsSold(int $saleDetailId): void + { + $this->update([ + 'status' => 'vendido', + 'sale_detail_id' => $saleDetailId, + ]); + } + + /** + * Marcar como disponible (ej: cancelación de venta) + */ + public function markAsAvailable(): void + { + $this->update([ + 'status' => 'disponible', + 'sale_detail_id' => null, + ]); + } +} diff --git a/app/Models/Sale.php b/app/Models/Sale.php index 9f73266..8febc95 100644 --- a/app/Models/Sale.php +++ b/app/Models/Sale.php @@ -17,6 +17,7 @@ class Sale extends Model { protected $fillable = [ 'user_id', + 'client_id', 'cash_register_id', 'invoice_number', 'subtotal', @@ -50,4 +51,9 @@ public function cashRegister() { return $this->belongsTo(CashRegister::class); } + + public function client() + { + return $this->belongsTo(Client::class); + } } diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 995eafc..0972e5f 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -38,4 +38,17 @@ public function inventory() { return $this->belongsTo(Inventory::class); } + + public function serials() + { + return $this->hasMany(InventorySerial::class); + } + + /** + * Obtener números de serie vendidos + */ + public function getSerialNumbersAttribute(): array + { + return $this->serials()->pluck('serial_number')->toArray(); + } } diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 8931e3e..09a730a 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -4,6 +4,7 @@ use App\Models\Sale; use App\Models\SaleDetail; use App\Models\Inventory; +use App\Models\InventorySerial; use Illuminate\Support\Facades\DB; class SaleService @@ -38,27 +39,60 @@ public function createSale(array $data) 'status' => $data['status'] ?? 'completed', ]); - // 2. Crear los detalles de la venta y actualizar stock + // 2. Crear los detalles de la venta y asignar seriales foreach ($data['items'] as $item) { // Crear detalle de venta - SaleDetail::create([ + $saleDetail = SaleDetail::create([ 'sale_id' => $sale->id, 'inventory_id' => $item['inventory_id'], 'product_name' => $item['product_name'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'subtotal' => $item['subtotal'], + 'serial_numbers' => $item['serial_numbers'] ?? null, // Si vienen del frontend ]); - // Descontar del stock + // Obtener el inventario $inventory = Inventory::find($item['inventory_id']); + if ($inventory) { - $inventory->decrement('stock', $item['quantity']); + // Si se proporcionaron números de serie específicos + if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { + foreach ($item['serial_numbers'] as $serialNumber) { + $serial = InventorySerial::where('inventory_id', $inventory->id) + ->where('serial_number', $serialNumber) + ->where('status', 'disponible') + ->first(); + + if ($serial) { + $serial->markAsSold($saleDetail->id); + } else { + throw new \Exception("Serial {$serialNumber} no disponible"); + } + } + } else { + // Asignar automáticamente los primeros N seriales disponibles + $serials = InventorySerial::where('inventory_id', $inventory->id) + ->where('status', 'disponible') + ->limit($item['quantity']) + ->get(); + + if ($serials->count() < $item['quantity']) { + throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}"); + } + + foreach ($serials as $serial) { + $serial->markAsSold($saleDetail->id); + } + } + + // Sincronizar el stock + $inventory->syncStock(); } } // 3. Retornar la venta con sus relaciones cargadas - return $sale->load(['details.inventory', 'user']); + return $sale->load(['details.inventory', 'details.serials', 'user']); }); } @@ -74,18 +108,22 @@ public function cancelSale(Sale $sale) throw new \Exception('Solo se pueden cancelar ventas completadas.'); } - // Restaurar stock de cada producto + // Restaurar seriales a disponible foreach ($sale->details as $detail) { - $inventory = Inventory::find($detail->inventory_id); - if ($inventory) { - $inventory->increment('stock', $detail->quantity); + $serials = InventorySerial::where('sale_detail_id', $detail->id)->get(); + + foreach ($serials as $serial) { + $serial->markAsAvailable(); } + + // Sincronizar stock + $detail->inventory->syncStock(); } // Marcar venta como cancelada $sale->update(['status' => 'cancelled']); - return $sale->fresh(['details.inventory', 'user']); + return $sale->fresh(['details.inventory', 'details.serials', 'user']); }); } diff --git a/database/migrations/2025_12_30_151510_create_sale_details_table.php b/database/migrations/2025_12_30_151510_create_sale_details_table.php index 4d5d331..336cd36 100644 --- a/database/migrations/2025_12_30_151510_create_sale_details_table.php +++ b/database/migrations/2025_12_30_151510_create_sale_details_table.php @@ -19,6 +19,7 @@ public function up(): void $table->integer('quantity'); $table->decimal('unit_price', 10, 2); $table->decimal('subtotal', 10, 2); + $table->json('serial_numbers')->nullable(); $table->timestamps(); }); } diff --git a/database/migrations/2026_01_16_162145_create_inventory_serials_table.php b/database/migrations/2026_01_16_162145_create_inventory_serials_table.php new file mode 100644 index 0000000..435f942 --- /dev/null +++ b/database/migrations/2026_01_16_162145_create_inventory_serials_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('inventory_id')->constrained('inventories')->onDelete('cascade'); + $table->string('serial_number')->unique(); + $table->enum('status', ['disponible', 'vendido'])->default('disponible'); + $table->foreignId('sale_detail_id')->nullable()->constrained('sale_details')->onDelete('set null'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['inventory_id', 'status']); + $table->index('serial_number'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_serials'); + } +}; diff --git a/database/migrations/2026_01_16_195146_add_client_id_to_sales_table.php b/database/migrations/2026_01_16_195146_add_client_id_to_sales_table.php new file mode 100644 index 0000000..f46d409 --- /dev/null +++ b/database/migrations/2026_01_16_195146_add_client_id_to_sales_table.php @@ -0,0 +1,46 @@ +foreignId('client_id')->nullable()->after('user_id')->constrained()->onDelete('set null'); + }); + + Schema::table('clients', function (Blueprint $table) { + $table->string('rfc', 13)->nullable()->change(); + + // Datos fiscales para facturación + $table->string('razon_social')->nullable()->after('rfc'); + $table->string('regimen_fiscal')->nullable()->after('razon_social'); + $table->string('cp_fiscal', 5)->nullable()->after('regimen_fiscal'); + $table->string('uso_cfdi')->nullable()->after('cp_fiscal'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sales', function (Blueprint $table) { + $table->dropForeign(['client_id']); + $table->dropColumn('client_id'); + }); + + Schema::table('clients', function (Blueprint $table) { + $table->dropColumn(['razon_social', 'regimen_fiscal', 'cp_fiscal', 'uso_cfdi']); + + // Revertimos el cambio en RFC a su estado original (varchar 255) + $table->string('rfc', 255)->comment('')->nullable()->change(); + }); + } +}; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index d7e0a22..0f2dae5 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -9,6 +9,7 @@ use App\Models\PermissionType; use App\Models\Role; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Spatie\Permission\Models\Permission; @@ -28,6 +29,19 @@ class RoleSeeder extends Seeder */ public function run(): void { + + // Limpiar tablas de permisos para poder re-ejecutar + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + DB::statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::table('role_has_permissions')->truncate(); + DB::table('model_has_permissions')->truncate(); + DB::table('model_has_roles')->truncate(); + DB::table('permissions')->truncate(); + DB::table('roles')->truncate(); + DB::table('permission_types')->truncate(); + DB::statement('SET FOREIGN_KEY_CHECKS=1;'); + $users = PermissionType::create([ 'name' => 'Usuarios' ]); @@ -95,7 +109,13 @@ public function run(): void 'name' => 'Inventario' ]); - $inventoryIndex = $this->onIndex('inventario', 'Mostrar datos', $inventoryType, 'api'); + + $inventoryIndex = $this->onIndex('inventario', 'Mostrar datos', $inventoryType, 'api'); + $inventoryCreate = $this->onCreate('inventario', 'Crear registros', $inventoryType, 'api'); + $inventoryEdit = $this->onEdit('inventario', 'Actualizar registro', $inventoryType, 'api'); + $inventoryDestroy = $this->onDestroy('inventario', 'Eliminar registro', $inventoryType, 'api'); + $inventoryImport = $this->onPermission('inventario.import', 'Importar productos desde Excel', $inventoryType, 'api'); + // Permisos de Clientes $clientsType = PermissionType::create([ @@ -146,6 +166,9 @@ public function run(): void $salesCreate, $salesCancel, $inventoryIndex, + $inventoryCreate, + $inventoryEdit, + $inventoryDestroy, $clientIndex, $clientCreate, $clientEdit, @@ -168,9 +191,9 @@ public function run(): void $salesCreate, // Crear ventas // Inventario (solo lectura) $inventoryIndex, // Listar productos + $inventoryImport, // Importar productos // Clientes $clientIndex, // Buscar clientes - $clientCreate // Crear clientes ); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index e9221be..0480f66 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -1,4 +1,7 @@ - 'Developer', - 'paternal' => 'Golsystems', - 'maternal' => 'Dev', - 'email' => $developer->email, - 'password' => $developer->hash, - ])->assignRole(__('developer')); + User::firstOrCreate( + ['email' => $developer->email], + [ + 'name' => 'Developer', + 'paternal' => 'Golsystems', + 'maternal' => 'Dev', + 'password' => $developer->hash, + ] + )->assignRole('developer'); $admin = UserSecureSupport::create('admin@golsystems.com'); - User::create([ - 'name' => 'Admin', - 'paternal' => 'Golsystems', - 'maternal' => 'Dev', - 'email' => $admin->email, - 'password' => 'SoyAdmin123..', - ])->assignRole(__('admin')); + User::firstOrCreate( + ['email' => $admin->email], + [ + 'name' => 'Admin', + 'paternal' => 'Golsystems', + 'maternal' => 'Dev', + 'password' => 'SoyAdmin123..', + ] + )->assignRole('admin'); $operadorPdv = UserSecureSupport::create('opv@golsystems.com'); - User::create([ - 'name' => 'Operador PDV', - 'paternal' => 'Golsystems', - 'maternal' => 'Dev', - 'email' => $operadorPdv->email, - 'password' => $operadorPdv->hash, - ])->assignRole(__('operador_pdv')); + User::firstOrCreate( + ['email' => $operadorPdv->email], + [ + 'name' => 'Operador PDV', + 'paternal' => 'Golsystems', + 'maternal' => 'Dev', + 'password' => $operadorPdv->hash, + ] + )->assignRole('operador_pdv'); } } diff --git a/routes/api.php b/routes/api.php index 04193aa..0a8f3a8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,8 @@ use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\SaleController; +use App\Http\Controllers\App\FacturaDataController; +use App\Http\Controllers\App\InventorySerialController; use Illuminate\Support\Facades\Route; /** @@ -32,6 +34,11 @@ Route::post('inventario/import', [InventoryController::class, 'import']); Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']); + // Números de serie + Route::resource('inventario.serials', InventorySerialController::class) + ->except(['create', 'edit']); + Route::post('inventario/{inventario}/serials/bulk', [InventorySerialController::class, 'bulkStore']); + //CATEGORIAS Route::resource('categorias', CategoryController::class); @@ -62,4 +69,8 @@ }); /** Rutas públicas */ -// Tus rutas públicas +// Formulario de datos fiscales para facturación +Route::prefix('facturacion')->group(function () { + Route::get('/{invoiceNumber}', [FacturaDataController::class, 'show']); + Route::post('/{invoiceNumber}', [FacturaDataController::class, 'store']); +});