diff --git a/backend/api/v1/modules/a76/items/line_items/schemas.py b/backend/api/v1/modules/a76/items/line_items/schemas.py index e86c5a62..c490352f 100644 --- a/backend/api/v1/modules/a76/items/line_items/schemas.py +++ b/backend/api/v1/modules/a76/items/line_items/schemas.py @@ -4,9 +4,9 @@ from pydantic import BaseModel, Field, ConfigDict # Import nested schemas from ..line_customs.schemas import ( - LineCustomsCreate, - LineCustomsUpdate, - LineCustomsResponse + LineCustomCreate, + LineCustomUpdate, + LineCustomResponse ) from ..line_descriptions.schemas import ( LineDescriptionCreate, @@ -137,7 +137,7 @@ class LineItemCreate(LineItemBase): """Schema for creating line item with all nested data""" financial: Optional[LineFinancialCreate] = Field(None, description="Financial data for this line") quantity: Optional[LineQuantityCreate] = Field(None, description="Quantity data for this line") - customs: Optional[LineCustomsCreate] = Field(None, description="Customs data for this line") + customs: Optional[LineCustomCreate] = Field(None, description="Customs data for this line") description: Optional[LineDescriptionCreate] = Field(None, description="Description data for this line") reference: Optional[LineReferenceCreate] = Field(None, description="Reference data for this line") @@ -147,7 +147,7 @@ class LineItemUpdate(LineItemBase): line_number: Optional[int] = Field(None, description="Line number") financial: Optional[LineFinancialUpdate] = Field(None, description="Financial data for this line") quantity: Optional[LineQuantityUpdate] = Field(None, description="Quantity data for this line") - customs: Optional[LineCustomsUpdate] = Field(None, description="Customs data for this line") + customs: Optional[LineCustomUpdate] = Field(None, description="Customs data for this line") description: Optional[LineDescriptionUpdate] = Field(None, description="Description data for this line") reference: Optional[LineReferenceUpdate] = Field(None, description="Reference data for this line") @@ -158,7 +158,7 @@ class LineItemResponse(LineItemBase): item_id: int financial: Optional[LineFinancialResponse] = None quantity: Optional[LineQuantityResponse] = None - customs: Optional[LineCustomsResponse] = None + customs: Optional[LineCustomResponse] = None description: Optional[LineDescriptionResponse] = None reference: Optional[LineReferenceResponse] = None diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index 5e6ece83..df310160 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -28,8 +28,7 @@ class Item(Base, TenantScopedMixin, TimestampMixin): } id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - invoice_id: Mapped[int] = mapped_column( - ForeignKey("a76.invoice_header.id")) # CONSECUTIVO + invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO # Type: IMPORT_TEMP, IMPORT_DEF, EXPORT, REPAIR, etc. item_type: Mapped[str] = mapped_column(String(20)) system_origin: Mapped[str] = mapped_column(String(10)) # SCAF or SCAII diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index 8d4b924a..4bf677e3 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -198,6 +198,7 @@ async def get_items_by_invoice( @router.get("/stats/summary") async def get_items_summary( + company_id: int = Query(..., description="Company ID"), invoice_id: Optional[int] = Query( None, description="Filter by invoice ID"), db: Session = Depends(get_core_db), @@ -206,11 +207,16 @@ async def get_items_summary( """ Get summary statistics for items """ - service = ItemService(db) - items, total = service.list_items( + tenant_id = validate_access_to_resource(db, company_id, current_user) + + filters = {"invoice_id": invoice_id} if invoice_id else None + items, total = ItemService.get_all( + db=db, + tenant_id=tenant_id, + company_id=company_id, skip=0, limit=10000, # Get all for stats - invoice_id=invoice_id + filters=filters ) # Calculate stats diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index c697d5b0..d8a30b99 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -344,457 +344,3 @@ class ItemService: db.rollback() logger.error(f"Error deleting item: {e}") raise HTTPException(status_code=500, detail="Error deleting item") - - def __init__(self, db: Session): - self.db = db - - def create_item(self, item_data: ItemCreate) -> Item: - """ - Create a new item with its nested line item (one-to-one) - Also creates the line's financial and quantity data - """ - try: - # Extract line data before creating the item - line_data = item_data.line - item_dict = item_data.model_dump(exclude={'line'}) - - # Create the item - db_item = Item(**item_dict) - self.db.add(db_item) - self.db.flush() # Get the item ID without committing - - # Create line item with nested data if provided - if line_data: - self._create_line_item(db_item.id, line_data) - - self.db.commit() - self.db.refresh(db_item) - return db_item - - except IntegrityError as e: - self.db.rollback() - logger.error(f"Integrity error creating item: {e}") - raise HTTPException( - status_code=400, - detail="Item creation failed due to data integrity constraint" - ) - except Exception as e: - self.db.rollback() - logger.error(f"Unexpected error creating item: {e}") - raise HTTPException( - status_code=500, - detail=f"Error creating item: {str(e)}" - ) - - def _create_line_item(self, item_id: int, line_data: LineItemCreate) -> LineItem: - """ - Create a line item with all its nested data (financial, quantity, customs, description, reference) - """ - # Extract nested data - financial_data = line_data.financial - quantity_data = line_data.quantity - customs_data = line_data.customs - description_data = line_data.description - reference_data = line_data.reference - - line_dict = line_data.model_dump(exclude={ - 'financial', 'quantity', 'customs', 'description', 'reference' - }) - - # Create line item - db_line = LineItem(item_id=item_id, **line_dict) - self.db.add(db_line) - self.db.flush() # Get the line ID - - # Create financial data if provided - if financial_data: - db_financial = LineFinancial( - item_line_id=db_line.id, - **financial_data.model_dump() - ) - self.db.add(db_financial) - - # Create quantity data if provided - if quantity_data: - db_quantity = LineQuantity( - item_line_id=db_line.id, - **quantity_data.model_dump() - ) - self.db.add(db_quantity) - - # Create customs data if provided - if customs_data: - db_customs = LineCustom( - item_line_id=db_line.id, - **customs_data.model_dump() - ) - self.db.add(db_customs) - - # Create description data if provided - if description_data: - db_description = LineDescription( - item_line_id=db_line.id, - **description_data.model_dump() - ) - self.db.add(db_description) - - # Create reference data if provided - if reference_data: - db_reference = LineReference( - item_line_id=db_line.id, - **reference_data.model_dump() - ) - self.db.add(db_reference) - - return db_line - - def get_item(self, item_id: int) -> Optional[Item]: - """ - Get an item by ID with all nested data loaded - """ - item = ( - self.db.query(Item) - .options(joinedload(Item.lines)) - .filter(Item.id == item_id) - .first() - ) - - if not item: - raise HTTPException( - status_code=404, - detail=f"Item with id {item_id} not found" - ) - - return item - - def list_items( - self, - skip: int = 0, - limit: int = 100, - invoice_id: Optional[int] = None, - item_type: Optional[str] = None, - system_origin: Optional[str] = None, - ) -> tuple[list[Item], int]: - """ - List items with optional filters and pagination - Returns tuple of (items, total_count) - """ - query = self.db.query(Item).options(joinedload(Item.lines)) - - # Apply filters - if invoice_id: - query = query.filter(Item.invoice_id == invoice_id) - if item_type: - query = query.filter(Item.item_type == item_type) - if system_origin: - query = query.filter(Item.system_origin == system_origin) - - # Get total count - total = query.count() - - # Apply pagination - items = query.offset(skip).limit(limit).all() - - return items, total - - def update_item(self, item_id: int, item_data: ItemUpdate) -> Item: - """ - Update an item and optionally its line item (one-to-one) - """ - try: - db_item = self.get_item(item_id) - - # Extract line data - line_data = item_data.line - update_dict = item_data.model_dump( - exclude={'line'}, exclude_unset=True) - - # Update item fields - for field, value in update_dict.items(): - setattr(db_item, field, value) - - # Update line if provided - if line_data is not None: - # Get the existing line or create new one - db_line = ( - self.db.query(LineItem) - .filter(LineItem.item_id == item_id) - .first() - ) - - if db_line: - self._update_line_item(db_line, line_data) - else: - # Create new line if it doesn't exist - self._create_line_item(item_id, line_data) - - self.db.commit() - self.db.refresh(db_item) - return db_item - - except HTTPException: - raise - except IntegrityError as e: - self.db.rollback() - logger.error(f"Integrity error updating item: {e}") - raise HTTPException( - status_code=400, - detail="Item update failed due to data integrity constraint" - ) - except Exception as e: - self.db.rollback() - logger.error(f"Unexpected error updating item: {e}") - raise HTTPException( - status_code=500, - detail=f"Error updating item: {str(e)}" - ) - - def _update_line_item(self, db_line: LineItem, line_data: LineItemUpdate): - """ - Update a line item and all its nested data - """ - # Extract nested data - financial_data = line_data.financial - quantity_data = line_data.quantity - customs_data = line_data.customs - description_data = line_data.description - reference_data = line_data.reference - - line_dict = line_data.model_dump( - exclude={'financial', 'quantity', - 'customs', 'description', 'reference'}, - exclude_unset=True - ) - - # Update line fields - for field, value in line_dict.items(): - setattr(db_line, field, value) - - # Update financial data - if financial_data: - db_financial = ( - self.db.query(LineFinancial) - .filter(LineFinancial.item_line_id == db_line.id) - .first() - ) - - if db_financial: - # Update existing - for field, value in financial_data.model_dump(exclude_unset=True).items(): - setattr(db_financial, field, value) - else: - # Create new - db_financial = LineFinancial( - item_line_id=db_line.id, - **financial_data.model_dump(exclude_unset=True) - ) - self.db.add(db_financial) - - # Update quantity data - if quantity_data: - db_quantity = ( - self.db.query(LineQuantity) - .filter(LineQuantity.item_line_id == db_line.id) - .first() - ) - - if db_quantity: - # Update existing - for field, value in quantity_data.model_dump(exclude_unset=True).items(): - setattr(db_quantity, field, value) - else: - # Create new - db_quantity = LineQuantity( - item_line_id=db_line.id, - **quantity_data.model_dump(exclude_unset=True) - ) - self.db.add(db_quantity) - - # Update customs data - if customs_data: - db_customs = ( - self.db.query(LineCustom) - .filter(LineCustom.item_line_id == db_line.id) - .first() - ) - - if db_customs: - # Update existing - for field, value in customs_data.model_dump(exclude_unset=True).items(): - setattr(db_customs, field, value) - else: - # Create new - db_customs = LineCustom( - item_line_id=db_line.id, - **customs_data.model_dump(exclude_unset=True) - ) - self.db.add(db_customs) - - # Update description data - if description_data: - db_description = ( - self.db.query(LineDescription) - .filter(LineDescription.item_line_id == db_line.id) - .first() - ) - - if db_description: - # Update existing - for field, value in description_data.model_dump(exclude_unset=True).items(): - setattr(db_description, field, value) - else: - # Create new - db_description = LineDescription( - item_line_id=db_line.id, - **description_data.model_dump(exclude_unset=True) - ) - self.db.add(db_description) - - # Update reference data - if reference_data: - db_reference = ( - self.db.query(LineReference) - .filter(LineReference.item_line_id == db_line.id) - .first() - ) - - if db_reference: - # Update existing - for field, value in reference_data.model_dump(exclude_unset=True).items(): - setattr(db_reference, field, value) - else: - # Create new - db_reference = LineReference( - item_line_id=db_line.id, - **reference_data.model_dump(exclude_unset=True) - ) - self.db.add(db_reference) - - def delete_item(self, item_id: int) -> bool: - """ - Delete an item and all its related data (cascade) - """ - try: - db_item = self.get_item(item_id) - self.db.delete(db_item) - self.db.commit() - return True - - except HTTPException: - raise - except Exception as e: - self.db.rollback() - logger.error(f"Error deleting item: {e}") - raise HTTPException( - status_code=500, - detail=f"Error deleting item: {str(e)}" - ) - - def search_items( - self, - search_term: Optional[str] = None, - skip: int = 0, - limit: int = 100 - ) -> tuple[list[Item], int]: - """ - Search items by various fields - """ - query = self.db.query(Item).options(joinedload(Item.lines)) - - if search_term: - search_filter = or_( - Item.invoice_number.ilike(f"%{search_term}%"), - Item.reference_number.ilike(f"%{search_term}%"), - Item.order.ilike(f"%{search_term}%"), - Item.guide_number.ilike(f"%{search_term}%"), - ) - query = query.filter(search_filter) - - total = query.count() - items = query.offset(skip).limit(limit).all() - - return items, total - - # ======================================================================== - # LINE ITEM SPECIFIC OPERATIONS (one-to-one) - # ======================================================================== - - def get_line_for_item(self, item_id: int) -> Optional[LineItem]: - """ - Get the line item for a specific item - """ - db_line = ( - self.db.query(LineItem) - .filter(LineItem.item_id == item_id) - .first() - ) - - return db_line - - def create_or_replace_line(self, item_id: int, line_data: LineItemCreate) -> LineItem: - """ - Create or replace the line for an item (one-to-one relationship) - """ - try: - # Verify item exists - db_item = self.get_item(item_id) - - # Check if line already exists - existing_line = ( - self.db.query(LineItem) - .filter(LineItem.item_id == item_id) - .first() - ) - - if existing_line: - # Delete existing line (cascade will delete financials and quantities) - self.db.delete(existing_line) - self.db.flush() - - # Create new line - db_line = self._create_line_item(item_id, line_data) - - self.db.commit() - self.db.refresh(db_line) - return db_line - - except HTTPException: - raise - except Exception as e: - self.db.rollback() - logger.error(f"Error creating/replacing line for item: {e}") - raise HTTPException( - status_code=500, - detail=f"Error creating/replacing line for item: {str(e)}" - ) - - def delete_line_from_item(self, item_id: int) -> bool: - """ - Delete the line from an item - """ - try: - db_line = ( - self.db.query(LineItem) - .filter(LineItem.item_id == item_id) - .first() - ) - - if not db_line: - raise HTTPException( - status_code=404, - detail=f"No line found for item {item_id}" - ) - - self.db.delete(db_line) - self.db.commit() - return True - - except HTTPException: - raise - except Exception as e: - self.db.rollback() - logger.error(f"Error deleting line from item: {e}") - raise HTTPException( - status_code=500, - detail=f"Error deleting line from item: {str(e)}" - ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 8bfee681..78cac9b3 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -9,6 +9,7 @@ from .customs_brokers.routes import router as customs_broker_router # Importar routers de módulos from .invoices.routes import router as invoices_router +from .items.routes import router as items_router from .classes import router as classes_router from .clients_and_providers import router as client_and_provider_router from .general_catalogs.company import router as company_router @@ -46,6 +47,7 @@ router = APIRouter() # Registrar módulos router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) +router.include_router(items_router, prefix="/a76", tags=["a76 / items"]) router.include_router(pedimentos_router, prefix="/a76") router.include_router( client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"] diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts new file mode 100644 index 00000000..94ea69d5 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -0,0 +1,133 @@ +/** + * API Client para Items + * Gestiona las operaciones CRUD para items de facturas + */ +import { api } from '$lib/api'; + +// --- Interfaces --- + +export interface Item { + id?: number; + invoice_id: number; + item_type: string; + system_origin: string; + invoice_number?: string; + reference_number?: string; + order?: string; + guide_number?: string; + invoice_date?: number; + depreciation_date?: number; + rectification?: number; + warehouse?: string; + location?: string; + created_at?: string; + updated_at?: string; +} + +export interface ItemListResponse { + items: Item[]; + total: number; + skip: number; + limit: number; +} + +export interface CreateItemData { + invoice_id: number; + item_type: string; + system_origin: string; + invoice_number?: string; + reference_number?: string; + order?: string; + guide_number?: string; + invoice_date?: number; + depreciation_date?: number; + rectification?: number; + warehouse?: string; + location?: string; +} + +export interface UpdateItemData { + item_type?: string; + system_origin?: string; + invoice_number?: string; + reference_number?: string; + order?: string; + guide_number?: string; + invoice_date?: number; + depreciation_date?: number; + rectification?: boolean; + warehouse?: string; + location?: string; +} + +/** + * API para Items + */ +export const itemsApi = { + /** + * Lista todos los items con paginación + */ + list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + skip: skip.toString(), + limit: limit.toString() + }); + + if (invoiceId) { + params.append('invoice_id', invoiceId.toString()); + } + + return api.get(`/v1/a76/items/?${params.toString()}`); + }, + + /** + * Lista items por invoice ID + */ + listByInvoice: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`); + }, + + /** + * Obtiene un item por ID + */ + get: (itemId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/items/${itemId}?${params.toString()}`); + }, + + /** + * Crea un nuevo item + */ + create: (companyId: number, data: CreateItemData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/items/?${params.toString()}`, data); + }, + + /** + * Actualiza un item existente + */ + update: (itemId: number, companyId: number, data: UpdateItemData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`/v1/a76/items/${itemId}?${params.toString()}`, data); + }, + + /** + * Elimina un item + */ + delete: (itemId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`); + } +}; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items-tab-form.svelte index c7af8c0f..6abacb33 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items-tab-form.svelte @@ -1,14 +1,16 @@
-
- 1 +
+
+

Items de la Factura

+ +
+ +
+ + + + Tipo + Factura + Referencia + Orden + Almacén + Ubicación + Acciones + + + + {#if displayedItems.length === 0} + + + No hay items disponibles + + + {:else} + {#each displayedItems as item (item.id)} + + {item.item_type} + {item.invoice_number || '-'} + {item.reference_number || '-'} + {item.order || '-'} + {item.warehouse || '-'} + {item.location || '-'} + +
+ + +
+
+
+ {/each} + {#if isLoadingMore} + + + Cargando más items... + + + {/if} + {/if} +
+
+
+ + {#if items.length > 0} +
+ Mostrando {displayedItems.length} de {items.length} items +
+ {/if}
-
+ +

Cantidades:

- Partidas: {invoice?.items?.length || 0} + Partidas: {items.length || 0}
- Bultos: {invoice?.packages || 0} + Bultos: 0
@@ -48,13 +331,184 @@

Valores de importacion:

- Dolares: {invoice?.items || 0} USD
- Pesos: {invoice?.items || 0} MXN
- De Captura: {invoice?.items || 0} USD + Dolares: 0 USD
+ Pesos: 0 MXN
+ De Captura: 0 USD

spacer

- Aduana: {invoice?.items || 0} USD
- Aduana: {invoice?.items || 0} MXN
+ Aduana: 0 USD
+ Aduana: 0 MXN
+ + + + + + {isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'} + + {isEditMode ? 'Modifica los campos del item y guarda los cambios.' : 'Completa la información del nuevo item.'} + + + + + + General + Clasificación + Cantidades + Otros + + + + + +
+

Información de la Factura

+ {#if !invoice?.id} +
+ ⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la factura. +
+ {:else} +
+
+ ID Factura: + {invoice.id} +
+
+ Tipo Operación: + {invoice.operation_type || 'N/A'} +
+
+ Número de Factura: + {invoice.invoice_number || 'Pendiente'} +
+
+ {/if} +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + + +
+

+ Aquí puedes agregar campos de clasificación como: +

+
    +
  • Fracción arancelaria
  • +
  • Código de producto
  • +
  • Clasificación SCAC
  • +
  • Material type
  • +
  • Categoría de mercancía
  • +
+
+
+ + + +
+

+ Aquí puedes agregar campos de cantidades como: +

+
    +
  • Cantidad
  • +
  • Unidad de medida
  • +
  • Peso neto
  • +
  • Peso bruto
  • +
  • Valor unitario
  • +
  • Valor total
  • +
+
+
+ + + +
+

+ Aquí puedes agregar otros campos como: +

+
    +
  • País de origen
  • +
  • Observaciones
  • +
  • Documentos adjuntos
  • +
  • Información adicional
  • +
+
+
+
+ + + + + +
+
+ + + + + + Confirmar Eliminación + + ¿Está seguro que desea eliminar este item? Esta acción no se puede deshacer. + + + + + + + + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 8c56a3c6..5c8a7830 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,7 @@ @@ -9,4 +10,5 @@ + {@render children?.()}