WIP: Serials

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-16 17:37:39 -06:00
parent 08871b8dde
commit 810aff1b0e
19 changed files with 738 additions and 49 deletions

View File

@ -15,11 +15,11 @@
/** /**
* Controlador de usuarios * Controlador de usuarios
* *
* Permite la administración de los usuarios en general. * Permite la administración de los usuarios en general.
* *
* @author Moisés Cortés C <moises.cortes@notsoweb.com> * @author Moisés Cortés C <moises.cortes@notsoweb.com>
* *
* @version 1.0.0 * @version 1.0.0
*/ */
class UserController extends Controller class UserController extends Controller
@ -29,7 +29,7 @@ class UserController extends Controller
*/ */
public function index() public function index()
{ {
$users = User::orderBy('name'); $users = User::orderBy('name')->where('id', '!=', 1);
QuerySupport::queryByKeys($users, ['name', 'email']); QuerySupport::queryByKeys($users, ['name', 'email']);
@ -152,7 +152,7 @@ public function activity(UserActivityRequest $request, User $user)
} }
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'models' => 'models' =>
$model->orderBy('created_at', 'desc') $model->orderBy('created_at', 'desc')
->paginate(config('app.pagination')) ->paginate(config('app.pagination'))
]); ]);

View File

@ -10,11 +10,24 @@ class ClientController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$clients = Client::where('name', 'LIKE', "%{$request->q}%") $query = Client::query();
->orderBy('name')
->paginate(config('app.pagination'));
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) public function show(Client $client)

View File

@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Client;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class FacturaDataController extends Controller
{
/**
* Muestra los datos de la venta para el formulario de facturación.
*/
public function show(string $invoiceNumber)
{
$sale = Sale::where('invoice_number', $invoiceNumber)
->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(),
];
}
}

View File

@ -0,0 +1,171 @@
<?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use App\Models\InventorySerial;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para gestión de números de serie
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @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(),
]);
}
}

View File

@ -17,7 +17,7 @@ public function __construct(
public function index(Request $request) public function index(Request $request)
{ {
$sales = Sale::with(['details.inventory', 'user']) $sales = Sale::with(['details.inventory', 'user', 'client'])
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
if ($request->has('q') && $request->q) { if ($request->has('q') && $request->q) {
@ -43,7 +43,7 @@ public function index(Request $request)
public function show(Sale $sale) public function show(Sale $sale)
{ {
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'model' => $sale->load(['details.inventory', 'user']) 'model' => $sale->load(['details.inventory', 'user', 'client'])
]); ]);
} }

View File

@ -37,7 +37,9 @@ public function rules(): array
'items.*.quantity' => ['required', 'integer', 'min:1'], 'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'], 'items.*.unit_price' => ['required', 'numeric', 'min:0'],
'items.*.subtotal' => ['required', 'numeric', 'min:0'], 'items.*.subtotal' => ['required', 'numeric', 'min:0'],
]; 'items.*.serial_numbers' => ['nullable', 'array'],
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
];
} }
/** /**

View File

@ -6,6 +6,7 @@
use App\Models\Price; use App\Models\Price;
use App\Models\Category; use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest; use App\Http\Requests\App\InventoryImportRequest;
use App\Models\InventorySerial;
use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
@ -48,6 +49,7 @@ public function map($row): array
'costo' => $row['costo'] ?? null, 'costo' => $row['costo'] ?? null,
'precio_venta' => $row['precio_venta'] ?? null, 'precio_venta' => $row['precio_venta'] ?? null,
'impuesto' => $row['impuesto'] ?? 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; $categoryId = $category->id;
} }
// Crear el producto en inventario (solo con campos específicos) // Crear el producto en inventario
$inventory = new Inventory(); $inventory = new Inventory();
$inventory->name = trim($row['nombre']); $inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId; $inventory->category_id = $categoryId;
$inventory->stock = (int) $row['stock']; $inventory->stock = 0; // Se calculará automáticamente
$inventory->is_active = true; $inventory->is_active = true;
$inventory->save(); $inventory->save();
@ -100,6 +102,37 @@ public function model(array $row)
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, '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++; $this->imported++;
return $inventory; return $inventory;

View File

@ -10,5 +10,14 @@ class Client extends Model
'phone', 'phone',
'address', 'address',
'rfc', 'rfc',
'razon_social',
'regimen_fiscal',
'cp_fiscal',
'uso_cfdi',
]; ];
public function sales()
{
return $this->hasMany(Sale::class);
}
} }

View File

@ -37,4 +37,34 @@ public function price()
{ {
return $this->hasOne(Price::class); 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()]);
}
} }

View File

@ -0,0 +1,64 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Modelo para números de serie de inventario
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @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,
]);
}
}

View File

@ -17,6 +17,7 @@ class Sale extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'client_id',
'cash_register_id', 'cash_register_id',
'invoice_number', 'invoice_number',
'subtotal', 'subtotal',
@ -50,4 +51,9 @@ public function cashRegister()
{ {
return $this->belongsTo(CashRegister::class); return $this->belongsTo(CashRegister::class);
} }
public function client()
{
return $this->belongsTo(Client::class);
}
} }

View File

@ -38,4 +38,17 @@ public function inventory()
{ {
return $this->belongsTo(Inventory::class); 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();
}
} }

View File

@ -4,6 +4,7 @@
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\Inventory; use App\Models\Inventory;
use App\Models\InventorySerial;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class SaleService class SaleService
@ -38,27 +39,60 @@ public function createSale(array $data)
'status' => $data['status'] ?? 'completed', '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) { foreach ($data['items'] as $item) {
// Crear detalle de venta // Crear detalle de venta
SaleDetail::create([ $saleDetail = SaleDetail::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'inventory_id' => $item['inventory_id'], 'inventory_id' => $item['inventory_id'],
'product_name' => $item['product_name'], 'product_name' => $item['product_name'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'], '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']); $inventory = Inventory::find($item['inventory_id']);
if ($inventory) { 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 // 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.'); throw new \Exception('Solo se pueden cancelar ventas completadas.');
} }
// Restaurar stock de cada producto // Restaurar seriales a disponible
foreach ($sale->details as $detail) { foreach ($sale->details as $detail) {
$inventory = Inventory::find($detail->inventory_id); $serials = InventorySerial::where('sale_detail_id', $detail->id)->get();
if ($inventory) {
$inventory->increment('stock', $detail->quantity); foreach ($serials as $serial) {
$serial->markAsAvailable();
} }
// Sincronizar stock
$detail->inventory->syncStock();
} }
// Marcar venta como cancelada // Marcar venta como cancelada
$sale->update(['status' => 'cancelled']); $sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.inventory', 'user']); return $sale->fresh(['details.inventory', 'details.serials', 'user']);
}); });
} }

View File

@ -19,6 +19,7 @@ public function up(): void
$table->integer('quantity'); $table->integer('quantity');
$table->decimal('unit_price', 10, 2); $table->decimal('unit_price', 10, 2);
$table->decimal('subtotal', 10, 2); $table->decimal('subtotal', 10, 2);
$table->json('serial_numbers')->nullable();
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('inventory_serials', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -9,6 +9,7 @@
use App\Models\PermissionType; use App\Models\PermissionType;
use App\Models\Role; use App\Models\Role;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Notsoweb\LaravelCore\Traits\MySql\RolePermission;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
@ -28,6 +29,19 @@ class RoleSeeder extends Seeder
*/ */
public function run(): void 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([ $users = PermissionType::create([
'name' => 'Usuarios' 'name' => 'Usuarios'
]); ]);
@ -95,7 +109,13 @@ public function run(): void
'name' => 'Inventario' '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 // Permisos de Clientes
$clientsType = PermissionType::create([ $clientsType = PermissionType::create([
@ -146,6 +166,9 @@ public function run(): void
$salesCreate, $salesCreate,
$salesCancel, $salesCancel,
$inventoryIndex, $inventoryIndex,
$inventoryCreate,
$inventoryEdit,
$inventoryDestroy,
$clientIndex, $clientIndex,
$clientCreate, $clientCreate,
$clientEdit, $clientEdit,
@ -168,9 +191,9 @@ public function run(): void
$salesCreate, // Crear ventas $salesCreate, // Crear ventas
// Inventario (solo lectura) // Inventario (solo lectura)
$inventoryIndex, // Listar productos $inventoryIndex, // Listar productos
$inventoryImport, // Importar productos
// Clientes // Clientes
$clientIndex, // Buscar clientes $clientIndex, // Buscar clientes
$clientCreate // Crear clientes
); );
} }
} }

View File

@ -1,4 +1,7 @@
<?php namespace Database\Seeders; <?php
namespace Database\Seeders;
/** /**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved * @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/ */
@ -23,32 +26,38 @@ public function run(): void
{ {
$developer = UserSecureSupport::create('developer@golsystems.com'); $developer = UserSecureSupport::create('developer@golsystems.com');
User::create([ User::firstOrCreate(
'name' => 'Developer', ['email' => $developer->email],
'paternal' => 'Golsystems', [
'maternal' => 'Dev', 'name' => 'Developer',
'email' => $developer->email, 'paternal' => 'Golsystems',
'password' => $developer->hash, 'maternal' => 'Dev',
])->assignRole(__('developer')); 'password' => $developer->hash,
]
)->assignRole('developer');
$admin = UserSecureSupport::create('admin@golsystems.com'); $admin = UserSecureSupport::create('admin@golsystems.com');
User::create([ User::firstOrCreate(
'name' => 'Admin', ['email' => $admin->email],
'paternal' => 'Golsystems', [
'maternal' => 'Dev', 'name' => 'Admin',
'email' => $admin->email, 'paternal' => 'Golsystems',
'password' => 'SoyAdmin123..', 'maternal' => 'Dev',
])->assignRole(__('admin')); 'password' => 'SoyAdmin123..',
]
)->assignRole('admin');
$operadorPdv = UserSecureSupport::create('opv@golsystems.com'); $operadorPdv = UserSecureSupport::create('opv@golsystems.com');
User::create([ User::firstOrCreate(
'name' => 'Operador PDV', ['email' => $operadorPdv->email],
'paternal' => 'Golsystems', [
'maternal' => 'Dev', 'name' => 'Operador PDV',
'email' => $operadorPdv->email, 'paternal' => 'Golsystems',
'password' => $operadorPdv->hash, 'maternal' => 'Dev',
])->assignRole(__('operador_pdv')); 'password' => $operadorPdv->hash,
]
)->assignRole('operador_pdv');
} }
} }

View File

@ -7,6 +7,8 @@
use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\PriceController;
use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\ReportController;
use App\Http\Controllers\App\SaleController; use App\Http\Controllers\App\SaleController;
use App\Http\Controllers\App\FacturaDataController;
use App\Http\Controllers\App\InventorySerialController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/** /**
@ -32,6 +34,11 @@
Route::post('inventario/import', [InventoryController::class, 'import']); Route::post('inventario/import', [InventoryController::class, 'import']);
Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']); 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 //CATEGORIAS
Route::resource('categorias', CategoryController::class); Route::resource('categorias', CategoryController::class);
@ -62,4 +69,8 @@
}); });
/** Rutas públicas */ /** 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']);
});