Add supplier and imporve inventory frontend

This commit is contained in:
Urtzi Alfaro
2025-09-18 23:32:53 +02:00
parent ae77a0e1c5
commit d61056df33
40 changed files with 2022 additions and 629 deletions

View File

@@ -28,6 +28,7 @@ volumes:
pos_db_data: pos_db_data:
orders_db_data: orders_db_data:
production_db_data: production_db_data:
alert_processor_db_data:
redis_data: redis_data:
rabbitmq_data: rabbitmq_data:
prometheus_data: prometheus_data:
@@ -373,6 +374,27 @@ services:
timeout: 5s timeout: 5s
retries: 5 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) # LOCATION SERVICES (NEW SECTION)
@@ -743,6 +765,8 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
depends_on: depends_on:
alert-processor-db:
condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:

View File

@@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { SSEProvider } from './contexts/SSEContext'; import { SSEProvider } from './contexts/SSEContext';
import './i18n';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {

View File

@@ -2,6 +2,44 @@
* Food Safety API Types - Mirror backend schemas * 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 { export interface FoodSafetyComplianceCreate {
ingredient_id: string; ingredient_id: string;
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification'; compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
@@ -224,3 +262,11 @@ export interface FoodSafetyDashboard {
quantity: number; quantity: number;
}>; }>;
} }
// Select option interface for enum helpers
export interface EnumOption {
value: string | number;
label: string;
disabled?: boolean;
description?: string;
}

View File

@@ -56,6 +56,18 @@ export enum ProductCategory {
OTHER_PRODUCTS = 'other_products' 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 // Base Inventory Types
export interface IngredientCreate { export interface IngredientCreate {
name: string; name: string;
@@ -67,7 +79,6 @@ export interface IngredientCreate {
reorder_point: number; reorder_point: number;
shelf_life_days?: number; // Default shelf life only shelf_life_days?: number; // Default shelf life only
is_seasonal?: boolean; is_seasonal?: boolean;
supplier_id?: string;
average_cost?: number; average_cost?: number;
notes?: string; notes?: string;
} }
@@ -135,6 +146,7 @@ export interface IngredientResponse {
// Stock Management Types // Stock Management Types
export interface StockCreate { export interface StockCreate {
ingredient_id: string; ingredient_id: string;
supplier_id?: string;
batch_number?: string; batch_number?: string;
lot_number?: string; lot_number?: string;
supplier_batch_ref?: string; supplier_batch_ref?: string;
@@ -174,6 +186,7 @@ export interface StockCreate {
} }
export interface StockUpdate { export interface StockUpdate {
supplier_id?: string;
batch_number?: string; batch_number?: string;
lot_number?: string; lot_number?: string;
supplier_batch_ref?: string; supplier_batch_ref?: string;
@@ -410,3 +423,11 @@ export interface DeletionSummary {
deleted_stock_alerts: number; deleted_stock_alerts: number;
success: boolean; success: boolean;
} }
// Select option interface for enum helpers
export interface EnumOption {
value: string | number;
label: string;
disabled?: boolean;
description?: string;
}

View File

@@ -22,12 +22,13 @@ export enum SupplierStatus {
} }
export enum PaymentTerms { export enum PaymentTerms {
CASH_ON_DELIVERY = 'cod', COD = 'cod',
NET_15 = 'net_15', NET_15 = 'net_15',
NET_30 = 'net_30', NET_30 = 'net_30',
NET_45 = 'net_45', NET_45 = 'net_45',
NET_60 = 'net_60', NET_60 = 'net_60',
PREPAID = 'prepaid', PREPAID = 'prepaid',
CREDIT_TERMS = 'credit_terms',
} }
export enum PurchaseOrderStatus { export enum PurchaseOrderStatus {
@@ -39,6 +40,7 @@ export enum PurchaseOrderStatus {
PARTIALLY_RECEIVED = 'partially_received', PARTIALLY_RECEIVED = 'partially_received',
COMPLETED = 'completed', COMPLETED = 'completed',
CANCELLED = 'cancelled', CANCELLED = 'cancelled',
DISPUTED = 'disputed',
} }
export enum DeliveryStatus { export enum DeliveryStatus {
@@ -48,6 +50,32 @@ export enum DeliveryStatus {
DELIVERED = 'delivered', DELIVERED = 'delivered',
PARTIALLY_DELIVERED = 'partially_delivered', PARTIALLY_DELIVERED = 'partially_delivered',
FAILED_DELIVERY = 'failed_delivery', 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 { export enum OrderPriority {
@@ -425,7 +453,10 @@ export interface ApiResponse<T> {
errors?: string[]; errors?: string[];
} }
// Export all types // Select option interface for enum helpers
export type { export interface EnumOption {
// Add any additional export aliases if needed value: string | number;
}; label: string;
disabled?: boolean;
description?: string;
}

View File

@@ -1,8 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react'; import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal'; 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 { 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'; import { statusColors } from '../../../styles/colors';
interface AddStockModalProps { interface AddStockModalProps {
@@ -27,11 +30,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
current_quantity: 0, current_quantity: 0,
unit_cost: Number(ingredient.average_cost) || 0, unit_cost: Number(ingredient.average_cost) || 0,
expiration_date: '', expiration_date: '',
production_stage: ProductionStage.RAW_INGREDIENT,
batch_number: '', batch_number: '',
supplier_id: '', supplier_id: '',
quality_status: 'good',
storage_location: '', storage_location: '',
requires_refrigeration: false, warehouse_zone: '',
requires_freezing: false, requires_refrigeration: 'no',
requires_freezing: 'no',
storage_temperature_min: undefined, storage_temperature_min: undefined,
storage_temperature_max: undefined, storage_temperature_max: undefined,
storage_humidity_max: undefined, storage_humidity_max: undefined,
@@ -43,12 +49,82 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'overview' | 'edit'>('edit'); 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 handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
const fieldMappings = [ const fieldMappings = [
// Basic Stock Information section // Basic Stock Information section
['current_quantity', 'unit_cost', 'expiration_date'], ['current_quantity', 'unit_cost', 'expiration_date', 'production_stage'],
// Additional Information section // Additional Information section
['batch_number', 'supplier_id', 'storage_location', 'notes'], ['batch_number', 'supplier_id', 'quality_status', 'storage_location', 'warehouse_zone', 'notes'],
// Storage Requirements section // Storage Requirements section
['requires_refrigeration', 'requires_freezing', 'storage_temperature_min', 'storage_temperature_max', 'storage_humidity_max', 'shelf_life_days', 'storage_instructions'] ['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<AddStockModalProps> = ({
current_quantity: Number(formData.current_quantity), current_quantity: Number(formData.current_quantity),
unit_cost: Number(formData.unit_cost), unit_cost: Number(formData.unit_cost),
expiration_date: formData.expiration_date || undefined, expiration_date: formData.expiration_date || undefined,
production_stage: formData.production_stage || ProductionStage.RAW_INGREDIENT,
batch_number: formData.batch_number || undefined, batch_number: formData.batch_number || undefined,
supplier_id: formData.supplier_id || undefined, supplier_id: formData.supplier_id || undefined,
quality_status: formData.quality_status || 'good',
storage_location: formData.storage_location || undefined, storage_location: formData.storage_location || undefined,
requires_refrigeration: formData.requires_refrigeration || false, warehouse_zone: formData.warehouse_zone || undefined,
requires_freezing: formData.requires_freezing || false, 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_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_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, storage_humidity_max: formData.storage_humidity_max ? Number(formData.storage_humidity_max) : undefined,
@@ -103,11 +182,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
current_quantity: 0, current_quantity: 0,
unit_cost: Number(ingredient.average_cost) || 0, unit_cost: Number(ingredient.average_cost) || 0,
expiration_date: '', expiration_date: '',
production_stage: ProductionStage.RAW_INGREDIENT,
batch_number: '', batch_number: '',
supplier_id: '', supplier_id: '',
quality_status: 'good',
storage_location: '', storage_location: '',
requires_refrigeration: false, warehouse_zone: '',
requires_freezing: false, requires_refrigeration: 'no',
requires_freezing: 'no',
storage_temperature_min: undefined, storage_temperature_min: undefined,
storage_temperature_max: undefined, storage_temperature_max: undefined,
storage_humidity_max: undefined, storage_humidity_max: undefined,
@@ -162,8 +244,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
label: 'Fecha de Vencimiento', label: 'Fecha de Vencimiento',
value: formData.expiration_date || '', value: formData.expiration_date || '',
type: 'date' as const, type: 'date' as const,
editable: true
},
{
label: 'Etapa de Producción',
value: formData.production_stage || ProductionStage.RAW_INGREDIENT,
type: 'select' as const,
editable: true, editable: true,
span: 2 as const options: inventoryEnums.getProductionStageOptions()
} }
] ]
}, },
@@ -179,19 +267,35 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
placeholder: 'Ej: LOTE2024001' placeholder: 'Ej: LOTE2024001'
}, },
{ {
label: 'ID Proveedor', label: 'Proveedor',
value: formData.supplier_id || '', value: formData.supplier_id || '',
type: 'text' as const, type: 'select' as const,
editable: true, 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', label: 'Ubicación de Almacenamiento',
value: formData.storage_location || '', value: formData.storage_location || '',
type: 'text' as const, type: 'select' as const,
editable: true, editable: true,
placeholder: 'Ej: Estante A-3', placeholder: 'Seleccionar ubicación',
span: 2 as const options: storageLocationOptions
},
{
label: 'Zona de Almacén',
value: formData.warehouse_zone || '',
type: 'select' as const,
editable: true,
placeholder: 'Seleccionar zona',
options: warehouseZoneOptions
}, },
{ {
label: 'Notas', label: 'Notas',
@@ -209,15 +313,17 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
fields: [ fields: [
{ {
label: 'Requiere Refrigeración', label: 'Requiere Refrigeración',
value: formData.requires_refrigeration || false, value: formData.requires_refrigeration || 'no',
type: 'boolean' as const, type: 'select' as const,
editable: true editable: true,
options: refrigerationOptions
}, },
{ {
label: 'Requiere Congelación', label: 'Requiere Congelación',
value: formData.requires_freezing || false, value: formData.requires_freezing || 'no',
type: 'boolean' as const, type: 'select' as const,
editable: true editable: true,
options: freezingOptions
}, },
{ {
label: 'Temperatura Mínima (°C)', label: 'Temperatura Mínima (°C)',

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Plus, Package, Calculator, Settings } from 'lucide-react'; import { Plus, Package, Calculator, Settings } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory'; import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
import { statusColors } from '../../../styles/colors'; import { statusColors } from '../../../styles/colors';
interface CreateIngredientModalProps { interface CreateIngredientModalProps {
@@ -28,7 +29,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
reorder_point: 20, reorder_point: 20,
max_stock_level: 100, max_stock_level: 100,
is_seasonal: false, is_seasonal: false,
supplier_id: '',
average_cost: 0, average_cost: 0,
notes: '' notes: ''
}); });
@@ -36,44 +36,16 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'overview' | 'edit'>('edit'); 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 = [ const categoryOptions = [
// Ingredient categories ...inventoryEnums.getIngredientCategoryOptions(),
{ label: 'Harinas', value: 'flour' }, ...inventoryEnums.getProductCategoryOptions()
{ 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' }
]; ];
const unitOptions = [ const unitOptions = inventoryEnums.getUnitOfMeasureOptions();
{ 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 handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => { const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
// Map field positions to form data fields // Map field positions to form data fields
@@ -82,8 +54,8 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
['name', 'description', 'category', 'unit_of_measure'], ['name', 'description', 'category', 'unit_of_measure'],
// Cost and Quantities section // Cost and Quantities section
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], ['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
// Additional Information section (moved up after removing storage section) // Additional Information section
['supplier_id', 'notes'] ['notes']
]; ];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate; const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate;
@@ -146,7 +118,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
requires_refrigeration: false, requires_refrigeration: false,
requires_freezing: false, requires_freezing: false,
is_seasonal: false, is_seasonal: false,
supplier_id: '',
average_cost: 0, average_cost: 0,
notes: '' notes: ''
}); });
@@ -174,7 +145,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
requires_refrigeration: false, requires_refrigeration: false,
requires_freezing: false, requires_freezing: false,
is_seasonal: false, is_seasonal: false,
supplier_id: '',
average_cost: 0, average_cost: 0,
notes: '' notes: ''
}); });
@@ -234,7 +204,7 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
{ {
label: 'Costo Promedio', label: 'Costo Promedio',
value: formData.average_cost || 0, value: formData.average_cost || 0,
type: 'number' as const, type: 'currency' as const,
editable: true, editable: true,
placeholder: '0.00' placeholder: '0.00'
}, },
@@ -267,13 +237,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
title: 'Información Adicional', title: 'Información Adicional',
icon: Settings, icon: Settings,
fields: [ fields: [
{
label: 'Proveedor',
value: formData.supplier_id || '',
type: 'text' as const,
editable: true,
placeholder: 'ID o nombre del proveedor'
},
{ {
label: 'Notas', label: 'Notas',
value: formData.notes || '', value: formData.notes || '',

View File

@@ -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<CreateSupplierFormProps> = ({
onSubmit,
onCancel
}) => {
const { t } = useTranslation(['suppliers', 'common']);
const supplierEnums = useSupplierEnums();
const [formData, setFormData] = useState<SupplierFormData>({
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 (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Supplier Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('common:forms.supplier_name')} *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => 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')}
/>
</div>
{/* Supplier Type - Using enum helper */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{supplierEnums.getFieldLabel('supplier_type')} *
</label>
<Select
options={supplierEnums.getSupplierTypeOptions()}
value={formData.supplier_type}
onChange={(value) => handleFieldChange('supplier_type', value as SupplierType)}
placeholder={t('common:forms.select_option')}
helperText={supplierEnums.getFieldDescription('supplier_type')}
/>
</div>
{/* Supplier Status - Using enum helper */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{supplierEnums.getFieldLabel('supplier_status')}
</label>
<Select
options={supplierEnums.getSupplierStatusOptions()}
value={formData.status}
onChange={(value) => handleFieldChange('status', value as SupplierStatus)}
placeholder={t('common:forms.select_option')}
/>
</div>
{/* Payment Terms - Using enum helper */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{supplierEnums.getFieldLabel('payment_terms')} *
</label>
<Select
options={supplierEnums.getPaymentTermsOptions()}
value={formData.payment_terms}
onChange={(value) => handleFieldChange('payment_terms', value as PaymentTerms)}
placeholder={t('common:forms.select_option')}
helperText={supplierEnums.getFieldDescription('payment_terms')}
/>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('common:forms.email')}
</label>
<input
type="email"
value={formData.email}
onChange={(e) => 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')}
/>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('common:forms.phone')}
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => 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')}
/>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="secondary"
onClick={onCancel}
>
{t('common:actions.cancel')}
</Button>
<Button
type="submit"
variant="primary"
>
{t('common:actions.create')}
</Button>
</div>
{/* Display current selections for debugging */}
<div className="mt-8 p-4 bg-gray-50 rounded-md">
<h3 className="text-sm font-medium text-gray-700 mb-2">Current Selections:</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li>
<strong>Tipo:</strong> {supplierEnums.getSupplierTypeLabel(formData.supplier_type)}
</li>
<li>
<strong>Estado:</strong> {supplierEnums.getSupplierStatusLabel(formData.status)}
</li>
<li>
<strong>Términos de Pago:</strong> {supplierEnums.getPaymentTermsLabel(formData.payment_terms)}
</li>
<li>
<strong>Debug - Payment Terms Raw:</strong> {formData.payment_terms}
</li>
<li>
<strong>Debug - Translation Test:</strong> {t('suppliers:payment_terms.net_30')}
</li>
</ul>
</div>
</form>
);
};

View File

@@ -227,10 +227,6 @@ export const StatusCard: React.FC<StatusCardProps> = ({
</div> </div>
)} )}
{/* Spacer for alignment when no progress bar */}
{!progress && (
<div className="h-12" />
)}
{/* Metadata */} {/* Metadata */}
{metadata.length > 0 && ( {metadata.length > 0 && (

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -40,6 +40,81 @@
"description": "Descripción", "description": "Descripción",
"notes": "Notas" "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": { "categories": {
"all": "Todas las categorías", "all": "Todas las categorías",
"flour": "Harinas", "flour": "Harinas",

View File

@@ -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"
}
}

View File

@@ -2,6 +2,8 @@
import commonEs from './es/common.json'; import commonEs from './es/common.json';
import authEs from './es/auth.json'; import authEs from './es/auth.json';
import inventoryEs from './es/inventory.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'; import errorsEs from './es/errors.json';
// Translation resources by language // Translation resources by language
@@ -10,6 +12,8 @@ export const resources = {
common: commonEs, common: commonEs,
auth: authEs, auth: authEs,
inventory: inventoryEs, inventory: inventoryEs,
foodSafety: foodSafetyEs,
suppliers: suppliersEs,
errors: errorsEs, errors: errorsEs,
}, },
}; };
@@ -33,7 +37,7 @@ export const languageConfig = {
}; };
// Namespaces available in translations // 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]; export type Namespace = typeof namespaces[number];
// Helper function to get language display name // 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 individual language modules for direct imports
export { commonEs, authEs, inventoryEs, errorsEs }; export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, errorsEs };
// Default export with all translations // Default export with all translations
export default resources; export default resources;

View File

@@ -207,6 +207,7 @@ const InventoryPage: React.FC = () => {
); );
} }
// Sort by priority: expired → out of stock → low stock → normal → overstock // Sort by priority: expired → out of stock → low stock → normal → overstock
// Within each priority level, sort by most critical items first // Within each priority level, sort by most critical items first
return items.sort((a, b) => { return items.sort((a, b) => {
@@ -263,22 +264,8 @@ const InventoryPage: React.FC = () => {
// Helper function to get category display name // Helper function to get category display name
const getCategoryDisplayName = (category?: string): string => { const getCategoryDisplayName = (category?: string): string => {
const categoryMappings: Record<string, string> = { if (!category) return 'Sin categoría';
'flour': 'Harina', return category;
'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';
}; };
// Focused action handlers // Focused action handlers

View File

@@ -1,93 +1,54 @@
import React, { useState } from 'react'; 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 { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers'; 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 SuppliersPage: React.FC = () => {
const [activeTab] = useState('all'); const [activeTab] = useState('all');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedSupplier, setSelectedSupplier] = useState<typeof mockSuppliers[0] | null>(null); const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
const [isCreating, setIsCreating] = useState(false);
const mockSuppliers = [ // Get tenant ID from tenant store (preferred) or auth user (fallback)
{ const currentTenant = useCurrentTenant();
id: 'SUP-2024-001', const user = useAuthUser();
supplier_code: 'HAR001', const tenantId = currentTenant?.id || user?.tenant_id || '';
name: 'Harinas del Norte S.L.',
supplier_type: SupplierType.INGREDIENTS, // API hooks
status: SupplierStatus.ACTIVE, const {
contact_person: 'María González', data: suppliersData,
email: 'maria@harinasdelnorte.es', isLoading: suppliersLoading,
phone: '+34 987 654 321', error: suppliersError
city: 'León', } = useSuppliers(tenantId, {
country: 'España', search_term: searchTerm || undefined,
payment_terms: PaymentTerms.NET_30, status: activeTab !== 'all' ? activeTab as any : undefined,
credit_limit: 5000, limit: 100
currency: 'EUR', });
standard_lead_time: 5,
minimum_order_amount: 200, const {
total_orders: 45, data: statisticsData,
total_spend: 12750.50, isLoading: statisticsLoading,
last_order_date: '2024-01-25T14:30:00Z', error: statisticsError
performance_score: 92, } = useSupplierStatistics(tenantId);
notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.'
}, const suppliers = suppliersData || [];
{ const supplierEnums = useSupplierEnums();
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.'
},
];
const getSupplierStatusConfig = (status: SupplierStatus) => { const getSupplierStatusConfig = (status: SupplierStatus) => {
const statusConfig = { const statusConfig = {
[SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle }, [SupplierStatus.ACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: CheckCircle },
[SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer }, [SupplierStatus.INACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: Timer },
[SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle }, [SupplierStatus.PENDING_APPROVAL]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
[SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle }, [SupplierStatus.SUSPENDED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
[SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle }, [SupplierStatus.BLACKLISTED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
}; };
const config = statusConfig[status]; const config = statusConfig[status];
@@ -104,110 +65,122 @@ const SuppliersPage: React.FC = () => {
}; };
const getSupplierTypeText = (type: SupplierType): string => { const getSupplierTypeText = (type: SupplierType): string => {
const typeMap = { return supplierEnums.getSupplierTypeLabel(type);
[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;
}; };
const getPaymentTermsText = (terms: PaymentTerms): string => { const getPaymentTermsText = (terms: PaymentTerms): string => {
const termsMap = { return supplierEnums.getPaymentTermsLabel(terms);
[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;
}; };
const filteredSuppliers = mockSuppliers.filter(supplier => { // Filtering is now handled by the API query parameters
const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) || const filteredSuppliers = suppliers;
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; const supplierStats = statisticsData || {
total_suppliers: 0,
return matchesSearch && matchesTab; active_suppliers: 0,
}); pending_suppliers: 0,
avg_quality_rating: 0,
const mockSupplierStats = { avg_delivery_rating: 0,
total: mockSuppliers.length, total_spend: 0
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 stats = [ const stats = [
{ {
title: 'Total Proveedores', title: 'Total Proveedores',
value: mockSupplierStats.total, value: supplierStats.total_suppliers,
variant: 'default' as const, variant: 'default' as const,
icon: Building2, icon: Building2,
}, },
{ {
title: 'Activos', title: 'Activos',
value: mockSupplierStats.active, value: supplierStats.active_suppliers,
variant: 'success' as const, variant: 'success' as const,
icon: CheckCircle, icon: CheckCircle,
}, },
{ {
title: 'Pendientes', title: 'Pendientes',
value: mockSupplierStats.pendingApproval, value: supplierStats.pending_suppliers,
variant: 'warning' as const, variant: 'warning' as const,
icon: AlertCircle, icon: AlertCircle,
}, },
{ {
title: 'Gasto Total', title: 'Gasto Total',
value: formatters.currency(mockSupplierStats.totalSpend), value: formatters.currency(supplierStats.total_spend),
variant: 'info' as const, variant: 'info' as const,
icon: DollarSign, icon: DollarSign,
}, },
{ {
title: 'Total Pedidos', title: 'Calidad Media',
value: mockSupplierStats.totalOrders, value: supplierStats.avg_quality_rating?.toFixed(1) || '0.0',
variant: 'default' as const,
icon: Building2,
},
{
title: 'Puntuación Media',
value: mockSupplierStats.averageScore.toFixed(1),
variant: 'success' as const, variant: 'success' as const,
icon: CheckCircle, 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 (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-[var(--primary)]" />
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
</div>
</div>
);
}
// Error state
if (suppliersError || statisticsError) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-4 text-red-500" />
<p className="text-red-600 mb-2">Error al cargar los proveedores</p>
<p className="text-[var(--text-secondary)] text-sm">
{(suppliersError as any)?.message || (statisticsError as any)?.message || 'Error desconocido'}
</p>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Gestión de Proveedores" title="Gestión de Proveedores"
description="Administra y supervisa todos los proveedores de la panadería" description="Administra y supervisa todos los proveedores de la panadería"
actions={[ actions={[
{
id: "export",
label: "Exportar",
variant: "outline" as const,
icon: Download,
onClick: () => console.log('Export suppliers')
},
{ {
id: "new", id: "new",
label: "Nuevo Proveedor", label: "Nuevo Proveedor",
variant: "primary" as const, variant: "primary" as const,
icon: Plus, 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" className="w-full"
/> />
</div> </div>
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div> </div>
</Card> </Card>
@@ -240,9 +209,6 @@ const SuppliersPage: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSuppliers.map((supplier) => { {filteredSuppliers.map((supplier) => {
const statusConfig = getSupplierStatusConfig(supplier.status); const statusConfig = getSupplierStatusConfig(supplier.status);
const performanceNote = supplier.performance_score
? `Puntuación: ${supplier.performance_score}/100`
: 'Sin evaluación';
return ( return (
<StatusCard <StatusCard
@@ -251,35 +217,40 @@ const SuppliersPage: React.FC = () => {
statusIndicator={statusConfig} statusIndicator={statusConfig}
title={supplier.name} title={supplier.name}
subtitle={supplier.supplier_code} subtitle={supplier.supplier_code}
primaryValue={formatters.currency(supplier.total_spend)} primaryValue={supplier.city || 'Sin ubicación'}
primaryValueLabel={`${supplier.total_orders} pedidos`} primaryValueLabel={getSupplierTypeText(supplier.supplier_type)}
secondaryInfo={{ secondaryInfo={{
label: 'Tipo', label: 'Condiciones',
value: getSupplierTypeText(supplier.supplier_type) value: getPaymentTermsText(supplier.payment_terms)
}} }}
metadata={[ metadata={[
supplier.contact_person || 'Sin contacto', supplier.contact_person || 'Sin contacto',
supplier.email || 'Sin email', supplier.email || 'Sin email',
supplier.phone || 'Sin teléfono', supplier.phone || 'Sin teléfono',
performanceNote `Creado: ${new Date(supplier.created_at).toLocaleDateString('es-ES')}`
]} ]}
actions={[ actions={[
// Primary action - View supplier details
{ {
label: 'Ver', label: 'Ver Detalles',
icon: Eye, icon: Eye,
variant: 'outline', variant: 'primary',
priority: 'primary',
onClick: () => { onClick: () => {
setSelectedSupplier(supplier); setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view'); setModalMode('view');
setShowForm(true); setShowForm(true);
} }
}, },
// Secondary action - Edit supplier
{ {
label: 'Editar', label: 'Editar',
icon: Edit, icon: Edit,
variant: 'outline', priority: 'secondary',
onClick: () => { onClick: () => {
setSelectedSupplier(supplier); setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('edit'); setModalMode('edit');
setShowForm(true); setShowForm(true);
} }
@@ -308,49 +279,55 @@ const SuppliersPage: React.FC = () => {
)} )}
{/* Supplier Details Modal */} {/* Supplier Details Modal */}
{showForm && selectedSupplier && ( {showForm && selectedSupplier && (() => {
<StatusModal const sections = [
isOpen={showForm}
onClose={() => {
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
}}
mode={modalMode}
onModeChange={setModalMode}
title={selectedSupplier.name}
subtitle={`Proveedor ${selectedSupplier.supplier_code}`}
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
size="lg"
sections={[
{ {
title: 'Información de Contacto', title: 'Información de Contacto',
icon: Users, icon: Users,
fields: [ fields: [
{ {
label: 'Nombre', label: 'Nombre',
value: selectedSupplier.name, value: selectedSupplier.name || '',
highlight: true type: 'text',
highlight: true,
editable: true,
required: true,
placeholder: 'Nombre del proveedor'
}, },
{ {
label: 'Persona de Contacto', label: 'Persona de Contacto',
value: selectedSupplier.contact_person || 'No especificado' value: selectedSupplier.contact_person || '',
type: 'text',
editable: true,
placeholder: 'Nombre del contacto'
}, },
{ {
label: 'Email', label: 'Email',
value: selectedSupplier.email || 'No especificado' value: selectedSupplier.email || '',
type: 'email',
editable: true,
placeholder: 'email@ejemplo.com'
}, },
{ {
label: 'Teléfono', label: 'Teléfono',
value: selectedSupplier.phone || 'No especificado' value: selectedSupplier.phone || '',
type: 'tel',
editable: true,
placeholder: '+34 123 456 789'
}, },
{ {
label: 'Ciudad', label: 'Ciudad',
value: selectedSupplier.city || 'No especificado' value: selectedSupplier.city || '',
type: 'text',
editable: true,
placeholder: 'Ciudad'
}, },
{ {
label: 'País', label: 'País',
value: selectedSupplier.country || 'No especificado' value: selectedSupplier.country || '',
type: 'text',
editable: true,
placeholder: 'País'
} }
] ]
}, },
@@ -360,30 +337,46 @@ const SuppliersPage: React.FC = () => {
fields: [ fields: [
{ {
label: 'Código de Proveedor', label: 'Código de Proveedor',
value: selectedSupplier.supplier_code, value: selectedSupplier.supplier_code || '',
highlight: true type: 'text',
highlight: true,
editable: true,
placeholder: 'Código único'
}, },
{ {
label: 'Tipo de Proveedor', label: 'Tipo de Proveedor',
value: getSupplierTypeText(selectedSupplier.supplier_type) value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
type: 'select',
editable: true,
options: supplierEnums.getSupplierTypeOptions()
}, },
{ {
label: 'Condiciones de Pago', label: 'Condiciones de Pago',
value: getPaymentTermsText(selectedSupplier.payment_terms) value: selectedSupplier.payment_terms || PaymentTerms.NET_30,
type: 'select',
editable: true,
options: supplierEnums.getPaymentTermsOptions()
}, },
{ {
label: 'Tiempo de Entrega', label: 'Tiempo de Entrega (días)',
value: `${selectedSupplier.standard_lead_time} días` value: selectedSupplier.standard_lead_time || 3,
type: 'number',
editable: true,
placeholder: '3'
}, },
{ {
label: 'Pedido Mínimo', label: 'Pedido Mínimo',
value: selectedSupplier.minimum_order_amount, value: selectedSupplier.minimum_order_amount || 0,
type: 'currency' type: 'currency',
editable: true,
placeholder: '0.00'
}, },
{ {
label: 'Límite de Crédito', label: 'Límite de Crédito',
value: selectedSupplier.credit_limit, value: selectedSupplier.credit_limit || 0,
type: 'currency' type: 'currency',
editable: true,
placeholder: '0.00'
} }
] ]
}, },
@@ -392,23 +385,22 @@ const SuppliersPage: React.FC = () => {
icon: DollarSign, icon: DollarSign,
fields: [ fields: [
{ {
label: 'Total de Pedidos', label: 'Moneda',
value: selectedSupplier.total_orders.toString() value: selectedSupplier.currency || 'EUR',
type: 'text',
editable: true,
placeholder: 'EUR'
}, },
{ {
label: 'Gasto Total', label: 'Fecha de Creación',
value: selectedSupplier.total_spend, value: selectedSupplier.created_at,
type: 'currency', type: 'datetime',
highlight: true highlight: true
}, },
{ {
label: 'Puntuación de Rendimiento', label: 'Última Actualización',
value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado' value: selectedSupplier.updated_at,
}, type: 'datetime'
{
label: 'Último Pedido',
value: selectedSupplier.last_order_date || 'Nunca',
type: selectedSupplier.last_order_date ? 'datetime' : undefined
} }
] ]
}, },
@@ -418,16 +410,69 @@ const SuppliersPage: React.FC = () => {
{ {
label: 'Observaciones', label: 'Observaciones',
value: selectedSupplier.notes, value: selectedSupplier.notes,
span: 2 as const type: 'list',
span: 2 as const,
editable: true,
placeholder: 'Notas sobre el proveedor'
} }
] ]
}] : []) }] : [])
]} ];
onEdit={() => {
console.log('Editing supplier:', selectedSupplier.id); return (
<StatusModal
isOpen={showForm}
onClose={() => {
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
setIsCreating(false);
}}
mode={modalMode}
onModeChange={setModalMode}
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={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);
}
}} }}
/> />
)} );
})()}
</div> </div>
); );
}; };

View File

@@ -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<T extends Record<string, string | number>>(
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<T>(
enumObject: Record<string, T>,
value: string | number
): T | undefined {
return Object.values(enumObject).find(enumValue => enumValue === value);
}
/**
* Utility to validate enum value
*/
export function isValidEnumValue<T>(
enumObject: Record<string, T>,
value: unknown
): value is T {
return Object.values(enumObject).includes(value as T);
}

View File

@@ -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<T extends Record<string, string | number>>(
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<T>(
enumObject: Record<string, T>,
value: string | number
): T | undefined {
return Object.values(enumObject).find(enumValue => enumValue === value);
}
/**
* Utility to validate enum value
*/
export function isValidEnumValue<T>(
enumObject: Record<string, T>,
value: unknown
): value is T {
return Object.values(enumObject).includes(value as T);
}

View File

@@ -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<T extends Record<string, string | number>>(
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<T>(
enumObject: Record<string, T>,
value: string | number
): T | undefined {
return Object.values(enumObject).find(enumValue => enumValue === value);
}
/**
* Utility to validate enum value
*/
export function isValidEnumValue<T>(
enumObject: Record<string, T>,
value: unknown
): value is T {
return Object.values(enumObject).includes(value as T);
}

View File

@@ -13,11 +13,10 @@ class AlertProcessorConfig(BaseServiceSettings):
APP_NAME: str = "Alert Processor Service" APP_NAME: str = "Alert Processor Service"
DESCRIPTION: str = "Central alert and recommendation processor" DESCRIPTION: str = "Central alert and recommendation processor"
# Use the notification database for alert storage # Use dedicated database for alert storage
# This makes sense since alerts and notifications are closely related
DATABASE_URL: str = os.getenv( DATABASE_URL: str = os.getenv(
"NOTIFICATION_DATABASE_URL", "ALERT_PROCESSOR_DATABASE_URL",
"postgresql+asyncpg://notification_user:notification_pass123@notification-db:5432/notification_db" "postgresql+asyncpg://alert_processor_user:alert_processor_pass123@alert-processor-db:5432/alert_processor_db"
) )
# Use dedicated Redis DB for alert processing # Use dedicated Redis DB for alert processing

View File

@@ -206,42 +206,47 @@ class AlertProcessorService:
async def store_item(self, item: dict) -> dict: async def store_item(self, item: dict) -> dict:
"""Store alert or recommendation in database""" """Store alert or recommendation in database"""
from sqlalchemy import text from app.models.alerts import Alert, AlertSeverity, AlertStatus
from sqlalchemy import select
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 *
""")
async with self.db_manager.get_session() as session: async with self.db_manager.get_session() as session:
result = await session.execute( # Create alert instance
query, alert = Alert(
{ id=item['id'],
'id': item['id'], tenant_id=item['tenant_id'],
'tenant_id': item['tenant_id'], item_type=item['item_type'], # 'alert' or 'recommendation'
'item_type': item['item_type'], # 'alert' or 'recommendation' alert_type=item['type'],
'alert_type': item['type'], severity=AlertSeverity(item['severity']),
'severity': item['severity'], status=AlertStatus.ACTIVE,
'status': 'active', service=item['service'],
'service': item['service'], title=item['title'],
'title': item['title'], message=item['message'],
'message': item['message'], actions=item.get('actions', []),
'actions': json.dumps(item.get('actions', [])), alert_metadata=item.get('metadata', {}),
'metadata': json.dumps(item.get('metadata', {})), created_at=datetime.fromisoformat(item['timestamp']) if isinstance(item['timestamp'], str) else item['timestamp']
'created_at': item['timestamp']
}
) )
row = result.fetchone() session.add(alert)
await session.commit() await session.commit()
await session.refresh(alert)
logger.debug("Item stored in database", item_id=item['id']) 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): async def stream_to_sse(self, tenant_id: str, item: dict):
"""Publish item to Redis for SSE streaming""" """Publish item to Redis for SSE streaming"""

View File

@@ -0,0 +1 @@
# services/alert_processor/app/models/__init__.py

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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')

View File

@@ -106,7 +106,6 @@ class Ingredient(Base):
# Product details # Product details
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
brand = Column(String(100), nullable=True) # Brand or central baker name 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) unit_of_measure = Column(SQLEnum(UnitOfMeasure), nullable=False)
package_size = Column(Float, nullable=True) # Size per package/unit 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_ingredient_category', 'tenant_id', 'ingredient_category', 'is_active'),
Index('idx_ingredients_product_category', 'tenant_id', 'product_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_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]: def to_dict(self) -> Dict[str, Any]:
@@ -194,7 +192,6 @@ class Ingredient(Base):
'subcategory': self.subcategory, 'subcategory': self.subcategory,
'description': self.description, 'description': self.description,
'brand': self.brand, 'brand': self.brand,
'supplier_name': self.supplier_name,
'unit_of_measure': self.unit_of_measure.value if self.unit_of_measure else None, 'unit_of_measure': self.unit_of_measure.value if self.unit_of_measure else None,
'package_size': self.package_size, 'package_size': self.package_size,
'average_cost': float(self.average_cost) if self.average_cost else None, '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) 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) 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 # Stock identification
batch_number = Column(String(100), nullable=True, index=True) batch_number = Column(String(100), nullable=True, index=True)
lot_number = Column(String(100), nullable=True, index=True) lot_number = Column(String(100), nullable=True, index=True)

View File

@@ -61,13 +61,6 @@ class IngredientCreate(InventoryBaseSchema):
is_perishable: bool = Field(False, description="Is perishable") is_perishable: bool = Field(False, description="Is perishable")
allergen_info: Optional[Dict[str, Any]] = Field(None, description="Allergen information") 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') @validator('reorder_point')
def validate_reorder_point(cls, v, values): def validate_reorder_point(cls, v, values):
if 'low_stock_threshold' in values and v <= values['low_stock_threshold']: if 'low_stock_threshold' in values and v <= values['low_stock_threshold']:
@@ -147,6 +140,7 @@ class IngredientResponse(InventoryBaseSchema):
class StockCreate(InventoryBaseSchema): class StockCreate(InventoryBaseSchema):
"""Schema for creating stock entries""" """Schema for creating stock entries"""
ingredient_id: str = Field(..., description="Ingredient ID") 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") batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
lot_number: Optional[str] = Field(None, max_length=100, description="Lot 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") 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") 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") 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): class StockUpdate(InventoryBaseSchema):
"""Schema for updating stock entries""" """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") batch_number: Optional[str] = Field(None, max_length=100, description="Batch number")
lot_number: Optional[str] = Field(None, max_length=100, description="Lot 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") supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
@@ -226,6 +227,7 @@ class StockResponse(InventoryBaseSchema):
id: str id: str
tenant_id: str tenant_id: str
ingredient_id: str ingredient_id: str
supplier_id: Optional[str]
batch_number: Optional[str] batch_number: Optional[str]
lot_number: Optional[str] lot_number: Optional[str]
supplier_batch_ref: Optional[str] supplier_batch_ref: Optional[str]

View File

@@ -451,17 +451,17 @@ class DashboardService:
SELECT SELECT
'stock_movement' as activity_type, 'stock_movement' as activity_type,
CASE CASE
WHEN movement_type = 'purchase' THEN 'Stock added: ' || 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 = '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 = '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 = 'ADJUSTMENT' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
ELSE 'Stock movement: ' || i.name ELSE 'Stock movement: ' || i.name
END as description, END as description,
sm.movement_date as timestamp, sm.movement_date as timestamp,
sm.created_by as user_id, sm.created_by as user_id,
CASE CASE
WHEN movement_type = 'waste' THEN 'high' WHEN movement_type = 'WASTE' THEN 'high'
WHEN movement_type = 'adjustment' THEN 'medium' WHEN movement_type = 'ADJUSTMENT' THEN 'medium'
ELSE 'low' ELSE 'low'
END as impact_level, END as impact_level,
sm.id as entity_id, sm.id as entity_id,
@@ -617,13 +617,18 @@ class DashboardService:
COUNT(*) as total_ingredients, COUNT(*) as total_ingredients,
COUNT(CASE WHEN product_type = 'finished_product' THEN 1 END) as finished_products, 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(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 AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level
FROM ingredients i FROM ingredients i
LEFT JOIN ( LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity SELECT ingredient_id, SUM(available_quantity) as available_quantity
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.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 WHERE i.tenant_id = :tenant_id AND i.is_active = true
""" """

View File

@@ -428,17 +428,17 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
i.low_stock_threshold as minimum_stock, i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock, i.max_stock_level as maximum_stock,
COALESCE(SUM(s.current_quantity), 0) as current_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, 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, 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 FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true 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 LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
WHERE i.is_active = true AND i.tenant_id = :tenant_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 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 AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3
), ),
recommendations AS ( recommendations AS (

View File

@@ -8,7 +8,7 @@ from typing import List, Optional
from uuid import UUID from uuid import UUID
import structlog import structlog
from sqlalchemy.orm import Session from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.services.supplier_service import SupplierService from app.services.supplier_service import SupplierService
from app.schemas.suppliers import ( from app.schemas.suppliers import (
@@ -27,7 +27,7 @@ async def create_supplier(
supplier_data: SupplierCreate, supplier_data: SupplierCreate,
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
try: try:
@@ -54,7 +54,7 @@ async def list_suppliers(
status: Optional[str] = Query(None, description="Status filter"), status: Optional[str] = Query(None, description="Status filter"),
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"), 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"), 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""" """List suppliers with optional filters"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -81,7 +81,7 @@ async def list_suppliers(
@router.get("/statistics", response_model=SupplierStatistics) @router.get("/statistics", response_model=SupplierStatistics)
async def get_supplier_statistics( async def get_supplier_statistics(
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get supplier statistics for dashboard""" """Get supplier statistics for dashboard"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -98,7 +98,7 @@ async def get_supplier_statistics(
@router.get("/active", response_model=List[SupplierSummary]) @router.get("/active", response_model=List[SupplierSummary])
async def get_active_suppliers( async def get_active_suppliers(
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get all active suppliers""" """Get all active suppliers"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -116,7 +116,7 @@ async def get_active_suppliers(
async def get_top_suppliers( async def get_top_suppliers(
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
limit: int = Query(10, ge=1, le=50, description="Number of top suppliers to return"), 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""" """Get top performing suppliers"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -134,7 +134,7 @@ async def get_top_suppliers(
async def get_suppliers_needing_review( async def get_suppliers_needing_review(
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
days_since_last_order: int = Query(30, ge=1, le=365, description="Days since last order"), 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""" """Get suppliers that may need performance review"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -154,7 +154,7 @@ async def get_suppliers_needing_review(
async def get_supplier( async def get_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"), supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get supplier by ID""" """Get supplier by ID"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])
@@ -179,7 +179,7 @@ async def update_supplier(
supplier_data: SupplierUpdate, supplier_data: SupplierUpdate,
supplier_id: UUID = Path(..., description="Supplier ID"), supplier_id: UUID = Path(..., description="Supplier ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Update supplier information""" """Update supplier information"""
# require_permissions(current_user, ["suppliers:update"]) # require_permissions(current_user, ["suppliers:update"])
@@ -214,7 +214,7 @@ async def update_supplier(
@router.delete("/{supplier_id}") @router.delete("/{supplier_id}")
async def delete_supplier( async def delete_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"), supplier_id: UUID = Path(..., description="Supplier ID"),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Delete supplier (soft delete)""" """Delete supplier (soft delete)"""
# require_permissions(current_user, ["suppliers:delete"]) # require_permissions(current_user, ["suppliers:delete"])
@@ -244,7 +244,7 @@ async def approve_supplier(
approval_data: SupplierApproval, approval_data: SupplierApproval,
supplier_id: UUID = Path(..., description="Supplier ID"), supplier_id: UUID = Path(..., description="Supplier ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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""" """Approve or reject a pending supplier"""
# require_permissions(current_user, ["suppliers:approve"]) # require_permissions(current_user, ["suppliers:approve"])
@@ -289,7 +289,7 @@ async def approve_supplier(
async def get_suppliers_by_type( async def get_suppliers_by_type(
supplier_type: str = Path(..., description="Supplier type"), supplier_type: str = Path(..., description="Supplier type"),
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
db: Session = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get suppliers by type""" """Get suppliers by type"""
# require_permissions(current_user, ["suppliers:read"]) # require_permissions(current_user, ["suppliers:read"])

View File

@@ -18,84 +18,84 @@ from shared.database.base import Base
class SupplierType(enum.Enum): class SupplierType(enum.Enum):
"""Types of suppliers""" """Types of suppliers"""
INGREDIENTS = "ingredients" # Raw materials supplier ingredients = "ingredients" # Raw materials supplier
PACKAGING = "packaging" # Packaging materials packaging = "packaging" # Packaging materials
EQUIPMENT = "equipment" # Bakery equipment equipment = "equipment" # Bakery equipment
SERVICES = "services" # Service providers services = "services" # Service providers
UTILITIES = "utilities" # Utilities (gas, electricity) utilities = "utilities" # Utilities (gas, electricity)
MULTI = "multi" # Multi-category supplier multi = "multi" # Multi-category supplier
class SupplierStatus(enum.Enum): class SupplierStatus(enum.Enum):
"""Supplier lifecycle status""" """Supplier lifecycle status"""
ACTIVE = "active" active = "active"
INACTIVE = "inactive" inactive = "inactive"
PENDING_APPROVAL = "pending_approval" pending_approval = "pending_approval"
SUSPENDED = "suspended" suspended = "suspended"
BLACKLISTED = "blacklisted" blacklisted = "blacklisted"
class PaymentTerms(enum.Enum): class PaymentTerms(enum.Enum):
"""Payment terms with suppliers""" """Payment terms with suppliers"""
CASH_ON_DELIVERY = "cod" cod = "cod"
NET_15 = "net_15" net_15 = "net_15"
NET_30 = "net_30" net_30 = "net_30"
NET_45 = "net_45" net_45 = "net_45"
NET_60 = "net_60" net_60 = "net_60"
PREPAID = "prepaid" prepaid = "prepaid"
CREDIT_TERMS = "credit_terms" credit_terms = "credit_terms"
class PurchaseOrderStatus(enum.Enum): class PurchaseOrderStatus(enum.Enum):
"""Purchase order lifecycle status""" """Purchase order lifecycle status"""
DRAFT = "draft" draft = "draft"
PENDING_APPROVAL = "pending_approval" pending_approval = "pending_approval"
APPROVED = "approved" approved = "approved"
SENT_TO_SUPPLIER = "sent_to_supplier" sent_to_supplier = "sent_to_supplier"
CONFIRMED = "confirmed" confirmed = "confirmed"
PARTIALLY_RECEIVED = "partially_received" partially_received = "partially_received"
COMPLETED = "completed" completed = "completed"
CANCELLED = "cancelled" cancelled = "cancelled"
DISPUTED = "disputed" disputed = "disputed"
class DeliveryStatus(enum.Enum): class DeliveryStatus(enum.Enum):
"""Delivery status tracking""" """Delivery status tracking"""
SCHEDULED = "scheduled" scheduled = "scheduled"
IN_TRANSIT = "in_transit" in_transit = "in_transit"
OUT_FOR_DELIVERY = "out_for_delivery" out_for_delivery = "out_for_delivery"
DELIVERED = "delivered" delivered = "delivered"
PARTIALLY_DELIVERED = "partially_delivered" partially_delivered = "partially_delivered"
FAILED_DELIVERY = "failed_delivery" failed_delivery = "failed_delivery"
RETURNED = "returned" returned = "returned"
class QualityRating(enum.Enum): class QualityRating(enum.Enum):
"""Quality rating scale""" """Quality rating scale"""
EXCELLENT = 5 excellent = 5
GOOD = 4 good = 4
AVERAGE = 3 average = 3
POOR = 2 poor = 2
VERY_POOR = 1 very_poor = 1
class DeliveryRating(enum.Enum): class DeliveryRating(enum.Enum):
"""Delivery performance rating scale""" """Delivery performance rating scale"""
EXCELLENT = 5 excellent = 5
GOOD = 4 good = 4
AVERAGE = 3 average = 3
POOR = 2 poor = 2
VERY_POOR = 1 very_poor = 1
class InvoiceStatus(enum.Enum): class InvoiceStatus(enum.Enum):
"""Invoice processing status""" """Invoice processing status"""
PENDING = "pending" pending = "pending"
APPROVED = "approved" approved = "approved"
PAID = "paid" paid = "paid"
OVERDUE = "overdue" overdue = "overdue"
DISPUTED = "disputed" disputed = "disputed"
CANCELLED = "cancelled" cancelled = "cancelled"
class Supplier(Base): class Supplier(Base):
@@ -113,7 +113,7 @@ class Supplier(Base):
# Supplier classification # Supplier classification
supplier_type = Column(SQLEnum(SupplierType), nullable=False, index=True) 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 information
contact_person = Column(String(200), nullable=True) contact_person = Column(String(200), nullable=True)
@@ -131,7 +131,7 @@ class Supplier(Base):
country = Column(String(100), nullable=True) country = Column(String(100), nullable=True)
# Business terms # 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) credit_limit = Column(Numeric(12, 2), nullable=True)
currency = Column(String(3), nullable=False, default="EUR") # ISO currency code 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 reference_number = Column(String(100), nullable=True) # Internal reference
# Order status and workflow # 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 priority = Column(String(20), nullable=False, default="normal") # urgent, high, normal, low
# Order details # Order details
@@ -363,7 +363,7 @@ class Delivery(Base):
supplier_delivery_note = Column(String(100), nullable=True) # Supplier's delivery reference supplier_delivery_note = Column(String(100), nullable=True) # Supplier's delivery reference
# Delivery status and tracking # 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 # Scheduling and timing
scheduled_date = Column(DateTime(timezone=True), nullable=True) scheduled_date = Column(DateTime(timezone=True), nullable=True)
@@ -517,7 +517,7 @@ class SupplierInvoice(Base):
supplier_invoice_number = Column(String(100), nullable=False) supplier_invoice_number = Column(String(100), nullable=False)
# Invoice status and dates # 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) invoice_date = Column(DateTime(timezone=True), nullable=False)
due_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)) received_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))

View File

@@ -4,8 +4,8 @@ Base repository class for common database operations
""" """
from typing import TypeVar, Generic, List, Optional, Dict, Any from typing import TypeVar, Generic, List, Optional, Dict, Any
from sqlalchemy.orm import Session from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import desc, asc from sqlalchemy import desc, asc, select, func
from uuid import UUID from uuid import UUID
T = TypeVar('T') T = TypeVar('T')
@@ -14,55 +14,59 @@ T = TypeVar('T')
class BaseRepository(Generic[T]): class BaseRepository(Generic[T]):
"""Base repository with common CRUD operations""" """Base repository with common CRUD operations"""
def __init__(self, model: type, db: Session): def __init__(self, model: type, db: AsyncSession):
self.model = model self.model = model
self.db = db 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""" """Create a new record"""
db_obj = self.model(**obj_data) db_obj = self.model(**obj_data)
self.db.add(db_obj) self.db.add(db_obj)
self.db.commit() await self.db.commit()
self.db.refresh(db_obj) await self.db.refresh(db_obj)
return 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""" """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""" """Get records by tenant ID with pagination"""
return ( stmt = select(self.model).filter(
self.db.query(self.model) self.model.tenant_id == tenant_id
.filter(self.model.tenant_id == tenant_id) ).limit(limit).offset(offset)
.limit(limit) result = await self.db.execute(stmt)
.offset(offset) return result.scalars().all()
.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""" """Update record by ID"""
db_obj = self.get_by_id(record_id) db_obj = await self.get_by_id(record_id)
if db_obj: if db_obj:
for key, value in update_data.items(): for key, value in update_data.items():
if hasattr(db_obj, key): if hasattr(db_obj, key):
setattr(db_obj, key, value) setattr(db_obj, key, value)
self.db.commit() await self.db.commit()
self.db.refresh(db_obj) await self.db.refresh(db_obj)
return db_obj return db_obj
def delete(self, record_id: UUID) -> bool: async def delete(self, record_id: UUID) -> bool:
"""Delete record by ID""" """Delete record by ID"""
db_obj = self.get_by_id(record_id) db_obj = await self.get_by_id(record_id)
if db_obj: if db_obj:
self.db.delete(db_obj) await self.db.delete(db_obj)
self.db.commit() await self.db.commit()
return True return True
return False 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""" """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( def list_with_filters(
self, self,

View File

@@ -4,8 +4,8 @@ Supplier repository for database operations
""" """
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import and_, or_, func from sqlalchemy import and_, or_, func, select
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
@@ -16,36 +16,32 @@ from app.repositories.base import BaseRepository
class SupplierRepository(BaseRepository[Supplier]): class SupplierRepository(BaseRepository[Supplier]):
"""Repository for supplier management operations""" """Repository for supplier management operations"""
def __init__(self, db: Session): def __init__(self, db: AsyncSession):
super().__init__(Supplier, db) 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""" """Get supplier by name within tenant"""
return ( stmt = select(self.model).filter(
self.db.query(self.model)
.filter(
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.name == name 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""" """Get supplier by supplier code within tenant"""
return ( stmt = select(self.model).filter(
self.db.query(self.model)
.filter(
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.supplier_code == supplier_code 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, self,
tenant_id: UUID, tenant_id: UUID,
search_term: Optional[str] = None, search_term: Optional[str] = None,
@@ -55,7 +51,7 @@ class SupplierRepository(BaseRepository[Supplier]):
offset: int = 0 offset: int = 0
) -> List[Supplier]: ) -> List[Supplier]:
"""Search suppliers with filters""" """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) # Search term filter (name, contact person, email)
if search_term: if search_term:
@@ -64,31 +60,30 @@ class SupplierRepository(BaseRepository[Supplier]):
self.model.contact_person.ilike(f"%{search_term}%"), self.model.contact_person.ilike(f"%{search_term}%"),
self.model.email.ilike(f"%{search_term}%") self.model.email.ilike(f"%{search_term}%")
) )
query = query.filter(search_filter) stmt = stmt.filter(search_filter)
# Type filter # Type filter
if supplier_type: if supplier_type:
query = query.filter(self.model.supplier_type == supplier_type) stmt = stmt.filter(self.model.supplier_type == supplier_type)
# Status filter # Status filter
if status: if status:
query = query.filter(self.model.status == status) stmt = stmt.filter(self.model.status == status)
return query.order_by(self.model.name).limit(limit).offset(offset).all() 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""" """Get all active suppliers for a tenant"""
return ( stmt = select(self.model).filter(
self.db.query(self.model)
.filter(
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE 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( def get_suppliers_by_type(
self, self,
@@ -102,7 +97,7 @@ class SupplierRepository(BaseRepository[Supplier]):
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.supplier_type == supplier_type, 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) .order_by(self.model.quality_rating.desc(), self.model.name)
@@ -120,7 +115,7 @@ class SupplierRepository(BaseRepository[Supplier]):
.filter( .filter(
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE self.model.status == SupplierStatus.active
) )
) )
.order_by( .order_by(
@@ -178,7 +173,7 @@ class SupplierRepository(BaseRepository[Supplier]):
.filter( .filter(
and_( and_(
self.model.tenant_id == tenant_id, self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE, self.model.status == SupplierStatus.active,
or_( or_(
self.model.quality_rating < 3.0, # Poor rating self.model.quality_rating < 3.0, # Poor rating
self.model.delivery_rating < 3.0, # Poor delivery self.model.delivery_rating < 3.0, # Poor delivery
@@ -190,66 +185,33 @@ class SupplierRepository(BaseRepository[Supplier]):
.all() .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""" """Get supplier statistics for dashboard"""
total_suppliers = self.count_by_tenant(tenant_id) total_suppliers = await self.count_by_tenant(tenant_id)
active_suppliers = ( # Get all suppliers for this tenant to avoid multiple queries and enum casting issues
self.db.query(self.model) all_stmt = select(self.model).filter(self.model.tenant_id == tenant_id)
.filter( all_result = await self.db.execute(all_stmt)
and_( all_suppliers = all_result.scalars().all()
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE
)
)
.count()
)
pending_suppliers = ( # Calculate statistics in Python to avoid database enum casting issues
self.db.query(self.model) active_suppliers = [s for s in all_suppliers if s.status == SupplierStatus.active]
.filter( pending_suppliers = [s for s in all_suppliers if s.status == SupplierStatus.pending_approval]
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.PENDING_APPROVAL
)
)
.count()
)
avg_quality_rating = ( # Calculate averages from active suppliers
self.db.query(func.avg(self.model.quality_rating)) quality_ratings = [s.quality_rating for s in active_suppliers if s.quality_rating and s.quality_rating > 0]
.filter( avg_quality_rating = sum(quality_ratings) / len(quality_ratings) if quality_ratings else 0.0
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE,
self.model.quality_rating > 0
)
)
.scalar()
) or 0.0
avg_delivery_rating = ( delivery_ratings = [s.delivery_rating for s in active_suppliers if s.delivery_rating and s.delivery_rating > 0]
self.db.query(func.avg(self.model.delivery_rating)) avg_delivery_rating = sum(delivery_ratings) / len(delivery_ratings) if delivery_ratings else 0.0
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE,
self.model.delivery_rating > 0
)
)
.scalar()
) or 0.0
total_spend = ( # Total spend for all suppliers
self.db.query(func.sum(self.model.total_amount)) total_spend = sum(float(s.total_amount or 0) for s in all_suppliers)
.filter(self.model.tenant_id == tenant_id)
.scalar()
) or 0.0
return { return {
"total_suppliers": total_suppliers, "total_suppliers": total_suppliers,
"active_suppliers": active_suppliers, "active_suppliers": len(active_suppliers),
"pending_suppliers": pending_suppliers, "pending_suppliers": len(pending_suppliers),
"avg_quality_rating": round(float(avg_quality_rating), 2), "avg_quality_rating": round(float(avg_quality_rating), 2),
"avg_delivery_rating": round(float(avg_delivery_rating), 2), "avg_delivery_rating": round(float(avg_delivery_rating), 2),
"total_spend": float(total_spend) "total_spend": float(total_spend)

View File

@@ -42,7 +42,7 @@ class SupplierCreate(BaseModel):
country: Optional[str] = Field(None, max_length=100) country: Optional[str] = Field(None, max_length=100)
# Business terms # Business terms
payment_terms: PaymentTerms = PaymentTerms.NET_30 payment_terms: PaymentTerms = PaymentTerms.net_30
credit_limit: Optional[Decimal] = Field(None, ge=0) credit_limit: Optional[Decimal] = Field(None, ge=0)
currency: str = Field(default="EUR", max_length=3) currency: str = Field(default="EUR", max_length=3)
standard_lead_time: int = Field(default=3, ge=0, le=365) standard_lead_time: int = Field(default=3, ge=0, le=365)

View File

@@ -138,7 +138,6 @@ class DashboardService:
return SupplierPerformanceInsights( return SupplierPerformanceInsights(
supplier_id=supplier_id, supplier_id=supplier_id,
supplier_name=supplier['name'],
current_overall_score=current_metrics.get('overall_score', 0), current_overall_score=current_metrics.get('overall_score', 0),
previous_score=previous_metrics.get('overall_score'), previous_score=previous_metrics.get('overall_score'),
score_change_percentage=self._calculate_change_percentage( score_change_percentage=self._calculate_change_percentage(

View File

@@ -7,7 +7,7 @@ import structlog
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.supplier_repository import SupplierRepository from app.repositories.supplier_repository import SupplierRepository
from app.models.suppliers import Supplier, SupplierStatus, SupplierType from app.models.suppliers import Supplier, SupplierStatus, SupplierType
@@ -23,7 +23,7 @@ logger = structlog.get_logger()
class SupplierService: class SupplierService:
"""Service for supplier management operations""" """Service for supplier management operations"""
def __init__(self, db: Session): def __init__(self, db: AsyncSession):
self.db = db self.db = db
self.repository = SupplierRepository(db) self.repository = SupplierRepository(db)
@@ -37,13 +37,13 @@ class SupplierService:
logger.info("Creating supplier", tenant_id=str(tenant_id), name=supplier_data.name) logger.info("Creating supplier", tenant_id=str(tenant_id), name=supplier_data.name)
# Check for duplicate 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: if existing:
raise ValueError(f"Supplier with name '{supplier_data.name}' already exists") raise ValueError(f"Supplier with name '{supplier_data.name}' already exists")
# Check for duplicate supplier code if provided # Check for duplicate supplier code if provided
if supplier_data.supplier_code: 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 tenant_id, supplier_data.supplier_code
) )
if existing_code: if existing_code:
@@ -61,7 +61,7 @@ class SupplierService:
create_data.update({ create_data.update({
'tenant_id': tenant_id, 'tenant_id': tenant_id,
'supplier_code': supplier_code, 'supplier_code': supplier_code,
'status': SupplierStatus.PENDING_APPROVAL, 'status': SupplierStatus.pending_approval,
'created_by': created_by, 'created_by': created_by,
'updated_by': created_by, 'updated_by': created_by,
'quality_rating': 0.0, 'quality_rating': 0.0,
@@ -70,7 +70,7 @@ class SupplierService:
'total_amount': 0.0 'total_amount': 0.0
}) })
supplier = self.repository.create(create_data) supplier = await self.repository.create(create_data)
logger.info( logger.info(
"Supplier created successfully", "Supplier created successfully",
@@ -83,7 +83,7 @@ class SupplierService:
async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]: async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]:
"""Get supplier by ID""" """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( async def update_supplier(
self, self,
@@ -138,7 +138,7 @@ class SupplierService:
# Soft delete by changing status # Soft delete by changing status
self.repository.update(supplier_id, { self.repository.update(supplier_id, {
'status': SupplierStatus.INACTIVE, 'status': SupplierStatus.inactive,
'updated_at': datetime.utcnow() 'updated_at': datetime.utcnow()
}) })
@@ -151,7 +151,7 @@ class SupplierService:
search_params: SupplierSearchParams search_params: SupplierSearchParams
) -> List[Supplier]: ) -> List[Supplier]:
"""Search suppliers with filters""" """Search suppliers with filters"""
return self.repository.search_suppliers( return await self.repository.search_suppliers(
tenant_id=tenant_id, tenant_id=tenant_id,
search_term=search_params.search_term, search_term=search_params.search_term,
supplier_type=search_params.supplier_type, supplier_type=search_params.supplier_type,
@@ -239,7 +239,7 @@ class SupplierService:
async 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""" """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( async def get_suppliers_needing_review(
self, self,

View File

@@ -23,6 +23,9 @@ def json_serializer(obj):
return obj.isoformat() return obj.isoformat()
elif isinstance(obj, uuid.UUID): elif isinstance(obj, uuid.UUID):
return str(obj) 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") raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
class RabbitMQClient: class RabbitMQClient: