From ae77a0e1c5dabf26d3fe87c588984bb38d1638ce Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 18 Sep 2025 08:06:32 +0200 Subject: [PATCH] Improve the inventory page 3 --- frontend/src/api/types/inventory.ts | 92 +++- .../domain/inventory/AddStockModal.tsx | 146 ++++-- .../domain/inventory/BatchModal.tsx | 450 ++++++++++++++++++ ...temModal.tsx => CreateIngredientModal.tsx} | 60 +-- .../inventory/DeleteIngredientExample.tsx | 100 ---- .../inventory/DeleteIngredientModal.tsx | 11 +- .../domain/inventory/EditItemModal.tsx | 297 ------------ .../domain/inventory/HistoryModal.tsx | 244 ---------- .../domain/inventory/QuickViewModal.tsx | 166 ------- .../domain/inventory/ShowInfoModal.tsx | 303 ++++++++++++ .../domain/inventory/StockHistoryModal.tsx | 232 +++++++++ .../domain/inventory/StockLotsModal.tsx | 311 ------------ .../domain/inventory/UseStockModal.tsx | 213 --------- .../src/components/domain/inventory/index.ts | 11 +- .../components/ui/ProgressBar/ProgressBar.tsx | 62 ++- .../components/ui/StatusCard/StatusCard.tsx | 105 ++-- frontend/src/contexts/AuthContext.tsx | 15 - .../operations/inventory/InventoryPage.tsx | 254 +++++----- frontend/src/utils/date.ts | 21 +- services/inventory/app/models/inventory.py | 58 +-- .../app/repositories/ingredient_repository.py | 29 +- .../repositories/stock_movement_repository.py | 80 +++- .../app/repositories/stock_repository.py | 182 ++++++- services/inventory/app/schemas/inventory.py | 61 +-- .../app/services/inventory_alert_service.py | 205 +++++++- .../app/services/inventory_service.py | 17 +- services/inventory/migrations/alembic.ini | 2 +- .../versions/003_add_production_stage_enum.py | 114 +++++ .../004_move_storage_config_to_batch.py | 104 ++++ .../production_batch_repository.py | 95 ++-- shared/utils/batch_generator.py | 110 +++++ 31 files changed, 2376 insertions(+), 1774 deletions(-) create mode 100644 frontend/src/components/domain/inventory/BatchModal.tsx rename frontend/src/components/domain/inventory/{CreateItemModal.tsx => CreateIngredientModal.tsx} (82%) delete mode 100644 frontend/src/components/domain/inventory/DeleteIngredientExample.tsx delete mode 100644 frontend/src/components/domain/inventory/EditItemModal.tsx delete mode 100644 frontend/src/components/domain/inventory/HistoryModal.tsx delete mode 100644 frontend/src/components/domain/inventory/QuickViewModal.tsx create mode 100644 frontend/src/components/domain/inventory/ShowInfoModal.tsx create mode 100644 frontend/src/components/domain/inventory/StockHistoryModal.tsx delete mode 100644 frontend/src/components/domain/inventory/StockLotsModal.tsx delete mode 100644 frontend/src/components/domain/inventory/UseStockModal.tsx create mode 100644 services/inventory/migrations/versions/003_add_production_stage_enum.py create mode 100644 services/inventory/migrations/versions/004_move_storage_config_to_batch.py create mode 100644 shared/utils/batch_generator.py diff --git a/frontend/src/api/types/inventory.ts b/frontend/src/api/types/inventory.ts index 51569a98..7fd7aa8f 100644 --- a/frontend/src/api/types/inventory.ts +++ b/frontend/src/api/types/inventory.ts @@ -65,9 +65,7 @@ export interface IngredientCreate { low_stock_threshold: number; max_stock_level?: number; reorder_point: number; - shelf_life_days?: number; - requires_refrigeration?: boolean; - requires_freezing?: boolean; + shelf_life_days?: number; // Default shelf life only is_seasonal?: boolean; supplier_id?: string; average_cost?: number; @@ -89,13 +87,7 @@ export interface IngredientUpdate { reorder_point?: number; reorder_quantity?: number; max_stock_level?: number; - requires_refrigeration?: boolean; - requires_freezing?: boolean; - storage_temperature_min?: number; - storage_temperature_max?: number; - storage_humidity_max?: number; - shelf_life_days?: number; - storage_instructions?: string; + shelf_life_days?: number; // Default shelf life only is_active?: boolean; is_perishable?: boolean; is_seasonal?: boolean; @@ -121,13 +113,7 @@ export interface IngredientResponse { reorder_point: number; reorder_quantity: number; max_stock_level?: number; - requires_refrigeration: boolean; - requires_freezing: boolean; - storage_temperature_min?: number; - storage_temperature_max?: number; - storage_humidity_max?: number; - shelf_life_days?: number; - storage_instructions?: string; + shelf_life_days?: number; // Default shelf life only is_active: boolean; is_perishable: boolean; is_seasonal?: boolean; @@ -149,38 +135,81 @@ export interface IngredientResponse { // Stock Management Types export interface StockCreate { ingredient_id: string; + batch_number?: string; + lot_number?: string; + supplier_batch_ref?: string; + + // Production stage tracking production_stage?: ProductionStage; transformation_reference?: string; - quantity: number; - unit_price: number; + + current_quantity: number; + received_date?: string; expiration_date?: string; - batch_number?: string; - supplier_id?: string; - purchase_order_reference?: string; + best_before_date?: string; // Stage-specific expiration fields original_expiration_date?: string; transformation_date?: string; final_expiration_date?: string; - notes?: string; + unit_cost?: number; + storage_location?: string; + warehouse_zone?: string; + shelf_position?: string; + + quality_status?: string; + + // Batch-specific storage requirements + requires_refrigeration?: boolean; + requires_freezing?: boolean; + storage_temperature_min?: number; + storage_temperature_max?: number; + storage_humidity_max?: number; + shelf_life_days?: number; + storage_instructions?: string; + + // Optional supplier reference + supplier_id?: string; } export interface StockUpdate { + batch_number?: string; + lot_number?: string; + supplier_batch_ref?: string; + + // Production stage tracking production_stage?: ProductionStage; transformation_reference?: string; - quantity?: number; - unit_price?: number; + + current_quantity?: number; + reserved_quantity?: number; + received_date?: string; expiration_date?: string; - batch_number?: string; + best_before_date?: string; // Stage-specific expiration fields original_expiration_date?: string; transformation_date?: string; final_expiration_date?: string; + unit_cost?: number; + storage_location?: string; + warehouse_zone?: string; + shelf_position?: string; + + quality_status?: string; notes?: string; is_available?: boolean; + + // Batch-specific storage requirements + requires_refrigeration?: boolean; + requires_freezing?: boolean; + storage_temperature_min?: number; + storage_temperature_max?: number; + storage_humidity_max?: number; + shelf_life_days?: number; + storage_instructions?: string; } export interface StockResponse { @@ -209,6 +238,7 @@ export interface StockResponse { batch_number?: string; supplier_id?: string; purchase_order_reference?: string; + storage_location?: string; // Stage-specific expiration fields original_expiration_date?: string; @@ -219,6 +249,16 @@ export interface StockResponse { is_available: boolean; is_expired: boolean; days_until_expiry?: number; + + // Batch-specific storage requirements + requires_refrigeration: boolean; + requires_freezing: boolean; + storage_temperature_min?: number; + storage_temperature_max?: number; + storage_humidity_max?: number; + shelf_life_days?: number; + storage_instructions?: string; + created_at: string; updated_at: string; created_by?: string; diff --git a/frontend/src/components/domain/inventory/AddStockModal.tsx b/frontend/src/components/domain/inventory/AddStockModal.tsx index db07e527..71178f0a 100644 --- a/frontend/src/components/domain/inventory/AddStockModal.tsx +++ b/frontend/src/components/domain/inventory/AddStockModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Plus, Package, Euro, Calendar, FileText } from 'lucide-react'; +import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react'; import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { IngredientResponse, StockCreate } from '../../../api/types/inventory'; import { Button } from '../../ui/Button'; @@ -24,35 +24,51 @@ export const AddStockModal: React.FC = ({ }) => { const [formData, setFormData] = useState>({ ingredient_id: ingredient.id, - quantity: 0, - unit_price: Number(ingredient.average_cost) || 0, + current_quantity: 0, + unit_cost: Number(ingredient.average_cost) || 0, expiration_date: '', batch_number: '', supplier_id: '', - purchase_order_reference: '', + storage_location: '', + requires_refrigeration: false, + requires_freezing: false, + storage_temperature_min: undefined, + storage_temperature_max: undefined, + storage_humidity_max: undefined, + shelf_life_days: undefined, + storage_instructions: '', notes: '' }); const [loading, setLoading] = useState(false); const [mode, setMode] = useState<'overview' | 'edit'>('edit'); - const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => { - const fields = ['quantity', 'unit_price', 'expiration_date', 'batch_number', 'supplier_id', 'purchase_order_reference', 'notes']; - const fieldName = fields[fieldIndex] as keyof typeof formData; + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { + const fieldMappings = [ + // Basic Stock Information section + ['current_quantity', 'unit_cost', 'expiration_date'], + // Additional Information section + ['batch_number', 'supplier_id', 'storage_location', 'notes'], + // Storage Requirements section + ['requires_refrigeration', 'requires_freezing', 'storage_temperature_min', 'storage_temperature_max', 'storage_humidity_max', 'shelf_life_days', 'storage_instructions'] + ]; - setFormData(prev => ({ - ...prev, - [fieldName]: value - })); + const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData; + if (fieldName) { + setFormData(prev => ({ + ...prev, + [fieldName]: value + })); + } }; const handleSave = async () => { - if (!formData.quantity || formData.quantity <= 0) { + if (!formData.current_quantity || formData.current_quantity <= 0) { alert('Por favor, ingresa una cantidad válida'); return; } - if (!formData.unit_price || formData.unit_price <= 0) { + if (!formData.unit_cost || formData.unit_cost <= 0) { alert('Por favor, ingresa un precio unitario válido'); return; } @@ -61,12 +77,19 @@ export const AddStockModal: React.FC = ({ try { const stockData: StockCreate = { ingredient_id: ingredient.id, - quantity: Number(formData.quantity), - unit_price: Number(formData.unit_price), + current_quantity: Number(formData.current_quantity), + unit_cost: Number(formData.unit_cost), expiration_date: formData.expiration_date || undefined, batch_number: formData.batch_number || undefined, supplier_id: formData.supplier_id || undefined, - purchase_order_reference: formData.purchase_order_reference || undefined, + storage_location: formData.storage_location || undefined, + requires_refrigeration: formData.requires_refrigeration || false, + requires_freezing: formData.requires_freezing || false, + storage_temperature_min: formData.storage_temperature_min ? Number(formData.storage_temperature_min) : undefined, + storage_temperature_max: formData.storage_temperature_max ? Number(formData.storage_temperature_max) : undefined, + storage_humidity_max: formData.storage_humidity_max ? Number(formData.storage_humidity_max) : undefined, + shelf_life_days: formData.shelf_life_days ? Number(formData.shelf_life_days) : undefined, + storage_instructions: formData.storage_instructions || undefined, notes: formData.notes || undefined }; @@ -77,12 +100,19 @@ export const AddStockModal: React.FC = ({ // Reset form setFormData({ ingredient_id: ingredient.id, - quantity: 0, - unit_price: Number(ingredient.average_cost) || 0, + current_quantity: 0, + unit_cost: Number(ingredient.average_cost) || 0, expiration_date: '', batch_number: '', supplier_id: '', - purchase_order_reference: '', + storage_location: '', + requires_refrigeration: false, + requires_freezing: false, + storage_temperature_min: undefined, + storage_temperature_max: undefined, + storage_humidity_max: undefined, + shelf_life_days: undefined, + storage_instructions: '', notes: '' }); @@ -96,13 +126,15 @@ export const AddStockModal: React.FC = ({ }; const currentStock = Number(ingredient.current_stock) || 0; - const newTotal = currentStock + (Number(formData.quantity) || 0); - const totalValue = (Number(formData.quantity) || 0) * (Number(formData.unit_price) || 0); + const newTotal = currentStock + (Number(formData.current_quantity) || 0); + const totalValue = (Number(formData.current_quantity) || 0) * (Number(formData.unit_cost) || 0); const statusConfig = { - color: statusColors.normal.primary, + color: statusColors.inProgress.primary, text: 'Agregar Stock', - icon: Plus + icon: Plus, + isCritical: false, + isHighlight: true }; const sections = [ @@ -112,7 +144,7 @@ export const AddStockModal: React.FC = ({ fields: [ { label: `Cantidad (${ingredient.unit_of_measure})`, - value: formData.quantity || 0, + value: formData.current_quantity || 0, type: 'number' as const, editable: true, required: true, @@ -120,7 +152,7 @@ export const AddStockModal: React.FC = ({ }, { label: 'Precio Unitario', - value: formData.unit_price || 0, + value: formData.unit_cost || 0, type: 'currency' as const, editable: true, required: true, @@ -154,11 +186,11 @@ export const AddStockModal: React.FC = ({ placeholder: 'Ej: PROV001' }, { - label: 'Referencia de Pedido', - value: formData.purchase_order_reference || '', + label: 'Ubicación de Almacenamiento', + value: formData.storage_location || '', type: 'text' as const, editable: true, - placeholder: 'Ej: PO-2024-001', + placeholder: 'Ej: Estante A-3', span: 2 as const }, { @@ -172,25 +204,55 @@ export const AddStockModal: React.FC = ({ ] }, { - title: 'Resumen', - icon: Euro, + title: 'Requisitos de Almacenamiento', + icon: Thermometer, fields: [ { - label: 'Stock Actual', - value: `${currentStock} ${ingredient.unit_of_measure}`, - span: 1 as const + label: 'Requiere Refrigeración', + value: formData.requires_refrigeration || false, + type: 'boolean' as const, + editable: true }, { - label: 'Nuevo Total', - value: `${newTotal} ${ingredient.unit_of_measure}`, - highlight: true, - span: 1 as const + label: 'Requiere Congelación', + value: formData.requires_freezing || false, + type: 'boolean' as const, + editable: true }, { - label: 'Valor de la Entrada', - value: `€${totalValue.toFixed(2)}`, - type: 'currency' as const, - highlight: true, + label: 'Temperatura Mínima (°C)', + value: formData.storage_temperature_min || '', + type: 'number' as const, + editable: true, + placeholder: 'Ej: 2' + }, + { + label: 'Temperatura Máxima (°C)', + value: formData.storage_temperature_max || '', + type: 'number' as const, + editable: true, + placeholder: 'Ej: 8' + }, + { + label: 'Humedad Máxima (%)', + value: formData.storage_humidity_max || '', + type: 'number' as const, + editable: true, + placeholder: 'Ej: 60' + }, + { + label: 'Vida Útil (días)', + value: formData.shelf_life_days || '', + type: 'number' as const, + editable: true, + placeholder: 'Ej: 30' + }, + { + label: 'Instrucciones de Almacenamiento', + value: formData.storage_instructions || '', + type: 'text' as const, + editable: true, + placeholder: 'Instrucciones específicas...', span: 2 as const } ] @@ -208,7 +270,7 @@ export const AddStockModal: React.FC = ({ label: 'Agregar Stock', variant: 'primary' as const, onClick: handleSave, - disabled: loading || !formData.quantity || !formData.unit_price, + disabled: loading || !formData.current_quantity || !formData.unit_cost, loading } ]; diff --git a/frontend/src/components/domain/inventory/BatchModal.tsx b/frontend/src/components/domain/inventory/BatchModal.tsx new file mode 100644 index 00000000..7eccce46 --- /dev/null +++ b/frontend/src/components/domain/inventory/BatchModal.tsx @@ -0,0 +1,450 @@ +import React, { useState } from 'react'; +import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react'; +import { StatusModal } from '../../ui/StatusModal/StatusModal'; +import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory'; +import { formatters } from '../../ui/Stats/StatsPresets'; +import { statusColors } from '../../../styles/colors'; +import { Button } from '../../ui/Button'; + +interface BatchModalProps { + isOpen: boolean; + onClose: () => void; + ingredient: IngredientResponse; + batches: StockResponse[]; + loading?: boolean; + onAddBatch?: () => void; + onEditBatch?: (batchId: string, updateData: StockUpdate) => Promise; + onMarkAsWaste?: (batchId: string) => Promise; +} + +/** + * BatchModal - Card-based batch management modal + * Mobile-friendly design with edit and waste marking functionality + */ +export const BatchModal: React.FC = ({ + isOpen, + onClose, + ingredient, + batches = [], + loading = false, + onAddBatch, + onEditBatch, + onMarkAsWaste +}) => { + const [editingBatch, setEditingBatch] = useState(null); + const [editData, setEditData] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Get batch status based on expiration and availability + const getBatchStatus = (batch: StockResponse) => { + if (!batch.is_available) { + return { + label: 'No Disponible', + color: statusColors.cancelled.primary, + icon: X, + isCritical: true + }; + } + + if (batch.is_expired) { + return { + label: 'Vencido', + color: statusColors.expired.primary, + icon: AlertTriangle, + isCritical: true + }; + } + + if (!batch.expiration_date) { + return { + label: 'Sin Vencimiento', + color: statusColors.other.primary, + icon: Archive, + isCritical: false + }; + } + + const today = new Date(); + const expirationDate = new Date(batch.expiration_date); + const daysUntilExpiry = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + + if (daysUntilExpiry <= 0) { + return { + label: 'Vencido', + color: statusColors.expired.primary, + icon: AlertTriangle, + isCritical: true + }; + } else if (daysUntilExpiry <= 3) { + return { + label: 'Vence Pronto', + color: statusColors.low.primary, + icon: AlertTriangle, + isCritical: true + }; + } else if (daysUntilExpiry <= 7) { + return { + label: 'Por Vencer', + color: statusColors.pending.primary, + icon: Clock, + isCritical: false + }; + } else { + return { + label: 'Fresco', + color: statusColors.completed.primary, + icon: CheckCircle, + isCritical: false + }; + } + }; + + const handleEditStart = (batch: StockResponse) => { + setEditingBatch(batch.id); + setEditData({ + current_quantity: batch.current_quantity, + expiration_date: batch.expiration_date, + storage_location: batch.storage_location || '', + requires_refrigeration: batch.requires_refrigeration, + requires_freezing: batch.requires_freezing, + storage_temperature_min: batch.storage_temperature_min, + storage_temperature_max: batch.storage_temperature_max, + storage_humidity_max: batch.storage_humidity_max, + shelf_life_days: batch.shelf_life_days, + storage_instructions: batch.storage_instructions || '' + }); + }; + + const handleEditCancel = () => { + setEditingBatch(null); + setEditData({}); + }; + + const handleEditSave = async (batchId: string) => { + if (!onEditBatch) return; + + setIsSubmitting(true); + try { + await onEditBatch(batchId, editData); + setEditingBatch(null); + setEditData({}); + } catch (error) { + console.error('Error updating batch:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleMarkAsWaste = async (batchId: string) => { + if (!onMarkAsWaste) return; + + const confirmed = window.confirm('¿Está seguro que desea marcar este lote como desperdicio? Esta acción no se puede deshacer.'); + if (!confirmed) return; + + setIsSubmitting(true); + try { + await onMarkAsWaste(batchId); + } catch (error) { + console.error('Error marking batch as waste:', error); + } finally { + setIsSubmitting(false); + } + }; + + const statusConfig = { + color: statusColors.inProgress.primary, + text: `${batches.length} lotes`, + icon: Package + }; + + // Create card-based batch list + const batchCards = batches.length > 0 ? ( +
+ {batches.map((batch) => { + const status = getBatchStatus(batch); + const StatusIcon = status.icon; + const isEditing = editingBatch === batch.id; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

+ Lote #{batch.batch_number || 'Sin número'} +

+
+ {status.label} +
+
+
+ +
+ {!isEditing && ( + <> + + {status.isCritical && batch.is_available && ( + + )} + + )} + + {isEditing && ( + <> + + + + )} +
+
+
+ + {/* Content */} +
+ {/* Basic Info */} +
+
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, current_quantity: Number(e.target.value) }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {batch.current_quantity} {ingredient.unit_of_measure} +
+ )} +
+ +
+ +
+ {formatters.currency(Number(batch.total_cost || 0))} +
+
+
+ + {/* Dates */} +
+
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, expiration_date: e.target.value }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {batch.expiration_date + ? new Date(batch.expiration_date).toLocaleDateString('es-ES') + : 'Sin vencimiento' + } +
+ )} +
+ +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, storage_location: e.target.value }))} + placeholder="Ubicación del lote" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {batch.storage_location || 'No especificada'} +
+ )} +
+
+ + {/* Storage Requirements */} +
+
+ + + Almacenamiento + +
+ +
+
+ Tipo: + + {batch.requires_refrigeration ? 'Refrigeración' : + batch.requires_freezing ? 'Congelación' : 'Ambiente'} + +
+ + {(batch.storage_temperature_min || batch.storage_temperature_max) && ( +
+ Temp: + + {batch.storage_temperature_min || '-'}°C a {batch.storage_temperature_max || '-'}°C + +
+ )} + + {batch.storage_humidity_max && ( +
+ Humedad: + + ≤{batch.storage_humidity_max}% + +
+ )} + + {batch.shelf_life_days && ( +
+ Vida útil: + + {batch.shelf_life_days} días + +
+ )} +
+ + {batch.storage_instructions && ( +
+
+ "{batch.storage_instructions}" +
+
+ )} +
+
+
+ ); + })} +
+ ) : ( +
+ +

+ No hay lotes registrados +

+

+ Los lotes se crean automáticamente al agregar stock +

+ {onAddBatch && ( + + )} +
+ ); + + const sections = [ + { + title: 'Lotes de Stock', + icon: Package, + fields: [ + { + label: '', + value: batchCards, + span: 2 as const + } + ] + } + ]; + + const actions = []; + if (onAddBatch && batches.length > 0) { + actions.push({ + label: 'Agregar Lote', + icon: Plus, + variant: 'primary' as const, + onClick: onAddBatch + }); + } + + return ( + + ); +}; + +export default BatchModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/CreateItemModal.tsx b/frontend/src/components/domain/inventory/CreateIngredientModal.tsx similarity index 82% rename from frontend/src/components/domain/inventory/CreateItemModal.tsx rename to frontend/src/components/domain/inventory/CreateIngredientModal.tsx index 31b13111..af678ce2 100644 --- a/frontend/src/components/domain/inventory/CreateItemModal.tsx +++ b/frontend/src/components/domain/inventory/CreateIngredientModal.tsx @@ -1,20 +1,20 @@ import React, { useState } from 'react'; -import { Plus, Package, Calculator, Settings, Thermometer } from 'lucide-react'; +import { Plus, Package, Calculator, Settings } from 'lucide-react'; import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory'; import { statusColors } from '../../../styles/colors'; -interface CreateItemModalProps { +interface CreateIngredientModalProps { isOpen: boolean; onClose: () => void; onCreateIngredient?: (ingredientData: IngredientCreate) => Promise; } /** - * CreateItemModal - Modal for creating a new inventory ingredient + * CreateIngredientModal - Modal for creating a new inventory ingredient * Comprehensive form for adding new items to inventory */ -export const CreateItemModal: React.FC = ({ +export const CreateIngredientModal: React.FC = ({ isOpen, onClose, onCreateIngredient @@ -27,9 +27,6 @@ export const CreateItemModal: React.FC = ({ low_stock_threshold: 10, reorder_point: 20, max_stock_level: 100, - shelf_life_days: undefined, - requires_refrigeration: false, - requires_freezing: false, is_seasonal: false, supplier_id: '', average_cost: 0, @@ -85,9 +82,7 @@ export const CreateItemModal: React.FC = ({ ['name', 'description', 'category', 'unit_of_measure'], // Cost and Quantities section ['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], - // Storage Requirements section - ['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'is_seasonal'], - // Additional Information section + // Additional Information section (moved up after removing storage section) ['supplier_id', 'notes'] ]; @@ -268,49 +263,6 @@ export const CreateItemModal: React.FC = ({ } ] }, - { - title: 'Requisitos de Almacenamiento', - icon: Thermometer, - fields: [ - { - label: 'Requiere Refrigeración', - value: formData.requires_refrigeration ? 'Sí' : 'No', - type: 'select' as const, - editable: true, - options: [ - { label: 'No', value: false }, - { label: 'Sí', value: true } - ] - }, - { - label: 'Requiere Congelación', - value: formData.requires_freezing ? 'Sí' : 'No', - type: 'select' as const, - editable: true, - options: [ - { label: 'No', value: false }, - { label: 'Sí', value: true } - ] - }, - { - label: 'Vida Útil (días)', - value: formData.shelf_life_days || '', - type: 'number' as const, - editable: true, - placeholder: 'Días de vida útil' - }, - { - label: 'Es Estacional', - value: formData.is_seasonal ? 'Sí' : 'No', - type: 'select' as const, - editable: true, - options: [ - { label: 'No', value: false }, - { label: 'Sí', value: true } - ] - } - ] - }, { title: 'Información Adicional', icon: Settings, @@ -352,4 +304,4 @@ export const CreateItemModal: React.FC = ({ ); }; -export default CreateItemModal; \ No newline at end of file +export default CreateIngredientModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/DeleteIngredientExample.tsx b/frontend/src/components/domain/inventory/DeleteIngredientExample.tsx deleted file mode 100644 index efcba5ef..00000000 --- a/frontend/src/components/domain/inventory/DeleteIngredientExample.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useState } from 'react'; -import { Trash2 } from 'lucide-react'; -import { Button } from '../../ui'; -import { DeleteIngredientModal } from './'; -import { useSoftDeleteIngredient, useHardDeleteIngredient } from '../../../api/hooks/inventory'; -import { useAuthStore } from '../../../stores/auth.store'; -import { IngredientResponse } from '../../../api/types/inventory'; - -interface DeleteIngredientExampleProps { - ingredient: IngredientResponse; - onDeleteSuccess?: () => void; -} - -/** - * Example component showing how to use DeleteIngredientModal - * This can be integrated into inventory cards, tables, or detail pages - */ -export const DeleteIngredientExample: React.FC = ({ - ingredient, - onDeleteSuccess -}) => { - const [showDeleteModal, setShowDeleteModal] = useState(false); - const { tenantId } = useAuthStore(); - - // Hook for soft delete - const softDeleteMutation = useSoftDeleteIngredient({ - onSuccess: () => { - setShowDeleteModal(false); - onDeleteSuccess?.(); - }, - onError: (error) => { - console.error('Soft delete failed:', error); - // Here you could show a toast notification - } - }); - - // Hook for hard delete - const hardDeleteMutation = useHardDeleteIngredient({ - onSuccess: (result) => { - console.log('Hard delete completed:', result); - onDeleteSuccess?.(); - // Modal will handle closing itself after showing results - }, - onError: (error) => { - console.error('Hard delete failed:', error); - // Here you could show a toast notification - } - }); - - const handleSoftDelete = async (ingredientId: string) => { - if (!tenantId) { - throw new Error('No tenant ID available'); - } - - return softDeleteMutation.mutateAsync({ - tenantId, - ingredientId - }); - }; - - const handleHardDelete = async (ingredientId: string) => { - if (!tenantId) { - throw new Error('No tenant ID available'); - } - - return hardDeleteMutation.mutateAsync({ - tenantId, - ingredientId - }); - }; - - const isLoading = softDeleteMutation.isPending || hardDeleteMutation.isPending; - - return ( - <> - {/* Delete Button - This could be in a dropdown menu, action bar, etc. */} - - - {/* Delete Modal */} - setShowDeleteModal(false)} - ingredient={ingredient} - onSoftDelete={handleSoftDelete} - onHardDelete={handleHardDelete} - isLoading={isLoading} - /> - - ); -}; - -export default DeleteIngredientExample; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx b/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx index ec42728f..c20d0961 100644 --- a/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx +++ b/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx @@ -41,8 +41,11 @@ export const DeleteIngredientModal: React.FC = ({ if (selectedMode === 'hard') { const result = await onHardDelete(ingredient.id); setDeletionResult(result); + // Close modal immediately after successful hard delete + onClose(); } else { await onSoftDelete(ingredient.id); + // Close modal immediately after successful soft delete onClose(); } } catch (error) { @@ -212,16 +215,10 @@ export const DeleteIngredientModal: React.FC = ({ return (
-
+

Eliminar Artículo

-
diff --git a/frontend/src/components/domain/inventory/EditItemModal.tsx b/frontend/src/components/domain/inventory/EditItemModal.tsx deleted file mode 100644 index 21cf3021..00000000 --- a/frontend/src/components/domain/inventory/EditItemModal.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, { useState } from 'react'; -import { Edit, Package, AlertTriangle, Settings, Thermometer } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; -import { IngredientResponse, IngredientUpdate, IngredientCategory, UnitOfMeasure } from '../../../api/types/inventory'; -import { statusColors } from '../../../styles/colors'; - -interface EditItemModalProps { - isOpen: boolean; - onClose: () => void; - ingredient: IngredientResponse; - onUpdateIngredient?: (id: string, updateData: IngredientUpdate) => Promise; -} - -/** - * EditItemModal - Focused modal for editing ingredient details - * Organized form for updating ingredient properties - */ -export const EditItemModal: React.FC = ({ - isOpen, - onClose, - ingredient, - onUpdateIngredient -}) => { - const [formData, setFormData] = useState({ - name: ingredient.name, - description: ingredient.description || '', - category: ingredient.category, - brand: ingredient.brand || '', - unit_of_measure: ingredient.unit_of_measure, - average_cost: ingredient.average_cost || 0, - low_stock_threshold: ingredient.low_stock_threshold, - reorder_point: ingredient.reorder_point, - max_stock_level: ingredient.max_stock_level || undefined, - requires_refrigeration: ingredient.requires_refrigeration, - requires_freezing: ingredient.requires_freezing, - shelf_life_days: ingredient.shelf_life_days || undefined, - storage_instructions: ingredient.storage_instructions || '', - is_active: ingredient.is_active, - notes: ingredient.notes || '' - }); - - const [loading, setLoading] = useState(false); - const [mode, setMode] = useState<'overview' | 'edit'>('edit'); - - const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { - // Map field positions to form data fields - const fieldMappings = [ - // Basic Information section - ['name', 'description', 'category', 'brand'], - // Measurements section - ['unit_of_measure', 'average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], - // Storage Requirements section - ['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'storage_instructions'], - // Additional Settings section - ['is_active', 'notes'] - ]; - - const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientUpdate; - if (fieldName) { - setFormData(prev => ({ - ...prev, - [fieldName]: value - })); - } - }; - - const handleSave = async () => { - if (!formData.name?.trim()) { - alert('El nombre es requerido'); - return; - } - - if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) { - alert('El umbral de stock bajo debe ser un número positivo'); - return; - } - - if (!formData.reorder_point || formData.reorder_point < 0) { - alert('El punto de reorden debe ser un número positivo'); - return; - } - - setLoading(true); - try { - if (onUpdateIngredient) { - await onUpdateIngredient(ingredient.id, formData); - } - onClose(); - } catch (error) { - console.error('Error updating ingredient:', error); - alert('Error al actualizar el artículo. Por favor, intenta de nuevo.'); - } finally { - setLoading(false); - } - }; - - const statusConfig = { - color: statusColors.inProgress.primary, - text: 'Editar Artículo', - icon: Edit - }; - - const categoryOptions = Object.values(IngredientCategory).map(cat => ({ - label: cat.charAt(0).toUpperCase() + cat.slice(1), - value: cat - })); - - const unitOptions = Object.values(UnitOfMeasure).map(unit => ({ - label: unit, - value: unit - })); - - const sections = [ - { - title: 'Información Básica', - icon: Package, - fields: [ - { - label: 'Nombre', - value: formData.name || '', - type: 'text' as const, - editable: true, - required: true, - placeholder: 'Nombre del artículo' - }, - { - label: 'Descripción', - value: formData.description || '', - type: 'text' as const, - editable: true, - placeholder: 'Descripción del artículo' - }, - { - label: 'Categoría', - value: formData.category || '', - type: 'select' as const, - editable: true, - required: true, - options: categoryOptions - }, - { - label: 'Marca', - value: formData.brand || '', - type: 'text' as const, - editable: true, - placeholder: 'Marca del producto' - } - ] - }, - { - title: 'Medidas y Costos', - icon: Settings, - fields: [ - { - label: 'Unidad de Medida', - value: formData.unit_of_measure || '', - type: 'select' as const, - editable: true, - required: true, - options: unitOptions - }, - { - label: 'Costo Promedio', - value: formData.average_cost || 0, - type: 'currency' as const, - editable: true, - placeholder: '0.00' - }, - { - label: 'Umbral Stock Bajo', - value: formData.low_stock_threshold || 0, - type: 'number' as const, - editable: true, - required: true, - placeholder: 'Cantidad mínima' - }, - { - label: 'Punto de Reorden', - value: formData.reorder_point || 0, - type: 'number' as const, - editable: true, - required: true, - placeholder: 'Cuando reordenar' - }, - { - label: 'Stock Máximo', - value: formData.max_stock_level || 0, - type: 'number' as const, - editable: true, - placeholder: 'Stock máximo (opcional)' - } - ] - }, - { - title: 'Requisitos de Almacenamiento', - icon: Thermometer, - fields: [ - { - label: 'Requiere Refrigeración', - value: formData.requires_refrigeration ? 'Sí' : 'No', - type: 'select' as const, - editable: true, - options: [ - { label: 'No', value: false }, - { label: 'Sí', value: true } - ] - }, - { - label: 'Requiere Congelación', - value: formData.requires_freezing ? 'Sí' : 'No', - type: 'select' as const, - editable: true, - options: [ - { label: 'No', value: false }, - { label: 'Sí', value: true } - ] - }, - { - label: 'Vida Útil (días)', - value: formData.shelf_life_days || 0, - type: 'number' as const, - editable: true, - placeholder: 'Días de duración' - }, - { - label: 'Instrucciones de Almacenamiento', - value: formData.storage_instructions || '', - type: 'text' as const, - editable: true, - placeholder: 'Instrucciones especiales...', - span: 2 as const - } - ] - }, - { - title: 'Configuración Adicional', - icon: AlertTriangle, - fields: [ - { - label: 'Estado', - value: formData.is_active ? 'Activo' : 'Inactivo', - type: 'select' as const, - editable: true, - options: [ - { label: 'Activo', value: true }, - { label: 'Inactivo', value: false } - ] - }, - { - label: 'Notas', - value: formData.notes || '', - type: 'text' as const, - editable: true, - placeholder: 'Notas adicionales...', - span: 2 as const - } - ] - } - ]; - - const actions = [ - { - label: 'Cancelar', - variant: 'outline' as const, - onClick: onClose, - disabled: loading - }, - { - label: 'Guardar Cambios', - variant: 'primary' as const, - onClick: handleSave, - disabled: loading || !formData.name?.trim(), - loading - } - ]; - - return ( - - ); -}; - -export default EditItemModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/HistoryModal.tsx b/frontend/src/components/domain/inventory/HistoryModal.tsx deleted file mode 100644 index 1745a75c..00000000 --- a/frontend/src/components/domain/inventory/HistoryModal.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React from 'react'; -import { Clock, TrendingUp, TrendingDown, Package, AlertCircle, RotateCcw } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; -import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory'; -import { formatters } from '../../ui/Stats/StatsPresets'; -import { statusColors } from '../../../styles/colors'; - -interface HistoryModalProps { - isOpen: boolean; - onClose: () => void; - ingredient: IngredientResponse; - movements: StockMovementResponse[]; - loading?: boolean; -} - -/** - * HistoryModal - Focused modal for viewing stock movement history - * Clean, scannable list of recent movements - */ -export const HistoryModal: React.FC = ({ - isOpen, - onClose, - ingredient, - movements = [], - loading = false -}) => { - // Group movements by type for better organization - const groupedMovements = movements.reduce((acc, movement) => { - const type = movement.movement_type; - if (!acc[type]) acc[type] = []; - acc[type].push(movement); - return acc; - }, {} as Record); - - // Get movement type display info - const getMovementTypeInfo = (type: string) => { - switch (type) { - case 'purchase': - return { label: 'Compra', icon: TrendingUp, color: statusColors.normal.primary }; - case 'production_use': - return { label: 'Uso en Producción', icon: TrendingDown, color: statusColors.pending.primary }; - case 'adjustment': - return { label: 'Ajuste', icon: Package, color: statusColors.inProgress.primary }; - case 'waste': - return { label: 'Merma', icon: AlertCircle, color: statusColors.cancelled.primary }; - case 'transfer': - return { label: 'Transferencia', icon: RotateCcw, color: statusColors.bread.primary }; - case 'return': - return { label: 'Devolución', icon: RotateCcw, color: statusColors.inTransit.primary }; - case 'initial_stock': - return { label: 'Stock Inicial', icon: Package, color: statusColors.completed.primary }; - case 'transformation': - return { label: 'Transformación', icon: RotateCcw, color: statusColors.pastry.primary }; - default: - return { label: type, icon: Package, color: statusColors.other.primary }; - } - }; - - // Format movement for display - const formatMovement = (movement: StockMovementResponse) => { - const typeInfo = getMovementTypeInfo(movement.movement_type); - const date = new Date(movement.movement_date).toLocaleDateString('es-ES'); - const time = new Date(movement.movement_date).toLocaleTimeString('es-ES', { - hour: '2-digit', - minute: '2-digit' - }); - - const quantity = Number(movement.quantity); - const isPositive = quantity > 0; - const quantityText = `${isPositive ? '+' : ''}${quantity} ${ingredient.unit_of_measure}`; - - return { - id: movement.id, - type: typeInfo.label, - icon: typeInfo.icon, - color: typeInfo.color, - quantity: quantityText, - isPositive, - date: `${date} ${time}`, - reference: movement.reference_number || '-', - notes: movement.notes || '-', - cost: movement.total_cost ? formatters.currency(movement.total_cost) : '-', - quantityBefore: movement.quantity_before || 0, - quantityAfter: movement.quantity_after || 0 - }; - }; - - const recentMovements = movements - .slice(0, 20) // Show last 20 movements - .map(formatMovement); - - - const statusConfig = { - color: statusColors.inProgress.primary, - text: `${movements.length} movimientos`, - icon: Clock - }; - - // Create a visual movement list - const movementsList = recentMovements.length > 0 ? ( -
- {recentMovements.map((movement) => { - const MovementIcon = movement.icon; - return ( -
- {/* Icon and type */} -
- -
- - {/* Main content */} -
-
- - {movement.type} - - - {movement.quantity} - -
- -
- {movement.date} - {movement.cost} -
- - {movement.reference !== '-' && ( -
- Ref: {movement.reference} -
- )} - - {movement.notes !== '-' && ( -
- {movement.notes} -
- )} -
- - {/* Stock levels */} -
-
- {movement.quantityBefore} → {movement.quantityAfter} -
-
- {ingredient.unit_of_measure} -
-
-
- ); - })} -
- ) : ( -
- -

No hay movimientos de stock registrados

-
- ); - - const sections = [ - { - title: 'Historial de Movimientos', - icon: Clock, - fields: [ - { - label: '', - value: movementsList, - span: 2 as const - } - ] - } - ]; - - // Add summary if we have movements - if (movements.length > 0) { - const totalIn = movements - .filter(m => Number(m.quantity) > 0) - .reduce((sum, m) => sum + Number(m.quantity), 0); - - const totalOut = movements - .filter(m => Number(m.quantity) < 0) - .reduce((sum, m) => sum + Math.abs(Number(m.quantity)), 0); - - const totalValue = movements - .reduce((sum, m) => sum + (Number(m.total_cost) || 0), 0); - - sections.unshift({ - title: 'Resumen de Actividad', - icon: Package, - fields: [ - { - label: 'Total Entradas', - value: `${totalIn} ${ingredient.unit_of_measure}`, - highlight: true, - span: 1 as const - }, - { - label: 'Total Salidas', - value: `${totalOut} ${ingredient.unit_of_measure}`, - span: 1 as const - }, - { - label: 'Valor Total Movimientos', - value: formatters.currency(Math.abs(totalValue)), - type: 'currency' as const, - span: 2 as const - } - ] - }); - } - - return ( - - ); -}; - -export default HistoryModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/QuickViewModal.tsx b/frontend/src/components/domain/inventory/QuickViewModal.tsx deleted file mode 100644 index 8d3e5187..00000000 --- a/frontend/src/components/domain/inventory/QuickViewModal.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { Package, AlertTriangle, CheckCircle, Clock, Euro } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; -import { IngredientResponse } from '../../../api/types/inventory'; -import { formatters } from '../../ui/Stats/StatsPresets'; -import { statusColors } from '../../../styles/colors'; - -interface QuickViewModalProps { - isOpen: boolean; - onClose: () => void; - ingredient: IngredientResponse; -} - -/** - * QuickViewModal - Focused modal for viewing essential stock information - * Shows only the most important data users need for quick decisions - */ -export const QuickViewModal: React.FC = ({ - isOpen, - onClose, - ingredient -}) => { - // Safe number conversions - const currentStock = Number(ingredient.current_stock) || 0; - const maxStock = Number(ingredient.max_stock_level) || 0; - const averageCost = Number(ingredient.average_cost) || 0; - const lowThreshold = Number(ingredient.low_stock_threshold) || 0; - const reorderPoint = Number(ingredient.reorder_point) || 0; - - // Calculate derived values - const totalValue = currentStock * averageCost; - const stockPercentage = maxStock > 0 ? Math.round((currentStock / maxStock) * 100) : 0; - const daysOfStock = currentStock > 0 ? Math.round(currentStock / (averageCost || 1)) : 0; - - // Status configuration - const getStatusConfig = () => { - if (currentStock === 0) { - return { - color: statusColors.out.primary, - text: 'Sin Stock', - icon: AlertTriangle, - isCritical: true - }; - } - if (currentStock <= lowThreshold) { - return { - color: statusColors.low.primary, - text: 'Stock Bajo', - icon: AlertTriangle, - isHighlight: true - }; - } - return { - color: statusColors.normal.primary, - text: 'Stock Normal', - icon: CheckCircle - }; - }; - - const statusConfig = getStatusConfig(); - - const sections = [ - { - title: 'Estado del Stock', - icon: Package, - fields: [ - { - label: 'Cantidad Actual', - value: `${currentStock} ${ingredient.unit_of_measure}`, - highlight: true, - span: 1 as const - }, - { - label: 'Valor Total', - value: formatters.currency(totalValue), - type: 'currency' as const, - highlight: true, - span: 1 as const - }, - { - label: 'Nivel de Stock', - value: maxStock > 0 ? `${stockPercentage}%` : 'N/A', - span: 1 as const - }, - { - label: 'Días Estimados', - value: `~${daysOfStock} días`, - span: 1 as const - } - ] - }, - { - title: 'Umbrales de Control', - icon: AlertTriangle, - fields: [ - { - label: 'Punto de Reorden', - value: `${reorderPoint} ${ingredient.unit_of_measure}`, - span: 1 as const - }, - { - label: 'Stock Mínimo', - value: `${lowThreshold} ${ingredient.unit_of_measure}`, - span: 1 as const - }, - { - label: 'Stock Máximo', - value: maxStock > 0 ? `${maxStock} ${ingredient.unit_of_measure}` : 'No definido', - span: 1 as const - }, - { - label: 'Costo Promedio', - value: formatters.currency(averageCost), - type: 'currency' as const, - span: 1 as const - } - ] - } - ]; - - // Add storage requirements if available - if (ingredient.requires_refrigeration || ingredient.requires_freezing || ingredient.shelf_life_days) { - sections.push({ - title: 'Requisitos de Almacenamiento', - icon: Clock, - fields: [ - ...(ingredient.shelf_life_days ? [{ - label: 'Vida Útil', - value: `${ingredient.shelf_life_days} días`, - span: 1 as const - }] : []), - ...(ingredient.requires_refrigeration ? [{ - label: 'Refrigeración', - value: 'Requerida', - span: 1 as const - }] : []), - ...(ingredient.requires_freezing ? [{ - label: 'Congelación', - value: 'Requerida', - span: 1 as const - }] : []), - ...(ingredient.storage_instructions ? [{ - label: 'Instrucciones', - value: ingredient.storage_instructions, - span: 2 as const - }] : []) - ] - }); - } - - return ( - - ); -}; - -export default QuickViewModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/ShowInfoModal.tsx b/frontend/src/components/domain/inventory/ShowInfoModal.tsx new file mode 100644 index 00000000..be8b625b --- /dev/null +++ b/frontend/src/components/domain/inventory/ShowInfoModal.tsx @@ -0,0 +1,303 @@ +import React, { useState } from 'react'; +import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react'; +import { StatusModal } from '../../ui/StatusModal/StatusModal'; +import { IngredientResponse } from '../../../api/types/inventory'; +import { formatters } from '../../ui/Stats/StatsPresets'; +import { statusColors } from '../../../styles/colors'; + +interface ShowInfoModalProps { + isOpen: boolean; + onClose: () => void; + ingredient: IngredientResponse; + onSave?: (updatedData: Partial) => Promise; +} + +/** + * ShowInfoModal - Complete item details modal + * Shows ALL item information excluding stock, lots, and movements data + * Includes edit functionality for item properties + */ +export const ShowInfoModal: React.FC = ({ + isOpen, + onClose, + ingredient, + onSave +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editData, setEditData] = useState>({}); + + const handleEdit = () => { + setIsEditing(true); + setEditData(ingredient); + }; + + const handleCancel = () => { + setIsEditing(false); + setEditData({}); + }; + + const handleSave = async () => { + if (onSave) { + await onSave(editData); + setIsEditing(false); + setEditData({}); + } + }; + + // Status configuration based on item status (not stock) + const statusConfig = { + color: ingredient.is_active ? statusColors.normal.primary : statusColors.cancelled.primary, + text: ingredient.is_active ? 'Activo' : 'Inactivo', + icon: ingredient.is_active ? CheckCircle : AlertTriangle, + isCritical: !ingredient.is_active + }; + + const currentData = isEditing ? editData : ingredient; + + const sections = [ + { + title: 'Información Básica', + icon: Info, + fields: [ + { + label: 'Nombre', + value: currentData.name || '', + highlight: true, + span: 2 as const, + editable: true, + required: true + }, + { + label: 'Descripción', + value: currentData.description || '', + span: 2 as const, + editable: true, + placeholder: 'Descripción del producto' + }, + { + label: 'Categoría', + value: currentData.category || '', + span: 1 as const, + editable: true, + required: true + }, + { + label: 'Subcategoría', + value: currentData.subcategory || '', + span: 1 as const, + editable: true, + placeholder: 'Subcategoría' + }, + { + label: 'Marca', + value: currentData.brand || '', + span: 1 as const, + editable: true, + placeholder: 'Marca del producto' + }, + { + label: 'Tipo de Producto', + value: currentData.product_type || '', + span: 1 as const, + editable: true, + placeholder: 'Tipo de producto' + } + ] + }, + { + title: 'Especificaciones', + icon: Package, + fields: [ + { + label: 'Unidad de Medida', + value: currentData.unit_of_measure || '', + span: 1 as const, + editable: true, + required: true, + placeholder: 'kg, litros, unidades, etc.' + }, + { + label: 'Tamaño del Paquete', + value: currentData.package_size || '', + span: 1 as const, + editable: true, + type: 'number' as const, + placeholder: 'Tamaño del paquete' + }, + { + label: 'Es Perecedero', + value: currentData.is_perishable ? 'Sí' : 'No', + span: 1 as const, + editable: true, + type: 'select' as const, + options: [ + { label: 'Sí', value: 'true' }, + { label: 'No', value: 'false' } + ] + }, + { + label: 'Es de Temporada', + value: currentData.is_seasonal ? 'Sí' : 'No', + span: 1 as const, + editable: true, + type: 'select' as const, + options: [ + { label: 'Sí', value: 'true' }, + { label: 'No', value: 'false' } + ] + } + ] + }, + { + title: 'Costos y Precios', + icon: Euro, + fields: [ + { + label: 'Costo Promedio', + value: Number(currentData.average_cost) || 0, + type: 'currency' as const, + span: 1 as const, + editable: true, + placeholder: '0.00' + }, + { + label: 'Último Precio de Compra', + value: Number(currentData.last_purchase_price) || 0, + type: 'currency' as const, + span: 1 as const, + editable: true, + placeholder: '0.00' + }, + { + label: 'Costo Estándar', + value: Number(currentData.standard_cost) || 0, + type: 'currency' as const, + span: 2 as const, + editable: true, + placeholder: '0.00' + } + ] + }, + { + title: 'Parámetros de Inventario', + icon: TrendingUp, + fields: [ + { + label: 'Umbral Stock Bajo', + value: currentData.low_stock_threshold || 0, + span: 1 as const, + editable: true, + type: 'number' as const, + placeholder: 'Cantidad mínima antes de alerta' + }, + { + label: 'Punto de Reorden', + value: currentData.reorder_point || 0, + span: 1 as const, + editable: true, + type: 'number' as const, + placeholder: 'Punto para reordenar' + }, + { + label: 'Cantidad de Reorden', + value: currentData.reorder_quantity || 0, + span: 1 as const, + editable: true, + type: 'number' as const, + placeholder: 'Cantidad a reordenar' + }, + { + label: 'Stock Máximo', + value: currentData.max_stock_level || '', + span: 1 as const, + editable: true, + type: 'number' as const, + placeholder: 'Cantidad máxima permitida' + } + ] + } + ]; + + // Actions based on edit mode + const actions = []; + if (isEditing) { + actions.push( + { + label: 'Cancelar', + icon: X, + variant: 'outline' as const, + onClick: handleCancel + }, + { + label: 'Guardar', + icon: Save, + variant: 'primary' as const, + onClick: handleSave + } + ); + } else if (onSave) { + actions.push({ + label: 'Editar', + icon: Edit, + variant: 'primary' as const, + onClick: handleEdit + }); + } + + // Handle field changes + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => { + if (!isEditing) return; + + // Map field indices to ingredient properties + const fieldMappings = [ + // Section 0: Información Básica + ['name', 'description', 'category', 'subcategory', 'brand', 'product_type'], + // Section 1: Especificaciones + ['unit_of_measure', 'package_size', 'is_perishable', 'is_seasonal'], + // Section 2: Costos y Precios + ['average_cost', 'last_purchase_price', 'standard_cost'], + // Section 3: Parámetros de Inventario + ['low_stock_threshold', 'reorder_point', 'reorder_quantity', 'max_stock_level'] + ]; + + const fieldName = fieldMappings[sectionIndex]?.[fieldIndex]; + if (!fieldName) return; + + let processedValue = value; + + // Handle boolean fields + if (fieldName === 'is_perishable' || fieldName === 'is_seasonal') { + processedValue = value === 'true'; + } + + // Handle numeric fields + if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') || + fieldName.includes('threshold') || fieldName.includes('point') || fieldName.includes('quantity') || + fieldName.includes('level')) { + processedValue = Number(value) || 0; + } + + setEditData(prev => ({ + ...prev, + [fieldName]: processedValue + })); + }; + + return ( + + ); +}; + +export default ShowInfoModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/StockHistoryModal.tsx b/frontend/src/components/domain/inventory/StockHistoryModal.tsx new file mode 100644 index 00000000..7cc3115d --- /dev/null +++ b/frontend/src/components/domain/inventory/StockHistoryModal.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react'; +import { StatusModal } from '../../ui/StatusModal/StatusModal'; +import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory'; +import { formatters } from '../../ui/Stats/StatsPresets'; +import { statusColors } from '../../../styles/colors'; + +interface StockHistoryModalProps { + isOpen: boolean; + onClose: () => void; + ingredient: IngredientResponse; + movements: StockMovementResponse[]; + loading?: boolean; +} + +/** + * StockHistoryModal - Dedicated modal for stock movement history + * Shows only the movements list in a clean, focused interface + */ +export const StockHistoryModal: React.FC = ({ + isOpen, + onClose, + ingredient, + movements = [], + loading = false +}) => { + // Get movement type display info + const getMovementTypeInfo = (type: string, quantity: number) => { + const isPositive = quantity > 0; + const absQuantity = Math.abs(quantity); + + switch (type) { + case 'PURCHASE': + return { + type: 'Compra', + icon: Package, + color: statusColors.completed.primary, + isPositive: true, + quantity: `+${absQuantity}` + }; + case 'PRODUCTION_USE': + return { + type: 'Uso en Producción', + icon: TrendingDown, + color: statusColors.pending.primary, + isPositive: false, + quantity: `-${absQuantity}` + }; + case 'ADJUSTMENT': + return { + type: 'Ajuste', + icon: AlertCircle, + color: statusColors.inProgress.primary, + isPositive, + quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}` + }; + case 'WASTE': + return { + type: 'Desperdicio', + icon: X, + color: statusColors.out.primary, + isPositive: false, + quantity: `-${absQuantity}` + }; + case 'TRANSFORMATION': + return { + type: 'Transformación', + icon: RotateCcw, + color: statusColors.low.primary, + isPositive, + quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}` + }; + case 'INITIAL_STOCK': + return { + type: 'Stock Inicial', + icon: Package, + color: statusColors.normal.primary, + isPositive: true, + quantity: `+${absQuantity}` + }; + default: + return { + type: 'Otro', + icon: Package, + color: statusColors.other.primary, + isPositive, + quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}` + }; + } + }; + + // Process movements for display + const recentMovements = movements.slice(0, 20).map(movement => { + const movementInfo = getMovementTypeInfo(movement.movement_type, Number(movement.quantity)); + + return { + id: movement.id, + ...movementInfo, + date: movement.movement_date ? new Date(movement.movement_date).toLocaleDateString('es-ES') : 'Sin fecha', + cost: movement.unit_cost ? formatters.currency(Number(movement.unit_cost)) : '-', + reference: movement.reference_number || '-', + notes: movement.notes || '-', + quantityBefore: Number(movement.quantity_before) || 0, + quantityAfter: Number(movement.quantity_after) || 0 + }; + }); + + const statusConfig = { + color: statusColors.inProgress.primary, + text: `${movements.length} movimientos`, + icon: Clock + }; + + // Create movements list display + const movementsList = recentMovements.length > 0 ? ( +
+ {recentMovements.map((movement) => { + const MovementIcon = movement.icon; + return ( +
+ {/* Icon and type */} +
+ +
+ + {/* Main content */} +
+
+ + {movement.type} + + + {movement.quantity} + +
+ +
+ {movement.date} + {movement.cost} +
+ + {movement.reference !== '-' && ( +
+ Ref: {movement.reference} +
+ )} + + {movement.notes !== '-' && ( +
+ {movement.notes} +
+ )} +
+ + {/* Stock levels */} +
+
+ {movement.quantityBefore} → {movement.quantityAfter} +
+
+ {ingredient.unit_of_measure} +
+
+
+ ); + })} + + {movements.length > 20 && ( +
+ Y {movements.length - 20} movimientos más... +
+ )} +
+ ) : ( +
+ +

+ No hay movimientos registrados +

+

+ Los movimientos de stock aparecerán aquí cuando se agregue o use inventario +

+
+ ); + + const sections = [ + { + title: 'Historial de Movimientos', + icon: Clock, + fields: [ + { + label: '', + value: movementsList, + span: 2 as const + } + ] + } + ]; + + return ( + + ); +}; + +export default StockHistoryModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/StockLotsModal.tsx b/frontend/src/components/domain/inventory/StockLotsModal.tsx deleted file mode 100644 index 2bf5d321..00000000 --- a/frontend/src/components/domain/inventory/StockLotsModal.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import React from 'react'; -import { Package, Calendar, AlertTriangle, CheckCircle, Clock } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; -import { IngredientResponse, StockResponse } from '../../../api/types/inventory'; -import { formatters } from '../../ui/Stats/StatsPresets'; -import { statusColors } from '../../../styles/colors'; - -interface StockLotsModalProps { - isOpen: boolean; - onClose: () => void; - ingredient: IngredientResponse; - stockLots: StockResponse[]; - loading?: boolean; -} - -/** - * StockLotsModal - Focused modal for viewing individual stock lots/batches - * Shows detailed breakdown of all stock batches with expiration, quantities, etc. - */ -export const StockLotsModal: React.FC = ({ - isOpen, - onClose, - ingredient, - stockLots = [], - loading = false -}) => { - - // Sort stock lots by expiration date (earliest first, then by batch number) - // Use current_quantity from API response - const sortedLots = stockLots - .filter(lot => (lot.current_quantity || lot.quantity || 0) > 0) // Handle both API variations - .sort((a, b) => { - if (a.expiration_date && b.expiration_date) { - return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime(); - } - if (a.expiration_date && !b.expiration_date) return -1; - if (!a.expiration_date && b.expiration_date) return 1; - return (a.batch_number || '').localeCompare(b.batch_number || ''); - }); - - // Get lot status info using global color system - const getLotStatus = (lot: StockResponse) => { - if (!lot.expiration_date) { - return { - label: 'Sin Vencimiento', - color: statusColors.other.primary, - icon: Package, - isCritical: false - }; - } - - const today = new Date(); - const expirationDate = new Date(lot.expiration_date); - const daysUntilExpiry = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); - - if (daysUntilExpiry < 0) { - return { - label: 'Vencido', - color: statusColors.expired.primary, - icon: AlertTriangle, - isCritical: true - }; - } else if (daysUntilExpiry <= 3) { - return { - label: 'Vence Pronto', - color: statusColors.low.primary, - icon: AlertTriangle, - isCritical: true - }; - } else if (daysUntilExpiry <= 7) { - return { - label: 'Por Vencer', - color: statusColors.pending.primary, - icon: Clock, - isCritical: false - }; - } else { - return { - label: 'Fresco', - color: statusColors.normal.primary, - icon: CheckCircle, - isCritical: false - }; - } - }; - - // Format lot for display - const formatLot = (lot: StockResponse, index: number) => { - const status = getLotStatus(lot); - const expirationDate = lot.expiration_date ? - new Date(lot.expiration_date).toLocaleDateString('es-ES') : 'N/A'; - - const daysUntilExpiry = lot.expiration_date ? - Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)) : null; - - const StatusIcon = status.icon; - - return ( -
- {/* Status indicator */} -
- -
- - {/* Lot information */} -
-
-
-
- Lote #{index + 1} {lot.batch_number && `(${lot.batch_number})`} -
-
- {status.label} {daysUntilExpiry !== null && daysUntilExpiry >= 0 && `(${daysUntilExpiry} días)`} -
-
-
-
- {lot.current_quantity || lot.quantity} {ingredient.unit_of_measure} -
- {lot.available_quantity !== (lot.current_quantity || lot.quantity) && ( -
- Disponible: {lot.available_quantity} -
- )} -
-
- -
-
- Vencimiento: -
{expirationDate}
-
-
- Precio/unidad: -
{formatters.currency(lot.unit_cost || lot.unit_price || 0)}
-
-
- Valor total: -
{formatters.currency(lot.total_cost || lot.total_value || 0)}
-
-
- Etapa: -
{lot.production_stage.replace('_', ' ')}
-
-
- - {lot.notes && ( -
- 📝 {lot.notes} -
- )} -
-
- ); - }; - - const statusConfig = { - color: statusColors.inProgress.primary, - text: loading - ? 'Cargando...' - : stockLots.length === 0 - ? 'Sin datos de lotes' - : `${sortedLots.length} de ${stockLots.length} lotes`, - icon: Package - }; - - // Create the lots list - const lotsDisplay = loading ? ( -
-
-

Cargando lotes...

-
- ) : stockLots.length === 0 ? ( -
- -

No hay datos de lotes para este ingrediente

-

Es posible que no se hayan registrado lotes individuales

-
- ) : sortedLots.length === 0 ? ( -
- -

No hay lotes disponibles con stock

-

Total de lotes registrados: {stockLots.length}

-
-
Lotes filtrados por:
-
    -
  • Cantidad > 0
  • -
-
-
- ) : ( -
- {sortedLots.map((lot, index) => formatLot(lot, index))} -
- ); - - const sections = [ - { - title: stockLots.length === 0 ? 'Información de Stock' : 'Lotes de Stock Disponibles', - icon: Package, - fields: [ - { - label: '', - value: stockLots.length === 0 ? ( -
-
- -

Este ingrediente no tiene lotes individuales registrados

-

Se muestra información agregada del stock

-
-
-
- Stock Total: -
{ingredient.current_stock || 0} {ingredient.unit_of_measure}
-
-
- Costo Promedio: -
€{(ingredient.average_cost || 0).toFixed(2)}
-
-
- Umbral Mínimo: -
{ingredient.low_stock_threshold} {ingredient.unit_of_measure}
-
-
- Punto de Reorden: -
{ingredient.reorder_point} {ingredient.unit_of_measure}
-
-
-
- ) : lotsDisplay, - span: 2 as const - } - ] - } - ]; - - - // Add summary if we have lots - if (sortedLots.length > 0) { - const totalQuantity = sortedLots.reduce((sum, lot) => sum + (lot.current_quantity || lot.quantity || 0), 0); - const totalValue = sortedLots.reduce((sum, lot) => sum + (lot.total_cost || lot.total_value || 0), 0); - const expiringSoon = sortedLots.filter(lot => { - if (!lot.expiration_date) return false; - const daysUntilExpiry = Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); - return daysUntilExpiry <= 7; - }).length; - - sections.unshift({ - title: 'Resumen de Lotes', - icon: CheckCircle, - fields: [ - { - label: 'Total Cantidad', - value: `${totalQuantity} ${ingredient.unit_of_measure}`, - highlight: true, - span: 1 as const - }, - { - label: 'Número de Lotes', - value: sortedLots.length, - span: 1 as const - }, - { - label: 'Valor Total', - value: formatters.currency(totalValue), - type: 'currency' as const, - span: 1 as const - }, - { - label: 'Por Vencer (7 días)', - value: expiringSoon, - highlight: expiringSoon > 0, - span: 1 as const - } - ] - }); - } - - return ( - - ); -}; - -export default StockLotsModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/UseStockModal.tsx b/frontend/src/components/domain/inventory/UseStockModal.tsx deleted file mode 100644 index 0a594c22..00000000 --- a/frontend/src/components/domain/inventory/UseStockModal.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { useState } from 'react'; -import { Minus, Package, FileText, AlertTriangle } from 'lucide-react'; -import { StatusModal } from '../../ui/StatusModal/StatusModal'; -import { IngredientResponse, StockMovementCreate } from '../../../api/types/inventory'; -import { statusColors } from '../../../styles/colors'; - -interface UseStockModalProps { - isOpen: boolean; - onClose: () => void; - ingredient: IngredientResponse; - onUseStock?: (movementData: StockMovementCreate) => Promise; -} - -/** - * UseStockModal - Focused modal for recording stock consumption - * Quick form for production usage tracking - */ -export const UseStockModal: React.FC = ({ - isOpen, - onClose, - ingredient, - onUseStock -}) => { - const [formData, setFormData] = useState({ - quantity: 0, - reference_number: '', - notes: '', - reason_code: 'production_use' - }); - - const [loading, setLoading] = useState(false); - const [mode, setMode] = useState<'overview' | 'edit'>('edit'); - - const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => { - const fields = ['quantity', 'reference_number', 'notes', 'reason_code']; - const fieldName = fields[fieldIndex] as keyof typeof formData; - - setFormData(prev => ({ - ...prev, - [fieldName]: value - })); - }; - - const handleSave = async () => { - const currentStock = Number(ingredient.current_stock) || 0; - const requestedQuantity = Number(formData.quantity); - - if (!requestedQuantity || requestedQuantity <= 0) { - alert('Por favor, ingresa una cantidad válida'); - return; - } - - if (requestedQuantity > currentStock) { - alert(`No hay suficiente stock. Disponible: ${currentStock} ${ingredient.unit_of_measure}`); - return; - } - - setLoading(true); - try { - const movementData: StockMovementCreate = { - ingredient_id: ingredient.id, - movement_type: formData.reason_code === 'waste' ? 'waste' : 'production_use', - quantity: -requestedQuantity, // Negative for consumption - reference_number: formData.reference_number || undefined, - notes: formData.notes || undefined, - reason_code: formData.reason_code || undefined - }; - - if (onUseStock) { - await onUseStock(movementData); - } - - // Reset form - setFormData({ - quantity: 0, - reference_number: '', - notes: '', - reason_code: 'production_use' - }); - - onClose(); - } catch (error) { - console.error('Error using stock:', error); - alert('Error al registrar el uso de stock. Por favor, intenta de nuevo.'); - } finally { - setLoading(false); - } - }; - - const currentStock = Number(ingredient.current_stock) || 0; - const requestedQuantity = Number(formData.quantity) || 0; - const remainingStock = Math.max(0, currentStock - requestedQuantity); - const isLowStock = remainingStock <= (Number(ingredient.low_stock_threshold) || 0); - - const statusConfig = { - color: statusColors.pending.primary, - text: 'Usar Stock', - icon: Minus - }; - - const reasonOptions = [ - { label: 'Uso en Producción', value: 'production_use' }, - { label: 'Merma/Desperdicio', value: 'waste' }, - { label: 'Ajuste de Inventario', value: 'adjustment' }, - { label: 'Transferencia', value: 'transfer' } - ]; - - const sections = [ - { - title: 'Consumo de Stock', - icon: Package, - fields: [ - { - label: `Cantidad a Usar (${ingredient.unit_of_measure})`, - value: formData.quantity || 0, - type: 'number' as const, - editable: true, - required: true, - placeholder: `Máx: ${currentStock}` - }, - { - label: 'Motivo', - value: formData.reason_code, - type: 'select' as const, - editable: true, - required: true, - options: reasonOptions - }, - { - label: 'Referencia/Pedido', - value: formData.reference_number || '', - type: 'text' as const, - editable: true, - placeholder: 'Ej: PROD-2024-001', - span: 2 as const - } - ] - }, - { - title: 'Detalles Adicionales', - icon: FileText, - fields: [ - { - label: 'Notas', - value: formData.notes || '', - type: 'text' as const, - editable: true, - placeholder: 'Detalles del uso, receta, observaciones...', - span: 2 as const - } - ] - }, - { - title: 'Resumen del Movimiento', - icon: isLowStock ? AlertTriangle : Package, - fields: [ - { - label: 'Stock Actual', - value: `${currentStock} ${ingredient.unit_of_measure}`, - span: 1 as const - }, - { - label: 'Stock Restante', - value: `${remainingStock} ${ingredient.unit_of_measure}`, - highlight: isLowStock, - span: 1 as const - }, - ...(isLowStock ? [{ - label: 'Advertencia', - value: '⚠️ El stock quedará por debajo del umbral mínimo', - span: 2 as const - }] : []) - ] - } - ]; - - const actions = [ - { - label: 'Cancelar', - variant: 'outline' as const, - onClick: onClose, - disabled: loading - }, - { - label: formData.reason_code === 'waste' ? 'Registrar Merma' : 'Usar Stock', - variant: formData.reason_code === 'waste' ? 'outline' : 'primary' as const, - onClick: handleSave, - disabled: loading || !formData.quantity || requestedQuantity > currentStock, - loading - } - ]; - - return ( - - ); -}; - -export default UseStockModal; \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/index.ts b/frontend/src/components/domain/inventory/index.ts index 35690aae..352611f0 100644 --- a/frontend/src/components/domain/inventory/index.ts +++ b/frontend/src/components/domain/inventory/index.ts @@ -1,13 +1,10 @@ // Inventory Domain Components // Focused Modal Components -export { default as CreateItemModal } from './CreateItemModal'; -export { default as QuickViewModal } from './QuickViewModal'; -export { default as AddStockModal } from './AddStockModal'; -export { default as UseStockModal } from './UseStockModal'; -export { default as HistoryModal } from './HistoryModal'; -export { default as StockLotsModal } from './StockLotsModal'; -export { default as EditItemModal } from './EditItemModal'; +export { default as CreateIngredientModal } from './CreateIngredientModal'; +export { default as ShowInfoModal } from './ShowInfoModal'; +export { default as StockHistoryModal } from './StockHistoryModal'; +export { default as BatchModal } from './BatchModal'; export { default as DeleteIngredientModal } from './DeleteIngredientModal'; // Re-export related types from inventory types diff --git a/frontend/src/components/ui/ProgressBar/ProgressBar.tsx b/frontend/src/components/ui/ProgressBar/ProgressBar.tsx index 8f8ba416..7df77fa1 100644 --- a/frontend/src/components/ui/ProgressBar/ProgressBar.tsx +++ b/frontend/src/components/ui/ProgressBar/ProgressBar.tsx @@ -1,11 +1,19 @@ import React, { forwardRef } from 'react'; import { clsx } from 'clsx'; +import { baseColors, statusColors } from '../../../styles/colors'; +/** + * ProgressBar component with global color system integration + * + * Supports both base color variants (with gradients) and status color variants (solid colors) + * - Base variants: default, success, warning, danger, info (use gradients from color scales) + * - Status variants: pending, inProgress, completed (use solid colors from statusColors) + */ export interface ProgressBarProps { value: number; max?: number; size?: 'sm' | 'md' | 'lg'; - variant?: 'default' | 'success' | 'warning' | 'danger'; + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'pending' | 'inProgress' | 'completed'; showLabel?: boolean; label?: string; className?: string; @@ -33,11 +41,43 @@ const ProgressBar = forwardRef(({ lg: 'h-4', }; - const variantClasses = { - default: 'bg-[var(--color-info)]', - success: 'bg-[var(--color-success)]', - warning: 'bg-[var(--color-warning)]', - danger: 'bg-[var(--color-error)]', + const getVariantStyle = (variant: string) => { + // Map base color variants + const baseColorMap = { + default: baseColors.primary, + success: baseColors.success, + warning: baseColors.warning, + danger: baseColors.error, + info: baseColors.info, + }; + + // Map status color variants (these use single colors from statusColors) + const statusColorMap = { + pending: statusColors.pending.primary, + inProgress: statusColors.inProgress.primary, + completed: statusColors.completed.primary, + }; + + // Check if it's a base color variant (has color scales) + if (variant in baseColorMap) { + const colors = baseColorMap[variant as keyof typeof baseColorMap]; + return { + background: `linear-gradient(to right, ${colors[400]}, ${colors[500]})`, + }; + } + + // Check if it's a status color variant (single color) + if (variant in statusColorMap) { + const color = statusColorMap[variant as keyof typeof statusColorMap]; + return { + backgroundColor: color, + }; + } + + // Default fallback + return { + background: `linear-gradient(to right, ${baseColors.primary[400]}, ${baseColors.primary[500]})`, + }; }; return ( @@ -61,18 +101,22 @@ const ProgressBar = forwardRef(({
{animated && percentage < 100 && ( -
+
)}
diff --git a/frontend/src/components/ui/StatusCard/StatusCard.tsx b/frontend/src/components/ui/StatusCard/StatusCard.tsx index a4bbda5a..534919c3 100644 --- a/frontend/src/components/ui/StatusCard/StatusCard.tsx +++ b/frontend/src/components/ui/StatusCard/StatusCard.tsx @@ -241,69 +241,68 @@ export const StatusCard: React.FC = ({
)} - {/* Elegant Action System */} + {/* Simplified Action System */} {actions.length > 0 && ( -
- {/* Primary Actions Row */} - {primaryActions.length > 0 && ( -
- + + )} - {/* Secondary Action Button */} - {primaryActions.length > 1 && ( - - )} -
- )} - - {/* Secondary Actions Row - Smaller buttons */} - {secondaryActions.length > 0 && ( -
+ {/* Action icons for secondary actions */} +
{secondaryActions.map((action, index) => ( - + {action.icon && React.createElement(action.icon, { className: "w-4 h-4" })} + + ))} + + {/* Include additional primary actions as icons */} + {primaryActions.slice(1).map((action, index) => ( + ))}
- )} - +
)}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index baa285ad..d9432f22 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -59,21 +59,6 @@ export const AuthProvider: React.FC = ({ children }) => { initializeAuth(); }, []); - // Set up token refresh interval - useEffect(() => { - if (authStore.isAuthenticated && authStore.token) { - const refreshInterval = setInterval(() => { - if (authStore.refreshToken) { - authStore.refreshAuth().catch(() => { - // Refresh failed, logout user - authStore.logout(); - }); - } - }, 14 * 60 * 1000); // Refresh every 14 minutes - - return () => clearInterval(refreshInterval); - } - }, [authStore.isAuthenticated, authStore.token, authStore.refreshToken]); const contextValue: AuthContextType = { user: authStore.user, diff --git a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx index cdbf04cf..86cc45fe 100644 --- a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx +++ b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx @@ -1,20 +1,20 @@ import React, { useState, useMemo } from 'react'; -import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2 } from 'lucide-react'; +import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import { LoadingSpinner } from '../../../../components/shared'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; import { - CreateItemModal, - QuickViewModal, - AddStockModal, - UseStockModal, - HistoryModal, - StockLotsModal, - EditItemModal, + CreateIngredientModal, + ShowInfoModal, + StockHistoryModal, + BatchModal, DeleteIngredientModal } from '../../../../components/domain/inventory'; -import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient } from '../../../../api/hooks/inventory'; + +// Import AddStockModal separately since we need it for adding batches +import AddStockModal from '../../../../components/domain/inventory/AddStockModal'; +import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory'; import { useCurrentTenant } from '../../../../stores/tenant.store'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory'; @@ -23,14 +23,12 @@ const InventoryPage: React.FC = () => { const [selectedItem, setSelectedItem] = useState(null); // Modal states for focused actions - const [showCreateItem, setShowCreateItem] = useState(false); - const [showQuickView, setShowQuickView] = useState(false); - const [showAddStock, setShowAddStock] = useState(false); - const [showUseStock, setShowUseStock] = useState(false); - const [showHistory, setShowHistory] = useState(false); - const [showStockLots, setShowStockLots] = useState(false); - const [showEdit, setShowEdit] = useState(false); + const [showCreateIngredient, setShowCreateIngredient] = useState(false); + const [showInfo, setShowInfo] = useState(false); + const [showStockHistory, setShowStockHistory] = useState(false); + const [showBatches, setShowBatches] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showAddBatch, setShowAddBatch] = useState(false); const currentTenant = useCurrentTenant(); const tenantId = currentTenant?.id || ''; @@ -39,6 +37,9 @@ const InventoryPage: React.FC = () => { const createIngredientMutation = useCreateIngredient(); const softDeleteMutation = useSoftDeleteIngredient(); const hardDeleteMutation = useHardDeleteIngredient(); + const addStockMutation = useAddStock(); + const consumeStockMutation = useConsumeStock(); + const updateIngredientMutation = useUpdateIngredient(); // API Data const { @@ -69,10 +70,10 @@ const InventoryPage: React.FC = () => { selectedItem?.id, 50, 0, - { enabled: !!selectedItem?.id && showHistory } + { enabled: !!selectedItem?.id && showStockHistory } ); - // Stock lots for stock lots modal + // Stock lots for stock lots modal and history modal const { data: stockLotsData, isLoading: stockLotsLoading, @@ -81,17 +82,31 @@ const InventoryPage: React.FC = () => { tenantId, selectedItem?.id || '', false, // includeUnavailable - { enabled: !!selectedItem?.id && showStockLots } + { enabled: !!selectedItem?.id && showBatches } ); - // Debug stock lots data - console.log('Stock lots hook state:', { + // Transformations for history modal (not currently used in new design) + const { + data: transformationsData, + isLoading: transformationsLoading + } = useTransformationsByIngredient( + tenantId, + selectedItem?.id || '', + 50, // limit + { enabled: false } // Disabled for now since transformations not shown in new modals + ); + + // Debug data + console.log('Inventory data debug:', { selectedItem: selectedItem?.id, - showStockLots, + showBatches, + showStockHistory, stockLotsData, stockLotsLoading, stockLotsError, - enabled: !!selectedItem?.id && showStockLots + transformationsData, + transformationsLoading, + enabled: !!selectedItem?.id && showBatches }); @@ -267,41 +282,22 @@ const InventoryPage: React.FC = () => { }; // Focused action handlers - const handleQuickView = (ingredient: IngredientResponse) => { + const handleShowInfo = (ingredient: IngredientResponse) => { setSelectedItem(ingredient); - setShowQuickView(true); + setShowInfo(true); }; - const handleAddStock = (ingredient: IngredientResponse) => { + const handleShowStockHistory = (ingredient: IngredientResponse) => { setSelectedItem(ingredient); - setShowAddStock(true); + setShowStockHistory(true); }; - const handleUseStock = (ingredient: IngredientResponse) => { + const handleShowBatches = (ingredient: IngredientResponse) => { setSelectedItem(ingredient); - setShowUseStock(true); + setShowBatches(true); }; - const handleHistory = (ingredient: IngredientResponse) => { - setSelectedItem(ingredient); - setShowHistory(true); - }; - - const handleStockLots = (ingredient: IngredientResponse) => { - console.log('🔍 Opening stock lots for ingredient:', { - id: ingredient.id, - name: ingredient.name, - current_stock: ingredient.current_stock, - category: ingredient.category - }); - setSelectedItem(ingredient); - setShowStockLots(true); - }; - - const handleEdit = (ingredient: IngredientResponse) => { - setSelectedItem(ingredient); - setShowEdit(true); - }; + // This function is now replaced by handleShowBatches const handleDelete = (ingredient: IngredientResponse) => { setSelectedItem(ingredient); @@ -310,7 +306,7 @@ const InventoryPage: React.FC = () => { // Handle new item creation const handleNewItem = () => { - setShowCreateItem(true); + setShowCreateIngredient(true); }; // Handle creating a new ingredient @@ -329,19 +325,32 @@ const InventoryPage: React.FC = () => { // Modal action handlers const handleAddStockSubmit = async (stockData: StockCreate) => { - console.log('Add stock:', stockData); - // TODO: Implement API call + if (!tenantId) { + throw new Error('No tenant ID available'); + } + + return addStockMutation.mutateAsync({ + tenantId, + stockData + }); }; const handleUseStockSubmit = async (movementData: StockMovementCreate) => { - console.log('Use stock:', movementData); - // TODO: Implement API call + if (!tenantId) { + throw new Error('No tenant ID available'); + } + + return consumeStockMutation.mutateAsync({ + tenantId, + consumptionData: { + ingredient_id: movementData.ingredient_id, + quantity: Number(movementData.quantity), + reference_number: movementData.reference_number, + notes: movementData.notes + } + }); }; - const handleUpdateIngredient = async (id: string, updateData: any) => { - console.log('Update ingredient:', id, updateData); - // TODO: Implement API call - }; // Delete handlers using mutation hooks const handleSoftDelete = async (ingredientId: string) => { @@ -557,49 +566,29 @@ const InventoryPage: React.FC = () => { color: statusConfig.color } : undefined} actions={[ - // Primary action - Most common user need + // Primary action - View item details { - label: currentStock === 0 ? 'Agregar Stock' : 'Ver Detalles', - icon: currentStock === 0 ? Plus : Eye, - variant: currentStock === 0 ? 'primary' : 'outline', + label: 'Ver Detalles', + icon: Eye, + variant: 'primary', priority: 'primary', - onClick: () => currentStock === 0 ? handleAddStock(ingredient) : handleQuickView(ingredient) - }, - // Secondary primary - Quick access to other main action - { - label: currentStock === 0 ? 'Ver Info' : 'Agregar', - icon: currentStock === 0 ? Eye : Plus, - variant: 'outline', - priority: 'primary', - onClick: () => currentStock === 0 ? handleQuickView(ingredient) : handleAddStock(ingredient) - }, - // Secondary actions - Most used operations - { - label: 'Lotes', - icon: Package, - priority: 'secondary', - onClick: () => handleStockLots(ingredient) - }, - { - label: 'Usar', - icon: Minus, - priority: 'secondary', - onClick: () => handleUseStock(ingredient) + onClick: () => handleShowInfo(ingredient) }, + // Stock history action - Icon button { label: 'Historial', - icon: Clock, + icon: History, priority: 'secondary', - onClick: () => handleHistory(ingredient) + onClick: () => handleShowStockHistory(ingredient) }, - // Least common action + // Batch management action { - label: 'Editar', - icon: Edit, + label: 'Ver Lotes', + icon: Package, priority: 'secondary', - onClick: () => handleEdit(ingredient) + onClick: () => handleShowBatches(ingredient) }, - // Destructive action - separated for safety + // Destructive action { label: 'Eliminar', icon: Trash2, @@ -637,48 +626,39 @@ const InventoryPage: React.FC = () => { {/* Focused Action Modals */} - {/* Create Item Modal - doesn't need selectedItem */} - setShowCreateItem(false)} + {/* Create Ingredient Modal - doesn't need selectedItem */} + setShowCreateIngredient(false)} onCreateIngredient={handleCreateIngredient} /> {selectedItem && ( <> - { - setShowQuickView(false); + setShowInfo(false); setSelectedItem(null); }} ingredient={selectedItem} - /> + onSave={async (updatedData) => { + if (!tenantId || !selectedItem) { + throw new Error('Missing tenant ID or selected item'); + } - { - setShowAddStock(false); - setSelectedItem(null); + return updateIngredientMutation.mutateAsync({ + tenantId, + ingredientId: selectedItem.id, + updateData: updatedData + }); }} - ingredient={selectedItem} - onAddStock={handleAddStockSubmit} /> - { - setShowUseStock(false); - setSelectedItem(null); - }} - ingredient={selectedItem} - onUseStock={handleUseStockSubmit} - /> - - { - setShowHistory(false); + setShowStockHistory(false); setSelectedItem(null); }} ingredient={selectedItem} @@ -686,25 +666,26 @@ const InventoryPage: React.FC = () => { loading={movementsLoading} /> - { - setShowStockLots(false); + setShowBatches(false); setSelectedItem(null); }} ingredient={selectedItem} - stockLots={stockLotsData || []} + batches={stockLotsData || []} loading={stockLotsLoading} - /> - - { - setShowEdit(false); - setSelectedItem(null); + onAddBatch={() => { + setShowAddBatch(true); + }} + onEditBatch={async (batchId, updateData) => { + // TODO: Implement edit batch functionality + console.log('Edit batch:', batchId, updateData); + }} + onMarkAsWaste={async (batchId) => { + // TODO: Implement mark as waste functionality + console.log('Mark as waste:', batchId); }} - ingredient={selectedItem} - onUpdateIngredient={handleUpdateIngredient} /> { onHardDelete={handleHardDelete} isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending} /> + + { + setShowAddBatch(false); + }} + ingredient={selectedItem} + onAddStock={handleAddStockSubmit} + /> )}
diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 9a2426e8..7c86c59f 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -564,7 +564,7 @@ export const formatDateInTimezone = ( ): string => { try { const dateObj = typeof date === 'string' ? parseISO(date) : date; - + if (!isValid(dateObj)) { return ''; } @@ -580,4 +580,23 @@ export const formatDateInTimezone = ( } catch { return formatDate(date, formatStr); } +}; + +// Convert HTML date input (YYYY-MM-DD) to end-of-day datetime for API +export const formatExpirationDateForAPI = (dateString: string): string | undefined => { + try { + if (!dateString) return undefined; + + // Parse the date string (YYYY-MM-DD format from HTML date input) + const dateObj = parseISO(dateString); + + if (!isValid(dateObj)) { + return undefined; + } + + // Set to end of day for expiration dates and return ISO string + return getEndOfDay(dateObj).toISOString(); + } catch { + return undefined; + } }; \ No newline at end of file diff --git a/services/inventory/app/models/inventory.py b/services/inventory/app/models/inventory.py index 446f48db..0b889999 100644 --- a/services/inventory/app/models/inventory.py +++ b/services/inventory/app/models/inventory.py @@ -75,14 +75,14 @@ class ProductionStage(enum.Enum): class StockMovementType(enum.Enum): """Types of inventory movements""" - PURCHASE = "purchase" - PRODUCTION_USE = "production_use" - ADJUSTMENT = "adjustment" - WASTE = "waste" - TRANSFER = "transfer" - RETURN = "return" - INITIAL_STOCK = "initial_stock" - TRANSFORMATION = "transformation" # Converting between production stages + PURCHASE = "PURCHASE" + PRODUCTION_USE = "PRODUCTION_USE" + ADJUSTMENT = "ADJUSTMENT" + WASTE = "WASTE" + TRANSFER = "TRANSFER" + RETURN = "RETURN" + INITIAL_STOCK = "INITIAL_STOCK" + TRANSFORMATION = "TRANSFORMATION" # Converting between production stages class Ingredient(Base): @@ -121,15 +121,8 @@ class Ingredient(Base): reorder_quantity = Column(Float, nullable=False, default=50.0) max_stock_level = Column(Float, nullable=True) - # Storage requirements (applies to both ingredients and finished products) - requires_refrigeration = Column(Boolean, default=False) - requires_freezing = Column(Boolean, default=False) - storage_temperature_min = Column(Float, nullable=True) # Celsius - storage_temperature_max = Column(Float, nullable=True) # Celsius - storage_humidity_max = Column(Float, nullable=True) # Percentage - - # Shelf life (critical for finished products) - shelf_life_days = Column(Integer, nullable=True) + # Shelf life (critical for finished products) - default values only + shelf_life_days = Column(Integer, nullable=True) # Default shelf life - actual per batch display_life_hours = Column(Integer, nullable=True) # How long can be displayed (for fresh products) best_before_hours = Column(Integer, nullable=True) # Hours until best before (for same-day products) storage_instructions = Column(Text, nullable=True) @@ -211,11 +204,6 @@ class Ingredient(Base): 'reorder_point': self.reorder_point, 'reorder_quantity': self.reorder_quantity, 'max_stock_level': self.max_stock_level, - 'requires_refrigeration': self.requires_refrigeration, - 'requires_freezing': self.requires_freezing, - 'storage_temperature_min': self.storage_temperature_min, - 'storage_temperature_max': self.storage_temperature_max, - 'storage_humidity_max': self.storage_humidity_max, 'shelf_life_days': self.shelf_life_days, 'display_life_hours': self.display_life_hours, 'best_before_hours': self.best_before_hours, @@ -248,7 +236,7 @@ class Stock(Base): supplier_batch_ref = Column(String(100), nullable=True) # Production stage tracking - production_stage = Column(String(20), nullable=False, default='raw_ingredient', index=True) + production_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False, default='raw_ingredient', index=True) transformation_reference = Column(String(100), nullable=True, index=True) # Links related transformations # Quantities @@ -275,6 +263,15 @@ class Stock(Base): warehouse_zone = Column(String(50), nullable=True) shelf_position = Column(String(50), nullable=True) + # Batch-specific storage requirements + requires_refrigeration = Column(Boolean, default=False) + requires_freezing = Column(Boolean, default=False) + storage_temperature_min = Column(Float, nullable=True) # Celsius + storage_temperature_max = Column(Float, nullable=True) # Celsius + storage_humidity_max = Column(Float, nullable=True) # Percentage + shelf_life_days = Column(Integer, nullable=True) # Batch-specific shelf life + storage_instructions = Column(Text, nullable=True) # Batch-specific instructions + # Status is_available = Column(Boolean, default=True) is_expired = Column(Boolean, default=False, index=True) @@ -325,6 +322,13 @@ class Stock(Base): 'storage_location': self.storage_location, 'warehouse_zone': self.warehouse_zone, 'shelf_position': self.shelf_position, + 'requires_refrigeration': self.requires_refrigeration, + 'requires_freezing': self.requires_freezing, + 'storage_temperature_min': self.storage_temperature_min, + 'storage_temperature_max': self.storage_temperature_max, + 'storage_humidity_max': self.storage_humidity_max, + 'shelf_life_days': self.shelf_life_days, + 'storage_instructions': self.storage_instructions, 'is_available': self.is_available, 'is_expired': self.is_expired, 'quality_status': self.quality_status, @@ -343,7 +347,7 @@ class StockMovement(Base): stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True) # Movement details - movement_type = Column(SQLEnum(StockMovementType), nullable=False, index=True) + movement_type = Column(SQLEnum('PURCHASE', 'PRODUCTION_USE', 'ADJUSTMENT', 'WASTE', 'TRANSFER', 'RETURN', 'INITIAL_STOCK', name='stockmovementtype', create_type=False), nullable=False, index=True) quantity = Column(Float, nullable=False) unit_cost = Column(Numeric(10, 2), nullable=True) total_cost = Column(Numeric(10, 2), nullable=True) @@ -386,7 +390,7 @@ class StockMovement(Base): 'tenant_id': str(self.tenant_id), 'ingredient_id': str(self.ingredient_id), 'stock_id': str(self.stock_id) if self.stock_id else None, - 'movement_type': self.movement_type.value if self.movement_type else None, + 'movement_type': self.movement_type if self.movement_type else None, 'quantity': self.quantity, 'unit_cost': float(self.unit_cost) if self.unit_cost else None, 'total_cost': float(self.total_cost) if self.total_cost else None, @@ -415,8 +419,8 @@ class ProductTransformation(Base): target_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False) # Stage transformation - source_stage = Column(String(20), nullable=False) - target_stage = Column(String(20), nullable=False) + source_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False) + target_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False) # Quantities and conversion source_quantity = Column(Float, nullable=False) # Input quantity diff --git a/services/inventory/app/repositories/ingredient_repository.py b/services/inventory/app/repositories/ingredient_repository.py index 60f18609..ca46fb0c 100644 --- a/services/inventory/app/repositories/ingredient_repository.py +++ b/services/inventory/app/repositories/ingredient_repository.py @@ -395,7 +395,8 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]: """Update the last purchase price for an ingredient""" try: - update_data = {'last_purchase_price': price} + from app.schemas.inventory import IngredientUpdate + update_data = IngredientUpdate(last_purchase_price=price) return await self.update(ingredient_id, update_data) except Exception as e: @@ -442,4 +443,28 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie except Exception as e: await self.session.rollback() logger.error("Failed to hard delete ingredient", error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id) - raise \ No newline at end of file + raise + + async def get_active_tenants(self) -> List[UUID]: + """Get list of active tenant IDs from ingredients table""" + try: + result = await self.session.execute( + select(func.distinct(Ingredient.tenant_id)) + .where(Ingredient.is_active == True) + ) + + tenant_ids = [] + for row in result.fetchall(): + tenant_id = row[0] + # Convert to UUID if it's not already + if isinstance(tenant_id, UUID): + tenant_ids.append(tenant_id) + else: + tenant_ids.append(UUID(str(tenant_id))) + + logger.info("Retrieved active tenants from ingredients", count=len(tenant_ids)) + return tenant_ids + + except Exception as e: + logger.error("Failed to get active tenants from ingredients", error=str(e)) + return [] \ No newline at end of file diff --git a/services/inventory/app/repositories/stock_movement_repository.py b/services/inventory/app/repositories/stock_movement_repository.py index 418b00d1..48f59ccc 100644 --- a/services/inventory/app/repositories/stock_movement_repository.py +++ b/services/inventory/app/repositories/stock_movement_repository.py @@ -6,6 +6,7 @@ Stock Movement Repository using Repository Pattern from typing import List, Optional, Dict, Any from uuid import UUID from datetime import datetime, timedelta +from decimal import Decimal from sqlalchemy import select, func, and_, or_, desc, asc from sqlalchemy.ext.asyncio import AsyncSession import structlog @@ -35,6 +36,16 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, create_data = movement_data.model_dump() create_data['tenant_id'] = tenant_id create_data['created_by'] = created_by + + # Ensure movement_type is properly converted to enum value + if 'movement_type' in create_data: + movement_type = create_data['movement_type'] + if hasattr(movement_type, 'value'): + # It's an enum object, use its value + create_data['movement_type'] = movement_type.value + elif isinstance(movement_type, str): + # It's already a string, ensure it's uppercase for database + create_data['movement_type'] = movement_type.upper() # Set movement date if not provided if not create_data.get('movement_date'): @@ -42,7 +53,9 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, # Calculate total cost if unit cost provided if create_data.get('unit_cost') and create_data.get('quantity'): - create_data['total_cost'] = create_data['unit_cost'] * create_data['quantity'] + unit_cost = create_data['unit_cost'] + quantity = Decimal(str(create_data['quantity'])) + create_data['total_cost'] = unit_cost * quantity # Create record record = await self.create(create_data) @@ -50,7 +63,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, "Created stock movement", movement_id=record.id, ingredient_id=record.ingredient_id, - movement_type=record.movement_type.value if record.movement_type else None, + movement_type=record.movement_type if record.movement_type else None, quantity=record.quantity, tenant_id=tenant_id ) @@ -234,7 +247,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, summary = {} for row in result: - movement_type = row.movement_type.value if row.movement_type else "unknown" + movement_type = row.movement_type if row.movement_type else "unknown" summary[movement_type] = { 'count': row.count, 'total_quantity': float(row.total_quantity), @@ -417,4 +430,65 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate, ingredient_id=str(ingredient_id), tenant_id=str(tenant_id) ) + raise + + async def create_automatic_waste_movement( + self, + tenant_id: UUID, + ingredient_id: UUID, + stock_id: UUID, + quantity: float, + unit_cost: Optional[float], + batch_number: Optional[str], + expiration_date: datetime, + created_by: Optional[UUID] = None + ) -> StockMovement: + """Create an automatic waste movement for expired batches""" + try: + # Calculate total cost + total_cost = None + if unit_cost and quantity: + total_cost = Decimal(str(unit_cost)) * Decimal(str(quantity)) + + # Generate reference number + reference_number = f"AUTO-EXPIRE-{batch_number or stock_id}" + + # Create movement data + movement_data = { + 'tenant_id': tenant_id, + 'ingredient_id': ingredient_id, + 'stock_id': stock_id, + 'movement_type': StockMovementType.WASTE.value, + 'quantity': quantity, + 'unit_cost': Decimal(str(unit_cost)) if unit_cost else None, + 'total_cost': total_cost, + 'quantity_before': quantity, + 'quantity_after': 0, + 'reference_number': reference_number, + 'reason_code': 'expired', + 'notes': f"Lote automáticamente marcado como caducado. Vencimiento: {expiration_date.strftime('%Y-%m-%d')}", + 'movement_date': datetime.now(), + 'created_by': created_by + } + + # Create the movement record + movement = await self.create(movement_data) + + logger.info("Created automatic waste movement for expired batch", + movement_id=str(movement.id), + tenant_id=str(tenant_id), + ingredient_id=str(ingredient_id), + stock_id=str(stock_id), + quantity=quantity, + batch_number=batch_number, + reference_number=reference_number) + + return movement + + except Exception as e: + logger.error("Failed to create automatic waste movement", + error=str(e), + tenant_id=str(tenant_id), + ingredient_id=str(ingredient_id), + stock_id=str(stock_id)) raise \ No newline at end of file diff --git a/services/inventory/app/repositories/stock_repository.py b/services/inventory/app/repositories/stock_repository.py index fe54f19e..a1be30a2 100644 --- a/services/inventory/app/repositories/stock_repository.py +++ b/services/inventory/app/repositories/stock_repository.py @@ -6,6 +6,7 @@ Stock Repository using Repository Pattern from typing import List, Optional, Dict, Any, Tuple from uuid import UUID from datetime import datetime, timedelta +from decimal import Decimal from sqlalchemy import select, func, and_, or_, desc, asc, update from sqlalchemy.ext.asyncio import AsyncSession import structlog @@ -13,11 +14,12 @@ import structlog from app.models.inventory import Stock, Ingredient from app.schemas.inventory import StockCreate, StockUpdate from shared.database.repository import BaseRepository +from shared.utils.batch_generator import BatchCountProvider logger = structlog.get_logger() -class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): +class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider): """Repository for stock operations""" def __init__(self, session: AsyncSession): @@ -29,6 +31,20 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): # Prepare data create_data = stock_data.model_dump() create_data['tenant_id'] = tenant_id + + # Ensure production_stage is properly converted to enum value + if 'production_stage' in create_data: + if hasattr(create_data['production_stage'], 'value'): + create_data['production_stage'] = create_data['production_stage'].value + elif isinstance(create_data['production_stage'], str): + # If it's a string, ensure it's the correct enum value + from app.models.inventory import ProductionStage + try: + enum_obj = ProductionStage[create_data['production_stage']] + create_data['production_stage'] = enum_obj.value + except KeyError: + # If it's already the value, keep it as is + pass # Calculate available quantity available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0) @@ -36,7 +52,9 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): # Calculate total cost if unit cost provided if create_data.get('unit_cost') and create_data.get('current_quantity'): - create_data['total_cost'] = create_data['unit_cost'] * create_data['current_quantity'] + unit_cost = create_data['unit_cost'] + current_quantity = Decimal(str(create_data['current_quantity'])) + create_data['total_cost'] = unit_cost * current_quantity # Create record record = await self.create(create_data) @@ -524,4 +542,164 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): ingredient_id=str(ingredient_id), tenant_id=str(tenant_id) ) + raise + + async def get_daily_batch_count( + self, + tenant_id: str, + date_start: datetime, + date_end: datetime, + prefix: Optional[str] = None + ) -> int: + """Get the count of batches created today for the given tenant""" + try: + conditions = [ + Stock.tenant_id == tenant_id, + Stock.created_at >= date_start, + Stock.created_at <= date_end + ] + + if prefix: + conditions.append(Stock.batch_number.like(f"{prefix}-%")) + + stmt = select(func.count(Stock.id)).where(and_(*conditions)) + result = await self.session.execute(stmt) + count = result.scalar() or 0 + + logger.debug( + "Retrieved daily batch count", + tenant_id=tenant_id, + prefix=prefix, + count=count, + date_start=date_start, + date_end=date_end + ) + + return count + + except Exception as e: + logger.error( + "Failed to get daily batch count", + error=str(e), + tenant_id=tenant_id, + prefix=prefix + ) + raise + + async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]: + """Get expired batches that haven't been processed yet (for automatic processing)""" + try: + current_date = datetime.now() + + # Find expired batches that are still available and not yet marked as expired + result = await self.session.execute( + select(Stock, Ingredient) + .join(Ingredient, Stock.ingredient_id == Ingredient.id) + .where( + and_( + Stock.tenant_id == tenant_id, + Stock.is_available == True, + Stock.is_expired == False, + Stock.current_quantity > 0, + or_( + and_( + Stock.final_expiration_date.isnot(None), + Stock.final_expiration_date <= current_date + ), + and_( + Stock.final_expiration_date.is_(None), + Stock.expiration_date.isnot(None), + Stock.expiration_date <= current_date + ) + ) + ) + ) + .order_by( + asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date)) + ) + ) + + expired_batches = result.all() + logger.info("Found expired batches for processing", + tenant_id=str(tenant_id), + count=len(expired_batches)) + + return expired_batches + + except Exception as e: + logger.error("Failed to get expired batches for processing", + error=str(e), tenant_id=tenant_id) + raise + + async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool: + """Mark a specific batch as expired and unavailable""" + try: + result = await self.session.execute( + update(Stock) + .where( + and_( + Stock.id == stock_id, + Stock.tenant_id == tenant_id + ) + ) + .values( + is_expired=True, + is_available=False, + quality_status="expired", + updated_at=datetime.now() + ) + ) + + if result.rowcount > 0: + logger.info("Marked batch as expired", + stock_id=str(stock_id), + tenant_id=str(tenant_id)) + return True + else: + logger.warning("No batch found to mark as expired", + stock_id=str(stock_id), + tenant_id=str(tenant_id)) + return False + + except Exception as e: + logger.error("Failed to mark batch as expired", + error=str(e), + stock_id=str(stock_id), + tenant_id=str(tenant_id)) + raise + + async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool: + """Update stock quantities to zero after moving to waste""" + try: + result = await self.session.execute( + update(Stock) + .where( + and_( + Stock.id == stock_id, + Stock.tenant_id == tenant_id + ) + ) + .values( + current_quantity=0, + available_quantity=0, + updated_at=datetime.now() + ) + ) + + if result.rowcount > 0: + logger.info("Updated stock quantities to zero", + stock_id=str(stock_id), + tenant_id=str(tenant_id)) + return True + else: + logger.warning("No stock found to update to zero", + stock_id=str(stock_id), + tenant_id=str(tenant_id)) + return False + + except Exception as e: + logger.error("Failed to update stock to zero", + error=str(e), + stock_id=str(stock_id), + tenant_id=str(tenant_id)) raise \ No newline at end of file diff --git a/services/inventory/app/schemas/inventory.py b/services/inventory/app/schemas/inventory.py index 75ff7721..788f6100 100644 --- a/services/inventory/app/schemas/inventory.py +++ b/services/inventory/app/schemas/inventory.py @@ -54,16 +54,8 @@ class IngredientCreate(InventoryBaseSchema): reorder_quantity: float = Field(50.0, gt=0, description="Default reorder quantity") max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level") - # Storage requirements - requires_refrigeration: bool = Field(False, description="Requires refrigeration") - requires_freezing: bool = Field(False, description="Requires freezing") - storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)") - storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)") - storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)") - - # Shelf life - shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days") - storage_instructions: Optional[str] = Field(None, description="Storage instructions") + # Shelf life (default value only - actual per batch) + shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days") # Properties is_perishable: bool = Field(False, description="Is perishable") @@ -106,16 +98,8 @@ class IngredientUpdate(InventoryBaseSchema): reorder_quantity: Optional[float] = Field(None, gt=0, description="Default reorder quantity") max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level") - # Storage requirements - requires_refrigeration: Optional[bool] = Field(None, description="Requires refrigeration") - requires_freezing: Optional[bool] = Field(None, description="Requires freezing") - storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)") - storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)") - storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)") - - # Shelf life - shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days") - storage_instructions: Optional[str] = Field(None, description="Storage instructions") + # Shelf life (default value only - actual per batch) + shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days") # Properties is_active: Optional[bool] = Field(None, description="Is active") @@ -144,13 +128,7 @@ class IngredientResponse(InventoryBaseSchema): reorder_point: float reorder_quantity: float max_stock_level: Optional[float] - requires_refrigeration: bool - requires_freezing: bool - storage_temperature_min: Optional[float] - storage_temperature_max: Optional[float] - storage_humidity_max: Optional[float] - shelf_life_days: Optional[int] - storage_instructions: Optional[str] + shelf_life_days: Optional[int] # Default value only is_active: bool is_perishable: bool allergen_info: Optional[Dict[str, Any]] @@ -174,7 +152,7 @@ class StockCreate(InventoryBaseSchema): supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference") # Production stage tracking - production_stage: ProductionStage = Field(ProductionStage.RAW_INGREDIENT, description="Production stage of the stock") + production_stage: ProductionStage = Field(default=ProductionStage.RAW_INGREDIENT, description="Production stage of the stock") transformation_reference: Optional[str] = Field(None, max_length=100, description="Transformation reference ID") current_quantity: float = Field(..., ge=0, description="Current quantity") @@ -194,6 +172,15 @@ class StockCreate(InventoryBaseSchema): quality_status: str = Field("good", description="Quality status") + # Batch-specific storage requirements + requires_refrigeration: bool = Field(False, description="Requires refrigeration") + requires_freezing: bool = Field(False, description="Requires freezing") + storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)") + storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)") + storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)") + shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days") + storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions") + class StockUpdate(InventoryBaseSchema): """Schema for updating stock entries""" @@ -224,6 +211,15 @@ class StockUpdate(InventoryBaseSchema): is_available: Optional[bool] = Field(None, description="Is available") quality_status: Optional[str] = Field(None, description="Quality status") + # Batch-specific storage requirements + requires_refrigeration: Optional[bool] = Field(None, description="Requires refrigeration") + requires_freezing: Optional[bool] = Field(None, description="Requires freezing") + storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)") + storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)") + storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)") + shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days") + storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions") + class StockResponse(InventoryBaseSchema): """Schema for stock API responses""" @@ -258,6 +254,15 @@ class StockResponse(InventoryBaseSchema): is_available: bool is_expired: bool quality_status: str + + # Batch-specific storage requirements + requires_refrigeration: bool + requires_freezing: bool + storage_temperature_min: Optional[float] + storage_temperature_max: Optional[float] + storage_humidity_max: Optional[float] + shelf_life_days: Optional[int] + storage_instructions: Optional[str] created_at: datetime updated_at: datetime diff --git a/services/inventory/app/services/inventory_alert_service.py b/services/inventory/app/services/inventory_alert_service.py index a7825913..b406e972 100644 --- a/services/inventory/app/services/inventory_alert_service.py +++ b/services/inventory/app/services/inventory_alert_service.py @@ -6,15 +6,18 @@ Implements hybrid detection patterns for critical stock issues and optimization import asyncio import json +import uuid from typing import List, Dict, Any, Optional from uuid import UUID -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import structlog from apscheduler.triggers.cron import CronTrigger from sqlalchemy import text from shared.alerts.base_service import BaseAlertService, AlertServiceMixin from shared.alerts.templates import format_item_message +from app.repositories.stock_repository import StockRepository +from app.repositories.stock_movement_repository import StockMovementRepository logger = structlog.get_logger() @@ -70,6 +73,15 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): misfire_grace_time=300, max_instances=1 ) + + # Expired batch detection - daily at 6:00 AM (alerts and automated processing) + self.scheduler.add_job( + self.check_and_process_expired_batches, + CronTrigger(hour=6, minute=0), # Daily at 6:00 AM + id='expired_batch_processing', + misfire_grace_time=1800, # 30 minute grace time + max_instances=1 + ) logger.info("Inventory alert schedules configured", service=self.config.SERVICE_NAME) @@ -770,4 +782,193 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): logger.error("Error getting stock after order", ingredient_id=ingredient_id, error=str(e)) - return None \ No newline at end of file + return None + + async def check_and_process_expired_batches(self): + """Daily check and automated processing of expired stock batches""" + try: + self._checks_performed += 1 + + # Use existing method to get active tenants from ingredients table + tenants = await self.get_active_tenants() + + if not tenants: + logger.info("No active tenants found") + return + + total_processed = 0 + for tenant_id in tenants: + try: + # Get expired batches for each tenant + async with self.db_manager.get_background_session() as session: + stock_repo = StockRepository(session) + expired_batches = await stock_repo.get_expired_batches_for_processing(tenant_id) + + if expired_batches: + processed_count = await self._process_expired_batches_for_tenant(tenant_id, expired_batches) + total_processed += processed_count + + except Exception as e: + logger.error("Error processing expired batches for tenant", + tenant_id=str(tenant_id), + error=str(e)) + + logger.info("Expired batch processing completed", + total_processed=total_processed, + tenants_processed=len(tenants)) + + except Exception as e: + logger.error("Expired batch processing failed", error=str(e)) + self._errors_count += 1 + + async def _process_expired_batches_for_tenant(self, tenant_id: UUID, batches: List[tuple]) -> int: + """Process expired batches for a specific tenant""" + processed_count = 0 + processed_batches = [] + + try: + for stock, ingredient in batches: + try: + # Process each batch individually with its own transaction + await self._process_single_expired_batch(tenant_id, stock, ingredient) + processed_count += 1 + processed_batches.append((stock, ingredient)) + + except Exception as e: + logger.error("Error processing individual expired batch", + tenant_id=str(tenant_id), + stock_id=str(stock.id), + batch_number=stock.batch_number, + error=str(e)) + + # Generate summary alert for the tenant if any batches were processed + if processed_count > 0: + await self._generate_expired_batch_summary_alert(tenant_id, processed_batches) + + except Exception as e: + logger.error("Error processing expired batches for tenant", + tenant_id=str(tenant_id), + error=str(e)) + + return processed_count + + async def _process_single_expired_batch(self, tenant_id: UUID, stock, ingredient): + """Process a single expired batch: mark as expired, create waste movement, update stock""" + async with self.db_manager.get_background_session() as session: + async with session.begin(): # Use transaction for consistency + try: + stock_repo = StockRepository(session) + movement_repo = StockMovementRepository(session) + + # Calculate effective expiration date + effective_expiration_date = stock.final_expiration_date or stock.expiration_date + + # 1. Mark the stock batch as expired + await stock_repo.mark_batch_as_expired(stock.id, tenant_id) + + # 2. Create waste stock movement + await movement_repo.create_automatic_waste_movement( + tenant_id=tenant_id, + ingredient_id=stock.ingredient_id, + stock_id=stock.id, + quantity=stock.current_quantity, + unit_cost=float(stock.unit_cost) if stock.unit_cost else None, + batch_number=stock.batch_number, + expiration_date=effective_expiration_date, + created_by=None # Automatic system operation + ) + + # 3. Update the stock quantity to 0 (moved to waste) + await stock_repo.update_stock_to_zero(stock.id, tenant_id) + + # Calculate days expired + days_expired = (datetime.now().date() - effective_expiration_date.date()).days if effective_expiration_date else 0 + + logger.info("Expired batch processed successfully", + tenant_id=str(tenant_id), + stock_id=str(stock.id), + ingredient_name=ingredient.name, + batch_number=stock.batch_number, + quantity_wasted=stock.current_quantity, + days_expired=days_expired) + + except Exception as e: + logger.error("Error in expired batch transaction", + stock_id=str(stock.id), + error=str(e)) + raise # Re-raise to trigger rollback + + async def _generate_expired_batch_summary_alert(self, tenant_id: UUID, processed_batches: List[tuple]): + """Generate summary alert for automatically processed expired batches""" + try: + total_batches = len(processed_batches) + total_quantity = sum(float(stock.current_quantity) for stock, ingredient in processed_batches) + + # Get the most affected ingredients (top 3) + ingredient_summary = {} + for stock, ingredient in processed_batches: + ingredient_name = ingredient.name + if ingredient_name not in ingredient_summary: + ingredient_summary[ingredient_name] = { + 'quantity': 0, + 'batches': 0, + 'unit': ingredient.unit_of_measure.value if ingredient.unit_of_measure else 'kg' + } + ingredient_summary[ingredient_name]['quantity'] += float(stock.current_quantity) + ingredient_summary[ingredient_name]['batches'] += 1 + + # Sort by quantity and get top 3 + top_ingredients = sorted(ingredient_summary.items(), + key=lambda x: x[1]['quantity'], + reverse=True)[:3] + + # Build ingredient list for message + ingredient_list = [] + for name, info in top_ingredients: + ingredient_list.append(f"{name} ({info['quantity']:.1f}{info['unit']}, {info['batches']} lote{'s' if info['batches'] > 1 else ''})") + + remaining_count = total_batches - sum(info['batches'] for _, info in top_ingredients) + if remaining_count > 0: + ingredient_list.append(f"y {remaining_count} lote{'s' if remaining_count > 1 else ''} más") + + # Create alert message + title = f"🗑️ Lotes Caducados Procesados Automáticamente" + message = ( + f"Se han procesado automáticamente {total_batches} lote{'s' if total_batches > 1 else ''} " + f"caducado{'s' if total_batches > 1 else ''} ({total_quantity:.1f}kg total) y se ha{'n' if total_batches > 1 else ''} " + f"movido automáticamente a desperdicio:\n\n" + f"• {chr(10).join(ingredient_list)}\n\n" + f"Los lotes han sido marcados como no disponibles y se han generado los movimientos de desperdicio correspondientes." + ) + + await self.publish_item(tenant_id, { + 'type': 'expired_batches_auto_processed', + 'severity': 'medium', + 'title': title, + 'message': message, + 'actions': [ + 'Revisar movimientos de desperdicio', + 'Analizar causas de caducidad', + 'Ajustar niveles de stock', + 'Revisar rotación de inventario' + ], + 'metadata': { + 'total_batches_processed': total_batches, + 'total_quantity_wasted': total_quantity, + 'processing_date': datetime.now(timezone.utc).isoformat(), + 'affected_ingredients': [ + { + 'name': name, + 'quantity_wasted': info['quantity'], + 'batches_count': info['batches'], + 'unit': info['unit'] + } for name, info in ingredient_summary.items() + ], + 'automation_source': 'daily_expired_batch_check' + } + }, item_type='alert') + + except Exception as e: + logger.error("Error generating expired batch summary alert", + tenant_id=str(tenant_id), + error=str(e)) \ No newline at end of file diff --git a/services/inventory/app/services/inventory_service.py b/services/inventory/app/services/inventory_service.py index a305e1a8..da5e07e5 100644 --- a/services/inventory/app/services/inventory_service.py +++ b/services/inventory/app/services/inventory_service.py @@ -20,6 +20,7 @@ from app.schemas.inventory import ( ) from app.core.database import get_db_transaction from shared.database.exceptions import DatabaseError +from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number logger = structlog.get_logger() @@ -237,7 +238,21 @@ class InventoryService: ingredient = await ingredient_repo.get_by_id(UUID(stock_data.ingredient_id)) if not ingredient or ingredient.tenant_id != tenant_id: raise ValueError("Ingredient not found") - + + # Generate batch number if not provided + if not stock_data.batch_number: + try: + batch_generator = BatchNumberGenerator(stock_repo) + stock_data.batch_number = await batch_generator.generate_batch_number( + tenant_id=str(tenant_id), + prefix="INV" + ) + logger.info("Generated batch number", batch_number=stock_data.batch_number) + except Exception as e: + # Fallback to a simple batch number if generation fails + stock_data.batch_number = create_fallback_batch_number("INV") + logger.warning("Used fallback batch number", batch_number=stock_data.batch_number, error=str(e)) + # Create stock entry stock = await stock_repo.create_stock_entry(stock_data, tenant_id) diff --git a/services/inventory/migrations/alembic.ini b/services/inventory/migrations/alembic.ini index 17a1e925..ed8d8e58 100644 --- a/services/inventory/migrations/alembic.ini +++ b/services/inventory/migrations/alembic.ini @@ -2,7 +2,7 @@ [alembic] # path to migration scripts -script_location = migrations +script_location = . # template used to generate migration files # file_template = %%(rev)s_%%(slug)s diff --git a/services/inventory/migrations/versions/003_add_production_stage_enum.py b/services/inventory/migrations/versions/003_add_production_stage_enum.py new file mode 100644 index 00000000..aa869a70 --- /dev/null +++ b/services/inventory/migrations/versions/003_add_production_stage_enum.py @@ -0,0 +1,114 @@ +"""Add production stage enum and columns + +Revision ID: 003 +Revises: 002 +Create Date: 2025-01-17 15:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '003' +down_revision = '002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create ProductionStage enum type + op.execute(""" + CREATE TYPE productionstage AS ENUM ( + 'raw_ingredient', 'par_baked', 'fully_baked', + 'prepared_dough', 'frozen_product' + ); + """) + + # Add production_stage column to stock table + op.add_column('stock', sa.Column('production_stage', + sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'), + nullable=False, server_default='raw_ingredient')) + + # Add transformation_reference column to stock table + op.add_column('stock', sa.Column('transformation_reference', sa.String(100), nullable=True)) + + # Add stage-specific expiration tracking columns + op.add_column('stock', sa.Column('original_expiration_date', sa.DateTime(timezone=True), nullable=True)) + op.add_column('stock', sa.Column('transformation_date', sa.DateTime(timezone=True), nullable=True)) + op.add_column('stock', sa.Column('final_expiration_date', sa.DateTime(timezone=True), nullable=True)) + + # Create product_transformations table + op.create_table( + 'product_transformations', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('transformation_reference', sa.String(100), nullable=False), + sa.Column('source_ingredient_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('target_ingredient_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('source_stage', sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'), nullable=False), + sa.Column('target_stage', sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'), nullable=False), + sa.Column('source_quantity', sa.Float(), nullable=False), + sa.Column('target_quantity', sa.Float(), nullable=False), + sa.Column('conversion_ratio', sa.Float(), nullable=False, server_default='1.0'), + sa.Column('expiration_calculation_method', sa.String(50), nullable=False, server_default='days_from_transformation'), + sa.Column('expiration_days_offset', sa.Integer(), nullable=True), + sa.Column('transformation_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('process_notes', sa.Text(), nullable=True), + sa.Column('performed_by', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('source_batch_numbers', sa.Text(), nullable=True), + sa.Column('target_batch_number', sa.String(100), nullable=True), + sa.Column('is_completed', sa.Boolean(), nullable=True, server_default='true'), + sa.Column('is_reversed', sa.Boolean(), nullable=True, server_default='false'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(['source_ingredient_id'], ['ingredients.id'], ), + sa.ForeignKeyConstraint(['target_ingredient_id'], ['ingredients.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Add new indexes for enhanced functionality + op.create_index('idx_stock_production_stage', 'stock', ['tenant_id', 'production_stage', 'is_available']) + op.create_index('idx_stock_transformation', 'stock', ['tenant_id', 'transformation_reference']) + op.create_index('idx_stock_final_expiration', 'stock', ['tenant_id', 'final_expiration_date', 'is_available']) + + # Create indexes for product_transformations table + op.create_index('idx_transformations_tenant_date', 'product_transformations', ['tenant_id', 'transformation_date']) + op.create_index('idx_transformations_reference', 'product_transformations', ['transformation_reference']) + op.create_index('idx_transformations_source', 'product_transformations', ['tenant_id', 'source_ingredient_id']) + op.create_index('idx_transformations_target', 'product_transformations', ['tenant_id', 'target_ingredient_id']) + op.create_index('idx_transformations_stages', 'product_transformations', ['source_stage', 'target_stage']) + + # Update existing stockmovementtype enum to include TRANSFORMATION + op.execute("ALTER TYPE stockmovementtype ADD VALUE 'transformation';") + + +def downgrade() -> None: + # Drop indexes for product_transformations + op.drop_index('idx_transformations_stages', table_name='product_transformations') + op.drop_index('idx_transformations_target', table_name='product_transformations') + op.drop_index('idx_transformations_source', table_name='product_transformations') + op.drop_index('idx_transformations_reference', table_name='product_transformations') + op.drop_index('idx_transformations_tenant_date', table_name='product_transformations') + + # Drop new stock indexes + op.drop_index('idx_stock_final_expiration', table_name='stock') + op.drop_index('idx_stock_transformation', table_name='stock') + op.drop_index('idx_stock_production_stage', table_name='stock') + + # Drop product_transformations table + op.drop_table('product_transformations') + + # Remove new columns from stock table + op.drop_column('stock', 'final_expiration_date') + op.drop_column('stock', 'transformation_date') + op.drop_column('stock', 'original_expiration_date') + op.drop_column('stock', 'transformation_reference') + op.drop_column('stock', 'production_stage') + + # Drop ProductionStage enum type + op.execute("DROP TYPE productionstage;") + + # Note: Cannot easily remove 'transformation' from existing enum in PostgreSQL + # This would require recreating the enum and updating all references + # For now, we leave the enum value as it won't cause issues \ No newline at end of file diff --git a/services/inventory/migrations/versions/004_move_storage_config_to_batch.py b/services/inventory/migrations/versions/004_move_storage_config_to_batch.py new file mode 100644 index 00000000..2f54ca77 --- /dev/null +++ b/services/inventory/migrations/versions/004_move_storage_config_to_batch.py @@ -0,0 +1,104 @@ +"""Move storage configuration from ingredient to batch level + +Revision ID: 004_move_storage_config_to_batch +Revises: 003_add_production_stage_enum +Create Date: 2025-01-17 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '004_move_storage_config_to_batch' +down_revision = '003_add_production_stage_enum' +branch_labels = None +depends_on = None + + +def upgrade(): + """Move storage configuration from ingredients to stock batches""" + + # Add batch-specific storage columns to stock table + op.add_column('stock', sa.Column('requires_refrigeration', sa.Boolean(), default=False)) + op.add_column('stock', sa.Column('requires_freezing', sa.Boolean(), default=False)) + op.add_column('stock', sa.Column('storage_temperature_min', sa.Float(), nullable=True)) + op.add_column('stock', sa.Column('storage_temperature_max', sa.Float(), nullable=True)) + op.add_column('stock', sa.Column('storage_humidity_max', sa.Float(), nullable=True)) + op.add_column('stock', sa.Column('shelf_life_days', sa.Integer(), nullable=True)) + op.add_column('stock', sa.Column('storage_instructions', sa.Text(), nullable=True)) + + # Migrate existing data from ingredients to stock batches + # This will copy the ingredient-level storage config to all existing stock batches + op.execute(""" + UPDATE stock + SET + requires_refrigeration = i.requires_refrigeration, + requires_freezing = i.requires_freezing, + storage_temperature_min = i.storage_temperature_min, + storage_temperature_max = i.storage_temperature_max, + storage_humidity_max = i.storage_humidity_max, + shelf_life_days = i.shelf_life_days, + storage_instructions = i.storage_instructions + FROM ingredients i + WHERE stock.ingredient_id = i.id + """) + + # Remove storage configuration columns from ingredients table + # Keep only shelf_life_days as default value + op.drop_column('ingredients', 'requires_refrigeration') + op.drop_column('ingredients', 'requires_freezing') + op.drop_column('ingredients', 'storage_temperature_min') + op.drop_column('ingredients', 'storage_temperature_max') + op.drop_column('ingredients', 'storage_humidity_max') + op.drop_column('ingredients', 'storage_instructions') + + +def downgrade(): + """Revert storage configuration back to ingredient level""" + + # Add storage configuration columns back to ingredients table + op.add_column('ingredients', sa.Column('requires_refrigeration', sa.Boolean(), default=False)) + op.add_column('ingredients', sa.Column('requires_freezing', sa.Boolean(), default=False)) + op.add_column('ingredients', sa.Column('storage_temperature_min', sa.Float(), nullable=True)) + op.add_column('ingredients', sa.Column('storage_temperature_max', sa.Float(), nullable=True)) + op.add_column('ingredients', sa.Column('storage_humidity_max', sa.Float(), nullable=True)) + op.add_column('ingredients', sa.Column('storage_instructions', sa.Text(), nullable=True)) + + # Migrate data back from stock to ingredients (use most common values per ingredient) + op.execute(""" + UPDATE ingredients + SET + requires_refrigeration = COALESCE( + (SELECT bool_or(s.requires_refrigeration) FROM stock s WHERE s.ingredient_id = ingredients.id), + false + ), + requires_freezing = COALESCE( + (SELECT bool_or(s.requires_freezing) FROM stock s WHERE s.ingredient_id = ingredients.id), + false + ), + storage_temperature_min = ( + SELECT MIN(s.storage_temperature_min) FROM stock s WHERE s.ingredient_id = ingredients.id + ), + storage_temperature_max = ( + SELECT MAX(s.storage_temperature_max) FROM stock s WHERE s.ingredient_id = ingredients.id + ), + storage_humidity_max = ( + SELECT MAX(s.storage_humidity_max) FROM stock s WHERE s.ingredient_id = ingredients.id + ), + storage_instructions = ( + SELECT s.storage_instructions FROM stock s + WHERE s.ingredient_id = ingredients.id + AND s.storage_instructions IS NOT NULL + LIMIT 1 + ) + """) + + # Remove batch-specific storage columns from stock table + op.drop_column('stock', 'requires_refrigeration') + op.drop_column('stock', 'requires_freezing') + op.drop_column('stock', 'storage_temperature_min') + op.drop_column('stock', 'storage_temperature_max') + op.drop_column('stock', 'storage_humidity_max') + op.drop_column('stock', 'shelf_life_days') + op.drop_column('stock', 'storage_instructions') \ No newline at end of file diff --git a/services/production/app/repositories/production_batch_repository.py b/services/production/app/repositories/production_batch_repository.py index 83b04387..f04170d7 100644 --- a/services/production/app/repositories/production_batch_repository.py +++ b/services/production/app/repositories/production_batch_repository.py @@ -14,11 +14,12 @@ from .base import ProductionBaseRepository from app.models.production import ProductionBatch, ProductionStatus, ProductionPriority from shared.database.exceptions import DatabaseError, ValidationError from shared.database.transactions import transactional +from shared.utils.batch_generator import BatchCountProvider, BatchNumberGenerator, create_fallback_batch_number logger = structlog.get_logger() -class ProductionBatchRepository(ProductionBaseRepository): +class ProductionBatchRepository(ProductionBaseRepository, BatchCountProvider): """Repository for production batch operations""" def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 300): @@ -41,9 +42,17 @@ class ProductionBatchRepository(ProductionBaseRepository): # Generate batch number if not provided if "batch_number" not in batch_data or not batch_data["batch_number"]: - batch_data["batch_number"] = await self._generate_batch_number( - batch_data["tenant_id"] - ) + try: + batch_generator = BatchNumberGenerator(self) + batch_data["batch_number"] = await batch_generator.generate_batch_number( + tenant_id=batch_data["tenant_id"], + prefix="PROD" + ) + logger.info("Generated production batch number", batch_number=batch_data["batch_number"]) + except Exception as e: + # Fallback to a simple batch number if generation fails + batch_data["batch_number"] = create_fallback_batch_number("PROD") + logger.warning("Used fallback batch number", batch_number=batch_data["batch_number"], error=str(e)) # Set default values if "status" not in batch_data: @@ -314,33 +323,57 @@ class ProductionBatchRepository(ProductionBaseRepository): logger.error("Error fetching urgent batches", error=str(e)) raise DatabaseError(f"Failed to fetch urgent batches: {str(e)}") - async def _generate_batch_number(self, tenant_id: str) -> str: - """Generate a unique batch number""" + async def get_daily_batch_count( + self, + tenant_id: str, + date_start: datetime, + date_end: datetime, + prefix: Optional[str] = None + ) -> int: + """Get the count of production batches created today for the given tenant""" try: - # Get current date for prefix - today = datetime.utcnow().date() - date_prefix = today.strftime("%Y%m%d") - - # Count batches created today - today_start = datetime.combine(today, datetime.min.time()) - today_end = datetime.combine(today, datetime.max.time()) - - daily_batches = await self.get_multi( - filters={ - "tenant_id": tenant_id, - "created_at__gte": today_start, - "created_at__lte": today_end - } + conditions = { + "tenant_id": tenant_id, + "created_at__gte": date_start, + "created_at__lte": date_end + } + + if prefix: + # Filter by batch numbers that start with the given prefix + filters_list = [ + and_( + ProductionBatch.tenant_id == tenant_id, + ProductionBatch.created_at >= date_start, + ProductionBatch.created_at <= date_end, + ProductionBatch.batch_number.like(f"{prefix}-%") + ) + ] + result = await self.session.execute( + select(func.count(ProductionBatch.id)).where(and_(*filters_list)) + ) + else: + batches = await self.get_multi(filters=conditions) + result_count = len(batches) + return result_count + + count = result.scalar() or 0 + + logger.debug( + "Retrieved daily production batch count", + tenant_id=tenant_id, + prefix=prefix, + count=count, + date_start=date_start, + date_end=date_end ) - - # Generate sequential number - sequence = len(daily_batches) + 1 - batch_number = f"PROD-{date_prefix}-{sequence:03d}" - - return batch_number - + + return count + except Exception as e: - logger.error("Error generating batch number", error=str(e)) - # Fallback to timestamp-based number - timestamp = int(datetime.utcnow().timestamp()) - return f"PROD-{timestamp}" \ No newline at end of file + logger.error( + "Failed to get daily production batch count", + error=str(e), + tenant_id=tenant_id, + prefix=prefix + ) + raise \ No newline at end of file diff --git a/shared/utils/batch_generator.py b/shared/utils/batch_generator.py new file mode 100644 index 00000000..f0326bbe --- /dev/null +++ b/shared/utils/batch_generator.py @@ -0,0 +1,110 @@ +""" +Shared batch number generator utility +""" + +from datetime import datetime +from typing import Optional, Protocol, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +import structlog + +logger = structlog.get_logger() + + +class BatchCountProvider(Protocol): + """Protocol for providing batch counts for a specific tenant and date range""" + + async def get_daily_batch_count( + self, + tenant_id: str, + date_start: datetime, + date_end: datetime, + prefix: Optional[str] = None + ) -> int: + """Get the count of batches created today for the given tenant""" + ... + + +class BatchNumberGenerator: + """Generates unique batch numbers across different services""" + + def __init__(self, batch_provider: BatchCountProvider): + self.batch_provider = batch_provider + + async def generate_batch_number( + self, + tenant_id: str, + prefix: str = "BATCH", + date: Optional[datetime] = None + ) -> str: + """ + Generate a unique batch number with format: {PREFIX}-{YYYYMMDD}-{XXX} + + Args: + tenant_id: The tenant ID + prefix: Prefix for the batch number (e.g., "INV", "PROD", "BATCH") + date: Date to use for the batch number (defaults to today) + + Returns: + Unique batch number string + """ + try: + # Use provided date or current date + target_date = date or datetime.utcnow() + date_prefix = target_date.strftime("%Y%m%d") + + # Calculate date range for the day + today_start = datetime.combine(target_date.date(), datetime.min.time()) + today_end = datetime.combine(target_date.date(), datetime.max.time()) + + # Get count of batches created today with this prefix + daily_count = await self.batch_provider.get_daily_batch_count( + tenant_id=tenant_id, + date_start=today_start, + date_end=today_end, + prefix=prefix + ) + + # Generate sequential number (starting from 1) + sequence = daily_count + 1 + batch_number = f"{prefix}-{date_prefix}-{sequence:03d}" + + logger.info( + "Generated batch number", + tenant_id=tenant_id, + prefix=prefix, + date=target_date.date(), + sequence=sequence, + batch_number=batch_number + ) + + return batch_number + + except Exception as e: + logger.error( + "Failed to generate batch number", + tenant_id=tenant_id, + prefix=prefix, + error=str(e) + ) + raise + + +def create_fallback_batch_number( + prefix: str = "BATCH", + date: Optional[datetime] = None, + sequence: int = 1 +) -> str: + """ + Create a fallback batch number when database access fails + + Args: + prefix: Prefix for the batch number + date: Date to use (defaults to now) + sequence: Sequence number to use + + Returns: + Fallback batch number string + """ + target_date = date or datetime.utcnow() + date_prefix = target_date.strftime("%Y%m%d") + return f"{prefix}-{date_prefix}-{sequence:03d}" \ No newline at end of file