From d61056df3389425f1c9603b2b0a3d7e2b013ec63 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Thu, 18 Sep 2025 23:32:53 +0200 Subject: [PATCH] Add supplier and imporve inventory frontend --- docker-compose.yml | 24 + frontend/src/App.tsx | 1 + frontend/src/api/types/foodSafety.ts | 46 ++ frontend/src/api/types/inventory.ts | 23 +- frontend/src/api/types/suppliers.ts | 41 +- .../domain/inventory/AddStockModal.tsx | 150 ++++- .../inventory/CreateIngredientModal.tsx | 59 +- .../domain/suppliers/CreateSupplierForm.tsx | 183 ++++++ .../components/ui/StatusCard/StatusCard.tsx | 4 - frontend/src/i18n/index.ts | 38 ++ frontend/src/locales/es/foodSafety.json | 48 ++ frontend/src/locales/es/inventory.json | 75 +++ frontend/src/locales/es/suppliers.json | 84 +++ frontend/src/locales/index.ts | 8 +- .../operations/inventory/InventoryPage.tsx | 19 +- .../operations/suppliers/SuppliersPage.tsx | 563 ++++++++++-------- frontend/src/utils/enumHelpers.ts | 166 ++++++ frontend/src/utils/foodSafetyEnumHelpers.ts | 109 ++++ frontend/src/utils/inventoryEnumHelpers.ts | 140 +++++ services/alert_processor/app/config.py | 7 +- services/alert_processor/app/main.py | 69 ++- .../alert_processor/app/models/__init__.py | 1 + services/alert_processor/app/models/alerts.py | 56 ++ .../alert_processor/migrations/alembic.ini | 93 +++ services/alert_processor/migrations/env.py | 109 ++++ .../alert_processor/migrations/script.py.mako | 24 + .../versions/001_initial_alerts_table.py | 64 ++ services/inventory/app/models/inventory.py | 6 +- services/inventory/app/schemas/inventory.py | 18 +- .../app/services/dashboard_service.py | 21 +- .../app/services/inventory_alert_service.py | 8 +- .../notification/app/models/notifications.py | 10 +- services/suppliers/app/api/suppliers.py | 24 +- services/suppliers/app/models/suppliers.py | 110 ++-- services/suppliers/app/repositories/base.py | 56 +- .../app/repositories/supplier_repository.py | 166 ++---- services/suppliers/app/schemas/suppliers.py | 2 +- .../app/services/dashboard_service.py | 1 - .../app/services/supplier_service.py | 22 +- shared/messaging/rabbitmq.py | 3 + 40 files changed, 2022 insertions(+), 629 deletions(-) create mode 100644 frontend/src/components/domain/suppliers/CreateSupplierForm.tsx create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/locales/es/foodSafety.json create mode 100644 frontend/src/locales/es/suppliers.json create mode 100644 frontend/src/utils/enumHelpers.ts create mode 100644 frontend/src/utils/foodSafetyEnumHelpers.ts create mode 100644 frontend/src/utils/inventoryEnumHelpers.ts create mode 100644 services/alert_processor/app/models/__init__.py create mode 100644 services/alert_processor/app/models/alerts.py create mode 100644 services/alert_processor/migrations/alembic.ini create mode 100644 services/alert_processor/migrations/env.py create mode 100644 services/alert_processor/migrations/script.py.mako create mode 100644 services/alert_processor/migrations/versions/001_initial_alerts_table.py diff --git a/docker-compose.yml b/docker-compose.yml index 4e4ff616..879f7a8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ volumes: pos_db_data: orders_db_data: production_db_data: + alert_processor_db_data: redis_data: rabbitmq_data: prometheus_data: @@ -373,6 +374,27 @@ services: timeout: 5s retries: 5 + alert-processor-db: + image: postgres:15-alpine + container_name: bakery-alert-processor-db + restart: unless-stopped + environment: + - POSTGRES_DB=${ALERT_PROCESSOR_DB_NAME} + - POSTGRES_USER=${ALERT_PROCESSOR_DB_USER} + - POSTGRES_PASSWORD=${ALERT_PROCESSOR_DB_PASSWORD} + - POSTGRES_INITDB_ARGS=${POSTGRES_INITDB_ARGS} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - alert_processor_db_data:/var/lib/postgresql/data + networks: + bakery-network: + ipv4_address: 172.20.0.34 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${ALERT_PROCESSOR_DB_USER} -d ${ALERT_PROCESSOR_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + # ================================================================ # LOCATION SERVICES (NEW SECTION) @@ -743,6 +765,8 @@ services: restart: unless-stopped env_file: .env depends_on: + alert-processor-db: + condition: service_healthy redis: condition: service_healthy rabbitmq: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8458338..2b729211 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; import { SSEProvider } from './contexts/SSEContext'; +import './i18n'; const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/api/types/foodSafety.ts b/frontend/src/api/types/foodSafety.ts index a494dbbe..88a53bdd 100644 --- a/frontend/src/api/types/foodSafety.ts +++ b/frontend/src/api/types/foodSafety.ts @@ -2,6 +2,44 @@ * Food Safety API Types - Mirror backend schemas */ +// Food Safety Enums +export enum FoodSafetyStandard { + HACCP = 'haccp', + FDA = 'fda', + USDA = 'usda', + FSMA = 'fsma', + SQF = 'sqf', + BRC = 'brc', + IFS = 'ifs', + ISO22000 = 'iso22000', + ORGANIC = 'organic', + NON_GMO = 'non_gmo', + ALLERGEN_FREE = 'allergen_free', + KOSHER = 'kosher', + HALAL = 'halal' +} + +export enum ComplianceStatus { + COMPLIANT = 'compliant', + NON_COMPLIANT = 'non_compliant', + PENDING_REVIEW = 'pending_review', + EXPIRED = 'expired', + WARNING = 'warning' +} + +export enum FoodSafetyAlertType { + TEMPERATURE_VIOLATION = 'temperature_violation', + EXPIRATION_WARNING = 'expiration_warning', + EXPIRED_PRODUCT = 'expired_product', + CONTAMINATION_RISK = 'contamination_risk', + ALLERGEN_CROSS_CONTAMINATION = 'allergen_cross_contamination', + STORAGE_VIOLATION = 'storage_violation', + QUALITY_DEGRADATION = 'quality_degradation', + RECALL_NOTICE = 'recall_notice', + CERTIFICATION_EXPIRY = 'certification_expiry', + SUPPLIER_COMPLIANCE_ISSUE = 'supplier_compliance_issue' +} + export interface FoodSafetyComplianceCreate { ingredient_id: string; compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification'; @@ -223,4 +261,12 @@ export interface FoodSafetyDashboard { days_until_expiry: number; quantity: number; }>; +} + +// Select option interface for enum helpers +export interface EnumOption { + value: string | number; + label: string; + disabled?: boolean; + description?: string; } \ No newline at end of file diff --git a/frontend/src/api/types/inventory.ts b/frontend/src/api/types/inventory.ts index 7fd7aa8f..64c192e9 100644 --- a/frontend/src/api/types/inventory.ts +++ b/frontend/src/api/types/inventory.ts @@ -56,6 +56,18 @@ export enum ProductCategory { OTHER_PRODUCTS = 'other_products' } +export enum StockMovementType { + PURCHASE = 'PURCHASE', + PRODUCTION_USE = 'PRODUCTION_USE', + ADJUSTMENT = 'ADJUSTMENT', + WASTE = 'WASTE', + TRANSFER = 'TRANSFER', + RETURN = 'RETURN', + INITIAL_STOCK = 'INITIAL_STOCK', + TRANSFORMATION = 'TRANSFORMATION' +} + + // Base Inventory Types export interface IngredientCreate { name: string; @@ -67,7 +79,6 @@ export interface IngredientCreate { reorder_point: number; shelf_life_days?: number; // Default shelf life only is_seasonal?: boolean; - supplier_id?: string; average_cost?: number; notes?: string; } @@ -135,6 +146,7 @@ export interface IngredientResponse { // Stock Management Types export interface StockCreate { ingredient_id: string; + supplier_id?: string; batch_number?: string; lot_number?: string; supplier_batch_ref?: string; @@ -174,6 +186,7 @@ export interface StockCreate { } export interface StockUpdate { + supplier_id?: string; batch_number?: string; lot_number?: string; supplier_batch_ref?: string; @@ -409,4 +422,12 @@ export interface DeletionSummary { deleted_stock_movements: number; deleted_stock_alerts: number; success: boolean; +} + +// Select option interface for enum helpers +export interface EnumOption { + value: string | number; + label: string; + disabled?: boolean; + description?: string; } \ No newline at end of file diff --git a/frontend/src/api/types/suppliers.ts b/frontend/src/api/types/suppliers.ts index 891d65a3..6fcc77d6 100644 --- a/frontend/src/api/types/suppliers.ts +++ b/frontend/src/api/types/suppliers.ts @@ -22,12 +22,13 @@ export enum SupplierStatus { } export enum PaymentTerms { - CASH_ON_DELIVERY = 'cod', + COD = 'cod', NET_15 = 'net_15', NET_30 = 'net_30', NET_45 = 'net_45', NET_60 = 'net_60', PREPAID = 'prepaid', + CREDIT_TERMS = 'credit_terms', } export enum PurchaseOrderStatus { @@ -39,6 +40,7 @@ export enum PurchaseOrderStatus { PARTIALLY_RECEIVED = 'partially_received', COMPLETED = 'completed', CANCELLED = 'cancelled', + DISPUTED = 'disputed', } export enum DeliveryStatus { @@ -48,6 +50,32 @@ export enum DeliveryStatus { DELIVERED = 'delivered', PARTIALLY_DELIVERED = 'partially_delivered', FAILED_DELIVERY = 'failed_delivery', + RETURNED = 'returned', +} + +export enum QualityRating { + EXCELLENT = 5, + GOOD = 4, + AVERAGE = 3, + POOR = 2, + VERY_POOR = 1, +} + +export enum DeliveryRating { + EXCELLENT = 5, + GOOD = 4, + AVERAGE = 3, + POOR = 2, + VERY_POOR = 1, +} + +export enum InvoiceStatus { + PENDING = 'pending', + APPROVED = 'approved', + PAID = 'paid', + OVERDUE = 'overdue', + DISPUTED = 'disputed', + CANCELLED = 'cancelled', } export enum OrderPriority { @@ -425,7 +453,10 @@ export interface ApiResponse { errors?: string[]; } -// Export all types -export type { - // Add any additional export aliases if needed -}; \ No newline at end of file +// Select option interface for enum helpers +export interface EnumOption { + value: string | number; + label: string; + disabled?: boolean; + description?: string; +} \ No newline at end of file diff --git a/frontend/src/components/domain/inventory/AddStockModal.tsx b/frontend/src/components/domain/inventory/AddStockModal.tsx index 71178f0a..ccbb1839 100644 --- a/frontend/src/components/domain/inventory/AddStockModal.tsx +++ b/frontend/src/components/domain/inventory/AddStockModal.tsx @@ -1,8 +1,11 @@ import React, { useState } from '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 { IngredientResponse, StockCreate, ProductionStage } from '../../../api/types/inventory'; import { Button } from '../../ui/Button'; +import { useSuppliers } from '../../../api/hooks/suppliers'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers'; import { statusColors } from '../../../styles/colors'; interface AddStockModalProps { @@ -27,11 +30,14 @@ export const AddStockModal: React.FC = ({ current_quantity: 0, unit_cost: Number(ingredient.average_cost) || 0, expiration_date: '', + production_stage: ProductionStage.RAW_INGREDIENT, batch_number: '', supplier_id: '', + quality_status: 'good', storage_location: '', - requires_refrigeration: false, - requires_freezing: false, + warehouse_zone: '', + requires_refrigeration: 'no', + requires_freezing: 'no', storage_temperature_min: undefined, storage_temperature_max: undefined, storage_humidity_max: undefined, @@ -43,12 +49,82 @@ export const AddStockModal: React.FC = ({ const [loading, setLoading] = useState(false); const [mode, setMode] = useState<'overview' | 'edit'>('edit'); + // Get current tenant and fetch suppliers + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { data: suppliersData } = useSuppliers(tenantId, { + limit: 100 + }, { + enabled: !!tenantId + }); + const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active'); + + // Get inventory enum helpers + const inventoryEnums = useInventoryEnums(); + + // Create supplier options for select + const supplierOptions = [ + { value: '', label: 'Sin proveedor asignado' }, + ...suppliers.map(supplier => ({ + value: supplier.id, + label: `${supplier.name} (${supplier.supplier_code || 'Sin código'})` + })) + ]; + + // Create quality status options + const qualityStatusOptions = [ + { value: 'good', label: 'Bueno' }, + { value: 'damaged', label: 'Dañado' }, + { value: 'expired', label: 'Vencido' }, + { value: 'returned', label: 'Devuelto' } + ]; + + // Create storage location options (predefined common locations) + const storageLocationOptions = [ + { value: '', label: 'Sin ubicación específica' }, + { value: 'estante-a1', label: 'Estante A-1' }, + { value: 'estante-a2', label: 'Estante A-2' }, + { value: 'estante-a3', label: 'Estante A-3' }, + { value: 'estante-b1', label: 'Estante B-1' }, + { value: 'estante-b2', label: 'Estante B-2' }, + { value: 'frigorifico', label: 'Frigorífico' }, + { value: 'congelador', label: 'Congelador' }, + { value: 'almacen-principal', label: 'Almacén Principal' }, + { value: 'zona-recepcion', label: 'Zona de Recepción' } + ]; + + // Create warehouse zone options + const warehouseZoneOptions = [ + { value: '', label: 'Sin zona específica' }, + { value: 'zona-a', label: 'Zona A' }, + { value: 'zona-b', label: 'Zona B' }, + { value: 'zona-c', label: 'Zona C' }, + { value: 'refrigerado', label: 'Refrigerado' }, + { value: 'congelado', label: 'Congelado' }, + { value: 'ambiente', label: 'Temperatura Ambiente' } + ]; + + // Create refrigeration requirement options + const refrigerationOptions = [ + { value: 'no', label: 'No requiere refrigeración' }, + { value: 'yes', label: 'Requiere refrigeración' }, + { value: 'recommended', label: 'Refrigeración recomendada' } + ]; + + // Create freezing requirement options + const freezingOptions = [ + { value: 'no', label: 'No requiere congelación' }, + { value: 'yes', label: 'Requiere congelación' }, + { value: 'recommended', label: 'Congelación recomendada' } + ]; + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { const fieldMappings = [ // Basic Stock Information section - ['current_quantity', 'unit_cost', 'expiration_date'], + ['current_quantity', 'unit_cost', 'expiration_date', 'production_stage'], // Additional Information section - ['batch_number', 'supplier_id', 'storage_location', 'notes'], + ['batch_number', 'supplier_id', 'quality_status', 'storage_location', 'warehouse_zone', 'notes'], // Storage Requirements section ['requires_refrigeration', 'requires_freezing', 'storage_temperature_min', 'storage_temperature_max', 'storage_humidity_max', 'shelf_life_days', 'storage_instructions'] ]; @@ -80,11 +156,14 @@ export const AddStockModal: React.FC = ({ current_quantity: Number(formData.current_quantity), unit_cost: Number(formData.unit_cost), expiration_date: formData.expiration_date || undefined, + production_stage: formData.production_stage || ProductionStage.RAW_INGREDIENT, batch_number: formData.batch_number || undefined, supplier_id: formData.supplier_id || undefined, + quality_status: formData.quality_status || 'good', storage_location: formData.storage_location || undefined, - requires_refrigeration: formData.requires_refrigeration || false, - requires_freezing: formData.requires_freezing || false, + warehouse_zone: formData.warehouse_zone || undefined, + requires_refrigeration: formData.requires_refrigeration === 'yes', + requires_freezing: formData.requires_freezing === 'yes', 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, @@ -103,11 +182,14 @@ export const AddStockModal: React.FC = ({ current_quantity: 0, unit_cost: Number(ingredient.average_cost) || 0, expiration_date: '', + production_stage: ProductionStage.RAW_INGREDIENT, batch_number: '', supplier_id: '', + quality_status: 'good', storage_location: '', - requires_refrigeration: false, - requires_freezing: false, + warehouse_zone: '', + requires_refrigeration: 'no', + requires_freezing: 'no', storage_temperature_min: undefined, storage_temperature_max: undefined, storage_humidity_max: undefined, @@ -162,8 +244,14 @@ export const AddStockModal: React.FC = ({ label: 'Fecha de Vencimiento', value: formData.expiration_date || '', type: 'date' as const, + editable: true + }, + { + label: 'Etapa de Producción', + value: formData.production_stage || ProductionStage.RAW_INGREDIENT, + type: 'select' as const, editable: true, - span: 2 as const + options: inventoryEnums.getProductionStageOptions() } ] }, @@ -179,19 +267,35 @@ export const AddStockModal: React.FC = ({ placeholder: 'Ej: LOTE2024001' }, { - label: 'ID Proveedor', + label: 'Proveedor', value: formData.supplier_id || '', - type: 'text' as const, + type: 'select' as const, editable: true, - placeholder: 'Ej: PROV001' + placeholder: 'Seleccionar proveedor', + options: supplierOptions + }, + { + label: 'Estado de Calidad', + value: formData.quality_status || 'good', + type: 'select' as const, + editable: true, + options: qualityStatusOptions }, { label: 'Ubicación de Almacenamiento', value: formData.storage_location || '', - type: 'text' as const, + type: 'select' as const, editable: true, - placeholder: 'Ej: Estante A-3', - span: 2 as const + placeholder: 'Seleccionar ubicación', + options: storageLocationOptions + }, + { + label: 'Zona de Almacén', + value: formData.warehouse_zone || '', + type: 'select' as const, + editable: true, + placeholder: 'Seleccionar zona', + options: warehouseZoneOptions }, { label: 'Notas', @@ -209,15 +313,17 @@ export const AddStockModal: React.FC = ({ fields: [ { label: 'Requiere Refrigeración', - value: formData.requires_refrigeration || false, - type: 'boolean' as const, - editable: true + value: formData.requires_refrigeration || 'no', + type: 'select' as const, + editable: true, + options: refrigerationOptions }, { label: 'Requiere Congelación', - value: formData.requires_freezing || false, - type: 'boolean' as const, - editable: true + value: formData.requires_freezing || 'no', + type: 'select' as const, + editable: true, + options: freezingOptions }, { label: 'Temperatura Mínima (°C)', diff --git a/frontend/src/components/domain/inventory/CreateIngredientModal.tsx b/frontend/src/components/domain/inventory/CreateIngredientModal.tsx index af678ce2..e88f1823 100644 --- a/frontend/src/components/domain/inventory/CreateIngredientModal.tsx +++ b/frontend/src/components/domain/inventory/CreateIngredientModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from '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 { useInventoryEnums } from '../../../utils/inventoryEnumHelpers'; import { statusColors } from '../../../styles/colors'; interface CreateIngredientModalProps { @@ -28,7 +29,6 @@ export const CreateIngredientModal: React.FC = ({ reorder_point: 20, max_stock_level: 100, is_seasonal: false, - supplier_id: '', average_cost: 0, notes: '' }); @@ -36,44 +36,16 @@ export const CreateIngredientModal: React.FC = ({ const [loading, setLoading] = useState(false); const [mode, setMode] = useState<'overview' | 'edit'>('edit'); - // Category options combining ingredient and product categories + // Get enum options using helpers + const inventoryEnums = useInventoryEnums(); + + // Combine ingredient and product categories const categoryOptions = [ - // Ingredient categories - { label: 'Harinas', value: 'flour' }, - { label: 'Levaduras', value: 'yeast' }, - { label: 'Lácteos', value: 'dairy' }, - { label: 'Huevos', value: 'eggs' }, - { label: 'Azúcar', value: 'sugar' }, - { label: 'Grasas', value: 'fats' }, - { label: 'Sal', value: 'salt' }, - { label: 'Especias', value: 'spices' }, - { label: 'Aditivos', value: 'additives' }, - { label: 'Envases', value: 'packaging' }, - { label: 'Limpieza', value: 'cleaning' }, - // Product categories - { label: 'Pan', value: 'bread' }, - { label: 'Croissants', value: 'croissants' }, - { label: 'Pastelería', value: 'pastries' }, - { label: 'Tartas', value: 'cakes' }, - { label: 'Galletas', value: 'cookies' }, - { label: 'Muffins', value: 'muffins' }, - { label: 'Sandwiches', value: 'sandwiches' }, - { label: 'Temporada', value: 'seasonal' }, - { label: 'Bebidas', value: 'beverages' }, - { label: 'Otros', value: 'other' } + ...inventoryEnums.getIngredientCategoryOptions(), + ...inventoryEnums.getProductCategoryOptions() ]; - const unitOptions = [ - { label: 'Kilogramo (kg)', value: 'kg' }, - { label: 'Gramo (g)', value: 'g' }, - { label: 'Litro (l)', value: 'l' }, - { label: 'Mililitro (ml)', value: 'ml' }, - { label: 'Unidades', value: 'units' }, - { label: 'Piezas', value: 'pcs' }, - { label: 'Paquetes', value: 'pkg' }, - { label: 'Bolsas', value: 'bags' }, - { label: 'Cajas', value: 'boxes' } - ]; + const unitOptions = inventoryEnums.getUnitOfMeasureOptions(); const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { // Map field positions to form data fields @@ -82,8 +54,8 @@ export const CreateIngredientModal: React.FC = ({ ['name', 'description', 'category', 'unit_of_measure'], // Cost and Quantities section ['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], - // Additional Information section (moved up after removing storage section) - ['supplier_id', 'notes'] + // Additional Information section + ['notes'] ]; const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate; @@ -146,7 +118,6 @@ export const CreateIngredientModal: React.FC = ({ requires_refrigeration: false, requires_freezing: false, is_seasonal: false, - supplier_id: '', average_cost: 0, notes: '' }); @@ -174,7 +145,6 @@ export const CreateIngredientModal: React.FC = ({ requires_refrigeration: false, requires_freezing: false, is_seasonal: false, - supplier_id: '', average_cost: 0, notes: '' }); @@ -234,7 +204,7 @@ export const CreateIngredientModal: React.FC = ({ { label: 'Costo Promedio', value: formData.average_cost || 0, - type: 'number' as const, + type: 'currency' as const, editable: true, placeholder: '0.00' }, @@ -267,13 +237,6 @@ export const CreateIngredientModal: React.FC = ({ title: 'Información Adicional', icon: Settings, fields: [ - { - label: 'Proveedor', - value: formData.supplier_id || '', - type: 'text' as const, - editable: true, - placeholder: 'ID o nombre del proveedor' - }, { label: 'Notas', value: formData.notes || '', diff --git a/frontend/src/components/domain/suppliers/CreateSupplierForm.tsx b/frontend/src/components/domain/suppliers/CreateSupplierForm.tsx new file mode 100644 index 00000000..f59eddcd --- /dev/null +++ b/frontend/src/components/domain/suppliers/CreateSupplierForm.tsx @@ -0,0 +1,183 @@ +// frontend/src/components/domain/suppliers/CreateSupplierForm.tsx +/** + * Example usage of enum helpers with i18n in a supplier form + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Select } from '../../ui/Select'; +import { Button } from '../../ui/Button/Button'; +import { useSupplierEnums } from '../../../utils/enumHelpers'; +import { SupplierType, SupplierStatus, PaymentTerms } from '../../../api/types/suppliers'; + +interface CreateSupplierFormProps { + onSubmit: (data: SupplierFormData) => void; + onCancel: () => void; +} + +interface SupplierFormData { + name: string; + supplier_type: SupplierType; + status: SupplierStatus; + payment_terms: PaymentTerms; + email: string; + phone: string; +} + +export const CreateSupplierForm: React.FC = ({ + onSubmit, + onCancel +}) => { + const { t } = useTranslation(['suppliers', 'common']); + const supplierEnums = useSupplierEnums(); + + const [formData, setFormData] = useState({ + name: '', + supplier_type: SupplierType.INGREDIENTS, + status: SupplierStatus.PENDING_APPROVAL, + payment_terms: PaymentTerms.NET_30, + email: '', + phone: '' + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + }; + + const handleFieldChange = (field: keyof SupplierFormData, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }; + + return ( +
+
+ {/* Supplier Name */} +
+ + handleFieldChange('name', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={t('common:forms.enter_supplier_name')} + /> +
+ + {/* Supplier Type - Using enum helper */} +
+ + handleFieldChange('status', value as SupplierStatus)} + placeholder={t('common:forms.select_option')} + /> +
+ + {/* Payment Terms - Using enum helper */} +
+ + handleFieldChange('email', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={t('common:forms.enter_email')} + /> +
+ + {/* Phone */} +
+ + handleFieldChange('phone', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={t('common:forms.enter_phone')} + /> +
+
+ + {/* Actions */} +
+ + +
+ + {/* Display current selections for debugging */} +
+

Current Selections:

+
    +
  • + Tipo: {supplierEnums.getSupplierTypeLabel(formData.supplier_type)} +
  • +
  • + Estado: {supplierEnums.getSupplierStatusLabel(formData.status)} +
  • +
  • + Términos de Pago: {supplierEnums.getPaymentTermsLabel(formData.payment_terms)} +
  • +
  • + Debug - Payment Terms Raw: {formData.payment_terms} +
  • +
  • + Debug - Translation Test: {t('suppliers:payment_terms.net_30')} +
  • +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/StatusCard/StatusCard.tsx b/frontend/src/components/ui/StatusCard/StatusCard.tsx index 534919c3..087b19bf 100644 --- a/frontend/src/components/ui/StatusCard/StatusCard.tsx +++ b/frontend/src/components/ui/StatusCard/StatusCard.tsx @@ -227,10 +227,6 @@ export const StatusCard: React.FC = ({ )} - {/* Spacer for alignment when no progress bar */} - {!progress && ( -
- )} {/* Metadata */} {metadata.length > 0 && ( diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 00000000..e19b6306 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,38 @@ +// frontend/src/i18n/index.ts +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { resources, defaultLanguage } from '../locales'; + +i18n + .use(initReactI18next) + .init({ + resources, + lng: defaultLanguage, + fallbackLng: defaultLanguage, + + interpolation: { + escapeValue: false, // React already does escaping + }, + + // Enable debug in development + debug: process.env.NODE_ENV === 'development', + + // Default namespace + defaultNS: 'common', + + // Fallback namespace + fallbackNS: 'common', + + // Key separator for nested keys + keySeparator: '.', + + // Namespace separator + nsSeparator: ':', + + // React options + react: { + useSuspense: false, + }, + }); + +export default i18n; \ No newline at end of file diff --git a/frontend/src/locales/es/foodSafety.json b/frontend/src/locales/es/foodSafety.json new file mode 100644 index 00000000..9a46aa14 --- /dev/null +++ b/frontend/src/locales/es/foodSafety.json @@ -0,0 +1,48 @@ +{ + "enums": { + "food_safety_standard": { + "haccp": "HACCP", + "fda": "FDA", + "usda": "USDA", + "fsma": "FSMA", + "sqf": "SQF", + "brc": "BRC", + "ifs": "IFS", + "iso22000": "ISO 22000", + "organic": "Orgánico", + "non_gmo": "Sin OGM", + "allergen_free": "Libre de Alérgenos", + "kosher": "Kosher", + "halal": "Halal" + }, + "compliance_status": { + "compliant": "Conforme", + "non_compliant": "No Conforme", + "pending_review": "Pendiente de Revisión", + "expired": "Vencido", + "warning": "Advertencia" + }, + "food_safety_alert_type": { + "temperature_violation": "Violación de Temperatura", + "expiration_warning": "Advertencia de Vencimiento", + "expired_product": "Producto Vencido", + "contamination_risk": "Riesgo de Contaminación", + "allergen_cross_contamination": "Contaminación Cruzada de Alérgenos", + "storage_violation": "Violación de Almacenamiento", + "quality_degradation": "Degradación de Calidad", + "recall_notice": "Aviso de Retiro", + "certification_expiry": "Vencimiento de Certificación", + "supplier_compliance_issue": "Problema de Cumplimiento del Proveedor" + } + }, + "labels": { + "food_safety_standard": "Estándar de Seguridad Alimentaria", + "compliance_status": "Estado de Cumplimiento", + "food_safety_alert_type": "Tipo de Alerta de Seguridad" + }, + "descriptions": { + "food_safety_standard": "Estándar de seguridad alimentaria aplicable", + "compliance_status": "Estado actual de cumplimiento normativo", + "food_safety_alert_type": "Tipo de alerta de seguridad alimentaria" + } +} \ No newline at end of file diff --git a/frontend/src/locales/es/inventory.json b/frontend/src/locales/es/inventory.json index e174fa96..f4cd7157 100644 --- a/frontend/src/locales/es/inventory.json +++ b/frontend/src/locales/es/inventory.json @@ -40,6 +40,81 @@ "description": "Descripción", "notes": "Notas" }, + "enums": { + "product_type": { + "ingredient": "Ingrediente", + "finished_product": "Producto Terminado" + }, + "production_stage": { + "raw_ingredient": "Ingrediente Crudo", + "par_baked": "Pre-cocido", + "fully_baked": "Completamente Cocido", + "prepared_dough": "Masa Preparada", + "frozen_product": "Producto Congelado" + }, + "unit_of_measure": { + "kg": "Kilogramos", + "g": "Gramos", + "l": "Litros", + "ml": "Mililitros", + "units": "Unidades", + "pcs": "Piezas", + "pkg": "Paquetes", + "bags": "Bolsas", + "boxes": "Cajas" + }, + "ingredient_category": { + "flour": "Harinas", + "yeast": "Levaduras", + "dairy": "Lácteos", + "eggs": "Huevos", + "sugar": "Azúcares", + "fats": "Grasas", + "salt": "Sal", + "spices": "Especias", + "additives": "Aditivos", + "packaging": "Embalaje", + "cleaning": "Limpieza", + "other": "Otros" + }, + "product_category": { + "bread": "Panes", + "croissants": "Croissants", + "pastries": "Bollería", + "cakes": "Tartas", + "cookies": "Galletas", + "muffins": "Muffins", + "sandwiches": "Sándwiches", + "seasonal": "Temporales", + "beverages": "Bebidas", + "other_products": "Otros Productos" + }, + "stock_movement_type": { + "PURCHASE": "Compra", + "PRODUCTION_USE": "Uso en Producción", + "ADJUSTMENT": "Ajuste", + "WASTE": "Desperdicio", + "TRANSFER": "Transferencia", + "RETURN": "Devolución", + "INITIAL_STOCK": "Stock Inicial", + "TRANSFORMATION": "Transformación" + } + }, + "labels": { + "product_type": "Tipo de Producto", + "production_stage": "Etapa de Producción", + "unit_of_measure": "Unidad de Medida", + "ingredient_category": "Categoría de Ingrediente", + "product_category": "Categoría de Producto", + "stock_movement_type": "Tipo de Movimiento" + }, + "descriptions": { + "product_type": "Selecciona si es un ingrediente básico o un producto terminado", + "production_stage": "Indica la etapa de producción en la que se encuentra el producto", + "unit_of_measure": "Unidad de medida utilizada para este producto", + "ingredient_category": "Categoría que mejor describe este ingrediente", + "stock_movement_type": "Tipo de movimiento de inventario a registrar" + }, "categories": { "all": "Todas las categorías", "flour": "Harinas", diff --git a/frontend/src/locales/es/suppliers.json b/frontend/src/locales/es/suppliers.json new file mode 100644 index 00000000..0896e680 --- /dev/null +++ b/frontend/src/locales/es/suppliers.json @@ -0,0 +1,84 @@ +{ + "types": { + "ingredients": "Materias Primas", + "packaging": "Embalaje", + "equipment": "Equipamiento", + "services": "Servicios", + "utilities": "Servicios Públicos", + "multi": "Múltiples Categorías" + }, + "status": { + "active": "Activo", + "inactive": "Inactivo", + "pending_approval": "Pendiente de Aprobación", + "suspended": "Suspendido", + "blacklisted": "Lista Negra" + }, + "payment_terms": { + "cod": "Contra Entrega", + "net_15": "Neto 15 días", + "net_30": "Neto 30 días", + "net_45": "Neto 45 días", + "net_60": "Neto 60 días", + "prepaid": "Prepago", + "credit_terms": "Términos de Crédito" + }, + "purchase_order_status": { + "draft": "Borrador", + "pending_approval": "Pendiente de Aprobación", + "approved": "Aprobado", + "sent_to_supplier": "Enviado al Proveedor", + "confirmed": "Confirmado", + "partially_received": "Parcialmente Recibido", + "completed": "Completado", + "cancelled": "Cancelado", + "disputed": "Disputado" + }, + "delivery_status": { + "scheduled": "Programado", + "in_transit": "En Tránsito", + "out_for_delivery": "En Reparto", + "delivered": "Entregado", + "partially_delivered": "Parcialmente Entregado", + "failed_delivery": "Entrega Fallida", + "returned": "Devuelto" + }, + "quality_rating": { + "5": "Excelente", + "4": "Bueno", + "3": "Promedio", + "2": "Malo", + "1": "Muy Malo" + }, + "delivery_rating": { + "5": "Excelente", + "4": "Bueno", + "3": "Promedio", + "2": "Malo", + "1": "Muy Malo" + }, + "invoice_status": { + "pending": "Pendiente", + "approved": "Aprobado", + "paid": "Pagado", + "overdue": "Vencido", + "disputed": "Disputado", + "cancelled": "Cancelado" + }, + "labels": { + "supplier_type": "Tipo de Proveedor", + "supplier_status": "Estado del Proveedor", + "payment_terms": "Términos de Pago", + "purchase_order_status": "Estado de Orden de Compra", + "delivery_status": "Estado de Entrega", + "quality_rating": "Calificación de Calidad", + "delivery_rating": "Calificación de Entrega", + "invoice_status": "Estado de Factura" + }, + "descriptions": { + "supplier_type": "Selecciona el tipo de productos o servicios que ofrece este proveedor", + "payment_terms": "Términos de pago acordados con el proveedor", + "quality_rating": "Calificación de 1 a 5 estrellas basada en la calidad de los productos", + "delivery_rating": "Calificación de 1 a 5 estrellas basada en la puntualidad y estado de las entregas" + } +} \ No newline at end of file diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index 166a48d8..2c056e2a 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -2,6 +2,8 @@ import commonEs from './es/common.json'; import authEs from './es/auth.json'; import inventoryEs from './es/inventory.json'; +import foodSafetyEs from './es/foodSafety.json'; +import suppliersEs from './es/suppliers.json'; import errorsEs from './es/errors.json'; // Translation resources by language @@ -10,6 +12,8 @@ export const resources = { common: commonEs, auth: authEs, inventory: inventoryEs, + foodSafety: foodSafetyEs, + suppliers: suppliersEs, errors: errorsEs, }, }; @@ -33,7 +37,7 @@ export const languageConfig = { }; // Namespaces available in translations -export const namespaces = ['common', 'auth', 'inventory', 'errors'] as const; +export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'errors'] as const; export type Namespace = typeof namespaces[number]; // Helper function to get language display name @@ -47,7 +51,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang }; // Export individual language modules for direct imports -export { commonEs, authEs, inventoryEs, errorsEs }; +export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, errorsEs }; // Default export with all translations export default resources; \ No newline at end of file diff --git a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx index 86cc45fe..cd1f96bb 100644 --- a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx +++ b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx @@ -207,6 +207,7 @@ const InventoryPage: React.FC = () => { ); } + // Sort by priority: expired → out of stock → low stock → normal → overstock // Within each priority level, sort by most critical items first return items.sort((a, b) => { @@ -263,22 +264,8 @@ const InventoryPage: React.FC = () => { // Helper function to get category display name const getCategoryDisplayName = (category?: string): string => { - const categoryMappings: Record = { - 'flour': 'Harina', - 'dairy': 'Lácteos', - 'eggs': 'Huevos', - 'sugar': 'Azúcar', - 'yeast': 'Levadura', - 'fats': 'Grasas', - 'spices': 'Especias', - 'croissants': 'Croissants', - 'pastries': 'Pastelería', - 'beverages': 'Bebidas', - 'bread': 'Pan', - 'other': 'Otros' - }; - - return categoryMappings[category?.toLowerCase() || ''] || category || 'Sin categoría'; + if (!category) return 'Sin categoría'; + return category; }; // Focused action handlers diff --git a/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx index 1d950d79..81834203 100644 --- a/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx +++ b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx @@ -1,93 +1,54 @@ import React, { useState } from 'react'; -import { Plus, Download, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react'; +import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react'; import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers'; +import { useSuppliers, useSupplierStatistics } from '../../../../api/hooks/suppliers'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { useSupplierEnums } from '../../../../utils/enumHelpers'; const SuppliersPage: React.FC = () => { const [activeTab] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); const [showForm, setShowForm] = useState(false); const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); - const [selectedSupplier, setSelectedSupplier] = useState(null); + const [selectedSupplier, setSelectedSupplier] = useState(null); + const [isCreating, setIsCreating] = useState(false); - const mockSuppliers = [ - { - id: 'SUP-2024-001', - supplier_code: 'HAR001', - name: 'Harinas del Norte S.L.', - supplier_type: SupplierType.INGREDIENTS, - status: SupplierStatus.ACTIVE, - contact_person: 'María González', - email: 'maria@harinasdelnorte.es', - phone: '+34 987 654 321', - city: 'León', - country: 'España', - payment_terms: PaymentTerms.NET_30, - credit_limit: 5000, - currency: 'EUR', - standard_lead_time: 5, - minimum_order_amount: 200, - total_orders: 45, - total_spend: 12750.50, - last_order_date: '2024-01-25T14:30:00Z', - performance_score: 92, - notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.' - }, - { - id: 'SUP-2024-002', - supplier_code: 'EMB002', - name: 'Embalajes Biodegradables SA', - supplier_type: SupplierType.PACKAGING, - status: SupplierStatus.ACTIVE, - contact_person: 'Carlos Ruiz', - email: 'carlos@embalajes-bio.com', - phone: '+34 600 123 456', - city: 'Valencia', - country: 'España', - payment_terms: PaymentTerms.NET_15, - credit_limit: 2500, - currency: 'EUR', - standard_lead_time: 3, - minimum_order_amount: 150, - total_orders: 28, - total_spend: 4280.75, - last_order_date: '2024-01-24T10:15:00Z', - performance_score: 88, - notes: 'Especialista en packaging sostenible.' - }, - { - id: 'SUP-2024-003', - supplier_code: 'MAN003', - name: 'Maquinaria Industrial López', - supplier_type: SupplierType.EQUIPMENT, - status: SupplierStatus.PENDING_APPROVAL, - contact_person: 'Ana López', - email: 'ana@maquinaria-lopez.es', - phone: '+34 655 987 654', - city: 'Madrid', - country: 'España', - payment_terms: PaymentTerms.NET_45, - credit_limit: 15000, - currency: 'EUR', - standard_lead_time: 14, - minimum_order_amount: 500, - total_orders: 0, - total_spend: 0, - last_order_date: null, - performance_score: null, - notes: 'Nuevo proveedor de equipamiento industrial. Pendiente de aprobación.' - }, - ]; + // Get tenant ID from tenant store (preferred) or auth user (fallback) + const currentTenant = useCurrentTenant(); + const user = useAuthUser(); + const tenantId = currentTenant?.id || user?.tenant_id || ''; + + // API hooks + const { + data: suppliersData, + isLoading: suppliersLoading, + error: suppliersError + } = useSuppliers(tenantId, { + search_term: searchTerm || undefined, + status: activeTab !== 'all' ? activeTab as any : undefined, + limit: 100 + }); + + const { + data: statisticsData, + isLoading: statisticsLoading, + error: statisticsError + } = useSupplierStatistics(tenantId); + + const suppliers = suppliersData || []; + const supplierEnums = useSupplierEnums(); const getSupplierStatusConfig = (status: SupplierStatus) => { const statusConfig = { - [SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle }, - [SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer }, - [SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle }, - [SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle }, - [SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle }, + [SupplierStatus.ACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: CheckCircle }, + [SupplierStatus.INACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: Timer }, + [SupplierStatus.PENDING_APPROVAL]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle }, + [SupplierStatus.SUSPENDED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle }, + [SupplierStatus.BLACKLISTED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle }, }; const config = statusConfig[status]; @@ -104,110 +65,122 @@ const SuppliersPage: React.FC = () => { }; const getSupplierTypeText = (type: SupplierType): string => { - const typeMap = { - [SupplierType.INGREDIENTS]: 'Ingredientes', - [SupplierType.PACKAGING]: 'Embalajes', - [SupplierType.EQUIPMENT]: 'Equipamiento', - [SupplierType.SERVICES]: 'Servicios', - [SupplierType.UTILITIES]: 'Servicios Públicos', - [SupplierType.MULTI]: 'Múltiple', - }; - return typeMap[type] || type; + return supplierEnums.getSupplierTypeLabel(type); }; const getPaymentTermsText = (terms: PaymentTerms): string => { - const termsMap = { - [PaymentTerms.CASH_ON_DELIVERY]: 'Pago Contraentrega', - [PaymentTerms.NET_15]: 'Neto 15 días', - [PaymentTerms.NET_30]: 'Neto 30 días', - [PaymentTerms.NET_45]: 'Neto 45 días', - [PaymentTerms.NET_60]: 'Neto 60 días', - [PaymentTerms.PREPAID]: 'Prepago', - }; - return termsMap[terms] || terms; + return supplierEnums.getPaymentTermsLabel(terms); }; - const filteredSuppliers = mockSuppliers.filter(supplier => { - const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) || - supplier.supplier_code.toLowerCase().includes(searchTerm.toLowerCase()) || - supplier.email?.toLowerCase().includes(searchTerm.toLowerCase()) || - supplier.contact_person?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesTab = activeTab === 'all' || supplier.status === activeTab; - - return matchesSearch && matchesTab; - }); + // Filtering is now handled by the API query parameters + const filteredSuppliers = suppliers; - const mockSupplierStats = { - total: mockSuppliers.length, - active: mockSuppliers.filter(s => s.status === SupplierStatus.ACTIVE).length, - pendingApproval: mockSuppliers.filter(s => s.status === SupplierStatus.PENDING_APPROVAL).length, - suspended: mockSuppliers.filter(s => s.status === SupplierStatus.SUSPENDED).length, - totalSpend: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_spend, 0), - averageScore: mockSuppliers - .filter(s => s.performance_score !== null) - .reduce((sum, supplier, _, arr) => sum + (supplier.performance_score || 0) / arr.length, 0), - totalOrders: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_orders, 0), + const supplierStats = statisticsData || { + total_suppliers: 0, + active_suppliers: 0, + pending_suppliers: 0, + avg_quality_rating: 0, + avg_delivery_rating: 0, + total_spend: 0 }; const stats = [ { title: 'Total Proveedores', - value: mockSupplierStats.total, + value: supplierStats.total_suppliers, variant: 'default' as const, icon: Building2, }, { title: 'Activos', - value: mockSupplierStats.active, + value: supplierStats.active_suppliers, variant: 'success' as const, icon: CheckCircle, }, { title: 'Pendientes', - value: mockSupplierStats.pendingApproval, + value: supplierStats.pending_suppliers, variant: 'warning' as const, icon: AlertCircle, }, { title: 'Gasto Total', - value: formatters.currency(mockSupplierStats.totalSpend), + value: formatters.currency(supplierStats.total_spend), variant: 'info' as const, icon: DollarSign, }, { - title: 'Total Pedidos', - value: mockSupplierStats.totalOrders, - variant: 'default' as const, - icon: Building2, - }, - { - title: 'Puntuación Media', - value: mockSupplierStats.averageScore.toFixed(1), + title: 'Calidad Media', + value: supplierStats.avg_quality_rating?.toFixed(1) || '0.0', variant: 'success' as const, icon: CheckCircle, }, + { + title: 'Entrega Media', + value: supplierStats.avg_delivery_rating?.toFixed(1) || '0.0', + variant: 'info' as const, + icon: Building2, + }, ]; + // Loading state + if (suppliersLoading || statisticsLoading) { + return ( +
+
+ +

Cargando proveedores...

+
+
+ ); + } + + // Error state + if (suppliersError || statisticsError) { + return ( +
+
+ +

Error al cargar los proveedores

+

+ {(suppliersError as any)?.message || (statisticsError as any)?.message || 'Error desconocido'} +

+
+
+ ); + } + return (
console.log('Export suppliers') - }, { id: "new", - label: "Nuevo Proveedor", + label: "Nuevo Proveedor", variant: "primary" as const, icon: Plus, - onClick: () => setShowForm(true) + onClick: () => { + setSelectedSupplier({ + name: '', + contact_person: '', + email: '', + phone: '', + city: '', + country: '', + supplier_code: '', + supplier_type: SupplierType.INGREDIENTS, + payment_terms: PaymentTerms.NET_30, + standard_lead_time: 3, + minimum_order_amount: 0, + credit_limit: 0, + currency: 'EUR' + }); + setIsCreating(true); + setModalMode('edit'); + setShowForm(true); + } } ]} /> @@ -229,10 +202,6 @@ const SuppliersPage: React.FC = () => { className="w-full" />
-
@@ -240,10 +209,7 @@ const SuppliersPage: React.FC = () => {
{filteredSuppliers.map((supplier) => { const statusConfig = getSupplierStatusConfig(supplier.status); - const performanceNote = supplier.performance_score - ? `Puntuación: ${supplier.performance_score}/100` - : 'Sin evaluación'; - + return ( { statusIndicator={statusConfig} title={supplier.name} subtitle={supplier.supplier_code} - primaryValue={formatters.currency(supplier.total_spend)} - primaryValueLabel={`${supplier.total_orders} pedidos`} + primaryValue={supplier.city || 'Sin ubicación'} + primaryValueLabel={getSupplierTypeText(supplier.supplier_type)} secondaryInfo={{ - label: 'Tipo', - value: getSupplierTypeText(supplier.supplier_type) + label: 'Condiciones', + value: getPaymentTermsText(supplier.payment_terms) }} metadata={[ supplier.contact_person || 'Sin contacto', supplier.email || 'Sin email', supplier.phone || 'Sin teléfono', - performanceNote + `Creado: ${new Date(supplier.created_at).toLocaleDateString('es-ES')}` ]} actions={[ + // Primary action - View supplier details { - label: 'Ver', + label: 'Ver Detalles', icon: Eye, - variant: 'outline', + variant: 'primary', + priority: 'primary', onClick: () => { setSelectedSupplier(supplier); + setIsCreating(false); setModalMode('view'); setShowForm(true); } }, + // Secondary action - Edit supplier { label: 'Editar', icon: Edit, - variant: 'outline', + priority: 'secondary', onClick: () => { setSelectedSupplier(supplier); + setIsCreating(false); setModalMode('edit'); setShowForm(true); } @@ -308,126 +279,200 @@ const SuppliersPage: React.FC = () => { )} {/* Supplier Details Modal */} - {showForm && selectedSupplier && ( - { + const sections = [ + { + title: 'Información de Contacto', + icon: Users, + fields: [ + { + label: 'Nombre', + value: selectedSupplier.name || '', + type: 'text', + highlight: true, + editable: true, + required: true, + placeholder: 'Nombre del proveedor' + }, + { + label: 'Persona de Contacto', + value: selectedSupplier.contact_person || '', + type: 'text', + editable: true, + placeholder: 'Nombre del contacto' + }, + { + label: 'Email', + value: selectedSupplier.email || '', + type: 'email', + editable: true, + placeholder: 'email@ejemplo.com' + }, + { + label: 'Teléfono', + value: selectedSupplier.phone || '', + type: 'tel', + editable: true, + placeholder: '+34 123 456 789' + }, + { + label: 'Ciudad', + value: selectedSupplier.city || '', + type: 'text', + editable: true, + placeholder: 'Ciudad' + }, + { + label: 'País', + value: selectedSupplier.country || '', + type: 'text', + editable: true, + placeholder: 'País' + } + ] + }, + { + title: 'Información Comercial', + icon: Building2, + fields: [ + { + label: 'Código de Proveedor', + value: selectedSupplier.supplier_code || '', + type: 'text', + highlight: true, + editable: true, + placeholder: 'Código único' + }, + { + label: 'Tipo de Proveedor', + value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS, + type: 'select', + editable: true, + options: supplierEnums.getSupplierTypeOptions() + }, + { + label: 'Condiciones de Pago', + value: selectedSupplier.payment_terms || PaymentTerms.NET_30, + type: 'select', + editable: true, + options: supplierEnums.getPaymentTermsOptions() + }, + { + label: 'Tiempo de Entrega (días)', + value: selectedSupplier.standard_lead_time || 3, + type: 'number', + editable: true, + placeholder: '3' + }, + { + label: 'Pedido Mínimo', + value: selectedSupplier.minimum_order_amount || 0, + type: 'currency', + editable: true, + placeholder: '0.00' + }, + { + label: 'Límite de Crédito', + value: selectedSupplier.credit_limit || 0, + type: 'currency', + editable: true, + placeholder: '0.00' + } + ] + }, + { + title: 'Rendimiento y Estadísticas', + icon: DollarSign, + fields: [ + { + label: 'Moneda', + value: selectedSupplier.currency || 'EUR', + type: 'text', + editable: true, + placeholder: 'EUR' + }, + { + label: 'Fecha de Creación', + value: selectedSupplier.created_at, + type: 'datetime', + highlight: true + }, + { + label: 'Última Actualización', + value: selectedSupplier.updated_at, + type: 'datetime' + } + ] + }, + ...(selectedSupplier.notes ? [{ + title: 'Notas', + fields: [ + { + label: 'Observaciones', + value: selectedSupplier.notes, + type: 'list', + span: 2 as const, + editable: true, + placeholder: 'Notas sobre el proveedor' + } + ] + }] : []) + ]; + + return ( + { setShowForm(false); setSelectedSupplier(null); setModalMode('view'); + setIsCreating(false); }} mode={modalMode} onModeChange={setModalMode} - title={selectedSupplier.name} - subtitle={`Proveedor ${selectedSupplier.supplier_code}`} - statusIndicator={getSupplierStatusConfig(selectedSupplier.status)} + title={isCreating ? 'Nuevo Proveedor' : selectedSupplier.name || 'Proveedor'} + subtitle={isCreating ? 'Crear nuevo proveedor' : `Proveedor ${selectedSupplier.supplier_code || ''}`} + statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)} size="lg" - sections={[ - { - title: 'Información de Contacto', - icon: Users, - fields: [ - { - label: 'Nombre', - value: selectedSupplier.name, - highlight: true - }, - { - label: 'Persona de Contacto', - value: selectedSupplier.contact_person || 'No especificado' - }, - { - label: 'Email', - value: selectedSupplier.email || 'No especificado' - }, - { - label: 'Teléfono', - value: selectedSupplier.phone || 'No especificado' - }, - { - label: 'Ciudad', - value: selectedSupplier.city || 'No especificado' - }, - { - label: 'País', - value: selectedSupplier.country || 'No especificado' - } - ] - }, - { - title: 'Información Comercial', - icon: Building2, - fields: [ - { - label: 'Código de Proveedor', - value: selectedSupplier.supplier_code, - highlight: true - }, - { - label: 'Tipo de Proveedor', - value: getSupplierTypeText(selectedSupplier.supplier_type) - }, - { - label: 'Condiciones de Pago', - value: getPaymentTermsText(selectedSupplier.payment_terms) - }, - { - label: 'Tiempo de Entrega', - value: `${selectedSupplier.standard_lead_time} días` - }, - { - label: 'Pedido Mínimo', - value: selectedSupplier.minimum_order_amount, - type: 'currency' - }, - { - label: 'Límite de Crédito', - value: selectedSupplier.credit_limit, - type: 'currency' - } - ] - }, - { - title: 'Rendimiento y Estadísticas', - icon: DollarSign, - fields: [ - { - label: 'Total de Pedidos', - value: selectedSupplier.total_orders.toString() - }, - { - label: 'Gasto Total', - value: selectedSupplier.total_spend, - type: 'currency', - highlight: true - }, - { - label: 'Puntuación de Rendimiento', - value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado' - }, - { - label: 'Último Pedido', - value: selectedSupplier.last_order_date || 'Nunca', - type: selectedSupplier.last_order_date ? 'datetime' : undefined - } - ] - }, - ...(selectedSupplier.notes ? [{ - title: 'Notas', - fields: [ - { - label: 'Observaciones', - value: selectedSupplier.notes, - span: 2 as const - } - ] - }] : []) - ]} - onEdit={() => { - console.log('Editing supplier:', selectedSupplier.id); + sections={sections} + showDefaultActions={true} + onSave={async () => { + // TODO: Implement save functionality + console.log('Saving supplier:', selectedSupplier); + }} + onFieldChange={(sectionIndex, fieldIndex, value) => { + // Update the selectedSupplier state when fields change + const newSupplier = { ...selectedSupplier }; + const section = sections[sectionIndex]; + const field = section.fields[fieldIndex]; + + // Map field labels to supplier properties + const fieldMapping: { [key: string]: string } = { + 'Nombre': 'name', + 'Persona de Contacto': 'contact_person', + 'Email': 'email', + 'Teléfono': 'phone', + 'Ciudad': 'city', + 'País': 'country', + 'Código de Proveedor': 'supplier_code', + 'Tipo de Proveedor': 'supplier_type', + 'Condiciones de Pago': 'payment_terms', + 'Tiempo de Entrega (días)': 'standard_lead_time', + 'Pedido Mínimo': 'minimum_order_amount', + 'Límite de Crédito': 'credit_limit', + 'Moneda': 'currency', + 'Observaciones': 'notes' + }; + + const propertyName = fieldMapping[field.label]; + if (propertyName) { + newSupplier[propertyName] = value; + setSelectedSupplier(newSupplier); + } }} /> - )} + ); + })()}
); }; diff --git a/frontend/src/utils/enumHelpers.ts b/frontend/src/utils/enumHelpers.ts new file mode 100644 index 00000000..03f1b0fd --- /dev/null +++ b/frontend/src/utils/enumHelpers.ts @@ -0,0 +1,166 @@ +// frontend/src/utils/enumHelpers.ts +/** + * Utilities for working with enums and translations + */ + +import { useTranslation } from 'react-i18next'; +import type { SelectOption } from '../components/ui/Select'; +import { + SupplierType, + SupplierStatus, + PaymentTerms, + PurchaseOrderStatus, + DeliveryStatus, + QualityRating, + DeliveryRating, + InvoiceStatus, + type EnumOption +} from '../api/types/suppliers'; + +/** + * Generic function to convert enum to select options with i18n translations + */ +export function enumToSelectOptions>( + enumObject: T, + translationKey: string, + t: (key: string) => string, + options?: { + includeDescription?: boolean; + descriptionKey?: string; + sortAlphabetically?: boolean; + } +): SelectOption[] { + const selectOptions = Object.entries(enumObject).map(([key, value]) => ({ + value, + label: t(`${translationKey}.${value}`), + ...(options?.includeDescription && options?.descriptionKey && { + description: t(`${options.descriptionKey}.${value}`) + }) + })); + + if (options?.sortAlphabetically) { + selectOptions.sort((a, b) => a.label.localeCompare(b.label)); + } + + return selectOptions; +} + +/** + * Hook for supplier enum utilities + */ +export function useSupplierEnums() { + const { t } = useTranslation('suppliers'); + + return { + // Supplier Type + getSupplierTypeOptions: (): SelectOption[] => + enumToSelectOptions(SupplierType, 'types', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getSupplierTypeLabel: (type: SupplierType): string => { + if (!type) return 'Tipo no definido'; + return t(`types.${type}`); + }, + + // Supplier Status + getSupplierStatusOptions: (): SelectOption[] => + enumToSelectOptions(SupplierStatus, 'status', t), + + getSupplierStatusLabel: (status: SupplierStatus): string => { + if (!status) return 'Estado no definido'; + return t(`status.${status}`); + }, + + // Payment Terms + getPaymentTermsOptions: (): SelectOption[] => + enumToSelectOptions(PaymentTerms, 'payment_terms', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getPaymentTermsLabel: (terms: PaymentTerms): string => { + if (!terms) return 'Sin términos definidos'; + return t(`payment_terms.${terms}`); + }, + + // Purchase Order Status + getPurchaseOrderStatusOptions: (): SelectOption[] => + enumToSelectOptions(PurchaseOrderStatus, 'purchase_order_status', t), + + getPurchaseOrderStatusLabel: (status: PurchaseOrderStatus): string => { + if (!status) return 'Estado no definido'; + return t(`purchase_order_status.${status}`); + }, + + // Delivery Status + getDeliveryStatusOptions: (): SelectOption[] => + enumToSelectOptions(DeliveryStatus, 'delivery_status', t), + + getDeliveryStatusLabel: (status: DeliveryStatus): string => { + if (!status) return 'Estado no definido'; + return t(`delivery_status.${status}`); + }, + + // Quality Rating + getQualityRatingOptions: (): SelectOption[] => + enumToSelectOptions(QualityRating, 'quality_rating', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getQualityRatingLabel: (rating: QualityRating): string => { + if (rating === undefined || rating === null) return 'Sin calificación'; + return t(`quality_rating.${rating}`); + }, + + // Delivery Rating + getDeliveryRatingOptions: (): SelectOption[] => + enumToSelectOptions(DeliveryRating, 'delivery_rating', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getDeliveryRatingLabel: (rating: DeliveryRating): string => { + if (rating === undefined || rating === null) return 'Sin calificación'; + return t(`delivery_rating.${rating}`); + }, + + // Invoice Status + getInvoiceStatusOptions: (): SelectOption[] => + enumToSelectOptions(InvoiceStatus, 'invoice_status', t), + + getInvoiceStatusLabel: (status: InvoiceStatus): string => { + if (!status) return 'Estado no definido'; + return t(`invoice_status.${status}`); + }, + + // Field Labels + getFieldLabel: (field: string): string => + t(`labels.${field}`), + + getFieldDescription: (field: string): string => + t(`descriptions.${field}`) + }; +} + +/** + * Utility to get enum value from select option value + */ +export function getEnumFromValue( + enumObject: Record, + value: string | number +): T | undefined { + return Object.values(enumObject).find(enumValue => enumValue === value); +} + +/** + * Utility to validate enum value + */ +export function isValidEnumValue( + enumObject: Record, + value: unknown +): value is T { + return Object.values(enumObject).includes(value as T); +} \ No newline at end of file diff --git a/frontend/src/utils/foodSafetyEnumHelpers.ts b/frontend/src/utils/foodSafetyEnumHelpers.ts new file mode 100644 index 00000000..4948f1f5 --- /dev/null +++ b/frontend/src/utils/foodSafetyEnumHelpers.ts @@ -0,0 +1,109 @@ +// frontend/src/utils/foodSafetyEnumHelpers.ts +/** + * Utilities for working with food safety enums and translations + */ + +import { useTranslation } from 'react-i18next'; +import type { SelectOption } from '../components/ui/Select'; +import { + FoodSafetyStandard, + ComplianceStatus, + FoodSafetyAlertType, + type EnumOption +} from '../api/types/foodSafety'; + +/** + * Generic function to convert enum to select options with i18n translations + */ +export function enumToSelectOptions>( + enumObject: T, + translationKey: string, + t: (key: string) => string, + options?: { + includeDescription?: boolean; + descriptionKey?: string; + sortAlphabetically?: boolean; + } +): SelectOption[] { + const selectOptions = Object.entries(enumObject).map(([, value]) => ({ + value, + label: t(`${translationKey}.${value}`), + ...(options?.includeDescription && options?.descriptionKey && { + description: t(`${options.descriptionKey}.${value}`) + }) + })); + + if (options?.sortAlphabetically) { + selectOptions.sort((a, b) => a.label.localeCompare(b.label)); + } + + return selectOptions; +} + +/** + * Hook for food safety enum utilities + */ +export function useFoodSafetyEnums() { + const { t } = useTranslation('foodSafety'); + + return { + // Food Safety Standard + getFoodSafetyStandardOptions: (): SelectOption[] => + enumToSelectOptions(FoodSafetyStandard, 'enums.food_safety_standard', t, { + includeDescription: true, + descriptionKey: 'descriptions', + sortAlphabetically: true + }), + + getFoodSafetyStandardLabel: (standard: FoodSafetyStandard): string => + t(`enums.food_safety_standard.${standard}`), + + // Compliance Status + getComplianceStatusOptions: (): SelectOption[] => + enumToSelectOptions(ComplianceStatus, 'enums.compliance_status', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getComplianceStatusLabel: (status: ComplianceStatus): string => + t(`enums.compliance_status.${status}`), + + // Food Safety Alert Type + getFoodSafetyAlertTypeOptions: (): SelectOption[] => + enumToSelectOptions(FoodSafetyAlertType, 'enums.food_safety_alert_type', t, { + includeDescription: true, + descriptionKey: 'descriptions', + sortAlphabetically: true + }), + + getFoodSafetyAlertTypeLabel: (type: FoodSafetyAlertType): string => + t(`enums.food_safety_alert_type.${type}`), + + // Field Labels + getFieldLabel: (field: string): string => + t(`labels.${field}`), + + getFieldDescription: (field: string): string => + t(`descriptions.${field}`) + }; +} + +/** + * Utility to get enum value from select option value + */ +export function getEnumFromValue( + enumObject: Record, + value: string | number +): T | undefined { + return Object.values(enumObject).find(enumValue => enumValue === value); +} + +/** + * Utility to validate enum value + */ +export function isValidEnumValue( + enumObject: Record, + value: unknown +): value is T { + return Object.values(enumObject).includes(value as T); +} \ No newline at end of file diff --git a/frontend/src/utils/inventoryEnumHelpers.ts b/frontend/src/utils/inventoryEnumHelpers.ts new file mode 100644 index 00000000..46749b22 --- /dev/null +++ b/frontend/src/utils/inventoryEnumHelpers.ts @@ -0,0 +1,140 @@ +// frontend/src/utils/inventoryEnumHelpers.ts +/** + * Utilities for working with inventory enums and translations + */ + +import { useTranslation } from 'react-i18next'; +import type { SelectOption } from '../components/ui/Select'; +import { + ProductType, + ProductionStage, + UnitOfMeasure, + IngredientCategory, + ProductCategory, + StockMovementType, + type EnumOption +} from '../api/types/inventory'; + +/** + * Generic function to convert enum to select options with i18n translations + */ +export function enumToSelectOptions>( + enumObject: T, + translationKey: string, + t: (key: string) => string, + options?: { + includeDescription?: boolean; + descriptionKey?: string; + sortAlphabetically?: boolean; + } +): SelectOption[] { + const selectOptions = Object.entries(enumObject).map(([, value]) => ({ + value, + label: t(`${translationKey}.${value}`), + ...(options?.includeDescription && options?.descriptionKey && { + description: t(`${options.descriptionKey}.${value}`) + }) + })); + + if (options?.sortAlphabetically) { + selectOptions.sort((a, b) => a.label.localeCompare(b.label)); + } + + return selectOptions; +} + +/** + * Hook for inventory enum utilities + */ +export function useInventoryEnums() { + const { t } = useTranslation('inventory'); + + return { + // Product Type + getProductTypeOptions: (): SelectOption[] => + enumToSelectOptions(ProductType, 'enums.product_type', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getProductTypeLabel: (type: ProductType): string => + t(`enums.product_type.${type}`), + + // Production Stage + getProductionStageOptions: (): SelectOption[] => + enumToSelectOptions(ProductionStage, 'enums.production_stage', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getProductionStageLabel: (stage: ProductionStage): string => + t(`enums.production_stage.${stage}`), + + // Unit of Measure + getUnitOfMeasureOptions: (): SelectOption[] => + enumToSelectOptions(UnitOfMeasure, 'enums.unit_of_measure', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getUnitOfMeasureLabel: (unit: UnitOfMeasure): string => + t(`enums.unit_of_measure.${unit}`), + + // Ingredient Category + getIngredientCategoryOptions: (): SelectOption[] => + enumToSelectOptions(IngredientCategory, 'enums.ingredient_category', t, { + includeDescription: true, + descriptionKey: 'descriptions', + sortAlphabetically: true + }), + + getIngredientCategoryLabel: (category: IngredientCategory): string => + t(`enums.ingredient_category.${category}`), + + // Product Category + getProductCategoryOptions: (): SelectOption[] => + enumToSelectOptions(ProductCategory, 'enums.product_category', t, { + sortAlphabetically: true + }), + + getProductCategoryLabel: (category: ProductCategory): string => + t(`enums.product_category.${category}`), + + // Stock Movement Type + getStockMovementTypeOptions: (): SelectOption[] => + enumToSelectOptions(StockMovementType, 'enums.stock_movement_type', t, { + includeDescription: true, + descriptionKey: 'descriptions' + }), + + getStockMovementTypeLabel: (type: StockMovementType): string => + t(`enums.stock_movement_type.${type}`), + + // Field Labels + getFieldLabel: (field: string): string => + t(`labels.${field}`), + + getFieldDescription: (field: string): string => + t(`descriptions.${field}`) + }; +} + +/** + * Utility to get enum value from select option value + */ +export function getEnumFromValue( + enumObject: Record, + value: string | number +): T | undefined { + return Object.values(enumObject).find(enumValue => enumValue === value); +} + +/** + * Utility to validate enum value + */ +export function isValidEnumValue( + enumObject: Record, + value: unknown +): value is T { + return Object.values(enumObject).includes(value as T); +} \ No newline at end of file diff --git a/services/alert_processor/app/config.py b/services/alert_processor/app/config.py index 20ec06b6..4ccef6d7 100644 --- a/services/alert_processor/app/config.py +++ b/services/alert_processor/app/config.py @@ -13,11 +13,10 @@ class AlertProcessorConfig(BaseServiceSettings): APP_NAME: str = "Alert Processor Service" DESCRIPTION: str = "Central alert and recommendation processor" - # Use the notification database for alert storage - # This makes sense since alerts and notifications are closely related + # Use dedicated database for alert storage DATABASE_URL: str = os.getenv( - "NOTIFICATION_DATABASE_URL", - "postgresql+asyncpg://notification_user:notification_pass123@notification-db:5432/notification_db" + "ALERT_PROCESSOR_DATABASE_URL", + "postgresql+asyncpg://alert_processor_user:alert_processor_pass123@alert-processor-db:5432/alert_processor_db" ) # Use dedicated Redis DB for alert processing diff --git a/services/alert_processor/app/main.py b/services/alert_processor/app/main.py index dd847151..d628928d 100644 --- a/services/alert_processor/app/main.py +++ b/services/alert_processor/app/main.py @@ -206,42 +206,47 @@ class AlertProcessorService: async def store_item(self, item: dict) -> dict: """Store alert or recommendation in database""" - from sqlalchemy import text - - query = text(""" - INSERT INTO alerts ( - id, tenant_id, item_type, alert_type, severity, status, - service, title, message, actions, metadata, - created_at - ) VALUES (:id, :tenant_id, :item_type, :alert_type, :severity, :status, - :service, :title, :message, :actions, :metadata, :created_at) - RETURNING * - """) - + from app.models.alerts import Alert, AlertSeverity, AlertStatus + from sqlalchemy import select + async with self.db_manager.get_session() as session: - result = await session.execute( - query, - { - 'id': item['id'], - 'tenant_id': item['tenant_id'], - 'item_type': item['item_type'], # 'alert' or 'recommendation' - 'alert_type': item['type'], - 'severity': item['severity'], - 'status': 'active', - 'service': item['service'], - 'title': item['title'], - 'message': item['message'], - 'actions': json.dumps(item.get('actions', [])), - 'metadata': json.dumps(item.get('metadata', {})), - 'created_at': item['timestamp'] - } + # Create alert instance + alert = Alert( + id=item['id'], + tenant_id=item['tenant_id'], + item_type=item['item_type'], # 'alert' or 'recommendation' + alert_type=item['type'], + severity=AlertSeverity(item['severity']), + status=AlertStatus.ACTIVE, + service=item['service'], + title=item['title'], + message=item['message'], + actions=item.get('actions', []), + alert_metadata=item.get('metadata', {}), + created_at=datetime.fromisoformat(item['timestamp']) if isinstance(item['timestamp'], str) else item['timestamp'] ) - - row = result.fetchone() + + session.add(alert) await session.commit() - + await session.refresh(alert) + logger.debug("Item stored in database", item_id=item['id']) - return dict(row._mapping) + + # Convert to dict for return + return { + 'id': str(alert.id), + 'tenant_id': str(alert.tenant_id), + 'item_type': alert.item_type, + 'alert_type': alert.alert_type, + 'severity': alert.severity.value, + 'status': alert.status.value, + 'service': alert.service, + 'title': alert.title, + 'message': alert.message, + 'actions': alert.actions, + 'metadata': alert.alert_metadata, + 'created_at': alert.created_at + } async def stream_to_sse(self, tenant_id: str, item: dict): """Publish item to Redis for SSE streaming""" diff --git a/services/alert_processor/app/models/__init__.py b/services/alert_processor/app/models/__init__.py new file mode 100644 index 00000000..99940351 --- /dev/null +++ b/services/alert_processor/app/models/__init__.py @@ -0,0 +1 @@ +# services/alert_processor/app/models/__init__.py \ No newline at end of file diff --git a/services/alert_processor/app/models/alerts.py b/services/alert_processor/app/models/alerts.py new file mode 100644 index 00000000..f1ecb9c6 --- /dev/null +++ b/services/alert_processor/app/models/alerts.py @@ -0,0 +1,56 @@ +# services/alert_processor/app/models/alerts.py +""" +Alert models for the alert processor service +""" + +from sqlalchemy import Column, String, Text, DateTime, JSON, Enum +from sqlalchemy.dialects.postgresql import UUID +from datetime import datetime +import uuid +import enum + +from shared.database.base import Base + + +class AlertStatus(enum.Enum): + """Alert status values""" + ACTIVE = "active" + RESOLVED = "resolved" + ACKNOWLEDGED = "acknowledged" + IGNORED = "ignored" + + +class AlertSeverity(enum.Enum): + """Alert severity levels""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +class Alert(Base): + """Alert records for the alert processor service""" + __tablename__ = "alerts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + # Alert classification + item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation' + alert_type = Column(String(100), nullable=False) # e.g., 'overstock_warning' + severity = Column(Enum(AlertSeverity), nullable=False, index=True) + status = Column(Enum(AlertStatus), default=AlertStatus.ACTIVE, index=True) + + # Source and content + service = Column(String(100), nullable=False) # originating service + title = Column(String(255), nullable=False) + message = Column(Text, nullable=False) + + # Actions and metadata + actions = Column(JSON, nullable=True) # List of available actions + alert_metadata = Column(JSON, nullable=True) # Additional alert-specific data + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + resolved_at = Column(DateTime, nullable=True) \ No newline at end of file diff --git a/services/alert_processor/migrations/alembic.ini b/services/alert_processor/migrations/alembic.ini new file mode 100644 index 00000000..a5d4e2f5 --- /dev/null +++ b/services/alert_processor/migrations/alembic.ini @@ -0,0 +1,93 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = . + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version number format +# Uses Alembic datetime format +version_num_format = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d + +# version name format +version_path_separator = / + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+asyncpg://alert_processor_user:alert_processor_pass123@alert-processor-db:5432/alert_processor_db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/services/alert_processor/migrations/env.py b/services/alert_processor/migrations/env.py new file mode 100644 index 00000000..f4db3958 --- /dev/null +++ b/services/alert_processor/migrations/env.py @@ -0,0 +1,109 @@ +""" +Alembic environment configuration for Alert Processor Service +""" + +import asyncio +from logging.config import fileConfig +import os +import sys +from pathlib import Path + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Add the app directory to the path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Import models to ensure they're registered +from app.models.alerts import * # noqa +from shared.database.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set the SQLAlchemy URL from environment variable if available +database_url = os.getenv('ALERT_PROCESSOR_DATABASE_URL') +if database_url: + config.set_main_option('sqlalchemy.url', database_url) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with database connection""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in async mode""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/services/alert_processor/migrations/script.py.mako b/services/alert_processor/migrations/script.py.mako new file mode 100644 index 00000000..37d0cac3 --- /dev/null +++ b/services/alert_processor/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/services/alert_processor/migrations/versions/001_initial_alerts_table.py b/services/alert_processor/migrations/versions/001_initial_alerts_table.py new file mode 100644 index 00000000..1a52e593 --- /dev/null +++ b/services/alert_processor/migrations/versions/001_initial_alerts_table.py @@ -0,0 +1,64 @@ +"""Initial alerts table + +Revision ID: 001 +Revises: +Create Date: 2025-09-18 23:17:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create enum types + alert_status_enum = postgresql.ENUM('active', 'resolved', 'acknowledged', 'ignored', name='alertstatus') + alert_severity_enum = postgresql.ENUM('low', 'medium', 'high', 'urgent', name='alertseverity') + + alert_status_enum.create(op.get_bind()) + alert_severity_enum.create(op.get_bind()) + + # Create alerts table + op.create_table('alerts', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('item_type', sa.String(length=50), nullable=False), + sa.Column('alert_type', sa.String(length=100), nullable=False), + sa.Column('severity', alert_severity_enum, nullable=False), + sa.Column('status', alert_status_enum, nullable=False), + sa.Column('service', sa.String(length=100), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('actions', sa.JSON(), nullable=True), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + ) + + # Create indexes + op.create_index('ix_alerts_tenant_id', 'alerts', ['tenant_id']) + op.create_index('ix_alerts_severity', 'alerts', ['severity']) + op.create_index('ix_alerts_status', 'alerts', ['status']) + op.create_index('ix_alerts_created_at', 'alerts', ['created_at']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_alerts_created_at', 'alerts') + op.drop_index('ix_alerts_status', 'alerts') + op.drop_index('ix_alerts_severity', 'alerts') + op.drop_index('ix_alerts_tenant_id', 'alerts') + + # Drop table + op.drop_table('alerts') + + # Drop enum types + op.execute('DROP TYPE alertseverity') + op.execute('DROP TYPE alertstatus') \ No newline at end of file diff --git a/services/inventory/app/models/inventory.py b/services/inventory/app/models/inventory.py index 0b889999..f7258098 100644 --- a/services/inventory/app/models/inventory.py +++ b/services/inventory/app/models/inventory.py @@ -106,7 +106,6 @@ class Ingredient(Base): # Product details description = Column(Text, nullable=True) brand = Column(String(100), nullable=True) # Brand or central baker name - supplier_name = Column(String(200), nullable=True) # Central baker or distributor unit_of_measure = Column(SQLEnum(UnitOfMeasure), nullable=False) package_size = Column(Float, nullable=True) # Size per package/unit @@ -158,7 +157,6 @@ class Ingredient(Base): Index('idx_ingredients_ingredient_category', 'tenant_id', 'ingredient_category', 'is_active'), Index('idx_ingredients_product_category', 'tenant_id', 'product_category', 'is_active'), Index('idx_ingredients_stock_levels', 'tenant_id', 'low_stock_threshold', 'reorder_point'), - Index('idx_ingredients_central_baker', 'tenant_id', 'supplier_name', 'product_type'), ) def to_dict(self) -> Dict[str, Any]: @@ -194,7 +192,6 @@ class Ingredient(Base): 'subcategory': self.subcategory, 'description': self.description, 'brand': self.brand, - 'supplier_name': self.supplier_name, 'unit_of_measure': self.unit_of_measure.value if self.unit_of_measure else None, 'package_size': self.package_size, 'average_cost': float(self.average_cost) if self.average_cost else None, @@ -230,6 +227,9 @@ class Stock(Base): tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False, index=True) + # Supplier association + supplier_id = Column(UUID(as_uuid=True), nullable=True, index=True) + # Stock identification batch_number = Column(String(100), nullable=True, index=True) lot_number = Column(String(100), nullable=True, index=True) diff --git a/services/inventory/app/schemas/inventory.py b/services/inventory/app/schemas/inventory.py index 788f6100..6c47a408 100644 --- a/services/inventory/app/schemas/inventory.py +++ b/services/inventory/app/schemas/inventory.py @@ -61,13 +61,6 @@ class IngredientCreate(InventoryBaseSchema): is_perishable: bool = Field(False, description="Is perishable") allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information") - @validator('storage_temperature_max') - def validate_temperature_range(cls, v, values): - if v is not None and 'storage_temperature_min' in values and values['storage_temperature_min'] is not None: - if v <= values['storage_temperature_min']: - raise ValueError('Max temperature must be greater than min temperature') - return v - @validator('reorder_point') def validate_reorder_point(cls, v, values): if 'low_stock_threshold' in values and v <= values['low_stock_threshold']: @@ -147,6 +140,7 @@ class IngredientResponse(InventoryBaseSchema): class StockCreate(InventoryBaseSchema): """Schema for creating stock entries""" ingredient_id: str = Field(..., description="Ingredient ID") + supplier_id: Optional[str] = Field(None, description="Supplier ID") batch_number: Optional[str] = Field(None, max_length=100, description="Batch number") lot_number: Optional[str] = Field(None, max_length=100, description="Lot number") supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference") @@ -181,9 +175,16 @@ class StockCreate(InventoryBaseSchema): 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") - + @validator('storage_temperature_max') + def validate_temperature_range(cls, v, values): + min_temp = values.get('storage_temperature_min') + if v is not None and min_temp is not None and v <= min_temp: + raise ValueError('Max temperature must be greater than min temperature') + return v + class StockUpdate(InventoryBaseSchema): """Schema for updating stock entries""" + supplier_id: Optional[str] = Field(None, description="Supplier ID") batch_number: Optional[str] = Field(None, max_length=100, description="Batch number") lot_number: Optional[str] = Field(None, max_length=100, description="Lot number") supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference") @@ -226,6 +227,7 @@ class StockResponse(InventoryBaseSchema): id: str tenant_id: str ingredient_id: str + supplier_id: Optional[str] batch_number: Optional[str] lot_number: Optional[str] supplier_batch_ref: Optional[str] diff --git a/services/inventory/app/services/dashboard_service.py b/services/inventory/app/services/dashboard_service.py index 097263e3..e3dd0085 100644 --- a/services/inventory/app/services/dashboard_service.py +++ b/services/inventory/app/services/dashboard_service.py @@ -451,17 +451,17 @@ class DashboardService: SELECT 'stock_movement' as activity_type, CASE - WHEN movement_type = 'purchase' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' - WHEN movement_type = 'production_use' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' - WHEN movement_type = 'waste' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' - WHEN movement_type = 'adjustment' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' + WHEN movement_type = 'PURCHASE' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' + WHEN movement_type = 'PRODUCTION_USE' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' + WHEN movement_type = 'WASTE' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' + WHEN movement_type = 'ADJUSTMENT' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')' ELSE 'Stock movement: ' || i.name END as description, sm.movement_date as timestamp, sm.created_by as user_id, CASE - WHEN movement_type = 'waste' THEN 'high' - WHEN movement_type = 'adjustment' THEN 'medium' + WHEN movement_type = 'WASTE' THEN 'high' + WHEN movement_type = 'ADJUSTMENT' THEN 'medium' ELSE 'low' END as impact_level, sm.id as entity_id, @@ -613,17 +613,22 @@ class DashboardService: # Get ingredient metrics query = """ - SELECT + SELECT COUNT(*) as total_ingredients, COUNT(CASE WHEN product_type = 'finished_product' THEN 1 END) as finished_products, COUNT(CASE WHEN product_type = 'ingredient' THEN 1 END) as raw_ingredients, - COUNT(DISTINCT supplier_name) as supplier_count, + COUNT(DISTINCT st.supplier_id) as supplier_count, AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level FROM ingredients i LEFT JOIN ( SELECT ingredient_id, SUM(available_quantity) as available_quantity FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id ) s ON i.id = s.ingredient_id + LEFT JOIN ( + SELECT ingredient_id, supplier_id + FROM stock WHERE tenant_id = :tenant_id AND supplier_id IS NOT NULL + GROUP BY ingredient_id, supplier_id + ) st ON i.id = st.ingredient_id WHERE i.tenant_id = :tenant_id AND i.is_active = true """ diff --git a/services/inventory/app/services/inventory_alert_service.py b/services/inventory/app/services/inventory_alert_service.py index b406e972..dc03a37a 100644 --- a/services/inventory/app/services/inventory_alert_service.py +++ b/services/inventory/app/services/inventory_alert_service.py @@ -428,17 +428,17 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): i.low_stock_threshold as minimum_stock, i.max_stock_level as maximum_stock, COALESCE(SUM(s.current_quantity), 0) as current_stock, - AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'production_use' + AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage, - COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use' + COUNT(sm.id) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days, - MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'production_use') as last_used + MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE') as last_used FROM ingredients i LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id WHERE i.is_active = true AND i.tenant_id = :tenant_id GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level - HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use' + HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'PRODUCTION_USE' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3 ), recommendations AS ( diff --git a/services/notification/app/models/notifications.py b/services/notification/app/models/notifications.py index dd7a0ab2..e431a14a 100644 --- a/services/notification/app/models/notifications.py +++ b/services/notification/app/models/notifications.py @@ -162,23 +162,23 @@ class NotificationLog(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) notification_id = Column(UUID(as_uuid=True), nullable=False, index=True) - + # Attempt details attempt_number = Column(Integer, nullable=False) status = Column(Enum(NotificationStatus), nullable=False) - + # Provider details provider = Column(String(50), nullable=True) # e.g., "gmail", "twilio" provider_message_id = Column(String(255), nullable=True) provider_response = Column(JSON, nullable=True) - + # Timing attempted_at = Column(DateTime, default=datetime.utcnow) response_time_ms = Column(Integer, nullable=True) - + # Error details error_code = Column(String(50), nullable=True) error_message = Column(Text, nullable=True) - + # Additional metadata log_metadata = Column(JSON, nullable=True) \ No newline at end of file diff --git a/services/suppliers/app/api/suppliers.py b/services/suppliers/app/api/suppliers.py index 10f062f5..a77cb206 100644 --- a/services/suppliers/app/api/suppliers.py +++ b/services/suppliers/app/api/suppliers.py @@ -8,7 +8,7 @@ from typing import List, Optional from uuid import UUID import structlog -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.services.supplier_service import SupplierService from app.schemas.suppliers import ( @@ -27,7 +27,7 @@ async def create_supplier( supplier_data: SupplierCreate, tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): try: @@ -54,7 +54,7 @@ async def list_suppliers( status: Optional[str] = Query(None, description="Status filter"), limit: int = Query(50, ge=1, le=1000, description="Number of results to return"), offset: int = Query(0, ge=0, description="Number of results to skip"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """List suppliers with optional filters""" # require_permissions(current_user, ["suppliers:read"]) @@ -81,7 +81,7 @@ async def list_suppliers( @router.get("/statistics", response_model=SupplierStatistics) async def get_supplier_statistics( tenant_id: str = Path(..., description="Tenant ID"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get supplier statistics for dashboard""" # require_permissions(current_user, ["suppliers:read"]) @@ -98,7 +98,7 @@ async def get_supplier_statistics( @router.get("/active", response_model=List[SupplierSummary]) async def get_active_suppliers( tenant_id: str = Path(..., description="Tenant ID"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get all active suppliers""" # require_permissions(current_user, ["suppliers:read"]) @@ -116,7 +116,7 @@ async def get_active_suppliers( async def get_top_suppliers( tenant_id: str = Path(..., description="Tenant ID"), limit: int = Query(10, ge=1, le=50, description="Number of top suppliers to return"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get top performing suppliers""" # require_permissions(current_user, ["suppliers:read"]) @@ -134,7 +134,7 @@ async def get_top_suppliers( async def get_suppliers_needing_review( tenant_id: str = Path(..., description="Tenant ID"), days_since_last_order: int = Query(30, ge=1, le=365, description="Days since last order"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get suppliers that may need performance review""" # require_permissions(current_user, ["suppliers:read"]) @@ -154,7 +154,7 @@ async def get_suppliers_needing_review( async def get_supplier( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get supplier by ID""" # require_permissions(current_user, ["suppliers:read"]) @@ -179,7 +179,7 @@ async def update_supplier( supplier_data: SupplierUpdate, supplier_id: UUID = Path(..., description="Supplier ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Update supplier information""" # require_permissions(current_user, ["suppliers:update"]) @@ -214,7 +214,7 @@ async def update_supplier( @router.delete("/{supplier_id}") async def delete_supplier( supplier_id: UUID = Path(..., description="Supplier ID"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Delete supplier (soft delete)""" # require_permissions(current_user, ["suppliers:delete"]) @@ -244,7 +244,7 @@ async def approve_supplier( approval_data: SupplierApproval, supplier_id: UUID = Path(..., description="Supplier ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Approve or reject a pending supplier""" # require_permissions(current_user, ["suppliers:approve"]) @@ -289,7 +289,7 @@ async def approve_supplier( async def get_suppliers_by_type( supplier_type: str = Path(..., description="Supplier type"), tenant_id: str = Path(..., description="Tenant ID"), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get suppliers by type""" # require_permissions(current_user, ["suppliers:read"]) diff --git a/services/suppliers/app/models/suppliers.py b/services/suppliers/app/models/suppliers.py index a4362187..7e1536f2 100644 --- a/services/suppliers/app/models/suppliers.py +++ b/services/suppliers/app/models/suppliers.py @@ -18,84 +18,84 @@ from shared.database.base import Base class SupplierType(enum.Enum): """Types of suppliers""" - INGREDIENTS = "ingredients" # Raw materials supplier - PACKAGING = "packaging" # Packaging materials - EQUIPMENT = "equipment" # Bakery equipment - SERVICES = "services" # Service providers - UTILITIES = "utilities" # Utilities (gas, electricity) - MULTI = "multi" # Multi-category supplier + ingredients = "ingredients" # Raw materials supplier + packaging = "packaging" # Packaging materials + equipment = "equipment" # Bakery equipment + services = "services" # Service providers + utilities = "utilities" # Utilities (gas, electricity) + multi = "multi" # Multi-category supplier class SupplierStatus(enum.Enum): """Supplier lifecycle status""" - ACTIVE = "active" - INACTIVE = "inactive" - PENDING_APPROVAL = "pending_approval" - SUSPENDED = "suspended" - BLACKLISTED = "blacklisted" + active = "active" + inactive = "inactive" + pending_approval = "pending_approval" + suspended = "suspended" + blacklisted = "blacklisted" class PaymentTerms(enum.Enum): """Payment terms with suppliers""" - CASH_ON_DELIVERY = "cod" - NET_15 = "net_15" - NET_30 = "net_30" - NET_45 = "net_45" - NET_60 = "net_60" - PREPAID = "prepaid" - CREDIT_TERMS = "credit_terms" + cod = "cod" + net_15 = "net_15" + net_30 = "net_30" + net_45 = "net_45" + net_60 = "net_60" + prepaid = "prepaid" + credit_terms = "credit_terms" class PurchaseOrderStatus(enum.Enum): """Purchase order lifecycle status""" - DRAFT = "draft" - PENDING_APPROVAL = "pending_approval" - APPROVED = "approved" - SENT_TO_SUPPLIER = "sent_to_supplier" - CONFIRMED = "confirmed" - PARTIALLY_RECEIVED = "partially_received" - COMPLETED = "completed" - CANCELLED = "cancelled" - DISPUTED = "disputed" + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + sent_to_supplier = "sent_to_supplier" + confirmed = "confirmed" + partially_received = "partially_received" + completed = "completed" + cancelled = "cancelled" + disputed = "disputed" class DeliveryStatus(enum.Enum): """Delivery status tracking""" - SCHEDULED = "scheduled" - IN_TRANSIT = "in_transit" - OUT_FOR_DELIVERY = "out_for_delivery" - DELIVERED = "delivered" - PARTIALLY_DELIVERED = "partially_delivered" - FAILED_DELIVERY = "failed_delivery" - RETURNED = "returned" + scheduled = "scheduled" + in_transit = "in_transit" + out_for_delivery = "out_for_delivery" + delivered = "delivered" + partially_delivered = "partially_delivered" + failed_delivery = "failed_delivery" + returned = "returned" class QualityRating(enum.Enum): """Quality rating scale""" - EXCELLENT = 5 - GOOD = 4 - AVERAGE = 3 - POOR = 2 - VERY_POOR = 1 + excellent = 5 + good = 4 + average = 3 + poor = 2 + very_poor = 1 class DeliveryRating(enum.Enum): """Delivery performance rating scale""" - EXCELLENT = 5 - GOOD = 4 - AVERAGE = 3 - POOR = 2 - VERY_POOR = 1 + excellent = 5 + good = 4 + average = 3 + poor = 2 + very_poor = 1 class InvoiceStatus(enum.Enum): """Invoice processing status""" - PENDING = "pending" - APPROVED = "approved" - PAID = "paid" - OVERDUE = "overdue" - DISPUTED = "disputed" - CANCELLED = "cancelled" + pending = "pending" + approved = "approved" + paid = "paid" + overdue = "overdue" + disputed = "disputed" + cancelled = "cancelled" class Supplier(Base): @@ -113,7 +113,7 @@ class Supplier(Base): # Supplier classification supplier_type = Column(SQLEnum(SupplierType), nullable=False, index=True) - status = Column(SQLEnum(SupplierStatus), nullable=False, default=SupplierStatus.PENDING_APPROVAL, index=True) + status = Column(SQLEnum(SupplierStatus), nullable=False, default=SupplierStatus.pending_approval, index=True) # Contact information contact_person = Column(String(200), nullable=True) @@ -131,7 +131,7 @@ class Supplier(Base): country = Column(String(100), nullable=True) # Business terms - payment_terms = Column(SQLEnum(PaymentTerms), nullable=False, default=PaymentTerms.NET_30) + payment_terms = Column(SQLEnum(PaymentTerms), nullable=False, default=PaymentTerms.net_30) credit_limit = Column(Numeric(12, 2), nullable=True) currency = Column(String(3), nullable=False, default="EUR") # ISO currency code @@ -246,7 +246,7 @@ class PurchaseOrder(Base): reference_number = Column(String(100), nullable=True) # Internal reference # Order status and workflow - status = Column(SQLEnum(PurchaseOrderStatus), nullable=False, default=PurchaseOrderStatus.DRAFT, index=True) + status = Column(SQLEnum(PurchaseOrderStatus), nullable=False, default=PurchaseOrderStatus.draft, index=True) priority = Column(String(20), nullable=False, default="normal") # urgent, high, normal, low # Order details @@ -363,7 +363,7 @@ class Delivery(Base): supplier_delivery_note = Column(String(100), nullable=True) # Supplier's delivery reference # Delivery status and tracking - status = Column(SQLEnum(DeliveryStatus), nullable=False, default=DeliveryStatus.SCHEDULED, index=True) + status = Column(SQLEnum(DeliveryStatus), nullable=False, default=DeliveryStatus.scheduled, index=True) # Scheduling and timing scheduled_date = Column(DateTime(timezone=True), nullable=True) @@ -517,7 +517,7 @@ class SupplierInvoice(Base): supplier_invoice_number = Column(String(100), nullable=False) # Invoice status and dates - status = Column(SQLEnum(InvoiceStatus), nullable=False, default=InvoiceStatus.PENDING, index=True) + status = Column(SQLEnum(InvoiceStatus), nullable=False, default=InvoiceStatus.pending, index=True) invoice_date = Column(DateTime(timezone=True), nullable=False) due_date = Column(DateTime(timezone=True), nullable=False) received_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/services/suppliers/app/repositories/base.py b/services/suppliers/app/repositories/base.py index c0d9f27f..1971dad4 100644 --- a/services/suppliers/app/repositories/base.py +++ b/services/suppliers/app/repositories/base.py @@ -4,8 +4,8 @@ Base repository class for common database operations """ from typing import TypeVar, Generic, List, Optional, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy import desc, asc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import desc, asc, select, func from uuid import UUID T = TypeVar('T') @@ -14,55 +14,59 @@ T = TypeVar('T') class BaseRepository(Generic[T]): """Base repository with common CRUD operations""" - def __init__(self, model: type, db: Session): + def __init__(self, model: type, db: AsyncSession): self.model = model self.db = db - def create(self, obj_data: Dict[str, Any]) -> T: + async def create(self, obj_data: Dict[str, Any]) -> T: """Create a new record""" db_obj = self.model(**obj_data) self.db.add(db_obj) - self.db.commit() - self.db.refresh(db_obj) + await self.db.commit() + await self.db.refresh(db_obj) return db_obj - def get_by_id(self, record_id: UUID) -> Optional[T]: + async def get_by_id(self, record_id: UUID) -> Optional[T]: """Get record by ID""" - return self.db.query(self.model).filter(self.model.id == record_id).first() + stmt = select(self.model).filter(self.model.id == record_id) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() - def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]: + async def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]: """Get records by tenant ID with pagination""" - return ( - self.db.query(self.model) - .filter(self.model.tenant_id == tenant_id) - .limit(limit) - .offset(offset) - .all() - ) + stmt = select(self.model).filter( + self.model.tenant_id == tenant_id + ).limit(limit).offset(offset) + result = await self.db.execute(stmt) + return result.scalars().all() - def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]: + async def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]: """Update record by ID""" - db_obj = self.get_by_id(record_id) + db_obj = await self.get_by_id(record_id) if db_obj: for key, value in update_data.items(): if hasattr(db_obj, key): setattr(db_obj, key, value) - self.db.commit() - self.db.refresh(db_obj) + await self.db.commit() + await self.db.refresh(db_obj) return db_obj - def delete(self, record_id: UUID) -> bool: + async def delete(self, record_id: UUID) -> bool: """Delete record by ID""" - db_obj = self.get_by_id(record_id) + db_obj = await self.get_by_id(record_id) if db_obj: - self.db.delete(db_obj) - self.db.commit() + await self.db.delete(db_obj) + await self.db.commit() return True return False - def count_by_tenant(self, tenant_id: UUID) -> int: + async def count_by_tenant(self, tenant_id: UUID) -> int: """Count records by tenant""" - return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count() + stmt = select(func.count()).select_from(self.model).filter( + self.model.tenant_id == tenant_id + ) + result = await self.db.execute(stmt) + return result.scalar() or 0 def list_with_filters( self, diff --git a/services/suppliers/app/repositories/supplier_repository.py b/services/suppliers/app/repositories/supplier_repository.py index 7bf8bb57..4dab1db2 100644 --- a/services/suppliers/app/repositories/supplier_repository.py +++ b/services/suppliers/app/repositories/supplier_repository.py @@ -4,8 +4,8 @@ Supplier repository for database operations """ from typing import List, Optional, Dict, Any -from sqlalchemy.orm import Session -from sqlalchemy import and_, or_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import and_, or_, func, select from uuid import UUID from datetime import datetime @@ -16,36 +16,32 @@ from app.repositories.base import BaseRepository class SupplierRepository(BaseRepository[Supplier]): """Repository for supplier management operations""" - def __init__(self, db: Session): + def __init__(self, db: AsyncSession): super().__init__(Supplier, db) - def get_by_name(self, tenant_id: UUID, name: str) -> Optional[Supplier]: + async def get_by_name(self, tenant_id: UUID, name: str) -> Optional[Supplier]: """Get supplier by name within tenant""" - return ( - self.db.query(self.model) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.name == name - ) + stmt = select(self.model).filter( + and_( + self.model.tenant_id == tenant_id, + self.model.name == name ) - .first() ) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() - def get_by_supplier_code(self, tenant_id: UUID, supplier_code: str) -> Optional[Supplier]: + async def get_by_supplier_code(self, tenant_id: UUID, supplier_code: str) -> Optional[Supplier]: """Get supplier by supplier code within tenant""" - return ( - self.db.query(self.model) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.supplier_code == supplier_code - ) + stmt = select(self.model).filter( + and_( + self.model.tenant_id == tenant_id, + self.model.supplier_code == supplier_code ) - .first() ) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() - def search_suppliers( + async def search_suppliers( self, tenant_id: UUID, search_term: Optional[str] = None, @@ -55,8 +51,8 @@ class SupplierRepository(BaseRepository[Supplier]): offset: int = 0 ) -> List[Supplier]: """Search suppliers with filters""" - query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id) - + stmt = select(self.model).filter(self.model.tenant_id == tenant_id) + # Search term filter (name, contact person, email) if search_term: search_filter = or_( @@ -64,31 +60,30 @@ class SupplierRepository(BaseRepository[Supplier]): self.model.contact_person.ilike(f"%{search_term}%"), self.model.email.ilike(f"%{search_term}%") ) - query = query.filter(search_filter) - + stmt = stmt.filter(search_filter) + # Type filter if supplier_type: - query = query.filter(self.model.supplier_type == supplier_type) - + stmt = stmt.filter(self.model.supplier_type == supplier_type) + # Status filter if status: - query = query.filter(self.model.status == status) - - return query.order_by(self.model.name).limit(limit).offset(offset).all() + stmt = stmt.filter(self.model.status == status) + + stmt = stmt.order_by(self.model.name).limit(limit).offset(offset) + result = await self.db.execute(stmt) + return result.scalars().all() - def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]: + async def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]: """Get all active suppliers for a tenant""" - return ( - self.db.query(self.model) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE - ) + stmt = select(self.model).filter( + and_( + self.model.tenant_id == tenant_id, + self.model.status == SupplierStatus.active ) - .order_by(self.model.name) - .all() - ) + ).order_by(self.model.name) + result = await self.db.execute(stmt) + return result.scalars().all() def get_suppliers_by_type( self, @@ -102,7 +97,7 @@ class SupplierRepository(BaseRepository[Supplier]): and_( self.model.tenant_id == tenant_id, self.model.supplier_type == supplier_type, - self.model.status == SupplierStatus.ACTIVE + self.model.status == SupplierStatus.active ) ) .order_by(self.model.quality_rating.desc(), self.model.name) @@ -120,7 +115,7 @@ class SupplierRepository(BaseRepository[Supplier]): .filter( and_( self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE + self.model.status == SupplierStatus.active ) ) .order_by( @@ -178,7 +173,7 @@ class SupplierRepository(BaseRepository[Supplier]): .filter( and_( self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE, + self.model.status == SupplierStatus.active, or_( self.model.quality_rating < 3.0, # Poor rating self.model.delivery_rating < 3.0, # Poor delivery @@ -190,66 +185,33 @@ class SupplierRepository(BaseRepository[Supplier]): .all() ) - def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]: + async def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]: """Get supplier statistics for dashboard""" - total_suppliers = self.count_by_tenant(tenant_id) - - active_suppliers = ( - self.db.query(self.model) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE - ) - ) - .count() - ) - - pending_suppliers = ( - self.db.query(self.model) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.PENDING_APPROVAL - ) - ) - .count() - ) - - avg_quality_rating = ( - self.db.query(func.avg(self.model.quality_rating)) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE, - self.model.quality_rating > 0 - ) - ) - .scalar() - ) or 0.0 - - avg_delivery_rating = ( - self.db.query(func.avg(self.model.delivery_rating)) - .filter( - and_( - self.model.tenant_id == tenant_id, - self.model.status == SupplierStatus.ACTIVE, - self.model.delivery_rating > 0 - ) - ) - .scalar() - ) or 0.0 - - total_spend = ( - self.db.query(func.sum(self.model.total_amount)) - .filter(self.model.tenant_id == tenant_id) - .scalar() - ) or 0.0 - + total_suppliers = await self.count_by_tenant(tenant_id) + + # Get all suppliers for this tenant to avoid multiple queries and enum casting issues + all_stmt = select(self.model).filter(self.model.tenant_id == tenant_id) + all_result = await self.db.execute(all_stmt) + all_suppliers = all_result.scalars().all() + + # Calculate statistics in Python to avoid database enum casting issues + active_suppliers = [s for s in all_suppliers if s.status == SupplierStatus.active] + pending_suppliers = [s for s in all_suppliers if s.status == SupplierStatus.pending_approval] + + # Calculate averages from active suppliers + quality_ratings = [s.quality_rating for s in active_suppliers if s.quality_rating and s.quality_rating > 0] + avg_quality_rating = sum(quality_ratings) / len(quality_ratings) if quality_ratings else 0.0 + + delivery_ratings = [s.delivery_rating for s in active_suppliers if s.delivery_rating and s.delivery_rating > 0] + avg_delivery_rating = sum(delivery_ratings) / len(delivery_ratings) if delivery_ratings else 0.0 + + # Total spend for all suppliers + total_spend = sum(float(s.total_amount or 0) for s in all_suppliers) + return { "total_suppliers": total_suppliers, - "active_suppliers": active_suppliers, - "pending_suppliers": pending_suppliers, + "active_suppliers": len(active_suppliers), + "pending_suppliers": len(pending_suppliers), "avg_quality_rating": round(float(avg_quality_rating), 2), "avg_delivery_rating": round(float(avg_delivery_rating), 2), "total_spend": float(total_spend) diff --git a/services/suppliers/app/schemas/suppliers.py b/services/suppliers/app/schemas/suppliers.py index 737ab7eb..f5fb28c3 100644 --- a/services/suppliers/app/schemas/suppliers.py +++ b/services/suppliers/app/schemas/suppliers.py @@ -42,7 +42,7 @@ class SupplierCreate(BaseModel): country: Optional[str] = Field(None, max_length=100) # Business terms - payment_terms: PaymentTerms = PaymentTerms.NET_30 + payment_terms: PaymentTerms = PaymentTerms.net_30 credit_limit: Optional[Decimal] = Field(None, ge=0) currency: str = Field(default="EUR", max_length=3) standard_lead_time: int = Field(default=3, ge=0, le=365) diff --git a/services/suppliers/app/services/dashboard_service.py b/services/suppliers/app/services/dashboard_service.py index 27ba48d7..4e51da04 100644 --- a/services/suppliers/app/services/dashboard_service.py +++ b/services/suppliers/app/services/dashboard_service.py @@ -138,7 +138,6 @@ class DashboardService: return SupplierPerformanceInsights( supplier_id=supplier_id, - supplier_name=supplier['name'], current_overall_score=current_metrics.get('overall_score', 0), previous_score=previous_metrics.get('overall_score'), score_change_percentage=self._calculate_change_percentage( diff --git a/services/suppliers/app/services/supplier_service.py b/services/suppliers/app/services/supplier_service.py index c618dc8e..5598ec5f 100644 --- a/services/suppliers/app/services/supplier_service.py +++ b/services/suppliers/app/services/supplier_service.py @@ -7,7 +7,7 @@ import structlog from typing import List, Optional, Dict, Any from uuid import UUID from datetime import datetime -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession from app.repositories.supplier_repository import SupplierRepository from app.models.suppliers import Supplier, SupplierStatus, SupplierType @@ -23,7 +23,7 @@ logger = structlog.get_logger() class SupplierService: """Service for supplier management operations""" - def __init__(self, db: Session): + def __init__(self, db: AsyncSession): self.db = db self.repository = SupplierRepository(db) @@ -37,13 +37,13 @@ class SupplierService: logger.info("Creating supplier", tenant_id=str(tenant_id), name=supplier_data.name) # Check for duplicate name - existing = self.repository.get_by_name(tenant_id, supplier_data.name) + existing = await self.repository.get_by_name(tenant_id, supplier_data.name) if existing: raise ValueError(f"Supplier with name '{supplier_data.name}' already exists") - + # Check for duplicate supplier code if provided if supplier_data.supplier_code: - existing_code = self.repository.get_by_supplier_code( + existing_code = await self.repository.get_by_supplier_code( tenant_id, supplier_data.supplier_code ) if existing_code: @@ -61,7 +61,7 @@ class SupplierService: create_data.update({ 'tenant_id': tenant_id, 'supplier_code': supplier_code, - 'status': SupplierStatus.PENDING_APPROVAL, + 'status': SupplierStatus.pending_approval, 'created_by': created_by, 'updated_by': created_by, 'quality_rating': 0.0, @@ -70,7 +70,7 @@ class SupplierService: 'total_amount': 0.0 }) - supplier = self.repository.create(create_data) + supplier = await self.repository.create(create_data) logger.info( "Supplier created successfully", @@ -83,7 +83,7 @@ class SupplierService: async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]: """Get supplier by ID""" - return self.repository.get_by_id(supplier_id) + return await self.repository.get_by_id(supplier_id) async def update_supplier( self, @@ -138,7 +138,7 @@ class SupplierService: # Soft delete by changing status self.repository.update(supplier_id, { - 'status': SupplierStatus.INACTIVE, + 'status': SupplierStatus.inactive, 'updated_at': datetime.utcnow() }) @@ -151,7 +151,7 @@ class SupplierService: search_params: SupplierSearchParams ) -> List[Supplier]: """Search suppliers with filters""" - return self.repository.search_suppliers( + return await self.repository.search_suppliers( tenant_id=tenant_id, search_term=search_params.search_term, supplier_type=search_params.supplier_type, @@ -239,7 +239,7 @@ class SupplierService: async def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]: """Get supplier statistics for dashboard""" - return self.repository.get_supplier_statistics(tenant_id) + return await self.repository.get_supplier_statistics(tenant_id) async def get_suppliers_needing_review( self, diff --git a/shared/messaging/rabbitmq.py b/shared/messaging/rabbitmq.py index 9b2d4718..8a13cd14 100644 --- a/shared/messaging/rabbitmq.py +++ b/shared/messaging/rabbitmq.py @@ -23,6 +23,9 @@ def json_serializer(obj): return obj.isoformat() elif isinstance(obj, uuid.UUID): return str(obj) + elif hasattr(obj, '__class__') and obj.__class__.__name__ == 'Decimal': + # Handle Decimal objects from SQLAlchemy without importing decimal + return float(obj) raise TypeError(f"Object of type {type(obj)} is not JSON serializable") class RabbitMQClient: