feat: agregar gestión de números de serie en movimientos de inventario
This commit is contained in:
parent
c1d6f58697
commit
da5acc11c5
@ -69,7 +69,7 @@ public function index(Request $request)
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier'])
|
||||
$movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials'])
|
||||
->find($id);
|
||||
|
||||
if (!$movement) {
|
||||
|
||||
@ -22,6 +22,8 @@ public function rules(): array
|
||||
'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id',
|
||||
'invoice_reference' => 'sometimes|nullable|string|max:255',
|
||||
'notes' => 'sometimes|nullable|string|max:500',
|
||||
'serial_numbers' => ['nullable', 'array'],
|
||||
'serial_numbers.*' => ['string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -42,6 +44,7 @@ public function messages(): array
|
||||
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
|
||||
'notes.max' => 'Las notas no pueden exceder 1000 caracteres',
|
||||
'invoice_reference.max' => 'La referencia de factura no puede exceder 255 caracteres',
|
||||
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +48,10 @@ public function user() {
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function serials() {
|
||||
return $this->hasMany(InventorySerial::class, 'movement_id');
|
||||
}
|
||||
|
||||
// Relación polimórfica para la referencia
|
||||
public function reference() {
|
||||
return $this->morphTo();
|
||||
|
||||
@ -13,6 +13,7 @@ class InventorySerial extends Model
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'warehouse_id',
|
||||
'movement_id',
|
||||
'serial_number',
|
||||
'status',
|
||||
'sale_detail_id',
|
||||
@ -34,6 +35,11 @@ public function warehouse()
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function movement()
|
||||
{
|
||||
return $this->belongsTo(InventoryMovement::class);
|
||||
}
|
||||
|
||||
public function saleDetail()
|
||||
{
|
||||
return $this->belongsTo(SaleDetail::class);
|
||||
|
||||
@ -68,16 +68,35 @@ public function entry(array $data): InventoryMovement
|
||||
// Actualizar costo en prices
|
||||
$this->updateProductCost($inventory, $newCost);
|
||||
|
||||
// Solo crear seriales si se proporcionaron
|
||||
// Registrar movimiento PRIMERO
|
||||
$movement = InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => null,
|
||||
'warehouse_to_id' => $warehouse->id,
|
||||
'movement_type' => 'entry',
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'supplier_id' => $data['supplier_id'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'invoice_reference' => $data['invoice_reference'] ?? null,
|
||||
]);
|
||||
|
||||
// Crear seriales DESPUÉS del movimiento (para asignar movement_id)
|
||||
if ($requiresSerials && !empty($serialNumbers)) {
|
||||
$serials = [];
|
||||
foreach ($serialNumbers as $serialNumber) {
|
||||
InventorySerial::create([
|
||||
$serials[] = [
|
||||
'inventory_id' => $inventory->id,
|
||||
'movement_id' => $movement->id, // Ahora tenemos el ID
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'serial_number' => trim($serialNumber),
|
||||
'status' => 'disponible',
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
InventorySerial::insert($serials);
|
||||
|
||||
// Activar track_serials si no estaba activo
|
||||
if (!$inventory->track_serials) {
|
||||
@ -91,19 +110,7 @@ public function entry(array $data): InventoryMovement
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||
}
|
||||
|
||||
// Registrar movimiento
|
||||
return InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => null,
|
||||
'warehouse_to_id' => $warehouse->id,
|
||||
'movement_type' => 'entry',
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'supplier_id' => $data['supplier_id'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'invoice_reference' => $data['invoice_reference'] ?? null,
|
||||
]);
|
||||
return $movement;
|
||||
});
|
||||
}
|
||||
|
||||
@ -160,29 +167,7 @@ public function bulkEntry(array $data): array
|
||||
// Actualizar costo en prices
|
||||
$this->updateProductCost($inventory, $newCost);
|
||||
|
||||
// Crear seriales si se proporcionan
|
||||
if (!empty($serialNumbers)) {
|
||||
foreach ($serialNumbers as $serialNumber) {
|
||||
InventorySerial::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'serial_number' => trim($serialNumber),
|
||||
'status' => 'disponible',
|
||||
]);
|
||||
}
|
||||
|
||||
// Activar track_serials si no estaba activo
|
||||
if (!$inventory->track_serials) {
|
||||
$inventory->update(['track_serials' => true]);
|
||||
}
|
||||
|
||||
// Sincronizar stock desde seriales
|
||||
$inventory->syncStock();
|
||||
} else {
|
||||
// Sin seriales, actualizar stock manualmente
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||
}
|
||||
|
||||
// Crear movimiento PRIMERO
|
||||
$movement = InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => null,
|
||||
@ -196,6 +181,34 @@ public function bulkEntry(array $data): array
|
||||
'invoice_reference' => $data['invoice_reference'],
|
||||
]);
|
||||
|
||||
// Crear seriales DESPUÉS (para asignar movement_id)
|
||||
if (!empty($serialNumbers)) {
|
||||
$serials = [];
|
||||
foreach ($serialNumbers as $serialNumber) {
|
||||
$serials[] = [
|
||||
'inventory_id' => $inventory->id,
|
||||
'movement_id' => $movement->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'serial_number' => trim($serialNumber),
|
||||
'status' => 'disponible',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
InventorySerial::insert($serials);
|
||||
|
||||
// Activar track_serials si no estaba activo
|
||||
if (!$inventory->track_serials) {
|
||||
$inventory->update(['track_serials' => true]);
|
||||
}
|
||||
|
||||
// Sincronizar stock desde seriales
|
||||
$inventory->syncStock();
|
||||
} else {
|
||||
// Sin seriales, actualizar stock manualmente
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||
}
|
||||
|
||||
$movements[] = $movement->load(['inventory', 'warehouseTo']);
|
||||
}
|
||||
|
||||
@ -683,7 +696,7 @@ public function updateMovement(int $movementId, array $data): InventoryMovement
|
||||
// 4. Actualizar el registro del movimiento
|
||||
$movement->update($updateData);
|
||||
|
||||
return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user']);
|
||||
return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'serials']);
|
||||
});
|
||||
}
|
||||
|
||||
@ -697,6 +710,11 @@ protected function prepareUpdateData(InventoryMovement $movement, array $data):
|
||||
'notes' => $data['notes'] ?? $movement->notes,
|
||||
];
|
||||
|
||||
// Pasar serial_numbers si fueron proporcionados
|
||||
if (!empty($data['serial_numbers'])) {
|
||||
$updateData['serial_numbers'] = $data['serial_numbers'];
|
||||
}
|
||||
|
||||
// Campos específicos por tipo de movimiento
|
||||
if ($movement->movement_type === 'entry') {
|
||||
$updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost;
|
||||
@ -720,6 +738,7 @@ protected function revertMovement(InventoryMovement $movement): void
|
||||
switch ($movement->movement_type) {
|
||||
case 'entry':
|
||||
// Revertir entrada: restar del almacén destino
|
||||
// NOTA: Los seriales se manejan en updateMovementSerials(), no aquí
|
||||
$this->updateWarehouseStock(
|
||||
$movement->inventory_id,
|
||||
$movement->warehouse_to_id,
|
||||
@ -778,6 +797,9 @@ protected function applyEntryUpdate(InventoryMovement $movement, array $data): v
|
||||
|
||||
// Aplicar nuevo stock
|
||||
$this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity);
|
||||
|
||||
// Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad)
|
||||
$this->updateMovementSerials($movement, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -851,4 +873,115 @@ public function syncStockFromSerials(Inventory $inventory): void
|
||||
->whereNotIn('warehouse_id', $stockByWarehouse->keys())
|
||||
->update(['stock' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar seriales de un movimiento (revertir antiguos y aplicar nuevos)
|
||||
*/
|
||||
protected function updateMovementSerials(InventoryMovement $movement, array $data): void
|
||||
{
|
||||
$inventory = $movement->inventory;
|
||||
|
||||
if (!$inventory->track_serials) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Solo aplicar a movimientos de entrada
|
||||
if ($movement->movement_type !== 'entry') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si la cantidad cambió
|
||||
$quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity;
|
||||
$serialsProvided = !empty($data['serial_numbers']);
|
||||
|
||||
// Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada
|
||||
if (!$quantityChanged && !$serialsProvided) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si cambió la cantidad pero NO se proporcionaron seriales, lanzar error
|
||||
if ($quantityChanged && !$serialsProvided) {
|
||||
throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.');
|
||||
}
|
||||
|
||||
// Si llegamos aquí, hay que actualizar los seriales
|
||||
// Validar que TODOS los seriales actuales estén disponibles
|
||||
$totalSerials = InventorySerial::where('movement_id', $movement->id)->count();
|
||||
$availableSerials = InventorySerial::where('movement_id', $movement->id)
|
||||
->where('status', 'disponible')
|
||||
->count();
|
||||
|
||||
if ($totalSerials > $availableSerials) {
|
||||
throw new \Exception(
|
||||
'No se puede editar este movimiento porque algunos seriales ya fueron vendidos o devueltos.'
|
||||
);
|
||||
}
|
||||
|
||||
// Validar que la cantidad de nuevos seriales coincida con la cantidad
|
||||
$quantity = $data['quantity'] ?? $movement->quantity;
|
||||
if (count($data['serial_numbers']) !== (int)$quantity) {
|
||||
throw new \Exception('La cantidad de números de serie debe coincidir con la cantidad del movimiento.');
|
||||
}
|
||||
|
||||
// Validar que no existan seriales duplicados en los nuevos
|
||||
foreach ($data['serial_numbers'] as $serialNumber) {
|
||||
$exists = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('serial_number', $serialNumber)
|
||||
->where('movement_id', '!=', $movement->id) // Excluir los del movimiento actual
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto.");
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar seriales antiguos
|
||||
InventorySerial::where('movement_id', $movement->id)
|
||||
->where('status', 'disponible')
|
||||
->delete();
|
||||
|
||||
// Crear nuevos seriales
|
||||
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
|
||||
$serials = [];
|
||||
|
||||
foreach ($data['serial_numbers'] as $serialNumber) {
|
||||
$serials[] = [
|
||||
'inventory_id' => $inventory->id,
|
||||
'movement_id' => $movement->id,
|
||||
'serial_number' => $serialNumber,
|
||||
'warehouse_id' => $warehouseToId,
|
||||
'status' => 'disponible',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
InventorySerial::insert($serials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revertir seriales de un movimiento
|
||||
*/
|
||||
protected function revertSerials(InventoryMovement $movement): void
|
||||
{
|
||||
$inventory = $movement->inventory;
|
||||
|
||||
if (!$inventory->track_serials) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($movement->movement_type) {
|
||||
case 'entry':
|
||||
// Eliminar seriales creados por este movimiento (solo si están disponibles)
|
||||
InventorySerial::where('movement_id', $movement->id)
|
||||
->where('status', 'disponible')
|
||||
->delete();
|
||||
break;
|
||||
|
||||
case 'exit':
|
||||
case 'transfer':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ class WhatsAppService
|
||||
protected string $apiUrl;
|
||||
protected int $orgId;
|
||||
protected string $token;
|
||||
protected string $email = 'juan.zapata@golsystems.com.mx';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@ -124,15 +125,13 @@ public function sendInvoice(
|
||||
string $invoiceNumber,
|
||||
string $customerName
|
||||
): array {
|
||||
$userEmail = auth()->user()->email ?? config('mail.from.address');
|
||||
|
||||
// Enviar PDF
|
||||
$pdfResult = $this->sendDocument(
|
||||
phoneNumber: $phoneNumber,
|
||||
documentUrl: $pdfUrl,
|
||||
caption: "Factura {$invoiceNumber} - {$customerName}",
|
||||
filename: "Factura_{$invoiceNumber}.pdf",
|
||||
userEmail: $userEmail,
|
||||
userEmail: $this->email,
|
||||
ticket: $invoiceNumber,
|
||||
customerName: $customerName
|
||||
);
|
||||
@ -144,7 +143,7 @@ public function sendInvoice(
|
||||
documentUrl: $xmlUrl,
|
||||
caption: "XML - Factura {$invoiceNumber}",
|
||||
filename: "Factura_{$invoiceNumber}.xml",
|
||||
userEmail: $userEmail,
|
||||
userEmail: $this->email,
|
||||
ticket: "{$invoiceNumber}_XML",
|
||||
customerName: $customerName
|
||||
);
|
||||
@ -168,14 +167,12 @@ public function sendSaleTicket(
|
||||
string $saleNumber,
|
||||
string $customerName
|
||||
): array {
|
||||
$userEmail = auth()->user()->email ?? config('mail.from.address');
|
||||
|
||||
return $this->sendDocument(
|
||||
phoneNumber: $phoneNumber,
|
||||
documentUrl: $ticketUrl,
|
||||
caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.",
|
||||
filename: "Ticket_{$saleNumber}.pdf",
|
||||
userEmail: $userEmail,
|
||||
userEmail: $this->email,
|
||||
ticket: $saleNumber,
|
||||
customerName: $customerName
|
||||
);
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?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('inventory_serials', function (Blueprint $table) {
|
||||
$table->foreignId('movement_id')->nullable()->constrained('inventory_movements')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_serials', function (Blueprint $table) {
|
||||
$table->dropForeign(['movement_id']);
|
||||
$table->dropColumn('movement_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user