Add order page with real API calls

This commit is contained in:
Urtzi Alfaro
2025-09-19 11:44:38 +02:00
parent 447e2a5012
commit 105410c9d3
22 changed files with 2556 additions and 463 deletions

View File

@@ -25,6 +25,9 @@ class ApiClient {
private tenantId: string | null = null;
private refreshToken: string | null = null;
private isRefreshing: boolean = false;
private refreshAttempts: number = 0;
private maxRefreshAttempts: number = 3;
private lastRefreshAttempt: number = 0;
private failedQueue: Array<{
resolve: (value?: any) => void;
reject: (error?: any) => void;
@@ -72,6 +75,14 @@ class ApiClient {
// Check if error is 401 and we have a refresh token
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
// Check if we've exceeded max refresh attempts in a short time
const now = Date.now();
if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) {
console.log('Max refresh attempts exceeded, logging out');
await this.handleAuthFailure();
return Promise.reject(this.handleError(error));
}
if (this.isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
@@ -81,8 +92,12 @@ class ApiClient {
originalRequest._retry = true;
this.isRefreshing = true;
this.refreshAttempts++;
this.lastRefreshAttempt = now;
try {
console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`);
// Attempt to refresh the token
const response = await this.client.post('/auth/refresh', {
refresh_token: this.refreshToken
@@ -90,6 +105,11 @@ class ApiClient {
const { access_token, refresh_token } = response.data;
console.log('Token refresh successful');
// Reset refresh attempts on success
this.refreshAttempts = 0;
// Update tokens
this.setAuthToken(access_token);
if (refresh_token) {
@@ -107,6 +127,7 @@ class ApiClient {
return this.client(originalRequest);
} catch (refreshError) {
console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError);
// Refresh failed, clear tokens and redirect to login
this.processQueue(refreshError, null);
await this.handleAuthFailure();
@@ -165,13 +186,14 @@ class ApiClient {
try {
// Dynamically import to avoid circular dependency
const { useAuthStore } = await import('../../stores/auth.store');
const store = useAuthStore.getState();
const setState = useAuthStore.setState;
// Update the store with new tokens
store.token = accessToken;
if (refreshToken) {
store.refreshToken = refreshToken;
}
setState(state => ({
...state,
token: accessToken,
refreshToken: refreshToken || state.refreshToken,
}));
} catch (error) {
console.warn('Failed to update auth store:', error);
}

View File

@@ -3,18 +3,86 @@
* Based on backend schemas in services/orders/app/schemas/order_schemas.py
*/
export type CustomerType = 'individual' | 'business' | 'central_bakery';
export type DeliveryMethod = 'delivery' | 'pickup';
export type PaymentTerms = 'immediate' | 'net_30' | 'net_60';
export type PaymentMethod = 'cash' | 'card' | 'bank_transfer' | 'account';
export type PaymentStatus = 'pending' | 'partial' | 'paid' | 'failed' | 'refunded';
export type CustomerSegment = 'vip' | 'regular' | 'wholesale';
export type PriorityLevel = 'high' | 'normal' | 'low';
export type OrderType = 'standard' | 'rush' | 'recurring' | 'special';
export type OrderStatus = 'pending' | 'confirmed' | 'in_production' | 'ready' | 'out_for_delivery' | 'delivered' | 'cancelled' | 'failed';
export type OrderSource = 'manual' | 'online' | 'phone' | 'app' | 'api';
export type SalesChannel = 'direct' | 'wholesale' | 'retail';
export type BusinessModel = 'individual_bakery' | 'central_bakery';
export enum CustomerType {
INDIVIDUAL = 'individual',
BUSINESS = 'business',
CENTRAL_BAKERY = 'central_bakery'
}
export enum DeliveryMethod {
DELIVERY = 'delivery',
PICKUP = 'pickup'
}
export enum PaymentTerms {
IMMEDIATE = 'immediate',
NET_30 = 'net_30',
NET_60 = 'net_60'
}
export enum PaymentMethod {
CASH = 'cash',
CARD = 'card',
BANK_TRANSFER = 'bank_transfer',
ACCOUNT = 'account'
}
export enum PaymentStatus {
PENDING = 'pending',
PARTIAL = 'partial',
PAID = 'paid',
FAILED = 'failed',
REFUNDED = 'refunded'
}
export enum CustomerSegment {
VIP = 'vip',
REGULAR = 'regular',
WHOLESALE = 'wholesale'
}
export enum PriorityLevel {
HIGH = 'high',
NORMAL = 'normal',
LOW = 'low'
}
export enum OrderType {
STANDARD = 'standard',
RUSH = 'rush',
RECURRING = 'recurring',
SPECIAL = 'special'
}
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
IN_PRODUCTION = 'in_production',
READY = 'ready',
OUT_FOR_DELIVERY = 'out_for_delivery',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
FAILED = 'failed'
}
export enum OrderSource {
MANUAL = 'manual',
ONLINE = 'online',
PHONE = 'phone',
APP = 'app',
API = 'api'
}
export enum SalesChannel {
DIRECT = 'direct',
WHOLESALE = 'wholesale',
RETAIL = 'retail'
}
export enum BusinessModel {
INDIVIDUAL_BAKERY = 'individual_bakery',
CENTRAL_BAKERY = 'central_bakery'
}
// ===== Customer Types =====

View File

@@ -0,0 +1,695 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Minus, User, ShoppingCart, FileText, Calculator, Package } from 'lucide-react';
import {
Button,
Input,
Select,
Card,
Badge
} from '../../ui';
import { StatusModal } from '../../ui/StatusModal';
import type { StatusModalSection } from '../../ui/StatusModal';
import {
OrderCreate,
OrderItemCreate,
CustomerResponse,
OrderType,
PriorityLevel,
DeliveryMethod,
PaymentMethod,
PaymentTerms,
OrderSource,
SalesChannel,
CustomerCreate,
CustomerType,
CustomerSegment
} from '../../../api/types/orders';
import { useCustomers, useCreateCustomer } from '../../../api/hooks/orders';
import { useIngredients } from '../../../api/hooks/inventory';
import { ProductType, ProductCategory } from '../../../api/types/inventory';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useAuthUser } from '../../../stores/auth.store';
import { useOrderEnums } from '../../../utils/enumHelpers';
interface OrderFormModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (orderData: OrderCreate) => Promise<void>;
}
export const OrderFormModal: React.FC<OrderFormModalProps> = ({
isOpen,
onClose,
onSave
}) => {
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
const orderEnums = useOrderEnums();
// Form state
const [selectedCustomer, setSelectedCustomer] = useState<CustomerResponse | null>(null);
const [orderItems, setOrderItems] = useState<OrderItemCreate[]>([]);
const [orderData, setOrderData] = useState<Partial<OrderCreate>>({
order_type: OrderType.STANDARD,
priority: PriorityLevel.NORMAL,
delivery_method: DeliveryMethod.PICKUP,
discount_percentage: 0,
delivery_fee: 0,
payment_terms: PaymentTerms.IMMEDIATE,
order_source: OrderSource.MANUAL,
sales_channel: SalesChannel.DIRECT
});
// Customer modals
const [showCustomerForm, setShowCustomerForm] = useState(false);
const [showCustomerSelector, setShowCustomerSelector] = useState(false);
const [newCustomerData, setNewCustomerData] = useState<Partial<CustomerCreate>>({
customer_type: CustomerType.INDIVIDUAL,
country: 'España',
is_active: true,
preferred_delivery_method: DeliveryMethod.PICKUP,
payment_terms: PaymentTerms.IMMEDIATE,
discount_percentage: 0,
customer_segment: CustomerSegment.REGULAR,
priority_level: PriorityLevel.NORMAL
});
// Product selection
const [showProductModal, setShowProductModal] = useState(false);
const [productSearch, setProductSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
// API hooks
const { data: customers = [] } = useCustomers({
tenant_id: tenantId,
active_only: true,
limit: 100
});
// Fetch finished products from inventory
const { data: finishedProducts = [], isLoading: productsLoading } = useIngredients(
tenantId,
{
product_type: ProductType.FINISHED_PRODUCT,
is_active: true
}
);
const createCustomerMutation = useCreateCustomer();
// Calculate totals
const subtotal = orderItems.reduce((sum, item) => sum + (item.quantity * item.unit_price), 0);
const discountAmount = subtotal * (orderData.discount_percentage || 0) / 100;
const taxAmount = (subtotal - discountAmount) * 0.21; // 21% VAT
const total = subtotal - discountAmount + taxAmount + (orderData.delivery_fee || 0);
useEffect(() => {
if (!isOpen) {
// Reset form when modal closes
setSelectedCustomer(null);
setOrderItems([]);
setProductSearch('');
setSelectedCategory('');
setOrderData({
order_type: OrderType.STANDARD,
priority: PriorityLevel.NORMAL,
delivery_method: DeliveryMethod.PICKUP,
discount_percentage: 0,
delivery_fee: 0,
payment_terms: PaymentTerms.IMMEDIATE,
order_source: OrderSource.MANUAL,
sales_channel: SalesChannel.DIRECT
});
}
}, [isOpen]);
const handleAddProduct = (product: any) => {
const existingItem = orderItems.find(item => item.product_id === product.id);
if (existingItem) {
setOrderItems(items => items.map(item =>
item.product_id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
));
} else {
const newItem: OrderItemCreate = {
product_id: product.id,
product_name: product.name,
product_sku: product.sku || undefined,
product_category: product.category || undefined,
quantity: 1,
unit_of_measure: product.unit_of_measure || 'unidad',
unit_price: product.average_cost || product.standard_cost || 0,
line_discount: 0
};
setOrderItems(items => [...items, newItem]);
}
setShowProductModal(false);
};
const handleUpdateItemQuantity = (productId: string, quantity: number) => {
if (quantity <= 0) {
setOrderItems(items => items.filter(item => item.product_id !== productId));
} else {
setOrderItems(items => items.map(item =>
item.product_id === productId
? { ...item, quantity }
: item
));
}
};
const handleCreateCustomer = async () => {
if (!newCustomerData.name || !tenantId) return;
try {
const customerData: CustomerCreate = {
...newCustomerData,
tenant_id: tenantId,
customer_code: `CUST-${Date.now()}` // Generate simple code
} as CustomerCreate;
const newCustomer = await createCustomerMutation.mutateAsync(customerData);
setSelectedCustomer(newCustomer);
setShowCustomerForm(false);
setNewCustomerData({
customer_type: CustomerType.INDIVIDUAL,
country: 'España',
is_active: true,
preferred_delivery_method: DeliveryMethod.PICKUP,
payment_terms: PaymentTerms.IMMEDIATE,
discount_percentage: 0,
customer_segment: CustomerSegment.REGULAR,
priority_level: PriorityLevel.NORMAL
});
} catch (error) {
console.error('Error creating customer:', error);
}
};
const handleSaveOrder = async () => {
if (!selectedCustomer || orderItems.length === 0 || !tenantId) return;
const finalOrderData: OrderCreate = {
tenant_id: tenantId,
customer_id: selectedCustomer.id,
order_type: orderData.order_type || OrderType.STANDARD,
priority: orderData.priority || PriorityLevel.NORMAL,
requested_delivery_date: orderData.requested_delivery_date || new Date().toISOString(),
delivery_method: orderData.delivery_method || DeliveryMethod.PICKUP,
delivery_fee: orderData.delivery_fee || 0,
discount_percentage: orderData.discount_percentage || 0,
payment_terms: orderData.payment_terms || PaymentTerms.IMMEDIATE,
order_source: orderData.order_source || OrderSource.MANUAL,
sales_channel: orderData.sales_channel || SalesChannel.DIRECT,
items: orderItems,
special_instructions: orderData.special_instructions
};
await onSave(finalOrderData);
onClose();
};
// Get unique categories for filtering
const uniqueCategories = Array.from(new Set(
finishedProducts
.map(product => product.category)
.filter(Boolean)
)).sort();
const filteredProducts = finishedProducts.filter(product => {
const matchesSearch = product.name.toLowerCase().includes(productSearch.toLowerCase()) ||
(product.category && product.category.toLowerCase().includes(productSearch.toLowerCase())) ||
(product.description && product.description.toLowerCase().includes(productSearch.toLowerCase()));
const matchesCategory = !selectedCategory || product.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// Convert form data to StatusModal sections
const modalSections: StatusModalSection[] = [
{
title: 'Cliente',
icon: User,
fields: [
{
label: 'Cliente Seleccionado',
value: selectedCustomer ? `${selectedCustomer.name} (${selectedCustomer.customer_code})` : 'Ninguno',
span: 2
}
]
},
{
title: 'Detalles del Pedido',
icon: FileText,
fields: [
{
label: 'Tipo de Pedido',
value: orderData.order_type || OrderType.STANDARD,
type: 'select',
editable: true,
options: orderEnums.getOrderTypeOptions()
},
{
label: 'Prioridad',
value: orderData.priority || PriorityLevel.NORMAL,
type: 'select',
editable: true,
options: orderEnums.getPriorityLevelOptions()
},
{
label: 'Método de Entrega',
value: orderData.delivery_method || DeliveryMethod.PICKUP,
type: 'select',
editable: true,
options: orderEnums.getDeliveryMethodOptions()
},
{
label: 'Fecha de Entrega',
value: orderData.requested_delivery_date?.split('T')[0] || '',
type: 'date',
editable: true
}
]
},
{
title: 'Productos',
icon: Package,
fields: [
{
label: 'Total de Productos',
value: orderItems.length,
highlight: true
},
{
label: 'Lista de Productos',
value: orderItems.length > 0 ? orderItems.map(item => `${item.product_name} (x${item.quantity})`).join(', ') : 'Sin productos',
span: 2
}
]
},
{
title: 'Resumen Financiero',
icon: Calculator,
fields: [
{
label: 'Subtotal',
value: subtotal,
type: 'currency',
highlight: true
},
{
label: 'Descuento',
value: -discountAmount,
type: 'currency'
},
{
label: 'IVA (21%)',
value: taxAmount,
type: 'currency'
},
{
label: 'Gastos de Envío',
value: orderData.delivery_fee || 0,
type: 'currency',
editable: true
},
{
label: 'Total',
value: total,
type: 'currency',
highlight: true,
span: 2
}
]
}
];
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
const section = modalSections[sectionIndex];
const field = section.fields[fieldIndex];
// Update order data based on field changes
if (section.title === 'Detalles del Pedido') {
if (field.label === 'Tipo de Pedido') {
setOrderData(prev => ({ ...prev, order_type: value as OrderType }));
} else if (field.label === 'Prioridad') {
setOrderData(prev => ({ ...prev, priority: value as PriorityLevel }));
} else if (field.label === 'Método de Entrega') {
setOrderData(prev => ({ ...prev, delivery_method: value as DeliveryMethod }));
} else if (field.label === 'Fecha de Entrega') {
setOrderData(prev => ({ ...prev, requested_delivery_date: value ? `${value}T12:00:00Z` : undefined }));
}
} else if (section.title === 'Resumen Financiero' && field.label === 'Gastos de Envío') {
setOrderData(prev => ({ ...prev, delivery_fee: Number(value) }));
}
};
return (
<>
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="edit"
title="Nuevo Pedido"
subtitle="Complete los detalles del pedido"
sections={modalSections}
size="2xl"
showDefaultActions={false}
actions={[
{
label: selectedCustomer ? 'Cambiar Cliente' : 'Seleccionar Cliente',
variant: 'outline',
onClick: () => setShowCustomerSelector(true)
},
{
label: 'Agregar Productos',
variant: 'outline',
onClick: () => setShowProductModal(true),
icon: Plus
},
{
label: 'Cancelar',
variant: 'outline',
onClick: onClose
},
{
label: 'Crear Pedido',
variant: 'primary',
onClick: handleSaveOrder,
disabled: !selectedCustomer || orderItems.length === 0
}
]}
onFieldChange={handleFieldChange}
/>
{/* Legacy content for sections that need custom UI */}
<div className="hidden">
</div>
{/* Product Selection Modal - Using StatusModal for consistency */}
<StatusModal
isOpen={showProductModal}
onClose={() => setShowProductModal(false)}
mode="view"
title="Seleccionar Productos"
subtitle="Elija productos terminados para agregar al pedido"
size="2xl"
showDefaultActions={false}
sections={[
{
title: 'Filtros',
icon: Package,
fields: [
{
label: 'Buscar productos',
value: productSearch,
type: 'text',
editable: true,
placeholder: 'Buscar productos...',
span: 1
},
{
label: 'Categoría',
value: selectedCategory,
type: 'select',
editable: true,
placeholder: 'Todas las categorías',
options: [
{ value: '', label: 'Todas las categorías' },
...uniqueCategories.map(category => ({
value: category,
label: category
}))
],
span: 1
}
]
},
{
title: 'Productos Disponibles',
icon: ShoppingCart,
fields: [
{
label: 'Lista de productos',
value: (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
{productsLoading ? (
<div className="col-span-2 text-center py-8">
<p className="text-[var(--text-secondary)]">Cargando productos...</p>
</div>
) : filteredProducts.length === 0 ? (
<div className="col-span-2 text-center py-8">
<p className="text-[var(--text-secondary)]">
{productSearch ? 'No se encontraron productos que coincidan con la búsqueda' : 'No hay productos finalizados disponibles'}
</p>
</div>
) : (
filteredProducts.map(product => (
<div key={product.id} className="border border-[var(--border-primary)] rounded-lg p-4 hover:bg-[var(--bg-secondary)]">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-[var(--text-primary)]">{product.name}</h4>
{product.description && (
<p className="text-sm text-[var(--text-secondary)] mt-1">{product.description}</p>
)}
<div className="flex items-center mt-2 flex-wrap gap-2">
{product.category && (
<Badge variant="soft" color="gray">
{product.category}
</Badge>
)}
<span className="text-lg font-semibold text-[var(--color-info)]">
{(product.average_cost || product.standard_cost || 0).toFixed(2)}
</span>
</div>
</div>
<Button
size="sm"
onClick={() => handleAddProduct(product)}
disabled={!product.is_active || (product.total_quantity || 0) <= 0}
>
<Plus className="w-4 h-4 mr-1" />
Agregar
</Button>
</div>
<div className="text-xs text-[var(--text-tertiary)] mt-2 flex justify-between">
<span>Stock: {product.total_quantity || 0} {product.unit_of_measure}</span>
{product.sku && <span>SKU: {product.sku}</span>}
</div>
</div>
))
)}
</div>
),
span: 2
}
]
}
]}
onFieldChange={(sectionIndex, fieldIndex, value) => {
if (sectionIndex === 0) {
if (fieldIndex === 0) {
setProductSearch(String(value));
} else if (fieldIndex === 1) {
setSelectedCategory(String(value));
}
}
}}
/>
{/* New Customer Modal - Using StatusModal for consistency */}
<StatusModal
isOpen={showCustomerForm}
onClose={() => setShowCustomerForm(false)}
mode="edit"
title="Nuevo Cliente"
subtitle="Complete la información del cliente"
size="lg"
showDefaultActions={false}
sections={[
{
title: 'Información Personal',
icon: User,
fields: [
{
label: 'Nombre *',
value: newCustomerData.name || '',
type: 'text',
editable: true,
required: true,
placeholder: 'Nombre del cliente'
},
{
label: 'Teléfono',
value: newCustomerData.phone || '',
type: 'tel',
editable: true,
placeholder: 'Número de teléfono'
},
{
label: 'Email',
value: newCustomerData.email || '',
type: 'email',
editable: true,
placeholder: 'Correo electrónico',
span: 2
},
{
label: 'Tipo de Cliente',
value: newCustomerData.customer_type || CustomerType.INDIVIDUAL,
type: 'select',
editable: true,
options: orderEnums.getCustomerTypeOptions()
},
{
label: 'Método de Entrega Preferido',
value: newCustomerData.preferred_delivery_method || DeliveryMethod.PICKUP,
type: 'select',
editable: true,
options: orderEnums.getDeliveryMethodOptions()
}
]
}
]}
actions={[
{
label: 'Cancelar',
variant: 'outline',
onClick: () => setShowCustomerForm(false)
},
{
label: 'Crear Cliente',
variant: 'primary',
onClick: handleCreateCustomer,
disabled: !newCustomerData.name
}
]}
onFieldChange={(sectionIndex, fieldIndex, value) => {
// Get the customer modal sections instead of the order modal sections
const customerModalSections = [
{
title: 'Información Personal',
icon: User,
fields: [
{ label: 'Nombre *' },
{ label: 'Teléfono' },
{ label: 'Email' },
{ label: 'Tipo de Cliente' },
{ label: 'Método de Entrega Preferido' }
]
}
];
const field = customerModalSections[sectionIndex]?.fields[fieldIndex];
if (!field) return;
if (field.label === 'Nombre *') {
setNewCustomerData(prev => ({ ...prev, name: String(value) }));
} else if (field.label === 'Teléfono') {
setNewCustomerData(prev => ({ ...prev, phone: String(value) }));
} else if (field.label === 'Email') {
setNewCustomerData(prev => ({ ...prev, email: String(value) }));
} else if (field.label === 'Tipo de Cliente') {
setNewCustomerData(prev => ({ ...prev, customer_type: value as CustomerType }));
} else if (field.label === 'Método de Entrega Preferido') {
setNewCustomerData(prev => ({ ...prev, preferred_delivery_method: value as DeliveryMethod }));
}
}}
/>
{/* Customer Selector Modal */}
<StatusModal
isOpen={showCustomerSelector}
onClose={() => setShowCustomerSelector(false)}
mode="view"
title="Seleccionar Cliente"
subtitle="Elija un cliente existente o cree uno nuevo"
size="lg"
showDefaultActions={false}
sections={[
{
title: 'Clientes Disponibles',
icon: User,
fields: [
{
label: 'Lista de clientes',
value: (
<div className="space-y-3 max-h-96 overflow-y-auto">
{customers.length === 0 ? (
<div className="text-center py-8 text-[var(--text-secondary)]">
<p>No hay clientes disponibles</p>
<Button
size="sm"
onClick={() => {
setShowCustomerSelector(false);
setShowCustomerForm(true);
}}
className="mt-2"
>
Crear Primer Cliente
</Button>
</div>
) : (
customers.map(customer => (
<div key={customer.id} className="border border-[var(--border-primary)] rounded-lg p-4 hover:bg-[var(--bg-secondary)]">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-[var(--text-primary)]">{customer.name}</h4>
<p className="text-sm text-[var(--text-secondary)]">
Código: {customer.customer_code}
</p>
<div className="text-sm text-[var(--text-secondary)] space-y-1 mt-2">
{customer.email && <div>📧 {customer.email}</div>}
{customer.phone && <div>📞 {customer.phone}</div>}
</div>
</div>
<Button
size="sm"
onClick={() => {
setSelectedCustomer(customer);
setShowCustomerSelector(false);
}}
variant={selectedCustomer?.id === customer.id ? 'primary' : 'outline'}
>
{selectedCustomer?.id === customer.id ? 'Seleccionado' : 'Seleccionar'}
</Button>
</div>
</div>
))
)}
</div>
),
span: 2
}
]
}
]}
actions={[
{
label: 'Nuevo Cliente',
variant: 'outline',
onClick: () => {
setShowCustomerSelector(false);
setShowCustomerForm(true);
}
},
{
label: 'Cerrar',
variant: 'primary',
onClick: () => setShowCustomerSelector(false)
}
]}
/>
</>
);
};
export default OrderFormModal;

View File

@@ -0,0 +1 @@
export { default as OrderFormModal } from './OrderFormModal';

View File

@@ -110,12 +110,12 @@ export const StatusCard: React.FC<StatusCardProps> = ({
return (
<Card
className={`
p-6 transition-all duration-200 border-l-4 hover:shadow-lg
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.02]' : ''}
p-4 sm:p-6 transition-all duration-200 border-l-4 hover:shadow-lg
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.01]' : ''}
${statusIndicator.isCritical
? 'ring-2 ring-red-200 shadow-md border-l-8'
? 'ring-2 ring-red-200 shadow-md border-l-6 sm:border-l-8'
: statusIndicator.isHighlight
? 'ring-1 ring-yellow-200'
? 'ring-1 ring-yellow-200 border-l-6'
: ''
}
${className}
@@ -130,30 +130,30 @@ export const StatusCard: React.FC<StatusCardProps> = ({
}}
onClick={onClick}
>
<div className="space-y-5">
<div className="space-y-4 sm:space-y-5">
{/* Header with status indicator */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div
className={`flex-shrink-0 p-3 rounded-xl shadow-sm ${
className={`flex-shrink-0 p-2 sm:p-3 rounded-xl shadow-sm ${
statusIndicator.isCritical ? 'ring-2 ring-white' : ''
}`}
style={{ backgroundColor: `${statusIndicator.color}20` }}
>
{StatusIcon && (
<StatusIcon
className="w-5 h-5"
className="w-4 h-4 sm:w-5 sm:h-5"
style={{ color: statusIndicator.color }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-[var(--text-primary)] text-lg leading-tight mb-1">
<div className="font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 truncate">
{title}
</div>
<div className="flex items-center gap-2 mb-1">
<div
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold transition-all ${
className={`inline-flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs font-semibold transition-all ${
statusIndicator.isCritical
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
: statusIndicator.isHighlight
@@ -186,25 +186,25 @@ export const StatusCard: React.FC<StatusCardProps> = ({
)}
</div>
</div>
<div className="text-right flex-shrink-0 ml-4">
<div className="text-3xl font-bold text-[var(--text-primary)] leading-none">
<div className="text-right flex-shrink-0 ml-4 min-w-0">
<div className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none truncate">
{primaryValue}
</div>
{primaryValueLabel && (
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1">
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 truncate">
{primaryValueLabel}
</div>
)}
</div>
</div>
{/* Secondary info */}
{/* Secondary info - Mobile optimized */}
{secondaryInfo && (
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--text-secondary)]">
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-[var(--text-secondary)] truncate flex-shrink-0">
{secondaryInfo.label}
</span>
<span className="font-medium text-[var(--text-primary)]">
<span className="font-medium text-[var(--text-primary)] truncate text-right">
{secondaryInfo.value}
</span>
</div>
@@ -228,36 +228,36 @@ export const StatusCard: React.FC<StatusCardProps> = ({
)}
{/* Metadata */}
{/* Metadata - Improved mobile layout */}
{metadata.length > 0 && (
<div className="text-xs text-[var(--text-secondary)] space-y-1">
{metadata.map((item, index) => (
<div key={index}>{item}</div>
<div key={index} className="truncate" title={item}>{item}</div>
))}
</div>
)}
{/* Simplified Action System */}
{/* Simplified Action System - Mobile optimized */}
{actions.length > 0 && (
<div className="pt-4 border-t border-[var(--border-primary)]">
<div className="pt-3 sm:pt-4 border-t border-[var(--border-primary)]">
{/* All actions in a clean horizontal layout */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
{/* Primary action as a subtle text button */}
{primaryActions.length > 0 && (
<button
onClick={primaryActions[0].onClick}
className={`
flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg
transition-all duration-200 hover:scale-105 active:scale-95
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0
${primaryActions[0].destructive
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
}
`}
>
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4" })}
<span>{primaryActions[0].label}</span>
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
<span className="truncate">{primaryActions[0].label}</span>
</button>
)}
@@ -269,14 +269,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
onClick={action.onClick}
title={action.label}
className={`
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
${action.destructive
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]'
}
`}
>
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
{action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
</button>
))}
@@ -287,14 +287,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
onClick={action.onClick}
title={action.label}
className={`
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
${action.destructive
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
: 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
}
`}
>
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
{action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
</button>
))}
</div>

View File

@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
import React, { createContext, useContext, useEffect, ReactNode, useState } from 'react';
import { useAuthStore, User } from '../stores/auth.store';
import { authService } from '../api/services/auth';
interface AuthContextType {
user: User | null;
@@ -31,29 +32,43 @@ interface AuthProviderProps {
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const authStore = useAuthStore();
const [isInitializing, setIsInitializing] = useState(true);
// Initialize auth on mount
useEffect(() => {
const initializeAuth = async () => {
setIsInitializing(true);
// Wait a bit for zustand persist to rehydrate
await new Promise(resolve => setTimeout(resolve, 100));
// Check if we have stored auth data
const storedAuth = localStorage.getItem('auth-storage');
if (storedAuth) {
if (authStore.token && authStore.refreshToken) {
try {
const { state } = JSON.parse(storedAuth);
if (state.token && state.user) {
// Validate token by attempting to refresh
try {
await authStore.refreshAuth();
} catch (error) {
// Token is invalid, clear auth
authStore.logout();
}
}
// Validate current token by trying to verify it
await authService.verifyToken();
console.log('Token is valid, user authenticated');
} catch (error) {
console.error('Error parsing stored auth:', error);
authStore.logout();
console.log('Token expired, attempting refresh...');
// Token is invalid, try to refresh
try {
await authStore.refreshAuth();
console.log('Token refreshed successfully');
} catch (refreshError) {
console.log('Token refresh failed, logging out:', refreshError);
// Refresh failed, clear auth
authStore.logout();
}
}
} else if (authStore.isAuthenticated) {
// User is marked as authenticated but no tokens, logout
console.log('No tokens found but user marked as authenticated, logging out');
authStore.logout();
} else {
console.log('No stored auth data found');
}
setIsInitializing(false);
};
initializeAuth();
@@ -63,7 +78,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const contextValue: AuthContextType = {
user: authStore.user,
isAuthenticated: authStore.isAuthenticated,
isLoading: authStore.isLoading,
isLoading: authStore.isLoading || isInitializing,
error: authStore.error,
login: authStore.login,
logout: authStore.logout,

View File

@@ -0,0 +1,106 @@
{
"customer_types": {
"individual": "Individual",
"business": "Empresa",
"central_bakery": "Panadería Central"
},
"delivery_methods": {
"pickup": "Recogida",
"delivery": "Entrega a domicilio"
},
"payment_terms": {
"immediate": "Inmediato",
"net_30": "Neto 30 días",
"net_60": "Neto 60 días"
},
"payment_methods": {
"cash": "Efectivo",
"card": "Tarjeta",
"bank_transfer": "Transferencia bancaria",
"account": "Cuenta"
},
"payment_status": {
"pending": "Pendiente",
"partial": "Parcial",
"paid": "Pagado",
"failed": "Fallido",
"refunded": "Reembolsado"
},
"customer_segments": {
"regular": "Regular",
"vip": "VIP",
"wholesale": "Mayorista"
},
"priority_levels": {
"low": "Baja",
"normal": "Normal",
"high": "Alta"
},
"order_types": {
"standard": "Estándar",
"rush": "Urgente",
"recurring": "Recurrente",
"special": "Especial"
},
"order_status": {
"pending": "Pendiente",
"confirmed": "Confirmado",
"in_production": "En Producción",
"ready": "Listo",
"out_for_delivery": "En Reparto",
"delivered": "Entregado",
"cancelled": "Cancelado",
"failed": "Fallido"
},
"order_sources": {
"manual": "Manual",
"online": "En línea",
"phone": "Teléfono",
"app": "Aplicación",
"api": "API"
},
"sales_channels": {
"direct": "Directo",
"wholesale": "Mayorista",
"retail": "Minorista"
},
"labels": {
"name": "Nombre",
"business_name": "Nombre Comercial",
"customer_code": "Código de Cliente",
"email": "Email",
"phone": "Teléfono",
"address": "Dirección",
"city": "Ciudad",
"state": "Estado/Provincia",
"postal_code": "Código Postal",
"country": "País",
"customer_type": "Tipo de Cliente",
"delivery_method": "Método de Entrega",
"payment_terms": "Términos de Pago",
"payment_method": "Método de Pago",
"payment_status": "Estado del Pago",
"customer_segment": "Segmento de Cliente",
"priority_level": "Nivel de Prioridad",
"order_type": "Tipo de Pedido",
"order_status": "Estado del Pedido",
"order_source": "Origen del Pedido",
"sales_channel": "Canal de Ventas",
"order_number": "Número de Pedido",
"order_date": "Fecha del Pedido",
"delivery_date": "Fecha de Entrega",
"total_amount": "Importe Total",
"subtotal": "Subtotal",
"discount": "Descuento",
"tax": "Impuestos",
"shipping": "Envío",
"special_instructions": "Instrucciones Especiales"
},
"descriptions": {
"customer_type": "Tipo de cliente para determinar precios y términos comerciales",
"delivery_method": "Método preferido para la entrega de pedidos",
"payment_terms": "Términos y condiciones de pago acordados",
"customer_segment": "Segmentación del cliente para ofertas personalizadas",
"priority_level": "Nivel de prioridad para el procesamiento de pedidos"
}
}

View File

@@ -4,6 +4,7 @@ import authEs from './es/auth.json';
import inventoryEs from './es/inventory.json';
import foodSafetyEs from './es/foodSafety.json';
import suppliersEs from './es/suppliers.json';
import ordersEs from './es/orders.json';
import errorsEs from './es/errors.json';
// Translation resources by language
@@ -14,6 +15,7 @@ export const resources = {
inventory: inventoryEs,
foodSafety: foodSafetyEs,
suppliers: suppliersEs,
orders: ordersEs,
errors: errorsEs,
},
};
@@ -37,7 +39,7 @@ export const languageConfig = {
};
// Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'errors'] as const;
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'errors'] as const;
export type Namespace = typeof namespaces[number];
// Helper function to get language display name
@@ -51,7 +53,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang
};
// Export individual language modules for direct imports
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, errorsEs };
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, errorsEs };
// Default export with all translations
export default resources;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, Play, Pause, X, Save } from 'lucide-react';
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, Play, Pause, X, Save, Building2 } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -410,17 +410,22 @@ const ProcurementPage: React.FC = () => {
id={plan.plan_number}
statusIndicator={statusConfig}
title={`Plan ${plan.plan_number}`}
subtitle={new Date(plan.plan_date).toLocaleDateString('es-ES')}
primaryValue={formatters.currency(plan.total_estimated_cost)}
primaryValueLabel={`${plan.total_requirements} requerimientos`}
subtitle={`${new Date(plan.plan_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })} • ${plan.procurement_strategy}`}
primaryValue={plan.total_requirements}
primaryValueLabel="requerimientos"
secondaryInfo={{
label: 'Período',
value: `${new Date(plan.plan_period_start).toLocaleDateString('es-ES')} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES')}`
label: 'Presupuesto',
value: `${formatters.compact(plan.total_estimated_cost)}`
}}
progress={plan.planning_horizon_days ? {
label: `${plan.planning_horizon_days} días de horizonte`,
percentage: Math.min((plan.planning_horizon_days / 30) * 100, 100),
color: plan.planning_horizon_days > 14 ? '#10b981' : plan.planning_horizon_days > 7 ? '#f59e0b' : '#ef4444'
} : undefined}
metadata={[
`${plan.planning_horizon_days} días de horizonte`,
`Estrategia: ${plan.procurement_strategy}`,
...(plan.special_requirements ? [`"${plan.special_requirements}"`] : [])
`Período: ${new Date(plan.plan_period_start).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`,
`Creado: ${new Date(plan.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`,
...(plan.special_requirements ? [`Req. especiales: ${plan.special_requirements}`] : [])
]}
actions={actions}
/>
@@ -541,24 +546,43 @@ const ProcurementPage: React.FC = () => {
isCritical: true
}}
title={requirement.product_name}
subtitle={requirement.requirement_number}
primaryValue={`${requirement.required_quantity} ${requirement.unit_of_measure}`}
primaryValueLabel="Cantidad requerida"
subtitle={`${requirement.requirement_number}${requirement.supplier_name || 'Sin proveedor'}`}
primaryValue={requirement.required_quantity}
primaryValueLabel={requirement.unit_of_measure}
secondaryInfo={{
label: 'Fecha límite',
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES')
label: 'Límite',
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })
}}
progress={requirement.current_stock_level && requirement.required_quantity ? {
label: `${Math.round((requirement.current_stock_level / requirement.required_quantity) * 100)}% cubierto`,
percentage: Math.min((requirement.current_stock_level / requirement.required_quantity) * 100, 100),
color: requirement.current_stock_level >= requirement.required_quantity ? '#10b981' : requirement.current_stock_level >= requirement.required_quantity * 0.5 ? '#f59e0b' : '#ef4444'
} : undefined}
metadata={[
`Stock actual: ${requirement.current_stock_level} ${requirement.unit_of_measure}`,
`Proveedor: ${requirement.supplier_name || 'No asignado'}`,
`Costo estimado: ${formatters.currency(requirement.estimated_total_cost || 0)}`
`Stock: ${requirement.current_stock_level || 0} ${requirement.unit_of_measure}`,
`Necesario: ${requirement.required_quantity - (requirement.current_stock_level || 0)} ${requirement.unit_of_measure}`,
`Costo: ${formatters.compact(requirement.estimated_total_cost || 0)}`,
`Días restantes: ${Math.ceil((new Date(requirement.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))}`
]}
actions={[
{
label: 'Ver Detalles',
icon: Eye,
variant: 'outline',
variant: 'primary',
priority: 'primary',
onClick: () => console.log('View requirement details')
},
{
label: 'Asignar Proveedor',
icon: Building2,
priority: 'secondary',
onClick: () => console.log('Assign supplier')
},
{
label: 'Comprar Ahora',
icon: ShoppingCart,
priority: 'secondary',
onClick: () => console.log('Purchase now')
}
]}
/>

View File

@@ -254,11 +254,11 @@ const RecipesPage: React.FC = () => {
statusIndicator={statusConfig}
title={recipe.name}
subtitle={`${statusConfig.text}${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
primaryValue={formatters.currency(recipe.profit)}
primaryValueLabel="margen"
primaryValue={recipe.ingredients.length}
primaryValueLabel="ingredientes"
secondaryInfo={{
label: 'Precio de venta',
value: `${formatters.currency(recipe.price)} (costo: ${formatters.currency(recipe.cost)})`
label: 'Margen',
value: `${formatters.compact(recipe.profit)}`
}}
progress={{
label: 'Margen de beneficio',

View File

@@ -216,12 +216,12 @@ const SuppliersPage: React.FC = () => {
id={supplier.id}
statusIndicator={statusConfig}
title={supplier.name}
subtitle={supplier.supplier_code}
primaryValue={supplier.city || 'Sin ubicación'}
primaryValueLabel={getSupplierTypeText(supplier.supplier_type)}
subtitle={`${getSupplierTypeText(supplier.supplier_type)}${supplier.city || 'Sin ubicación'}`}
primaryValue={supplier.standard_lead_time || 0}
primaryValueLabel="días"
secondaryInfo={{
label: 'Condiciones',
value: getPaymentTermsText(supplier.payment_terms)
label: 'Pedido Min.',
value: `${formatters.compact(supplier.minimum_order_amount || 0)}`
}}
metadata={[
supplier.contact_person || 'Sin contacto',

View File

@@ -377,11 +377,11 @@ const TeamPage: React.FC = () => {
statusIndicator={getMemberStatusConfig(member)}
title={member.user?.full_name || member.user_full_name}
subtitle={member.user?.email || member.user_email}
primaryValue={member.is_active ? 'Activo' : 'Inactivo'}
primaryValueLabel="Estado"
primaryValue={Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24))}
primaryValueLabel="días"
secondaryInfo={{
label: 'Se unió',
value: new Date(member.joined_at).toLocaleDateString('es-ES')
label: 'Estado',
value: member.is_active ? 'Activo' : 'Inactivo'
}}
metadata={[
`Email: ${member.user?.email || member.user_email}`,

View File

@@ -256,16 +256,15 @@ export const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state) => {
// Initialize API client with stored tokens when store rehydrates
if (state?.token) {
import('../api').then(({ apiClient }) => {
apiClient.setAuthToken(state.token!);
if (state.refreshToken) {
apiClient.setRefreshToken(state.refreshToken);
}
// Use direct import to avoid timing issues
apiClient.setAuthToken(state.token);
if (state.refreshToken) {
apiClient.setRefreshToken(state.refreshToken);
}
if (state.user?.tenant_id) {
apiClient.setTenantId(state.user.tenant_id);
}
});
if (state.user?.tenant_id) {
apiClient.setTenantId(state.user.tenant_id);
}
}
},
}

View File

@@ -17,6 +17,20 @@ import {
type EnumOption
} from '../api/types/suppliers';
import {
CustomerType,
DeliveryMethod,
PaymentTerms as OrderPaymentTerms,
PaymentMethod,
PaymentStatus,
CustomerSegment,
PriorityLevel,
OrderType,
OrderStatus,
OrderSource,
SalesChannel
} from '../api/types/orders';
/**
* Generic function to convert enum to select options with i18n translations
*/
@@ -163,4 +177,132 @@ export function isValidEnumValue<T>(
value: unknown
): value is T {
return Object.values(enumObject).includes(value as T);
}
/**
* Hook for orders enum utilities
*/
export function useOrderEnums() {
const { t } = useTranslation('orders');
return {
// Customer Type
getCustomerTypeOptions: (): SelectOption[] =>
enumToSelectOptions(CustomerType, 'customer_types', t),
getCustomerTypeLabel: (type: CustomerType): string => {
if (!type) return 'Tipo no definido';
const translated = t(`customer_types.${type}`);
if (translated === `customer_types.${type}`) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
return translated;
},
// Delivery Method
getDeliveryMethodOptions: (): SelectOption[] =>
enumToSelectOptions(DeliveryMethod, 'delivery_methods', t),
getDeliveryMethodLabel: (method: DeliveryMethod): string => {
if (!method) return 'Método no definido';
const translated = t(`delivery_methods.${method}`);
if (translated === `delivery_methods.${method}`) {
return method.charAt(0).toUpperCase() + method.slice(1);
}
return translated;
},
// Payment Terms
getPaymentTermsOptions: (): SelectOption[] =>
enumToSelectOptions(OrderPaymentTerms, 'payment_terms', t),
getPaymentTermsLabel: (terms: OrderPaymentTerms): string => {
if (!terms) return 'Términos no definidos';
return t(`payment_terms.${terms}`);
},
// Payment Method
getPaymentMethodOptions: (): SelectOption[] =>
enumToSelectOptions(PaymentMethod, 'payment_methods', t),
getPaymentMethodLabel: (method: PaymentMethod): string => {
if (!method) return 'Método no definido';
return t(`payment_methods.${method}`);
},
// Payment Status
getPaymentStatusOptions: (): SelectOption[] =>
enumToSelectOptions(PaymentStatus, 'payment_status', t),
getPaymentStatusLabel: (status: PaymentStatus): string => {
if (!status) return 'Estado no definido';
return t(`payment_status.${status}`);
},
// Customer Segment
getCustomerSegmentOptions: (): SelectOption[] =>
enumToSelectOptions(CustomerSegment, 'customer_segments', t),
getCustomerSegmentLabel: (segment: CustomerSegment): string => {
if (!segment) return 'Segmento no definido';
return t(`customer_segments.${segment}`);
},
// Priority Level
getPriorityLevelOptions: (): SelectOption[] =>
enumToSelectOptions(PriorityLevel, 'priority_levels', t),
getPriorityLevelLabel: (level: PriorityLevel): string => {
if (!level) return 'Prioridad no definida';
return t(`priority_levels.${level}`);
},
// Order Type
getOrderTypeOptions: (): SelectOption[] =>
enumToSelectOptions(OrderType, 'order_types', t),
getOrderTypeLabel: (type: OrderType): string => {
if (!type) return 'Tipo no definido';
const translated = t(`order_types.${type}`);
// If translation failed, return a fallback
if (translated === `order_types.${type}`) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
return translated;
},
// Order Status
getOrderStatusOptions: (): SelectOption[] =>
enumToSelectOptions(OrderStatus, 'order_status', t),
getOrderStatusLabel: (status: OrderStatus): string => {
if (!status) return 'Estado no definido';
return t(`order_status.${status}`);
},
// Order Source
getOrderSourceOptions: (): SelectOption[] =>
enumToSelectOptions(OrderSource, 'order_sources', t),
getOrderSourceLabel: (source: OrderSource): string => {
if (!source) return 'Origen no definido';
return t(`order_sources.${source}`);
},
// Sales Channel
getSalesChannelOptions: (): SelectOption[] =>
enumToSelectOptions(SalesChannel, 'sales_channels', t),
getSalesChannelLabel: (channel: SalesChannel): string => {
if (!channel) return 'Canal no definido';
return t(`sales_channels.${channel}`);
},
// Field Labels
getFieldLabel: (field: string): string =>
t(`labels.${field}`),
getFieldDescription: (field: string): string =>
t(`descriptions.${field}`)
};
}

View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Script to seed the orders database with test data
set -e
echo "🌱 Seeding Orders Database with Test Data"
echo "========================================="
# Change to the orders service directory
cd services/orders
# Make sure we're in a virtual environment or have the dependencies
echo "📦 Setting up environment..."
# Run the seeding script
echo "🚀 Running seeding script..."
python scripts/seed_test_data.py
echo "✅ Database seeding completed!"
echo ""
echo "🎯 Test data created:"
echo " - 6 customers (including VIP, wholesale, and inactive)"
echo " - 25 orders in various statuses"
echo " - Order items with different products"
echo " - Order status history"
echo ""
echo "📋 You can now test the frontend with real data!"

View File

@@ -55,8 +55,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService:
status_history_repo=OrderStatusHistoryRepository(),
inventory_client=get_inventory_client(),
production_client=get_production_client(),
sales_client=get_sales_client(),
notification_client=None # Notification client not available
sales_client=get_sales_client()
)

View File

@@ -0,0 +1,152 @@
# services/orders/app/models/enums.py
"""
Enum definitions for Orders Service
Following the pattern used in the Inventory Service for better type safety and maintainability
"""
import enum
class CustomerType(enum.Enum):
"""Customer type classifications"""
INDIVIDUAL = "individual"
BUSINESS = "business"
CENTRAL_BAKERY = "central_bakery"
class DeliveryMethod(enum.Enum):
"""Order delivery methods"""
DELIVERY = "delivery"
PICKUP = "pickup"
class PaymentTerms(enum.Enum):
"""Payment terms for customers and orders"""
IMMEDIATE = "immediate"
NET_30 = "net_30"
NET_60 = "net_60"
class PaymentMethod(enum.Enum):
"""Payment methods for orders"""
CASH = "cash"
CARD = "card"
BANK_TRANSFER = "bank_transfer"
ACCOUNT = "account"
class PaymentStatus(enum.Enum):
"""Payment status for orders"""
PENDING = "pending"
PARTIAL = "partial"
PAID = "paid"
FAILED = "failed"
REFUNDED = "refunded"
class CustomerSegment(enum.Enum):
"""Customer segmentation categories"""
VIP = "vip"
REGULAR = "regular"
WHOLESALE = "wholesale"
class PriorityLevel(enum.Enum):
"""Priority levels for orders and customers"""
HIGH = "high"
NORMAL = "normal"
LOW = "low"
class OrderType(enum.Enum):
"""Order type classifications"""
STANDARD = "standard"
RUSH = "rush"
RECURRING = "recurring"
SPECIAL = "special"
class OrderStatus(enum.Enum):
"""Order status workflow"""
PENDING = "pending"
CONFIRMED = "confirmed"
IN_PRODUCTION = "in_production"
READY = "ready"
OUT_FOR_DELIVERY = "out_for_delivery"
DELIVERED = "delivered"
CANCELLED = "cancelled"
FAILED = "failed"
class OrderSource(enum.Enum):
"""Source of order creation"""
MANUAL = "manual"
ONLINE = "online"
PHONE = "phone"
APP = "app"
API = "api"
class SalesChannel(enum.Enum):
"""Sales channel classification"""
DIRECT = "direct"
WHOLESALE = "wholesale"
RETAIL = "retail"
class BusinessModel(enum.Enum):
"""Business model types"""
INDIVIDUAL_BAKERY = "individual_bakery"
CENTRAL_BAKERY = "central_bakery"
# Procurement-related enums
class ProcurementPlanType(enum.Enum):
"""Procurement plan types"""
REGULAR = "regular"
EMERGENCY = "emergency"
SEASONAL = "seasonal"
class ProcurementStrategy(enum.Enum):
"""Procurement strategies"""
JUST_IN_TIME = "just_in_time"
BULK = "bulk"
MIXED = "mixed"
class RiskLevel(enum.Enum):
"""Risk level classifications"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class RequirementStatus(enum.Enum):
"""Procurement requirement status"""
PENDING = "pending"
APPROVED = "approved"
ORDERED = "ordered"
PARTIALLY_RECEIVED = "partially_received"
RECEIVED = "received"
CANCELLED = "cancelled"
class PlanStatus(enum.Enum):
"""Procurement plan status"""
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
IN_EXECUTION = "in_execution"
COMPLETED = "completed"
CANCELLED = "cancelled"
class DeliveryStatus(enum.Enum):
"""Delivery status for procurement"""
PENDING = "pending"
IN_TRANSIT = "in_transit"
DELIVERED = "delivered"
DELAYED = "delayed"
CANCELLED = "cancelled"

View File

@@ -5,7 +5,7 @@
Order-related repositories for Orders Service
"""
from datetime import datetime, date
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from uuid import UUID
@@ -98,12 +98,86 @@ class CustomerRepository(BaseRepository[Customer, dict, dict]):
error=str(e))
raise
async def count_created_since(
self,
db: AsyncSession,
tenant_id: UUID,
since_date: datetime
) -> int:
"""Count customers created since a specific date"""
try:
query = select(func.count()).select_from(Customer).where(
and_(
Customer.tenant_id == tenant_id,
Customer.created_at >= since_date
)
)
result = await db.execute(query)
return result.scalar()
except Exception as e:
logger.error("Error counting customers created since date",
tenant_id=str(tenant_id),
since_date=str(since_date),
error=str(e))
raise
class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
"""Repository for customer order operations"""
def __init__(self):
super().__init__(CustomerOrder)
async def get_multi(
self,
db: AsyncSession,
tenant_id: Optional[UUID] = None,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
order_by: Optional[str] = None,
order_desc: bool = False
) -> List[CustomerOrder]:
"""Get multiple orders with eager loading of items and customer"""
try:
query = select(self.model).options(
selectinload(CustomerOrder.items),
selectinload(CustomerOrder.customer)
)
# Apply tenant filter
if tenant_id:
query = query.where(self.model.tenant_id == tenant_id)
# Apply additional filters
if filters:
for key, value in filters.items():
if hasattr(self.model, key) and value is not None:
field = getattr(self.model, key)
if isinstance(value, list):
query = query.where(field.in_(value))
else:
query = query.where(field == value)
# Apply ordering
if order_by and hasattr(self.model, order_by):
order_column = getattr(self.model, order_by)
if order_desc:
query = query.order_by(order_column.desc())
else:
query = query.order_by(order_column)
else:
# Default ordering by order_date desc
query = query.order_by(CustomerOrder.order_date.desc())
# Apply pagination
query = query.offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Error getting multiple orders", error=str(e))
raise
async def get_with_items(
self,

View File

@@ -11,13 +11,20 @@ from typing import Optional, List, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field, validator
from app.models.enums import (
CustomerType, DeliveryMethod, PaymentTerms, PaymentMethod, PaymentStatus,
CustomerSegment, PriorityLevel, OrderType, OrderStatus, OrderSource,
SalesChannel, BusinessModel, ProcurementPlanType, ProcurementStrategy,
RiskLevel, RequirementStatus, PlanStatus, DeliveryStatus
)
# ===== Customer Schemas =====
class CustomerBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
business_name: Optional[str] = Field(None, max_length=200)
customer_type: str = Field(default="individual", pattern="^(individual|business|central_bakery)$")
customer_type: CustomerType = Field(default=CustomerType.INDIVIDUAL)
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
address_line1: Optional[str] = Field(None, max_length=255)
@@ -27,16 +34,20 @@ class CustomerBase(BaseModel):
postal_code: Optional[str] = Field(None, max_length=20)
country: str = Field(default="US", max_length=100)
is_active: bool = Field(default=True)
preferred_delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$")
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
preferred_delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
credit_limit: Optional[Decimal] = Field(None, ge=0)
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
customer_segment: str = Field(default="regular", pattern="^(vip|regular|wholesale)$")
priority_level: str = Field(default="normal", pattern="^(high|normal|low)$")
customer_segment: CustomerSegment = Field(default=CustomerSegment.REGULAR)
priority_level: PriorityLevel = Field(default=PriorityLevel.NORMAL)
special_instructions: Optional[str] = None
delivery_preferences: Optional[Dict[str, Any]] = None
product_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class CustomerCreate(CustomerBase):
customer_code: str = Field(..., min_length=1, max_length=50)
@@ -46,7 +57,7 @@ class CustomerCreate(CustomerBase):
class CustomerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
business_name: Optional[str] = Field(None, max_length=200)
customer_type: Optional[str] = Field(None, pattern="^(individual|business|central_bakery)$")
customer_type: Optional[CustomerType] = None
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
address_line1: Optional[str] = Field(None, max_length=255)
@@ -56,16 +67,20 @@ class CustomerUpdate(BaseModel):
postal_code: Optional[str] = Field(None, max_length=20)
country: Optional[str] = Field(None, max_length=100)
is_active: Optional[bool] = None
preferred_delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$")
payment_terms: Optional[str] = Field(None, pattern="^(immediate|net_30|net_60)$")
preferred_delivery_method: Optional[DeliveryMethod] = None
payment_terms: Optional[PaymentTerms] = None
credit_limit: Optional[Decimal] = Field(None, ge=0)
discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
customer_segment: Optional[str] = Field(None, pattern="^(vip|regular|wholesale)$")
priority_level: Optional[str] = Field(None, pattern="^(high|normal|low)$")
customer_segment: Optional[CustomerSegment] = None
priority_level: Optional[PriorityLevel] = None
special_instructions: Optional[str] = None
delivery_preferences: Optional[Dict[str, Any]] = None
product_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class CustomerResponse(CustomerBase):
id: UUID
@@ -129,26 +144,30 @@ class OrderItemResponse(OrderItemBase):
class OrderBase(BaseModel):
customer_id: UUID
order_type: str = Field(default="standard", pattern="^(standard|rush|recurring|special)$")
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
order_type: OrderType = Field(default=OrderType.STANDARD)
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
requested_delivery_date: datetime
delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$")
delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
delivery_address: Optional[Dict[str, Any]] = None
delivery_instructions: Optional[str] = None
delivery_window_start: Optional[datetime] = None
delivery_window_end: Optional[datetime] = None
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
delivery_fee: Decimal = Field(default=Decimal("0.00"), ge=0)
payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
payment_method: Optional[PaymentMethod] = None
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
special_instructions: Optional[str] = None
custom_requirements: Optional[Dict[str, Any]] = None
allergen_warnings: Optional[Dict[str, Any]] = None
order_source: str = Field(default="manual", pattern="^(manual|online|phone|app|api)$")
sales_channel: str = Field(default="direct", pattern="^(direct|wholesale|retail)$")
order_source: OrderSource = Field(default=OrderSource.MANUAL)
sales_channel: SalesChannel = Field(default=SalesChannel.DIRECT)
order_origin: Optional[str] = Field(None, max_length=100)
communication_preferences: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class OrderCreate(OrderBase):
tenant_id: UUID
@@ -156,21 +175,25 @@ class OrderCreate(OrderBase):
class OrderUpdate(BaseModel):
status: Optional[str] = Field(None, pattern="^(pending|confirmed|in_production|ready|out_for_delivery|delivered|cancelled|failed)$")
priority: Optional[str] = Field(None, pattern="^(high|normal|low)$")
status: Optional[OrderStatus] = None
priority: Optional[PriorityLevel] = None
requested_delivery_date: Optional[datetime] = None
confirmed_delivery_date: Optional[datetime] = None
delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$")
delivery_method: Optional[DeliveryMethod] = None
delivery_address: Optional[Dict[str, Any]] = None
delivery_instructions: Optional[str] = None
delivery_window_start: Optional[datetime] = None
delivery_window_end: Optional[datetime] = None
payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
payment_status: Optional[str] = Field(None, pattern="^(pending|partial|paid|failed|refunded)$")
payment_method: Optional[PaymentMethod] = None
payment_status: Optional[PaymentStatus] = None
special_instructions: Optional[str] = None
custom_requirements: Optional[Dict[str, Any]] = None
allergen_warnings: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
use_enum_values = True
class OrderResponse(OrderBase):
id: UUID
@@ -205,17 +228,21 @@ class ProcurementRequirementBase(BaseModel):
product_name: str = Field(..., min_length=1, max_length=200)
product_sku: Optional[str] = Field(None, max_length=100)
product_category: Optional[str] = Field(None, max_length=100)
product_type: str = Field(default="ingredient", pattern="^(ingredient|packaging|supplies)$")
product_type: str = Field(default="ingredient") # TODO: Create ProductType enum if needed
required_quantity: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(..., min_length=1, max_length=50)
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)
required_by_date: date
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
preferred_supplier_id: Optional[UUID] = None
quality_specifications: Optional[Dict[str, Any]] = None
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = Field(None, max_length=200)
class Config:
from_attributes = True
use_enum_values = True
class ProcurementRequirementCreate(ProcurementRequirementBase):
pass
@@ -248,13 +275,17 @@ class ProcurementPlanBase(BaseModel):
plan_period_start: date
plan_period_end: date
planning_horizon_days: int = Field(default=14, ge=1, le=365)
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$")
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$")
plan_type: ProcurementPlanType = Field(default=ProcurementPlanType.REGULAR)
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
business_model: Optional[BusinessModel] = None
procurement_strategy: ProcurementStrategy = Field(default=ProcurementStrategy.JUST_IN_TIME)
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
special_requirements: Optional[str] = None
class Config:
from_attributes = True
use_enum_values = True
class ProcurementPlanCreate(ProcurementPlanBase):
tenant_id: UUID

View File

@@ -336,10 +336,10 @@ class OrdersService:
# Get new customers this month
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
new_customers_this_month = await self.customer_repo.count(
db,
tenant_id,
filters={"created_at": {"gte": month_start}}
new_customers_this_month = await self.customer_repo.count_created_since(
db,
tenant_id,
month_start
)
# Get recent orders

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Script to populate the database with test data for orders and customers
"""
import os
import sys
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
import asyncio
import random
# Add the parent directory to the path to import our modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.customer import Customer, CustomerContact
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
# Test tenant ID - in a real environment this would be provided
TEST_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Sample customer data
SAMPLE_CUSTOMERS = [
{
"name": "María García López",
"customer_type": "individual",
"email": "maria.garcia@email.com",
"phone": "+34 612 345 678",
"city": "Madrid",
"country": "España",
"customer_segment": "vip",
"is_active": True
},
{
"name": "Panadería San Juan",
"business_name": "Panadería San Juan S.L.",
"customer_type": "business",
"email": "pedidos@panaderiasjuan.com",
"phone": "+34 687 654 321",
"city": "Barcelona",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Carlos Rodríguez Martín",
"customer_type": "individual",
"email": "carlos.rodriguez@email.com",
"phone": "+34 698 765 432",
"city": "Valencia",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Ana Fernández Ruiz",
"customer_type": "individual",
"email": "ana.fernandez@email.com",
"phone": "+34 634 567 890",
"city": "Sevilla",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Café Central",
"business_name": "Café Central Madrid S.L.",
"customer_type": "business",
"email": "compras@cafecentral.es",
"phone": "+34 623 456 789",
"city": "Madrid",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Laura Martínez Silva",
"customer_type": "individual",
"email": "laura.martinez@email.com",
"phone": "+34 645 789 012",
"city": "Bilbao",
"country": "España",
"customer_segment": "regular",
"is_active": False # Inactive customer for testing
}
]
# Sample products (in a real system these would come from a products service)
SAMPLE_PRODUCTS = [
{"id": str(uuid.uuid4()), "name": "Pan Integral Artesano", "price": Decimal("2.50"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Croissant de Mantequilla", "price": Decimal("1.80"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Tarta de Santiago", "price": Decimal("18.90"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Magdalenas de Limón", "price": Decimal("0.90"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Empanada de Atún", "price": Decimal("3.50"), "category": "Salado"},
{"id": str(uuid.uuid4()), "name": "Brownie de Chocolate", "price": Decimal("3.20"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Baguette Francesa", "price": Decimal("2.80"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Palmera de Chocolate", "price": Decimal("2.40"), "category": "Bollería"},
]
async def create_customers(session: AsyncSession) -> list[Customer]:
"""Create sample customers"""
customers = []
for i, customer_data in enumerate(SAMPLE_CUSTOMERS):
customer = Customer(
tenant_id=TEST_TENANT_ID,
customer_code=f"CUST-{i+1:04d}",
name=customer_data["name"],
business_name=customer_data.get("business_name"),
customer_type=customer_data["customer_type"],
email=customer_data["email"],
phone=customer_data["phone"],
city=customer_data["city"],
country=customer_data["country"],
is_active=customer_data["is_active"],
preferred_delivery_method="delivery" if random.choice([True, False]) else "pickup",
payment_terms=random.choice(["immediate", "net_30"]),
customer_segment=customer_data["customer_segment"],
priority_level=random.choice(["normal", "high"]) if customer_data["customer_segment"] == "vip" else "normal",
discount_percentage=Decimal("5.0") if customer_data["customer_segment"] == "vip" else
Decimal("10.0") if customer_data["customer_segment"] == "wholesale" else Decimal("0.0"),
total_orders=random.randint(5, 50),
total_spent=Decimal(str(random.randint(100, 5000))),
average_order_value=Decimal(str(random.randint(15, 150))),
last_order_date=datetime.now() - timedelta(days=random.randint(1, 30))
)
session.add(customer)
customers.append(customer)
await session.commit()
return customers
async def create_orders(session: AsyncSession, customers: list[Customer]):
"""Create sample orders in different statuses"""
order_statuses = [
"pending", "confirmed", "in_production", "ready",
"out_for_delivery", "delivered", "cancelled"
]
order_types = ["standard", "rush", "recurring", "special"]
priorities = ["low", "normal", "high"]
delivery_methods = ["delivery", "pickup"]
payment_statuses = ["pending", "partial", "paid", "failed"]
for i in range(25): # Create 25 sample orders
customer = random.choice(customers)
order_status = random.choice(order_statuses)
# Create order date in the last 30 days
order_date = datetime.now() - timedelta(days=random.randint(0, 30))
# Create delivery date (1-7 days after order date)
delivery_date = order_date + timedelta(days=random.randint(1, 7))
order = CustomerOrder(
tenant_id=TEST_TENANT_ID,
order_number=f"ORD-{datetime.now().year}-{i+1:04d}",
customer_id=customer.id,
status=order_status,
order_type=random.choice(order_types),
priority=random.choice(priorities),
order_date=order_date,
requested_delivery_date=delivery_date,
confirmed_delivery_date=delivery_date if order_status not in ["pending", "cancelled"] else None,
actual_delivery_date=delivery_date if order_status == "delivered" else None,
delivery_method=random.choice(delivery_methods),
delivery_instructions=random.choice([
None, "Dejar en recepción", "Llamar al timbre", "Cuidado con el escalón"
]),
discount_percentage=customer.discount_percentage,
payment_status=random.choice(payment_statuses) if order_status != "cancelled" else "failed",
payment_method=random.choice(["cash", "card", "bank_transfer"]),
payment_terms=customer.payment_terms,
special_instructions=random.choice([
None, "Sin gluten", "Decoración especial", "Entrega temprano", "Cliente VIP"
]),
order_source=random.choice(["manual", "online", "phone"]),
sales_channel=random.choice(["direct", "wholesale"]),
customer_notified_confirmed=order_status not in ["pending", "cancelled"],
customer_notified_ready=order_status in ["ready", "out_for_delivery", "delivered"],
customer_notified_delivered=order_status == "delivered",
quality_score=Decimal(str(random.randint(70, 100) / 10)) if order_status == "delivered" else None,
customer_rating=random.randint(3, 5) if order_status == "delivered" else None
)
session.add(order)
await session.flush() # Flush to get the order ID
# Create order items
num_items = random.randint(1, 5)
subtotal = Decimal("0.00")
for _ in range(num_items):
product = random.choice(SAMPLE_PRODUCTS)
quantity = random.randint(1, 10)
unit_price = product["price"]
line_total = unit_price * quantity
order_item = OrderItem(
order_id=order.id,
product_id=product["id"],
product_name=product["name"],
product_category=product["category"],
quantity=quantity,
unit_of_measure="unidad",
unit_price=unit_price,
line_discount=Decimal("0.00"),
line_total=line_total,
status=order_status if order_status != "cancelled" else "cancelled"
)
session.add(order_item)
subtotal += line_total
# Calculate financial totals
discount_amount = subtotal * (order.discount_percentage / 100)
tax_amount = (subtotal - discount_amount) * Decimal("0.21") # 21% VAT
delivery_fee = Decimal("3.50") if order.delivery_method == "delivery" and subtotal < 25 else Decimal("0.00")
total_amount = subtotal - discount_amount + tax_amount + delivery_fee
# Update order with calculated totals
order.subtotal = subtotal
order.discount_amount = discount_amount
order.tax_amount = tax_amount
order.delivery_fee = delivery_fee
order.total_amount = total_amount
# Create status history
status_history = OrderStatusHistory(
order_id=order.id,
from_status=None,
to_status=order_status,
event_type="status_change",
event_description=f"Order created with status: {order_status}",
change_source="system",
changed_at=order_date,
customer_notified=order_status != "pending"
)
session.add(status_history)
# Add additional status changes for non-pending orders
if order_status != "pending":
current_date = order_date
for status in ["confirmed", "in_production", "ready"]:
if order_statuses.index(status) <= order_statuses.index(order_status):
current_date += timedelta(hours=random.randint(2, 12))
status_change = OrderStatusHistory(
order_id=order.id,
from_status="pending" if status == "confirmed" else None,
to_status=status,
event_type="status_change",
event_description=f"Order status changed to: {status}",
change_source="manual",
changed_at=current_date,
customer_notified=True
)
session.add(status_change)
await session.commit()
async def main():
"""Main function to seed the database"""
print("🌱 Starting database seeding...")
async for session in get_session():
try:
print("📋 Creating customers...")
customers = await create_customers(session)
print(f"✅ Created {len(customers)} customers")
print("📦 Creating orders...")
await create_orders(session, customers)
print("✅ Created orders with different statuses")
print("🎉 Database seeding completed successfully!")
except Exception as e:
print(f"❌ Error during seeding: {e}")
await session.rollback()
raise
finally:
await session.close()
if __name__ == "__main__":
asyncio.run(main())