diff --git a/.env.example b/.env.example index 8e04d045..87dcbf5f 100644 --- a/.env.example +++ b/.env.example @@ -27,15 +27,44 @@ CORE_DB_NAME=anexo76_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres +# Lista de orígenes permitidos (CORS) +# Ejemplo para Hub: http://localhost:5173,http://100.78.6.108:5174,http://100.78.6.108:8001 +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# ---- API Tipo cambio (External) --- +EXTERNAL_API_URL=http://74.208.80.245:3000 +EXTERNAL_API_USER=devAS +EXTERNAL_API_PASSWORD=T6Y4mqP09Y[-2-3H + +# ----- API SITAR (Warnings) --- +SITAR_API_URL="" +SITAR_API_USER="" +SITAR_API_PASSWORD="" + # ----- Frontend ----- NODE_ENV=development VITE_API_URL=http://localhost:8000/api INTERNAL_API_URL=http://backend:8000/api VITE_KEYCLOAK_REALM=master -VITE_KEYCLOAK_URL=http://localhost:8080 +VITE_KEYCLOAK_URL=http://localhost:8080/kcauth VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend -# ----- Sitar API ----- -SITAR_API_URL=http://api.sitar.aduanasoft.com -SITAR_API_USER=your_sitar_user -SITAR_API_PASSWORD=your_sitar_password +#------ Celery / Valkey ---------- +VALKEY_URL=redis://valkey:6379/0 + +# ================================== +# CONFIGURACIÓN DE SINCRONIZACIÓN +# ================================== +# Hub IP: 100.78.6.108 + +# URL del Hub para sincronización (Solo si es CLIENTE) +# Ejemplo: http://100.78.6.108:8001/api/v1/core/help-center/sync/ +CENTRAL_SERVER_URL= + +# UUID único de este cliente (Opcional, se genera uno si está vacío) + +# Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes) +SYNC_SECRET_TOKEN=change-this-sync-token-in-production + +# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional) +SPOKE_URLS="" diff --git a/.gitignore b/.gitignore index ff9b622c..6beb5975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python __pycache__/ +.mypy_cache/ *.py[cod] *$py.class *.so @@ -50,13 +51,15 @@ logs/ *.sqlite3 # Testing +backend/app_data/ +.mypy_cache/ .pytest_cache/ .coverage htmlcov/ -backend/app_data/ # Node (para frontend) -node_modules/ +**/node_modules/ +**/.svelte-kit/ .npm .yarn @@ -64,3 +67,5 @@ node_modules/ *.dockerignore postgres-data/ backend/uploads/ +docker-compose.yml +.mypy_cache/ diff --git a/backend/.env.example b/backend/.env.example index 23cd9f7b..77ae339c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,3 +27,8 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # License Service LICENSE_CHECK_ENABLED=True + +# Synchronization (Hub & Spoke) +SYNC_SECRET_TOKEN=change-this-sync-token-in-production +# Only for spokes/clients. Leave empty if this is the Hub. +CENTRAL_SERVER_URL=http://localhost:8000/api/v1/core/help-center/sync/ diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 5bffa511..a6fda94d 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -94,9 +94,8 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" + """Upgrade schema.""" - # --- UTILIDAD DE FORMATEO --- def format_value(val): if val is None or str(val).strip() == "" or str(val).upper() == "NONE": return "NULL" @@ -528,6 +527,7 @@ def upgrade() -> None: ) if values_historical_fractions: + op.execute("SET session_replication_role = replica;") op.execute( f""" INSERT INTO a76.historical_tariff_fractions @@ -539,6 +539,7 @@ def upgrade() -> None: ON CONFLICT DO NOTHING; """ ) + op.execute("SET session_replication_role = DEFAULT;") def downgrade() -> None: diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 91b23321..98f4b184 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -3,7 +3,7 @@ import logging from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request from pydantic import BaseModel from sqlalchemy.orm import Session @@ -137,6 +137,7 @@ class TenantCRUDRoutes( description=f"Get paginated list of {self.resource_name}s with optional filters", ) async def list_resources( + request: Request, company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query( @@ -145,13 +146,6 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), - status: Optional[str] = Query(None, description="Filter by status"), - operation_type: Optional[str] = Query( - None, description="Filter by operation type" - ), - invoice_type: Optional[str] = Query( - None, description="Filter by invoice type" - ), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): @@ -164,13 +158,15 @@ class TenantCRUDRoutes( ) skip = (page - 1) * page_size - filters = {} - if status: - filters["status"] = status - if operation_type: - filters["operation_type"] = operation_type - if invoice_type: - filters["invoice_type"] = invoice_type + + # Extraer todos los parámetros de búsqueda dinámicamente + # Excluimos los parámetros estándar de paginación y control + standard_params = {"company_id", "page", "page_size"} + filters = { + k: v + for k, v in request.query_params.items() + if k not in standard_params and v is not None and v != "" + } items, total = self.service.get_all( db, tenant_id, company_id, skip, page_size, filters diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index 00c141b4..b8047e2f 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -221,8 +221,13 @@ class ClassWithFADataResponse(BaseModel): # FA-specific fields (embedded from a24.fa_classes) fa_class_id: Optional[int] = None + import_tariff_code: Optional[str] = None + import_tariff_type: Optional[str] = None + export_tariff_code: Optional[str] = None + export_tariff_type: Optional[str] = None depreciation_rate: Optional[Decimal] = None fda_code: Optional[str] = None + eccn_code: Optional[str] = None class_enabled: Optional[bool] = None model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 1e3ba949..904adddf 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -150,8 +150,13 @@ class ClassService: "updated_at": base_class.updated_at, # FA extension fields (None if no FA record exists) "fa_class_id": fa_class.id if fa_class else None, + "import_tariff_code": fa_class.import_tariff_code if fa_class else None, + "import_tariff_type": fa_class.import_tariff_type if fa_class else None, + "export_tariff_code": fa_class.export_tariff_code if fa_class else None, + "export_tariff_type": fa_class.export_tariff_type if fa_class else None, "depreciation_rate": fa_class.depreciation_rate if fa_class else None, "fda_code": fa_class.fda_code if fa_class else None, + "eccn_code": fa_class.eccn_code if fa_class else None, "class_enabled": fa_class.class_enabled if fa_class else None, } combined.append(class_dict) diff --git a/backend/api/v1/modules/a76/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py index 624c99f1..292d9e41 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/dto.py +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -46,7 +46,7 @@ class ClientProviderProgramsDTO(BaseModel): program_number: Optional[str] = Field( None, max_length=40, description="Program number" ) - prosec: Optional[int] = Field(None, description="PROSEC") + prosec: Optional[str] = Field(None, max_length=8, description="PROSEC") prosec_authorization: Optional[str] = Field( None, max_length=20, description="PROSEC authorization" ) diff --git a/backend/api/v1/modules/a76/clients_and_providers/models.py b/backend/api/v1/modules/a76/clients_and_providers/models.py index 4cf08fe0..db5544ca 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/models.py +++ b/backend/api/v1/modules/a76/clients_and_providers/models.py @@ -136,7 +136,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin): # Program information program: Mapped[Optional[str]] = mapped_column(String(7)) program_number: Mapped[Optional[str]] = mapped_column(String(40)) - prosec: Mapped[Optional[int]] = mapped_column(SmallInteger) + prosec: Mapped[Optional[str]] = mapped_column(String(8)) prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20)) secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer) manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25)) diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py index 09f1cd48..c9c67efe 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -7,7 +7,7 @@ from typing import List, Optional from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from .models import ClientOrProviderEnum @@ -44,7 +44,10 @@ async def get_clients_and_providers( """Get clients and providers""" tenant_id = validate_access_to_resource(db, company_id, current_user) - query = db.query(ClientProvider).filter( + query = db.query(ClientProvider).options( + joinedload(ClientProvider.address), + joinedload(ClientProvider.programs) + ).filter( ClientProvider.tenant_id == tenant_id, ClientProvider.company_id == company_id, ) diff --git a/backend/api/v1/modules/a76/customs_brokers/dto.py b/backend/api/v1/modules/a76/customs_brokers/dto.py index 7f65b264..9f1309c7 100644 --- a/backend/api/v1/modules/a76/customs_brokers/dto.py +++ b/backend/api/v1/modules/a76/customs_brokers/dto.py @@ -43,6 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO): broker_key: str tenant_id: int company_id: int + vu: Optional["CustomsBrokerVUResponseDTO"] = None class Config: from_attributes = True @@ -75,25 +76,30 @@ class CustomsBrokerDTO(BaseModel): class CustomsBrokerVUCreateDTO(BaseModel): - certificate_path: Optional[str] - key_path: Optional[str] - access_key: Optional[str] - fiel_format: Optional[str] - signature_read_path: Optional[str] - archive_path: Optional[str] - fiel_access_key: Optional[str] - web_service_user: Optional[str] - web_service_access_key: Optional[str] - vu_email: Optional[str] - vu_figure_type: Optional[str] - xml_files_path: Optional[str] - query_tax_id: Optional[str] - doda_certificate_path: Optional[str] - doda_key_path: Optional[str] - doda_web_service_user: Optional[str] - doda_web_service_access_key: Optional[str] - doda_fiel_access_key: Optional[str] - doda_xml_files_path: Optional[str] + certificate_path: Optional[str] = None + key_path: Optional[str] = None + access_key: Optional[str] = None + fiel_format: Optional[str] = None + signature_read_path: Optional[str] = None + archive_path: Optional[str] = None + fiel_access_key: Optional[str] = None + web_service_user: Optional[str] = None + web_service_access_key: Optional[str] = None + vu_email: Optional[str] = None + vu_figure_type: Optional[str] = None + xml_files_path: Optional[str] = None + query_tax_id: Optional[str] = None + doda_certificate_path: Optional[str] = None + doda_key_path: Optional[str] = None + doda_web_service_user: Optional[str] = None + doda_web_service_access_key: Optional[str] = None + doda_fiel_access_key: Optional[str] = None + doda_xml_files_path: Optional[str] = None + tenant_id: Optional[int] = None + company_id: Optional[int] = None + +class CustomsBrokerVUResponseDTO(CustomsBrokerVUCreateDTO): + customs_broker_id: int class Config: from_attributes = True @@ -102,15 +108,17 @@ class CustomsBrokerVUCreateDTO(BaseModel): class CustomsBrokerPersonnelDTO(BaseModel): broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$") line: int - name: Optional[str] - tax_id: Optional[str] - personal_id: Optional[str] - position: Optional[str] + name: Optional[str] = None + tax_id: Optional[str] = None + personal_id: Optional[str] = None + position: Optional[str] = None license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$") - first_name: Optional[str] - last_name: Optional[str] - middle_name: Optional[str] - email: Optional[str] + first_name: Optional[str] = None + last_name: Optional[str] = None + middle_name: Optional[str] = None + email: Optional[str] = None + tenant_id: Optional[int] = None + company_id: Optional[int] = None class Config: from_attributes = True diff --git a/backend/api/v1/modules/a76/customs_brokers/models.py b/backend/api/v1/modules/a76/customs_brokers/models.py index a5925a62..11c29580 100644 --- a/backend/api/v1/modules/a76/customs_brokers/models.py +++ b/backend/api/v1/modules/a76/customs_brokers/models.py @@ -32,7 +32,7 @@ class CustomsBroker(Base, TenantScopedMixin, TimestampMixin): contact = Column(String(80), nullable=True) vu = relationship( - "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete" + "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete", uselist=False ) personnel = relationship( "CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete" diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py index 02f301a1..a94eccd1 100644 --- a/backend/api/v1/modules/a76/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -66,7 +66,7 @@ def update_customs_broker( @router.put( "/customs-broker-vu/{broker_key}", - response_model=dto.CustomsBrokerVUCreateDTO, + response_model=dto.CustomsBrokerVUResponseDTO, ) def update_customs_broker_vu( broker_key: str, @@ -81,7 +81,7 @@ def update_customs_broker_vu( if not broker: raise HTTPException(status_code=404, detail="Customs Broker not found") - updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data) + updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id) if not updated_vu: raise HTTPException(status_code=404, detail="Customs Broker VU not found") return updated_vu @@ -106,7 +106,7 @@ def update_customs_broker_personnel( raise HTTPException(status_code=404, detail="Customs Broker not found") updated_personnel = services.CustomsBrokerPersonnelService.update_personnel( - db, broker_key, line, personnel_data + db, broker_key, line, personnel_data, tenant_id, company_id ) if not updated_personnel: raise HTTPException( diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index ea72abe5..dc00f352 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -76,7 +76,8 @@ class CustomsBrokerVUService: def get_by_broker_key(db: Session, broker_key: str): return ( db.query(models.CustomsBrokerVU) - .filter(models.CustomsBrokerVU.broker_key == broker_key) + .join(models.CustomsBroker) + .filter(models.CustomsBroker.broker_key == broker_key) .first() ) @@ -89,14 +90,39 @@ class CustomsBrokerVUService: return new_vu @staticmethod - def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO): - vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key) + def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO, tenant_id: int, company_id: int): + # We need the custom broker ID to insert a new VU + broker = ( + db.query(models.CustomsBroker) + .filter( + models.CustomsBroker.broker_key == broker_key, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, + ) + .first() + ) + if not broker: + return None + + vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first() + if vu: - for key, value in vu_data.dict(exclude_unset=True).items(): + # Update existing + for key, value in vu_data.model_dump(exclude_unset=True).items(): setattr(vu, key, value) db.commit() db.refresh(vu) - return vu + return vu + else: + # Create new + new_vu_data = vu_data.model_dump() + new_vu_data["tenant_id"] = tenant_id + new_vu_data["company_id"] = company_id + new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data) + db.add(new_vu) + db.commit() + db.refresh(new_vu) + return new_vu @staticmethod def delete_vu(db: Session, broker_key: str): @@ -109,19 +135,37 @@ class CustomsBrokerVUService: class CustomsBrokerPersonnelService: @staticmethod - def get_by_broker_key_and_line(db: Session, broker_key: str, line: int): + def get_by_broker_key_and_line(db: Session, broker_key: str, line: int, tenant_id: int, company_id: int): return ( db.query(models.CustomsBrokerPersonnel) + .join(models.CustomsBroker) .filter( - models.CustomsBrokerPersonnel.broker_key == broker_key, + models.CustomsBroker.broker_key == broker_key, models.CustomsBrokerPersonnel.line == line, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, ) .first() ) @staticmethod - def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO): - new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict()) + def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO, tenant_id: int, company_id: int): + broker = ( + db.query(models.CustomsBroker) + .filter( + models.CustomsBroker.broker_key == broker_key, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, + ) + .first() + ) + if not broker: + return None + + new_personnel_data = personnel_data.model_dump() + new_personnel_data["tenant_id"] = tenant_id + new_personnel_data["company_id"] = company_id + new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data) db.add(new_personnel) db.commit() db.refresh(new_personnel) @@ -133,16 +177,22 @@ class CustomsBrokerPersonnelService: broker_key: str, line: int, personnel_data: dto.CustomsBrokerPersonnelDTO, + tenant_id: int, + company_id: int, ): personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line( - db, broker_key, line + db, broker_key, line, tenant_id, company_id ) if personnel: - for key, value in personnel_data.dict(exclude_unset=True).items(): + for key, value in personnel_data.model_dump(exclude_unset=True).items(): setattr(personnel, key, value) db.commit() db.refresh(personnel) - return personnel + return personnel + else: + return CustomsBrokerPersonnelService.create_personnel( + db, broker_key, personnel_data, tenant_id, company_id + ) @staticmethod def delete_personnel(db: Session, broker_key: str, line: int): diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index 5f0f1600..21277c99 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from core.database import get_core_db -from core.security import get_current_user +from core.security import get_current_user, get_tenant_from_token from .dto import ( TariffFractionCreateDTO, @@ -27,6 +27,7 @@ router = APIRouter(prefix="/tariff-fractions", tags=["a76 / general catalogs / t description="Get paginated list of Tariff Fractions with optional search filter (global catalog)", ) async def list_tariff_fractions( + company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=10000, description="Page size"), search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"), @@ -47,8 +48,9 @@ async def list_tariff_fractions( # Service.get_all calls Sitar (async) or DB (sync). # This should be fine. - tenant_id = current_user.get("tenant_id") - company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default? + tenant_id = get_tenant_from_token(current_user) + if tenant_id is None: + tenant_id = current_user.get("tenant_id") # If using headers for selected company, it might be in current_user context if middleware sets it. items, total = await TariffFractionService.get_all( @@ -121,7 +123,7 @@ async def create_tariff_fraction( pass us_dto = USTariffFractionCreateDTO( - code=fraction_data.code, + code=fraction_data.fraction, # Store the punctuated fraction in the DB description=fraction_data.description, unit_of_measure=fraction_data.umt, ad_valorem=ad_valorem, @@ -131,7 +133,7 @@ async def create_tariff_fraction( fixed_cost=None ) - created = USTariffFractionService.create(db, tenant_id, company_id, us_dto) + created = USTariffFractionService.create(db, us_dto, tenant_id, company_id) return TariffFractionService.to_domain_usa_local(created) else: @@ -171,12 +173,13 @@ async def update_tariff_fraction( pass us_dto = USTariffFractionUpdateDTO( + code=fraction_data.fraction, description=fraction_data.description, unit_of_measure=fraction_data.umt, ad_valorem=ad_valorem ) - updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto) + updated = USTariffFractionService.update(db, tariff_fraction_id, tenant_id, us_dto, company_id) if not updated: raise HTTPException(status_code=404, detail="US Tariff fraction not found") return TariffFractionService.to_domain_usa_local(updated) @@ -202,7 +205,7 @@ async def delete_tariff_fraction( if catalog == "american": from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService - success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id) + success = USTariffFractionService.delete(db, tariff_fraction_id, tenant_id, company_id) if not success: raise HTTPException(status_code=404, detail="US Tariff fraction not found") return {"ok": True} diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index 7a00b364..380cf508 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -96,10 +96,14 @@ class TariffFractionService: # US format: 1234.56.78.90. For now return as is or use helper if available. # item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available) + # Remove formatting (e.g. dots) for the 'code' property + code_str = str(item.code) + clean_code = code_str.replace(".", "").replace("-", "") + return TariffFraction( id=item.id, - code=item.code, - fraction=item.code, # TODO: Format if needed + code=clean_code, + fraction=code_str, description=item.description or "(Sin descripción)", nico=None, umt=item.unit_of_measure, @@ -133,11 +137,11 @@ class TariffFractionService: from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService # Use local service directly - usa_items, total = USTariffFractionService._get_all_local( + usa_items, total = await USTariffFractionService.get_all( db, tenant_id, company_id, skip, limit, filters ) - items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items] + items = [TariffFractionService.to_domain_usa_local(item) for item in usa_items] return items, total # USA CATALOG HANDLING (API - 'Fracciones US') diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py index 1c943349..c316dc08 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/dto.py @@ -3,9 +3,9 @@ DTOs para fracciones arancelarias americanas """ from datetime import datetime -from typing import Optional +from typing import Optional, Any -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator class USTariffFractionCreateDTO(BaseModel): @@ -23,6 +23,7 @@ class USTariffFractionCreateDTO(BaseModel): class USTariffFractionUpdateDTO(BaseModel): """DTO para actualizar fracción arancelaria americana""" + code: Optional[str] = Field(None, max_length=16) prefix: Optional[str] = Field(None, max_length=10) type_code: Optional[str] = Field(None, max_length=10) ad_valorem: Optional[float] = None @@ -38,6 +39,7 @@ class USTariffFractionResponseDTO(BaseModel): id: int code: str + fraction: Optional[str] = None prefix: Optional[str] = None type_code: Optional[str] = None ad_valorem: Optional[float] = None @@ -46,3 +48,36 @@ class USTariffFractionResponseDTO(BaseModel): description: Optional[str] = None created_at: datetime updated_at: datetime + + @model_validator(mode="before") + @classmethod + def format_code_and_fraction(cls, data: Any) -> Any: + # Check if data is an ORM model or dict + if hasattr(data, "code"): + raw_code = data.code + elif isinstance(data, dict): + raw_code = data.get("code") + else: + return data + + if raw_code: + code_str = str(raw_code) + # fraction keeps the original formatted string + fraction = code_str + # code strips dots and hyphens + code = code_str.replace(".", "").replace("-", "") + + if isinstance(data, dict): + data["code"] = code + data["fraction"] = fraction + else: + # If it's an ORM object, we can't easily modify the object's attribute + # cleanly without side effects for other things, so we convert it to dict + new_data = { + c.name: getattr(data, c.name) for c in data.__table__.columns + } + new_data["code"] = code + new_data["fraction"] = fraction + return new_data + + return data diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index d5552c7e..158bf765 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -17,10 +17,20 @@ from .dto import ( ) from .service import USTariffFractionService -# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar -# Writes are disabled at API level, but Service still supports fallback writes if needed internally +# Create router using TenantCRUDRoutes factory for basic CRUD operations +crud_router = TenantCRUDRoutes( + service=USTariffFractionService, + create_schema=USTariffFractionCreateDTO, + update_schema=USTariffFractionUpdateDTO, + response_schema=USTariffFractionResponseDTO, + prefix="/us-tariff-fractions", + tags=["a76 / general catalogs / us tariff fractions"], + resource_name="US Tariff Fraction", + id_name="id", + enable_list=False, # We implement our custom list endpoint +) -router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"]) +router = crud_router.router # Custom list endpoint with search filter @router.get( @@ -44,7 +54,6 @@ async def list_us_tariff_fractions( if search: filters["search"] = search - # Updated to async call with Sitar integration items, total = await USTariffFractionService.get_all( db, tenant_id, company_id, skip, page_size, filters ) @@ -56,24 +65,3 @@ async def list_us_tariff_fractions( "page_size": page_size, "pages": (total + page_size - 1) // page_size, } - - -@router.get( - "/{us_tariff_fraction_id}", - response_model=USTariffFractionResponseDTO, - summary="Get US Tariff Fraction by ID", - description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)", -) -async def get_us_tariff_fraction( - us_tariff_fraction_id: int, - company_id: int = Query(..., description="Company ID"), - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user), -): - tenant_id = validate_access_to_resource(db, company_id, current_user) - - item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id) - if not item: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="US Tariff fraction not found") - return USTariffFractionResponseDTO.model_validate(item) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py index 877dfcc5..e2fd10ee 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py @@ -1,66 +1,49 @@ -""" -Service para fracciones arancelarias americanas -""" - from typing import List, Optional, Tuple, Dict, Any +from datetime import datetime from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from fastapi import HTTPException -import zlib import logging -import re from decimal import Decimal from .models import USTariffFraction from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService -from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse logger = logging.getLogger(__name__) class USTariffFractionMapper: - """Helper to map Sitar USA responses to Local domain objects""" - + """Maps Sitar FraccionesUSAResponse objects to USTariffFraction domain objects""" + @staticmethod - def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction: - # Generate a deterministic numeric ID based on the unique code - # We use CRC32 to get a consistent integer implementation-independent - fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8')) - - # Parse numeric values safely - ad_valorem = None - if fraccion.TARIFA1: + def to_domain( + item: Any, tenant_id: int, company_id: int + ) -> USTariffFraction: + """Convert a FraccionesUSAResponse to a USTariffFraction instance (not persisted)""" + code = item.FRACCION_CON_PUNTO or item.FRACCION_MOSTRAR or item.FRACCION_SIN_PUNTO or "" + ad_valorem: Optional[float] = None + if item.TARIFA1: try: - # Extract numbers from string like "5.2%" or similar if present - # Assuming TARIFA1 might be clean number or percentage string - clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1)) - if clean_val: - ad_valorem = Decimal(clean_val) - except: - pass - - fixed_cost = None - if fraccion.ESPECIFICO: - try: - clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO)) - if clean_val: - fixed_cost = Decimal(clean_val) - except: - pass + ad_valorem = float(str(item.TARIFA1).replace("%", "").strip()) + except (ValueError, TypeError): + ad_valorem = None - return USTariffFraction( - id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash - tenant_id=tenant_id, - company_id=company_id, - code=fraccion.FRACCION_SIN_PUNTO or "", - prefix=None, # Not mapped from Sitar response currently - type_code=None, - ad_valorem=ad_valorem, - fixed_cost=fixed_cost, - unit_of_measure=fraccion.UNIDADCANTIDAD, - description=fraccion.DESCRIPCION - ) + fraction = USTariffFraction() + fraction.id = item.CONSECUTIVO + fraction.tenant_id = tenant_id + fraction.company_id = company_id + fraction.code = code + fraction.prefix = item.FRACCION_SIN_PUNTO + fraction.type_code = str(item.NIVEL) if item.NIVEL is not None else None + fraction.ad_valorem = ad_valorem + fraction.fixed_cost = None + fraction.unit_of_measure = item.UNIDADCANTIDAD + fraction.description = item.DESCRIPCION + now = datetime.now() + fraction.created_at = now + fraction.updated_at = now + return fraction class USTariffFractionService: @@ -100,9 +83,9 @@ class USTariffFractionService: limit=limit ) - # If Sitar returns empty list AND we didn't have specific filters, attempt fallback - if not sitar_items and not has_filters: - logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.") + # If Sitar returns empty list, attempt fallback to local DB + if not sitar_items: + logger.info(f"Sitar returned no results for USA query (filters={has_filters}). Attempting fallback to local DB.") return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters) # Map items @@ -148,15 +131,14 @@ class USTariffFractionService: total = query.count() items = query.order_by(USTariffFraction.code).offset(skip).limit(limit).all() - return items, total + return items, total @staticmethod def get_by_id( - db: Session, tenant_id: int, company_id: int, fraction_id: int + db: Session, fraction_id: int, tenant_id: int, company_id: int ) -> Optional[USTariffFraction]: """ - Obtiene por ID. - Legacy: Consulta Local DB. + Obtiene por ID local. """ return ( db.query(USTariffFraction) @@ -168,14 +150,12 @@ class USTariffFractionService: .first() ) - # WRITE OPERATIONS - DEPRECATED / LOCAL ONLY - @staticmethod def create( db: Session, + fraction_data: USTariffFractionCreateDTO, tenant_id: int, company_id: int, - fraction_data: USTariffFractionCreateDTO, ) -> USTariffFraction: try: db_fraction = USTariffFraction( @@ -198,13 +178,13 @@ class USTariffFractionService: @staticmethod def update( db: Session, - tenant_id: int, - company_id: int, fraction_id: int, + tenant_id: int, fraction_data: USTariffFractionUpdateDTO, + company_id: int, ) -> Optional[USTariffFraction]: db_fraction = USTariffFractionService.get_by_id( - db, tenant_id, company_id, fraction_id + db, fraction_id, tenant_id, company_id ) if not db_fraction: return None @@ -219,10 +199,10 @@ class USTariffFractionService: @staticmethod def delete( - db: Session, tenant_id: int, company_id: int, fraction_id: int + db: Session, fraction_id: int, tenant_id: int, company_id: int ) -> bool: db_fraction = USTariffFractionService.get_by_id( - db, tenant_id, company_id, fraction_id + db, fraction_id, tenant_id, company_id ) if not db_fraction: return False diff --git a/backend/api/v1/modules/a76/invoices/catalog_service.py b/backend/api/v1/modules/a76/invoices/catalog_service.py index d81f14d9..2c7feb47 100644 --- a/backend/api/v1/modules/a76/invoices/catalog_service.py +++ b/backend/api/v1/modules/a76/invoices/catalog_service.py @@ -135,12 +135,14 @@ class InvoiceCatalogService: # Drivers try: - drivers, _ = DriverService.get_all(db, tenant_id, company_id, limit=1000) + drivers = DriverService.list_drivers(db, str(company_id), str(tenant_id)) response.drivers = [ DriverResponseDTO.model_validate(d) for d in drivers ] except Exception as e: print(f"Error fetching drivers: {e}") + # Initialize drivers as empty list if an error occurs + drivers = [] # Trailers try: diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py index 9c254a9b..ac85ff24 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py @@ -36,17 +36,17 @@ def validate_update( # Validar campos requeridos según el tipo de operación invoice_dict = { - 'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else None, - 'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else None, - 'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else None, - 'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else None, - 'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else None, - 'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else None, + 'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None), + 'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None), + 'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None), + 'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None), + 'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None), + 'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None), } validate_required_fields_by_operation( invoice_data=invoice_dict, - operation_type=invoice_data.operation_type or 'IMP', + operation_type=invoice_data.operation_type or (existing_invoice.operation_type or 'imp'), errors=errors ) @@ -57,34 +57,36 @@ def validate_update( # Siguiendo la lógica del código Clarion original # Columna A: Pedimento (si no viene en CSV, usar el existente) - if invoice_data.compliance_mx.pedimento_id: - invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id - else: - invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.pedimento_id: + invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id + else: + invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None # Columna B: Remesa - if invoice_data.compliance_mx.remesa: - invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa - else: - invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.remesa: + invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa + else: + invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None # Columna C: Factura (OBLIGATORIO) - invoice_data.invoice_number = clean_str(invoice_data.invoice_number) - if not invoice_data.invoice_number: - errors.add_required_error("invoice_number") + if invoice_data.invoice_number is not None: + invoice_data.invoice_number = clean_str(invoice_data.invoice_number) + if not invoice_data.invoice_number: + errors.add_required_error("invoice_number") + else: + invoice_data.invoice_number = existing_invoice.invoice_number # Columna D: Fecha - if invoice_data.invoice_date: - invoice_data.invoice_date = invoice_data.invoice_date - else: + if not invoice_data.invoice_date: invoice_data.invoice_date = existing_invoice.invoice_date # Columna E: Tipo Cambio - if invoice_data.financials and invoice_data.financials.exchange_rate is not None: - invoice_data.financials.exchange_rate = invoice_data.financials.exchange_rate - else: - if existing_invoice.financials: - invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate + if invoice_data.financials: + if invoice_data.financials.exchange_rate is None: + if existing_invoice.financials: + invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate # Columna F: Régimen if invoice_data.document_type: @@ -93,157 +95,161 @@ def validate_update( invoice_data.document_type = existing_invoice.document_type # Columna G: Clave Proveedor - if invoice_data.compliance_mx and invoice_data.compliance_mx.provider_id is not None: - invoice_data.compliance_mx.provider_id = invoice_data.compliance_mx.provider_id - else: - invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.provider_id is None: + invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None # Columna H: Clave Vendido A - if invoice_data.compliance_mx and invoice_data.compliance_mx.sold_to_id is not None: - invoice_data.compliance_mx.sold_to_id = invoice_data.compliance_mx.sold_to_id - else: - invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.sold_to_id is None: + invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None # Columna I: Clave Enviado A - if invoice_data.compliance_mx and invoice_data.compliance_mx.shipped_to_id is not None: - invoice_data.compliance_mx.shipped_to_id = invoice_data.compliance_mx.shipped_to_id - else: - invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.shipped_to_id is None: + invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None # Columna J: Clave A. Aduanal - if invoice_data.compliance_mx and invoice_data.compliance_mx.customs_broker_id is not None: - invoice_data.compliance_mx.customs_broker_id = invoice_data.compliance_mx.customs_broker_id - else: - invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.customs_broker_id is None: + invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None # Columna K: Clave Transportista - if invoice_data.logistics and invoice_data.logistics.carrier_id is not None: - invoice_data.logistics.carrier_id = invoice_data.logistics.carrier_id - else: - invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None + if invoice_data.logistics: + # Note: logistics in update schema seems to be a single object, but in model it's a list. + # This validator seems to expect a single object (InvoiceLogisticsUpdate). + # We'll stick to the existing logic but make it safe. + if hasattr(invoice_data.logistics, 'carrier_id') and invoice_data.logistics.carrier_id is None: + invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None # Columna L: Nombre Conductor - if invoice_data.logistics and invoice_data.logistics.driver_name: - invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name) - else: - invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'driver_name') and not invoice_data.logistics.driver_name: + invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'driver_name'): + invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name) # Columna M: Tipo Transporte - if invoice_data.logistics and invoice_data.logistics.transport_type: - invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type) - else: - invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'transport_type') and not invoice_data.logistics.transport_type: + invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'transport_type'): + invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type) # Columna N: Número de Transporte - if invoice_data.logistics and invoice_data.logistics.transport_num: - invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num) - else: - invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'transport_num') and not invoice_data.logistics.transport_num: + invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'transport_num'): + invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num) # Columna O: Tipo de Moneda - if invoice_data.financials and invoice_data.financials.currency: - invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower() - else: - invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + if invoice_data.financials: + if not invoice_data.financials.currency: + invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + else: + invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower() # Columna P: Clave Moneda - if invoice_data.financials and invoice_data.financials.currency_type: - invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper() - else: - invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + if invoice_data.financials: + if not invoice_data.financials.currency_type: + invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + else: + invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper() # Columna Q: Flete - if invoice_data.financials and invoice_data.financials.freight is not None: - invoice_data.financials.freight = invoice_data.financials.freight - else: - invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.freight is None: + invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None # Columna R: Val Seguros - if invoice_data.financials and invoice_data.financials.insurance_value is not None: - invoice_data.financials.insurance_value = invoice_data.financials.insurance_value - else: - invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.insurance_value is None: + invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None # Columna S: Seguros - if invoice_data.financials and invoice_data.financials.insurance is not None: - invoice_data.financials.insurance = invoice_data.financials.insurance - else: - invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.insurance is None: + invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None # Columna T: Embalaje - if invoice_data.financials and invoice_data.financials.packaging is not None: - invoice_data.financials.packaging = invoice_data.financials.packaging - else: - invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.packaging is None: + invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None # Columna U: Otros Incrementables - if invoice_data.financials and invoice_data.financials.other_increments is not None: - invoice_data.financials.other_increments = invoice_data.financials.other_increments - else: - invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.other_increments is None: + invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None # Columna V: Incoterms - if invoice_data.logistics and invoice_data.logistics.incoterm: - invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper() - else: - invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'incoterm') and not invoice_data.logistics.incoterm: + invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'incoterm'): + invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper() # Columna W: Precinto - if invoice_data.logistics and invoice_data.logistics.seal_number: - invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number) - else: - invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'seal_number') and not invoice_data.logistics.seal_number: + invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'seal_number'): + invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number) # Columna X: Fecha de Emisión - if invoice_data.emission_date: - invoice_data.emission_date = invoice_data.emission_date - else: + if not invoice_data.emission_date: invoice_data.emission_date = existing_invoice.emission_date # Columna Y: Tipo de Peso (Opcional) - if invoice_data.logistics and invoice_data.logistics.weight_type: - invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper() - else: - invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'weight_type') and not invoice_data.logistics.weight_type: + invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'weight_type'): + invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper() # Columna Z: E-Document (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.edocument: - invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument) - else: - invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.edocument: + invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument) # Columna AA: Num. Operación (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.vucem_operation_num: - invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num) - else: - invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.vucem_operation_num: + invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num) # Columna AB: Aduana (OBLIGATORIO) - if invoice_data.compliance_mx and invoice_data.compliance_mx.aduana: - invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana) - else: - invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.aduana: + invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana) # Validar que aduana sea obligatorio (excepto para MEX) if existing_invoice.invoice_type != "MEX": - if not invoice_data.compliance_mx or not invoice_data.compliance_mx.aduana: + current_aduana = invoice_data.compliance_mx.aduana if invoice_data.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None) + if not current_aduana: errors.add_required_error("aduana") # Columna AC: Sección de Despacho / Puerto de Entrada (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.port_of_entry: - invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry) - else: - invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.port_of_entry: + invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry) # Columna AD: Observación en Español (Opcional) - if invoice_data.observation_es: - invoice_data.observation_es = clean_str(invoice_data.observation_es) - else: + if not invoice_data.observation_es: invoice_data.observation_es = existing_invoice.observation_es + else: + invoice_data.observation_es = clean_str(invoice_data.observation_es) # Columna AD: Observación en Inglés (Opcional) - if invoice_data.observation_en: - invoice_data.observation_en = clean_str(invoice_data.observation_en) - else: + if not invoice_data.observation_en: invoice_data.observation_en = existing_invoice.observation_en + else: + invoice_data.observation_en = clean_str(invoice_data.observation_en) diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index 46ba29ae..55124a29 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -131,7 +131,7 @@ class InvoiceComplianceMxBase(BaseModel): None, max_length=20, description="Shipped by header" ) shipped_by_id: Optional[int] = Field(None, description="Shipped by ID") - customs_broker_id: Optional[int] = Field(None, description="Customs broker ID") + customs_broker_id: int = Field(None, description="Customs broker ID") customs_broker_us_id: Optional[int] = Field( None, description="US customs broker ID" ) @@ -152,7 +152,7 @@ class InvoiceComplianceMxBase(BaseModel): ) value_method: Optional[str] = Field(None, max_length=2, description="Value method") act_value: Optional[str] = Field(None, max_length=5, description="Act value") - is_pedimento_pending: bool = Field(..., description="Is pedimento pending") + is_pedimento_pending: Optional[bool] = Field(False, description="Is pedimento pending") is_owner_of_goods: Optional[bool] = Field(False, description="Is owner of goods") generate_balances: Optional[bool] = Field(False, description="Generate balances") was_reviewed_by_company: Optional[bool] = Field( diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 58b57f24..90e6a84a 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -1,7 +1,9 @@ import traceback from typing import Optional, List, Tuple from sqlalchemy.orm import Session +from sqlalchemy import func from core.exceptions import ErrorCollector, DuplicateResourceException +from core.context import get_user_context from .common.mappers import clean_dict from .imports.temporary.validators.create import validate_create from .imports.temporary.validators.update import validate_update @@ -10,6 +12,26 @@ from .common.common_validators import invoice_exists from . import models, schemas +def _get_current_username() -> str: + """Helper to get current username from context or fallback to System""" + try: + context = get_user_context() + if context: + # Token usually has 'preferred_username' or 'name' or 'sub' + username = ( + context.get("preferred_username") + or context.get("email") + or context.get("sub") + or "System" + ) + print(f"DEBUG: _get_current_username found context: {username}") + return username + except Exception: + pass + print("DEBUG: _get_current_username NO context found, using System") + return "System" + + class InvoiceService: """Service for Invoice Header operations""" @@ -130,6 +152,11 @@ class InvoiceService: invoice_dict["tenant_id"] = tenant_id invoice_dict["company_id"] = company_id + # Automatic status and audit fields + username = _get_current_username() + invoice_dict["capture_user"] = username + invoice_dict["who_updated"] = username + # Ensure document_type respects DB constraints for MEX invoices (bypass clean_dict) if invoice_dict.get("invoice_type") == "MEX" and not invoice_dict.get("document_type"): invoice_dict["document_type"] = None @@ -254,6 +281,7 @@ class InvoiceService: # Update main invoice header fields update_dict = invoice_data.model_dump( exclude={ + "id", "compliance_mx", "financials", "logistics", @@ -265,6 +293,16 @@ class InvoiceService: for key, value in update_dict.items(): setattr(invoice, key, value) + # Audit update fields + username = _get_current_username() + invoice.who_updated = username + invoice.updated_date = func.now() + + # Backfill capture_user if missing or previous generic 'System' + if not invoice.capture_user or invoice.capture_user == "System": + if username != "System": + invoice.capture_user = username + # Update compliance_mx if provided if invoice_data.compliance_mx is not None: print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}") diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py new file mode 100644 index 00000000..7bc9c5af --- /dev/null +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py @@ -0,0 +1,79 @@ +from sqlalchemy.orm import Session +from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics +from core.exceptions import ErrorCollector + +from ....models import LineItem +from ....line_financials.models import LineFinancial +from ....line_financials.schemas import LineFinancialCreate +from ....line_quantities.models import LineQuantity +from ....line_quantities.schemas import LineQuantityCreate +from ....line_customs.models import LineCustom +from ....line_customs.schemas import LineCustomCreate +from ....line_descriptions.models import LineDescription +from ....line_descriptions.schemas import LineDescriptionCreate +from ....line_references.models import LineReference +from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem +from ....models import LineItem +from api.v1.modules.a76.classes.models import Class + + +def apply_calculations( + db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int +): + #TODO: SSisGen Logic + # if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1: + # unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion + caluclate_values(db, line, tenant_id, company_id) + + if not line.fa_data.is_subitem: + line.fa_data.subitem_number = None + + invoice_date = db.query(InvoiceHeader.invoice_date).filter(InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id).scalar() + + line.depreciation_date = invoice_date + + if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english): + line.description.description_spanish = line.part_info.description_spanish + line.description.description_english = line.part_info.description_english + else: + if not line.description.description_spanish: + class_desc = ( + db.query(Class.description_es, Class.description_en) + .filter(Class.id == line.class_id, Class.tenant_id == tenant_id, Class.company_id == company_id) + .first() + ) + if class_desc: + line.description.description_spanish, line.description.description_english = class_desc + + +def caluclate_values( + db: Session, line: LineItem, tenant_id: int, company_id: int +): + result = ( + db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate) + .filter(InvoiceFinancials.invoice_id == line.invoice_id, InvoiceFinancials.tenant_id == tenant_id, InvoiceFinancials.company_id == company_id) + .first() + ) + if not result: + return + + currency, exchange_rate = result + + if currency == "foreign": + line.financial.unit_cost_usd = line.financial.unit_cost_capture + line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity + line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate + line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity + line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity + elif currency == "local": + line.financial.unit_cost_mxn = line.financial.unit_cost_capture + line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity + line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate + line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity + line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity + elif currency == "manual": + line.financial.unit_cost_usd = line.financial.unit_cost_capture/exchange_rate + line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity + line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate + line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity + line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index 70dc5245..677df0ed 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -188,6 +188,23 @@ def validate_create( line.financial.unit_cost_mxn = unit_cost_capture # Si es otro tipo de moneda, dejamos el costo como está + # Calcular valores totales basados en cantidad y costo unitario + quantity = line.quantity.quantity or Decimal("0") + + # Valor Comercial + if line.financial.unit_cost_usd is not None: + line.financial.value_usd = line.financial.unit_cost_usd * quantity + if line.financial.unit_cost_mxn is not None: + line.financial.value_mxn = line.financial.unit_cost_mxn * quantity + + # Valor Aduanas (asumiendo que es igual al Valor Comercial por defecto) + line.financial.customs_value_usd = line.financial.value_usd + line.financial.customs_value_mxn = line.financial.value_mxn + + # Valor MP Temp (Materia Prima Temporal) + line.financial.value_temp_material_usd = line.financial.value_usd + line.financial.value_temp_material_mxn = line.financial.value_mxn + # ========================================== # VALIDAR Y CONVERTIR PESOS NETOS # ========================================== diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py index 1f5b917e..23717f2d 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -64,6 +64,30 @@ def validate_update( # Costo unitario if line.financial.unit_cost_capture is None: line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture + + # Recalcular valores monetarios si el costo o la cantidad cambian + currency_type = invoice.financials.currency_type + unit_cost_capture = line.financial.unit_cost_capture or Decimal("0") + + if currency_type in ["USD", "ME"]: + line.financial.unit_cost_usd = unit_cost_capture + line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate + elif currency_type in ["MXN", "MN"]: + line.financial.unit_cost_usd = (unit_cost_capture / exchange_rate) if exchange_rate else Decimal("0") + line.financial.unit_cost_mxn = unit_cost_capture + + quantity = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity + + if line.financial.unit_cost_usd is not None: + line.financial.value_usd = line.financial.unit_cost_usd * quantity + if line.financial.unit_cost_mxn is not None: + line.financial.value_mxn = line.financial.unit_cost_mxn * quantity + + line.financial.customs_value_usd = line.financial.value_usd + line.financial.customs_value_mxn = line.financial.value_mxn + + line.financial.value_temp_material_usd = line.financial.value_usd + line.financial.value_temp_material_mxn = line.financial.value_mxn # Convertir peso neto si se proporcionó invoice_weight_type = invoice.logistics.weight_type diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index cad3a134..b54c914b 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -4,7 +4,7 @@ Complete nested one-to-one structure: LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference """ -from typing import Any, Optional +from typing import Any, Optional, Union from datetime import datetime from decimal import Decimal from pydantic import BaseModel, Field, ConfigDict, model_validator @@ -58,13 +58,13 @@ class LineItemBase(BaseModel): line_number: int = Field(..., description="Line number") # Part identification - part_number_id: Optional[int] = Field( + part_number_id: Union[int, str, None] = Field( None, description="Part number", alias="part_number", serialization_alias="part_number_id", ) - component_part_number_id: Optional[int] = Field( + component_part_number_id: Union[int, str, None] = Field( None, description="Component part number", alias="component_part_number", diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index bf8321af..f75fa1d6 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -36,6 +36,7 @@ from .line_references.models import LineReference from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from .models import LineItem from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.parts.models import Part logger = logging.getLogger(__name__) @@ -45,6 +46,33 @@ class ItemService: Service for managing Items and related entities with tenant/company isolation """ + @staticmethod + def _resolve_part_number( + db: Session, + part_number: Optional[str], + tenant_id: int, + company_id: int, + ) -> Optional[int]: + """Try to resolve a part number string to its database ID.""" + if not part_number: + return None + + # If it's already an integer (or a string representing an integer), it might be the ID + try: + return int(part_number) + except (ValueError, TypeError): + # It's a string part number (e.g., "MAQ-001"), look it up + part = ( + db.query(Part) + .filter( + Part.part_number == part_number, + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .first() + ) + return part.id if part else None + @staticmethod def _get_next_line_number(db: Session, invoice_id: int) -> int: """Calculate the next line_number for a given invoice based on database.""" @@ -282,6 +310,24 @@ class ItemService: # Calculate the next line number for this single item line_number = ItemService._get_next_line_number(db, item_data.invoice_id) + # Resolve part ID if a string is provided in part_number (alias for part_number_id) + if item_data.part_number_id and not isinstance(item_data.part_number_id, int): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.part_number_id = resolved_id + + # Resolve component part ID + if item_data.component_part_number_id and not isinstance( + item_data.component_part_number_id, int + ): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.component_part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.component_part_number_id = resolved_id + # Validar el item validate_create( db, @@ -378,6 +424,24 @@ class ItemService: ): errors.raise_if_errors("Error al actualizar el item") + # Resolve part ID if a string is provided in part_number (alias for part_number_id) + if hasattr(item_data, 'part_number_id') and item_data.part_number_id and not isinstance(item_data.part_number_id, int): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.part_number_id = resolved_id + + # Resolve component part ID + if hasattr(item_data, 'component_part_number_id') and item_data.component_part_number_id and not isinstance( + item_data.component_part_number_id, int + ): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.component_part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.component_part_number_id = resolved_id + # Validar el item que se va a actualizar validate_update( db, diff --git a/backend/api/v1/modules/a76/pedmientos/catalog_service.py b/backend/api/v1/modules/a76/pedmientos/catalog_service.py index 13b4a01e..cc0dd1ab 100644 --- a/backend/api/v1/modules/a76/pedmientos/catalog_service.py +++ b/backend/api/v1/modules/a76/pedmientos/catalog_service.py @@ -6,6 +6,8 @@ from sqlalchemy.orm import Session from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen +from api.v1.modules.public.reference_data.transport_types.models import TransportType +from api.v1.modules.public.reference_data.transport_modes.models import TransportMode # Import A76 Services from api.v1.modules.a76.customs_brokers.services import CustomsBrokerService @@ -15,6 +17,8 @@ from api.v1.modules.a76.clients_and_providers.service import ClientProviderServi from api.v1.modules.public.reference_data.pedimento_codes.dto import PedimentoCodeDTO from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSectionDTO from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO +from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO +from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO from .dtos.pedimentos import PedimentosResponse @@ -53,6 +57,20 @@ class PedimentoCatalogService: except Exception as e: print(f"Error fetching code_pedimento_regimens: {e}") + try: + response.transport_types = [ + TransportTypeDTO.model_validate(obj) for obj in db.query(TransportType).limit(200).all() + ] + except Exception as e: + print(f"Error fetching transport_types: {e}") + + try: + response.transport_modes = [ + TransportModeDTO.model_validate(obj) for obj in db.query(TransportMode).limit(100).all() + ] + except Exception as e: + print(f"Error fetching transport_modes: {e}") + # Helper to fetch tenant/company specific data def fetch_tenant_data(): # Customs Brokers diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py index 796bfee1..c9718994 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py @@ -17,10 +17,10 @@ class PedimentoDecrementablesBase(BaseModel): others: Optional[Decimal] = Field(None, description="Others") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -41,8 +41,8 @@ class PedimentoDecrementablesUpdate(BaseModel): others: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoDecrementablesResponse(PedimentoDecrementablesBase): diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py index c8100d01..acc6312e 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py @@ -18,10 +18,10 @@ class PedimentoIncrementablesBase(BaseModel): deductibles: Optional[Decimal] = Field(None, description="Deductibles") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -43,8 +43,8 @@ class PedimentoIncrementablesUpdate(BaseModel): deductibles: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoIncrementablesResponse(PedimentoIncrementablesBase): diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py index 9a5fda2f..66f92514 100644 --- a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py @@ -40,21 +40,21 @@ class PedimentoRectificationOrigin(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer) pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) - original_pedimento_year: Mapped[str] = mapped_column(String(2)) - original_customs_office: Mapped[str] = mapped_column(String(3)) - original_license: Mapped[str] = mapped_column(String(4)) - original_pedimento_number: Mapped[str] = mapped_column(String(7)) - original_pedimento_code: Mapped[str] = mapped_column(String(2)) - original_payment_date: Mapped[datetime] = mapped_column(DateTime) - total_cash: Mapped[int] = mapped_column(Integer) - total_others: Mapped[int] = mapped_column(Integer) - reason: Mapped[str] = mapped_column(String(255)) - charge_to_client: Mapped[int] = mapped_column(SmallInteger) - use_original_payment_date_for_interest_calc: Mapped[int] = mapped_column( - SmallInteger + original_pedimento_year: Mapped[str | None] = mapped_column(String(2), nullable=True) + original_customs_office: Mapped[str | None] = mapped_column(String(3), nullable=True) + original_license: Mapped[str | None] = mapped_column(String(4), nullable=True) + original_pedimento_number: Mapped[str | None] = mapped_column(String(7), nullable=True) + original_pedimento_code: Mapped[str | None] = mapped_column(String(2), nullable=True) + original_payment_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + total_cash: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_others: Mapped[int | None] = mapped_column(Integer, nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + charge_to_client: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + use_original_payment_date_for_interest_calc: Mapped[int | None] = mapped_column( + SmallInteger, nullable=True ) - manual_calculation: Mapped[int] = mapped_column(SmallInteger) - original_pedimento_norms: Mapped[int] = mapped_column(SmallInteger) + manual_calculation: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + original_pedimento_norms: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) pedimento: Mapped["Pedimentos"] = relationship( "Pedimentos", back_populates="pedimento_rectification_origin" diff --git a/backend/api/v1/modules/a76/pedmientos/schemas.py b/backend/api/v1/modules/a76/pedmientos/schemas.py index aeec5ee3..8eec714e 100644 --- a/backend/api/v1/modules/a76/pedmientos/schemas.py +++ b/backend/api/v1/modules/a76/pedmientos/schemas.py @@ -11,6 +11,8 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO +from api.v1.modules.public.reference_data.transport_types.dto import TransportTypeDTO +from api.v1.modules.public.reference_data.transport_modes.dto import TransportModeDTO from .dtos.pedimentos import PedimentosResponse @@ -22,6 +24,8 @@ class PedimentoCatalogsResponse(BaseModel): code_pedimento_regimens: List[CodePedimentoRegimenDTO] = [] customs_brokers: List[CustomsBrokerResponseDTO] = [] clients: List[ClientProviderResponseDTO] = [] + transport_types: List[TransportTypeDTO] = [] + transport_modes: List[TransportModeDTO] = [] class PedimentoCreationResponse(PedimentoCatalogsResponse): diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py index b6538b9c..eab865ff 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py @@ -28,6 +28,7 @@ class PedimentoRectificationDestinationService: .filter( PedimentoRectificationDestination.pedimento_id == pedimento_id, PedimentoRectificationDestination.tenant_id == tenant_id, + PedimentoRectificationDestination.company_id == company_id, ) .first() ) diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py index b92db00e..39304792 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py @@ -26,6 +26,7 @@ class PedimentoRectificationOriginService: .filter( PedimentoRectificationOrigin.pedimento_id == pedimento_id, PedimentoRectificationOrigin.tenant_id == tenant_id, + PedimentoRectificationOrigin.company_id == company_id, ) .first() ) diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index 31a1f658..92f4d00f 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -148,7 +148,7 @@ class PedimentosService: Pedimento or None if not found """ query = db.query(Pedimentos).filter( - Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id + Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id ) if company_id is not None: @@ -199,6 +199,25 @@ class PedimentosService: Created pedimento """ try: + # Check for existing pedimento with same key (Year, Aduana, Patente, Number) + # This avoids IntegrityError in many cases and provides a better error message. + existing = db.query(Pedimentos).filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.year == pedimento_data.year, + Pedimentos.customs_office == pedimento_data.customs_office, + Pedimentos.license == pedimento_data.license, + Pedimentos.pedimento_number == pedimento_data.pedimento_number, + Pedimentos.deleted_at.is_(None) + ).first() + + if existing: + raise ValueError( + f"Ya existe un pedimento con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}" + ) + + # Extraer datos de tablas relacionadas + # Extraer datos de tablas relacionadas related_data = { 'pedimento_dates': pedimento_data.pedimento_dates, @@ -329,11 +348,23 @@ class PedimentosService: except IntegrityError as e: db.rollback() - # Detectar si es un error de pedimento duplicado - error_msg = str(e.orig) - if 'pedimentos_unique_key' in error_msg or 'duplicate key value violates unique constraint' in error_msg: - logger.warning(f"Attempted to create duplicate pedimento: {e}") - raise ValueError("Ya existe un pedimento con estos datos (Año, Aduana, Patente, Número)") + # Detectar si es un error de integridad de duplicados o similar + error_msg = str(e.orig).lower() + + # Case-insensitive check and support for both Spanish and English common error patterns + is_unique_violation = any(kw in error_msg for kw in [ + 'pedimentos_unique_key', + 'unique constraint', + 'duplicate key', + 'duplicada', + 'unicidad', + 'ya existe' + ]) + + if is_unique_violation: + logger.warning(f"Attempted to create duplicate pedimento or common record: {e}") + raise ValueError("Ya existe un pedimento o registro relacionado con estos datos. Verifica los campos únicos.") + logger.error(f"Integrity error creating pedimento: {e}") raise except Exception as e: @@ -363,6 +394,9 @@ class PedimentosService: if not pedimento: return None + # Ensure company_id is set from the existing record + company_id = pedimento.company_id + try: # Actualizar campos principales del pedimento update_data = pedimento_data.model_dump(exclude_unset=True, exclude={ diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 4a07a9a1..82e0c897 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -71,12 +71,18 @@ class ConsolidadoImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index bdd5adca..1163644e 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -103,12 +103,18 @@ class FacturaImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 324bb91a..12595fda 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -41,9 +41,10 @@ async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"), + currency_code: str = Query('ORIGINAL', description="Moneda: 'MXN', 'USD', o 'ORIGINAL'"), current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_core_db) ): validate_access_to_resource(db, company_id, current_user) - task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type) + task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type, currency_code) return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index fd45cd0f..951a3135 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -103,11 +103,12 @@ class FacturaImportacionUsaService: def formatear_numero(self, valor, decimales: int = 2): if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 8500c2d4..7238e369 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -66,10 +66,18 @@ class PackingListService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ + if valor is None: + valor = 0.0 try: - return round(float(valor), decimales) - except: return 0.0 + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" + except: + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/movements/__init__.py b/backend/api/v1/modules/a76/reports/movements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py new file mode 100644 index 00000000..be0ea597 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -0,0 +1,151 @@ +""" +CSV generation utilities for invoice movement reports. +""" +import csv +import io +from typing import List, Union +from datetime import datetime, date + +from .schemas import MovementItem, MovementItemDetailed, AllMovementsFilter + + +def generate_csv_from_movements( + movements: List[Union[MovementItem, MovementItemDetailed]], + filters: AllMovementsFilter +) -> str: + """ + Generate CSV content from movement items. + + Args: + movements: List of movement items (normal or detailed) + filters: Filter object containing report parameters + + Returns: + CSV content as string + """ + output = io.StringIO() + + if filters.report_type.value.lower() == "normal": + # Normal report + fieldnames = [ + # Identification + 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Values + 'ValorComercialMN', 'ValorMPTemp', 'TipoCambio', 'ValorAgre', + # Classification + 'TipoMovTemDef', 'Estatus', 'TipoExpo', 'EsCambioRegimen', + # Dates + 'Fecha_Pago', + # References + 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Logistics + 'NumCaja', 'NumGafUni', 'AduanaCru', + # Metadata + 'BaseDeDatos', 'UsuarioCap', 'UsuarioAcr' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['ValorMPTemp'] = _format_decimal(row.get('ValorMPTemp')) + row['ValorAgre'] = _format_decimal(row.get('ValorAgre')) + + writer.writerow(row) + + else: + # Detailed report + fieldnames = [ + # Identification + 'Linea', 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Parties + 'Proveedor', 'RFCProveedor', 'ProveedorTaxID', + 'VendidoA', 'VendidoARFC', 'VendidoATaxID', + # Customs broker + 'AgenteAduanal', 'Patente', + # Product + 'NumParte', 'DescripcionE', 'DescripcionI', 'CantidadIE', 'UniMed', + # Classification + 'FraccionArancelaria', 'FraccionAmericana', 'ECCN', 'Sector', 'PaisOrigen', + # Values + 'ValorComercialMN', 'TipoCambio', 'PesoNeto', 'PesoBruto', + # Customs + 'TipoMovTemDef', 'Regimen', 'Aduana', 'Advalorem', 'Preferencia', + # References + 'OrdenCompraVenta', 'Remesa', 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Identifiers + 'Series', 'Marca', 'Modelo', 'SimboloEx', + # Dates + 'Fecha_Pago', 'Fecha_Inicio', 'Fecha_Fin', 'FechaEmision', + # Logistics + 'Transportista', 'NumCaja', 'NumGafUni', 'AduanaCru', 'Lote', + # Metadata + 'Estatus', 'BaseDeDatos', 'TipoExpo', 'EsCambioRegimen', 'Pedimento18', + 'UsuarioCap', 'UsuarioAcr' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + row['Fecha_Inicio'] = _format_datetime(row.get('Fecha_Inicio')) + row['Fecha_Fin'] = _format_datetime(row.get('Fecha_Fin')) + row['FechaEmision'] = _format_datetime(row.get('FechaEmision')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['CantidadIE'] = _format_decimal(row.get('CantidadIE')) + row['PesoNeto'] = _format_decimal(row.get('PesoNeto')) + row['PesoBruto'] = _format_decimal(row.get('PesoBruto')) + + writer.writerow(row) + + csv_content = output.getvalue() + output.close() + return csv_content + + +def _format_datetime(dt) -> str: + """Format datetime for CSV export.""" + if not dt or dt == '' or dt == '-' or dt == '0': + return '' + try: + if isinstance(dt, str): + if 'T' in dt: + dt_obj = datetime.strptime(dt.split('T')[0], '%Y-%m-%d') + elif len(dt) == 8 and dt.isdigit(): + dt_obj = datetime.strptime(dt, '%Y%m%d') + elif '-' in dt: + dt_obj = datetime.strptime(dt, '%Y-%m-%d') + else: + return dt + elif isinstance(dt, (datetime, date)): + dt_obj = dt + else: + return '' + return dt_obj.strftime('%d/%m/%Y') + except (ValueError, TypeError): + return str(dt) if dt else '' + + +def _format_decimal(value, decimals: int = 2) -> str: + """Format decimal values for CSV export.""" + if value is None: + return '' + try: + return f"{float(value):.{decimals}f}" + except (ValueError, TypeError): + return str(value) if value else '' \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py new file mode 100644 index 00000000..41af2b37 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -0,0 +1,316 @@ +""" +Unified service for invoice movement operations. +This service delegates to specialized handlers for each import type. +""" + +import logging +from sqlalchemy.orm import Session +from typing import List + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + ExportRepairFilter, + AllMovementsFilter, + MovementItem, + MovementItemDetailed, + ReportType +) +from .services.temporary import TemporaryImportService +from .services.definitive import DefinitiveImportService +from .services.repair import RepairImportService +from .services.export import ExportService +from .services.export_repair import ExportRepairService + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Unified service for handling all types of movements. + Delegates to specialized services for each movement type. + """ + + def __init__(self): + self.temporary_service = TemporaryImportService() + self.definitive_service = DefinitiveImportService() + self.repair_service = RepairImportService() + self.export_service = ExportService() + self.export_repair_service = ExportRepairService() + + # ===== TEMPORARY IMPORTS ===== + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """Get temporary import movements (normal mode - grouped by invoice).""" + return self.temporary_service.get_movements(db, filters) + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """Get temporary import movements (detailed mode - line by line).""" + return self.temporary_service.get_movements_detailed(db, filters) + + # ===== DEFINITIVE IMPORTS ===== + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """Get definitive import movements (normal mode - grouped by invoice).""" + return self.definitive_service.get_movements(db, filters) + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """Get definitive import movements (detailed mode - line by line).""" + return self.definitive_service.get_movements_detailed(db, filters) + + # ===== REPAIR IMPORTS ===== + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """Get repair import movements (normal mode - grouped by invoice).""" + return self.repair_service.get_movements(db, filters) + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """Get repair import movements (detailed mode - line by line).""" + return self.repair_service.get_movements_detailed(db, filters) + + # ===== EXPORTS ===== + + def get_export_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """Get export movements (normal mode - grouped by invoice).""" + return self.export_service.get_movements(db, filters) + + def get_export_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """Get export movements (detailed mode - line by line).""" + return self.export_service.get_movements_detailed(db, filters) + + # ===== EXPORT REPAIRS ===== + + def get_export_repair_movements( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItem]: + """Get export repair movements (normal mode - grouped by invoice).""" + return self.export_repair_service.get_movements(db, filters) + + def get_export_repair_movements_detailed( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItemDetailed]: + """Get export repair movements (detailed mode - line by line).""" + return self.export_repair_service.get_movements_detailed(db, filters) + + # ===== ALL MOVEMENTS ===== + + def get_all_movements( + self, + db: Session, + filters: AllMovementsFilter + ) -> List[MovementItem]: + """ + Get all invoice movements (all types combined). + + This combines: + - Temporary imports + - Definitive imports + - Repair imports + - All export types + - Export repairs + + Returns a unified list sorted by date. + """ + all_movements = [] + + # Convert AllMovementsFilter to individual filter types + # We'll use the same filter parameters for all queries + + # Determine which services to call based on granular flags + # Default behavior: If granular flags are all defaults (True) but operation_type is set, + # we might need to respect operation_type. + # But for simplicity, we assume granular flags from frontend are the source of truth. + # If frontend didn't set them (legacy call?), they default to True. + + # Override based on operation_type if provided (legacy compatibility or coarse filter) + if filters.operation_type == 'imp': + filters.export_def = False + filters.export_rep = False + elif filters.operation_type == 'exp': + filters.import_temp = False + filters.import_def = False + filters.import_rep = False + + logger.info(f"Fetching movements with flags: Temp={filters.import_temp}, Def={filters.import_def}, Rep={filters.import_rep}, ExpDef={filters.export_def}, ExpRep={filters.export_rep}") + + # 1. Temporary Imports + if filters.import_temp: + temp_filter = ImportTemporaryFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default' + ) + if filters.report_type == ReportType.DETAILED: + temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter) + else: + temp_movements = self.temporary_service.get_movements(db, temp_filter) + all_movements.extend(temp_movements) + logger.info(f"Added {len(temp_movements)} temporary import movements") + + # 2. Definitive Imports + if filters.import_def: + def_filter = ImportDefinitiveFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + def_movements = self.definitive_service.get_movements_detailed(db, def_filter) + else: + def_movements = self.definitive_service.get_movements(db, def_filter) + all_movements.extend(def_movements) + logger.info(f"Added {len(def_movements)} definitive import movements") + + # 3. Repair Imports + if filters.import_rep: + repair_filter = ImportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + repair_movements = self.repair_service.get_movements_detailed(db, repair_filter) + else: + repair_movements = self.repair_service.get_movements(db, repair_filter) + all_movements.extend(repair_movements) + logger.info(f"Added {len(repair_movements)} repair import movements") + + # 4. Exports (Definitive) + if filters.export_def: + export_filter = ExportFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + export_movements = self.export_service.get_movements_detailed(db, export_filter) + else: + export_movements = self.export_service.get_movements(db, export_filter) + all_movements.extend(export_movements) + logger.info(f"Added {len(export_movements)} export movements") + + # 5. Export Repairs + if filters.export_rep: + export_repair_filter = ExportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL' + ) + if filters.report_type == ReportType.DETAILED: + export_repair_movements = self.export_repair_service.get_movements_detailed(db, export_repair_filter) + else: + export_repair_movements = self.export_repair_service.get_movements(db, export_repair_filter) + all_movements.extend(export_repair_movements) + logger.info(f"Added {len(export_repair_movements)} export repair movements") + + # Sort all movements by date (Fecha field) + # Handle mixed datetime and string types + def get_sort_key(movement): + fecha = movement.FechaFactura + if not fecha: + return "" + # Convert datetime to string for consistent comparison + if hasattr(fecha, 'strftime'): + return fecha.strftime('%Y%m%d') + return str(fecha) + + all_movements.sort(key=get_sort_key) + + logger.info(f"Total movements combined: {len(all_movements)}") + return all_movements + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py new file mode 100644 index 00000000..351e4d9e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -0,0 +1,748 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Union + +from core.database import get_core_db +from core.security import get_current_user +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + ExportRepairFilter, + AllMovementsFilter, + MovementItem, + MovementItemDetailed +) +from .movement_service import movement_service + +logger = logging.getLogger(__name__) + +router = APIRouter( + tags=["Reports - Movement Invoices"] +) + + +@router.post( + "/temporary", + response_model=List[MovementItem], + summary="Get Temporary Import Movements", + description=""" + Retrieve temporary import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADOTEMPORAL' (Fill Temporary) logic from the legacy system. + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_temporary_import_movements( + filters: ImportTemporaryFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get temporary import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting temporary import movements" + ) + movements = movement_service.get_temporary_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} movements") + return movements + except ValueError as e: + logger.warning(f"Validation error fetching movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error fetching movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing import temporary movements: {str(e)}" + ) + + +@router.post( + "/temporary-detailed", + response_model=List[MovementItemDetailed], + summary="Get Detailed Temporary Import Movements", + description=""" + Retrieve detailed temporary import movements (line by line) from legacy database. + This endpoint corresponds to the 'LLENADOTEMPORAL - DETALLADO' logic from the legacy system. + + Each line/partida is returned separately with complete information including: + - Provider and buyer details (name, RFC, Tax ID) + - Customs broker information + - Item descriptions and specifications + - Series information + - All related metadata + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_temporary_import_movements_detailed( + filters: ImportTemporaryFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed temporary import movements (line by line) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of detailed movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting DETAILED temporary import movements" + ) + movements = movement_service.get_temporary_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed movements") + return movements + except ValueError as e: + logger.warning(f"Validation error fetching detailed movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error fetching detailed movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed import temporary movements: {str(e)}" + ) + + +@router.post( + "/definitive", + response_model=List[MovementItem], + summary="Get Definitive Import Movements", + description=""" + Retrieve definitive import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADODEFINITIVO - NORMAL' logic from the legacy system. + + Definitive imports are aggregated by invoice number and can be filtered by: + - Movement type (COMEX or IMPDF based on ProvImpoDefCR field) + - Date range (invoice date or payment date) + - Provider and buyer + - Pedimento code + - Status (active or including cancelled) + + **Special Features**: + - Supports shelter company logic for exchange rate calculations + - MetTrans# = 1 logic for specific pedimento types (1, 4, 98E) + - Retrieves driver badge information + - Handles rectification pedimento lookups + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_definitive_import_movements( + filters: ImportDefinitiveFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get definitive import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting definitive import movements" + ) + movements = movement_service.get_definitive_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} definitive movements") + return movements + except Exception as e: + logger.error(f"Error fetching definitive movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing definitive import movements: {str(e)}" + ) + + +@router.post( + "/definitive-detailed", + response_model=List[MovementItemDetailed], + summary="Get Detailed Definitive Import Movements", + description=""" + Retrieve detailed definitive import movements (line by line) from legacy database. + This endpoint corresponds to the 'LLENADODEFINITIVO - DETALLADO' logic from the legacy system. + + Each line/partida is returned separately with complete information including: + - Provider and buyer details (name, RFC, Tax ID) + - Customs broker information + - Item descriptions and specifications + - Series information from QSeriesDef table + - All related metadata + + **Special Logic**: + - Only Partidas (EsSubPartida = 'P') have values calculated + - Subpartidas (EsSubPartida = 'S') return with zero values + - Series formatted as: "1) SERIE123. Modelo: MOD1. Parte: PART1 | 2) SERIE456..." + - Exchange rate calculation supports shelter and non-shelter logic + - MetTrans# = 1 logic for pedimento types 1, 4, 98E + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_definitive_import_movements_detailed( + filters: ImportDefinitiveFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed definitive import movements (line by line) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of detailed movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting DETAILED definitive import movements" + ) + movements = movement_service.get_definitive_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements") + return movements + except Exception as e: + logger.error(f"Error fetching detailed definitive movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed definitive import movements: {str(e)}" + ) + + +@router.post( + "/repair", + response_model=List[MovementItem], + summary="Get Repair Import Movements", + description=""" + Retrieve repair import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADOIMP_REPARACION - NORMAL' logic from the legacy system. + + Repair imports are aggregated by invoice number and can be filtered by: + - Discharge status (SiDes: discharged, NoDes: not discharged, ALL: no filter) + - Date range (invoice date or payment date) + - Provider and buyer + - Pedimento code + - Status (active or including cancelled) + + **Special Features**: + - Excludes regime changes (EsCambioRegimen <> 'S') + - Supports discharge filter (unique to repair imports) + - Exchange rate calculation with shelter/non-shelter logic + - MetTrans# = 1 logic for specific pedimento types (1, 4, 98E) + - Retrieves driver badge information + + **Database Tables**: + - QFacImpRep: Repair import invoices + - QEqiMaqRep: Repair import items/partidas + - QPedimentos: Pedimentos (customs declarations) + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_repair_import_movements( + filters: ImportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get repair import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting repair import movements" + ) + movements = movement_service.get_repair_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} repair movements") + return movements + except Exception as e: + logger.error(f"Error fetching repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing repair import movements: {str(e)}" + ) + + +@router.post( + "/repair-detailed", + response_model=List[MovementItemDetailed], + summary="Get detailed repair import movements", + description=""" + Retrieve detailed repair import movements (IMPRE) with individual partida lines. + + **LLENADOIMP_REPARACION - DETALLADO** + + Returns individual partida (line item) records for repair imports with full detail including: + - Complete invoice and customs clearance information + - Series, model, and part numbers for each item + - Client/supplier and sold-to information with tax IDs + - Exchange rate calculations (MN/ME) based on filter options + - Customs agent and customs section details + - Driver badge unique number + - All partida-level fields (part number, descriptions, quantities, weights, etc.) + + **Discharge Filter Options:** + - `SiDes`: Only include discharged items (Descarga = 1) + - `NoDes`: Only include non-discharged items (Descarga = 0) + - `ALL`: Include all items regardless of discharge status + + **Database Tables Used:** + - QFacImpRep: Repair import invoices + - QPedimentos: Customs declarations + - QEqiMaqRep: Repair import partidas (line items) + - QSeriesImpoRep: Series information + - GClientesPro: Suppliers + - GCliVendido: Sold-to clients + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers (for badge numbers) + - GTipoCambio: Exchange rates + """, + tags=["Import Movements - Repair"] +) +async def get_import_repair_movements_detailed( + filters: ImportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed repair import movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed repair movements") + movements = movement_service.get_repair_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed repair partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed repair import movements: {str(e)}" + ) + + +@router.post( + "/export", + response_model=List[MovementItem], + summary="Get export movements", + description=""" + Retrieve export movements (EXPO DEF) grouped by invoice. + + **LLENADOEXPORTACION - NORMAL** + + Returns aggregated data grouped by invoice number for export movements. + + **Movement Type Options:** + - `AFIJO`: Fixed assets + - `NODES`: No discharge + - `SCRAP`: Scrap materials + - `REEXP`: Re-exports + - `DONAC`: Donations + - `VEMEX`: Sales to Mexico + - `ALL`: All movement types + + **Discharge Filter Options:** + - `SiDes`: Only discharged items (Descarga = 1) + - `NoDes`: Only non-discharged items (Descarga = 0) + - `ALL`: All items regardless of discharge status + + **Database Tables Used:** + - QFacExp: Export invoices + - QEqeMaq: Export partidas (line items) + - QPedimentos: Customs declarations + - QClaAct: Part classifications + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers + - GTipoCambio: Exchange rates + + Automatically excludes regime changes (EsCambioRegimen = 'N') + """, + tags=["Export Movements"] +) +async def get_export_movements( + filters: ExportFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get export movements based on filter criteria. + Returns aggregated data grouped by invoice. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting export movements") + movements = movement_service.get_export_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} export movements") + return movements + except Exception as e: + logger.error(f"Error fetching export movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing export movements: {str(e)}" + ) + + +@router.post( + "/export-detailed", + response_model=List[MovementItemDetailed], + summary="Get detailed export movements", + description=""" + Retrieve detailed export movements with individual partida lines. + + **LLENADOEXPORTACION - DETALLADO** + + Returns individual partida (line item) records for exports with full detail including: + - Complete invoice and customs clearance information + - Series, model, and part numbers for each item + - Client/supplier and buyer information with tax IDs + - Exchange rate calculations (MN/ME) based on filter options + - Customs agent and customs section details + - Driver badge unique number + - All partida-level fields + + **Movement Type Options:** + - `AFIJO`: Fixed assets + - `NODES`: No discharge + - `SCRAP`: Scrap materials + - `REEXP`: Re-exports + - `DONAC`: Donations + - `VEMEX`: Sales to Mexico + - `ALL`: All movement types + + **Discharge Filter Options:** + - `SiDes`: Only discharged items + - `NoDes`: Only non-discharged items + - `ALL`: All items + + **Database Tables Used:** + - QFacExp: Export invoices + - QEqeMaq: Export partidas + - QSeriesExpo: Serial numbers + - QPedimentos: Customs declarations + - GClientesPro: Suppliers + - GCliVendido: Buyers + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers + - GTipoCambio: Exchange rates + """, + tags=["Export Movements"] +) +async def get_export_movements_detailed( + filters: ExportFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed export movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed export movements") + movements = movement_service.get_export_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed export partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed export movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed export movements: {str(e)}" + ) + + +@router.post("/export-repair", response_model=List[MovementItem]) +def get_export_repair_movements( + filters: ExportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + **LLENADOEXP_REPARACION - NORMAL** + + Get export repair movements (EXPO REP) based on filter criteria. + Groups results by invoice (FacturaExpo). + + Clarion logic: + - Query from QFacExpRep, QEqeMaqRep tables + - Filters: date range (FF/FP), provider, buyer, pedimento code + - Movement types: AFIJO, NODES + - Discharge filter: SiDes, NoDes, or ALL + - Calculates totals from partidas where EsSubpartida = 'P' + - Exchange rate logic based on currency type and Scaii.ini MetTrans + - Always filters by EsCambioRegimen = 'N' + """ + try: + logger.info(f"User {current_user.get('sub')} requesting export repair movements") + movements = movement_service.get_export_repair_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} export repair invoices") + return movements + except Exception as e: + logger.error(f"Error fetching export repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing export repair movements: {str(e)}" + ) + + +@router.post("/export-repair-detailed", response_model=List[MovementItemDetailed]) +def get_export_repair_movements_detailed( + filters: ExportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed export repair movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed export repair movements") + movements = movement_service.get_export_repair_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed export repair partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed export repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed export repair movements: {str(e)}" + ) + + +@router.post( + "/all", + response_model=Union[List[MovementItemDetailed], List[MovementItem]], + summary="Get All Invoice Movements", + description=""" + Retrieve all invoice movements (imports and exports of all types) from database. + This endpoint combines temporary, definitive, and repair imports with all export types. + + Use this when "TODAS" checkbox is selected to get a comprehensive view of all movements + regardless of their specific type. + + If send_email is True, the report will be sent to the authenticated user's email address. + """ +) +async def get_all_movements( + filters: AllMovementsFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get all invoice movements (all types combined) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of all movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting all invoice movements (send_email={filters.send_email})" + ) + movements = movement_service.get_all_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} total movements") + + # Send email if requested + if filters.send_email: + user_email = current_user.get('email') + if not user_email: + logger.warning(f"User {current_user.get('sub')} has no email address - skipping email") + else: + try: + from core.email import EmailService + from .csv_utils import generate_csv_from_movements + from datetime import datetime + + # Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + filters=filters + ) + + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email + email_sent = await EmailService.send_report_email( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if email_sent: + logger.info(f"Report emailed successfully to {user_email}") + else: + logger.warning(f"Failed to send email to {user_email} - SMTP may not be configured correctly") + + except Exception as email_error: + logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation") + + return movements + return movements + except ValueError as e: + logger.warning(f"Validation error fetching all movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching all movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing all movements: {str(e)}" + ) + + +@router.post( + "/generate", + summary="Generate Invoice Report (Async)", + description="Trigger background generation of invoice report." +) +def generate_invoice_report_async( + filters: AllMovementsFilter, + current_user: dict = Depends(get_current_user) +): + """ + Trigger background generation of invoice report. + Returns task_id to poll status. + """ + from .tasks import generate_invoice_movements_async + + logger.info(f"User {current_user.get('preferred_username', 'unknown')} triggering async report generation") + + # Serialize filters to dict for Celery + filter_data = filters.model_dump() + user_email = current_user.get('email') + + # Trigger task + task = generate_invoice_movements_async.delay(filter_data, user_email) + + return {"task_id": task.id} + + +@router.get( + "/task/{task_id}", + summary="Get Async Task Status", + description="Check status of background report generation task." +) +def get_task_status(task_id: str): + """ + Get status of background task. + """ + from celery.result import AsyncResult + from core.celery_app import celery_app + + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "status": task_result.status, + } + + if task_result.state == 'PROCESSING': + response["meta"] = task_result.info + + if task_result.ready(): + response["result"] = task_result.result + + return response diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py new file mode 100644 index 00000000..002331cf --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -0,0 +1,609 @@ +from typing import Optional +from pydantic import BaseModel, Field +from datetime import datetime, date +from enum import Enum + + +class RangeType(str, Enum): + """Date range type for filtering""" + INVOICE_DATE = "FF" # Filter by invoice date + PAYMENT_DATE = "FP" # Filter by payment date + + +class ReportType(str, Enum): + """Report type""" + NORMAL = "Normal" + DETAILED = "Detallado" + + +class CurrencyType(str, Enum): + """Currency type for calculations""" + FOREIGN = "ME" # Foreign currency (Moneda Extranjera) + LOCAL = "MN" # Local currency (Moneda Nacional) + + +class ExchangeRateType(str, Enum): + """Exchange rate calculation type""" + PAYMENT = "FP" # Use payment date + INVOICE = "FF" # Use invoice date + + +class MovementTypeFilter(str, Enum): + """Movement type filter for definitive imports""" + COMEX = "COMEX" # ProvImpoDefCR = 'P' + IMPDF = "IMPDF" # ProvImpoDefCR != 'P' + ALL = "ALL" # No filter + + +class DischargeFilter(str, Enum): + """Discharge filter for repair imports""" + DISCHARGED = "SiDes" # RepPim.Descarga = 1 + NOT_DISCHARGED = "NoDes" # RepPim.Descarga = 0 + ALL = "ALL" # No filter + + +class ExportMovementType(str, Enum): + """Export movement type filter""" + AFIJO = "AFIJO" # Fixed assets + NODES = "NODES" # No discharge + SCRAP = "SCRAP" # Scrap + REEXP = "REEXP" # Re-export + DONAC = "DONAC" # Donation + VEMEX = "VEMEX" # Sale to Mexico + ALL = "ALL" # All types + + +class AllMovementsFilter(BaseModel): + """Filters for all movements query (all types combined)""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal (grouped by invoice) or Detailed (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + operation_type: Optional[str] = Field( + default=None, + description="Filter by operation type: 'imp' for imports only, 'exp' for exports only, None for all" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + # Granular movement selection + import_temp: bool = Field(default=True, description="Include temporary imports (IMTEM)") + import_def: bool = Field(default=True, description="Include definitive imports (IMPDF/COMEX)") + import_rep: bool = Field(default=True, description="Include repair imports (IMPRE)") + export_def: bool = Field(default=True, description="Include definitive exports") + export_rep: bool = Field(default=True, description="Include repair exports") + + # Specific filters + export_types: Optional[list[str]] = Field( + default=None, + description="Specific export legacy codes to include (AFIJO, NODES, etc)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Global discharge filter for repair movements" + ) + + +class ImportTemporaryFilter(BaseModel): + """Filters for temporary import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + +class ImportDefinitiveFilter(BaseModel): + """Filters for definitive import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: MovementTypeFilter = Field( + default=MovementTypeFilter.ALL, + description="Movement type filter: COMEX, IMPDF, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + use_transport_method: bool = Field( + default=False, + description="Use MetTrans# = 1 logic for specific pedimento types" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + +class MovementItem(BaseModel): + """Movement item representing a temporary import invoice""" + Factura: Optional[str] = Field(None, description="Invoice number") + Pedimento: Optional[str] = Field(None, description="Pedimento number") + FechaFactura: Optional[datetime] = Field(None, description="Invoice date") + Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)") + ClavePed: Optional[str] = Field(None, description="Pedimento code") + TipoMovTemDef: Optional[str] = Field(None, description="Movement type (IMTEM=Temporary Import)") + EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)") + ValorMPTemp: Optional[float] = Field(None, description="Temporary raw material value") + ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN") + TipoCambio: Optional[float] = Field(None, description="Exchange rate used") + ValorAgre: Optional[float] = Field(default=0.0, description="Aggregate value") + TipoExpo: Optional[str] = Field(default='', description="Export type") + PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento") + EDocument: Optional[str] = Field(None, description="Electronic document") + NumOperacionVU: Optional[str] = Field(None, description="VU operation number") + BaseDeDatos: Optional[str] = Field(None, description="Source database name") + NumGafUni: Optional[str] = Field(None, description="Unique badge number (driver)") + UsuarioCap: Optional[str] = Field(None, description="Capture user") + UsuarioAcr: Optional[str] = Field(None, description="Update user") + Fecha_Pago: Optional[datetime] = Field(None, description="Payment date") + NumCaja: Optional[str] = Field(None, description="Box/Container number") + Pedimento18: Optional[str] = Field(None, description="18-digit pedimento") + AduanaCru: Optional[str] = Field(None, description="Crossing customs") + Lote: Optional[str] = Field(None, description="Lot number") + + model_config = { + "json_schema_extra": { + "example": { + "Factura": "F-2024-001", + "Pedimento": "24 47 3807 8001234", + "FechaFactura": "2024-01-15T00:00:00", + "Estatus": "AC", + "ClavePed": "IM", + "TipoMovTemDef": "IMTEM", + "ValorMPTemp": 10000.50, + "TipoCambio": 17.25 + } + } + } + + +class ImportRepairFilter(BaseModel): + """Filters for repair import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Discharge filter: SiDes (discharged), NoDes (not discharged), or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + use_transport_method: bool = Field( + default=False, + description="Use MetTrans# = 1 logic for specific pedimento types" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + +class MovementItemDetailed(BaseModel): + """Detailed movement item with all line-level information""" + Linea: Optional[int] = Field(None, description="Line number") + Factura: Optional[str] = Field(None, description="Invoice number") + Pedimento: Optional[str] = Field(None, description="Pedimento number") + FechaFactura: Optional[datetime] = Field(None, description="Invoice date") + Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)") + ClavePed: Optional[str] = Field(None, description="Pedimento code") + TipoMovTemDef: Optional[str] = Field(None, description="Movement type") + EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)") + Regimen: Optional[str] = Field(None, description="Regime") + Fecha_Inicio: Optional[datetime] = Field(None, description="Start date") + Fecha_Fin: Optional[datetime] = Field(None, description="End date") + Fecha_Pago: Optional[datetime] = Field(None, description="Payment date") + Remesa: Optional[str] = Field(None, description="Remesa") + + # Provider information + Proveedor: Optional[str] = Field(None, description="Provider name") + RFCProveedor: Optional[str] = Field(None, description="Provider RFC") + ProveedorTaxID: Optional[str] = Field(None, description="Provider Tax ID") + + # Buyer information + VendidoA: Optional[str] = Field(None, description="Buyer name") + VendidoARFC: Optional[str] = Field(None, description="Buyer RFC") + VendidoATaxID: Optional[str] = Field(None, description="Buyer Tax ID") + + # Customs broker + AgenteAduanal: Optional[str] = Field(None, description="Customs broker name") + Patente: Optional[str] = Field(None, description="Customs broker patent") + + # Item details + NumParte: Optional[str] = Field(None, description="Part number") + DescripcionE: Optional[str] = Field(None, description="Spanish description") + DescripcionI: Optional[str] = Field(None, description="English description") + CantidadIE: Optional[float] = Field(None, description="Quantity") + UniMed: Optional[str] = Field(None, description="Unit of measure") + ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN") + TipoCambio: Optional[float] = Field(None, description="Exchange rate") + PesoNeto: Optional[float] = Field(None, description="Net weight") + PesoBruto: Optional[float] = Field(None, description="Gross weight") + + # Additional fields + OrdenCompraVenta: Optional[str] = Field(None, description="Purchase order") + FraccionArancelaria: Optional[str] = Field(None, description="Tariff fraction") + Preferencia: Optional[str] = Field(None, description="Preference") + Sector: Optional[str] = Field(None, description="Sector") + PaisOrigen: Optional[str] = Field(None, description="Country of origin") + Aduana: Optional[str] = Field(None, description="Customs office") + Advalorem: Optional[str] = Field(None, description="Ad valorem") + TipoExpo: Optional[str] = Field(default='', description="Export type") + PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento") + EDocument: Optional[str] = Field(None, description="Electronic document") + NumOperacionVU: Optional[str] = Field(None, description="VU operation number") + + # Series information + Series: Optional[str] = Field(None, description="Serial numbers") + Marca: Optional[str] = Field(None, description="Brand") + Modelo: Optional[str] = Field(None, description="Model") + FraccionAmericana: Optional[str] = Field(None, description="American tariff fraction") + ECCN: Optional[str] = Field(None, description="ECCN code") + SimboloEx: Optional[str] = Field(None, description="Export symbol/license") + FechaEmision: Optional[datetime] = Field(None, description="Emission date") + + # Metadata + BaseDeDatos: Optional[str] = Field(None, description="Source database") + NumGafUni: Optional[str] = Field(None, description="Unique badge number") + UsuarioCap: Optional[str] = Field(None, description="Capture user") + UsuarioAcr: Optional[str] = Field(None, description="Update user") + Transportista: Optional[str] = Field(None, description="Transporter") + NumCaja: Optional[str] = Field(None, description="Box number") + Pedimento18: Optional[str] = Field(None, description="18-digit pedimento") + AduanaCru: Optional[str] = Field(None, description="Crossing customs") + Lote: Optional[str] = Field(None, description="Lot number") + + model_config = { + "json_schema_extra": { + "example": { + "Linea": 1 + } + } + } + + +class ExportFilter(BaseModel): + """Filters for export movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus = 'NA')" + ) + provider: Optional[str] = Field( + None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: ExportMovementType = Field( + default=ExportMovementType.ALL, + description="Filter by export movement type (AFIJO, NODES, SCRAP, REEXP, DONAC, VEMEX)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Filter by discharge status: SiDes, NoDes, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Normal (grouped by invoice) or Detallado (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type: ME (foreign) or MN (local)" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate type: FP (payment date) or FF (invoice date)" + ) + is_shelter: bool = Field( + default=False, + description="Shelter company flag" + ) + use_transport_method: bool = Field( + default=False, + description="Use transport method for exchange rate logic" + ) + database_name: str = Field( + ..., + description="Legacy database name" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + model_config = { + "json_schema_extra": { + "example": { + "range_type": "FF", + "start_date": "20240101", + "end_date": "20240131", + "include_cancelled": False, + "provider": None, + "buyer": None, + "pedimento_code": None, + "movement_type": "ALL", + "discharge_filter": "ALL", + "report_type": "Normal", + "currency_type": "ME", + "exchange_rate_type": "FP", + "is_shelter": False, + "use_transport_method": False, + "database_name": "MYDB" + } + } + } + +class ExportRepairFilter(BaseModel): + """Filters for export repair movements query (EXPO REP)""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus = 'NA')" + ) + provider: Optional[str] = Field( + None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: ExportMovementType = Field( + default=ExportMovementType.ALL, + description="Filter by movement type (AFIJO, NODES for repair exports)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Filter by discharge status: SiDes, NoDes, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Normal (grouped by invoice) or Detallado (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type: ME (foreign) or MN (local)" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate type: FP (payment date) or FF (invoice date)" + ) + is_shelter: bool = Field( + default=False, + description="Shelter company flag" + ) + database_name: str = Field( + ..., + description="Legacy database name" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + model_config = { + "json_schema_extra": { + "example": { + "range_type": "FF", + "start_date": "20240101", + "end_date": "20240131", + "include_cancelled": False, + "provider": None, + "buyer": None, + "pedimento_code": None, + "movement_type": "ALL", + "discharge_filter": "ALL", + "report_type": "Normal", + "currency_type": "ME", + "exchange_rate_type": "FP", + "is_shelter": False, + "database_name": "MYDB" + } + } + } \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services.py b/backend/api/v1/modules/a76/reports/movements/invoices/services.py new file mode 100644 index 00000000..f218bd71 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services.py @@ -0,0 +1,112 @@ +""" +Unified service for invoice movement operations. +This service delegates to specialized handlers for each import type. +""" + +import logging +from sqlalchemy.orm import Session +from typing import List + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + MovementItem, + MovementItemDetailed +) +from .services.temporary import TemporaryImportService +from .services.definitive import DefinitiveImportService +from .services.repair import RepairImportService +from .services.export import ExportService + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Unified service for handling all types of movements. + Delegates to specialized services for each movement type. + """ + + def __init__(self): + self.temporary_service = TemporaryImportService() + self.definitive_service = DefinitiveImportService() + self.repair_service = RepairImportService() + self.export_service = ExportService() + + # ===== TEMPORARY IMPORTS ===== + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """Get temporary import movements (normal mode - grouped by invoice).""" + return self.temporary_service.get_movements(db, filters) + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """Get temporary import movements (detailed mode - line by line).""" + return self.temporary_service.get_movements_detailed(db, filters) + + # ===== DEFINITIVE IMPORTS ===== + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """Get definitive import movements (normal mode - grouped by invoice).""" + return self.definitive_service.get_movements(db, filters) + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """Get definitive import movements (detailed mode - line by line).""" + return self.definitive_service.get_movements_detailed(db, filters) + + # ===== REPAIR IMPORTS ===== + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """Get repair import movements (normal mode - grouped by invoice).""" + return self.repair_service.get_movements(db, filters) + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """Get repair import movements (detailed mode - line by line).""" + return self.repair_service.get_movements_detailed(db, filters) + + # ===== EXPORTS ===== + + def get_export_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """Get export movements (normal mode - grouped by invoice).""" + return self.export_service.get_movements(db, filters) + + def get_export_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """Get export movements (detailed mode - line by line).""" + return self.export_service.get_movements_detailed(db, filters) + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py new file mode 100644 index 00000000..fdde7fed --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py @@ -0,0 +1,26 @@ +""" +Invoice Movement Services Module + +This package contains the business logic for handling different types of movements: +- Temporary imports (IMTEM) +- Definitive imports (COMEX/IMPDF) +- Repair imports (IMPRE) +- Exports (EXPO DEF) +- Export repairs (EXPO REP) + +The services are organized into specialized modules for better maintainability. +""" + +from .temporary import TemporaryImportService +from .definitive import DefinitiveImportService +from .repair import RepairImportService +from .export import ExportService +from .export_repair import ExportRepairService + +__all__ = [ + 'TemporaryImportService', + 'DefinitiveImportService', + 'RepairImportService', + 'ExportService', + 'ExportRepairService', +] diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py new file mode 100644 index 00000000..e04262a8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py @@ -0,0 +1,85 @@ +""" +Base utilities and configuration helpers for invoice movement services. +""" + +import logging +import configparser +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ConfigHelper: + """Helper for reading configuration files.""" + + @staticmethod + def get_met_trans_config() -> int: + """ + Read MetTrans configuration from Scaii.ini file. + + Returns: + MetTrans value (0 or 1) + """ + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + return met_trans + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + return 0 + + +class StringHelper: + """Helper for string manipulation.""" + + @staticmethod + def remove_commas(text: Optional[str]) -> Optional[str]: + """Remove commas from text for CSV compatibility.""" + if not text: + return text + return text.replace(',', '') + + @staticmethod + def clean_text(text: Optional[str]) -> Optional[str]: + """Clean text by stripping whitespace and removing special characters.""" + if not text: + return None + # Remove special characters and extra whitespace + cleaned = text.strip() + return cleaned if cleaned else None + + +class DateHelper: + """Helper for date-related operations.""" + + @staticmethod + def get_fecha_tipo_cambio( + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + use_transport_method: bool, + met_trans: int + ): + """ + Determine which date to use for exchange rate lookup based on MetTrans logic. + + Args: + fecha_pago: Payment date + fecha_inicio: Start/entry date + tipo_pedimento: Pedimento type code + use_transport_method: Whether to apply transport method logic + met_trans: MetTrans configuration value + + Returns: + Date to use for exchange rate lookup + """ + fecha = fecha_pago + + # MetTrans# = 1 logic: use fecha_inicio for specific pedimento types + if use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha = fecha_inicio + + return fecha diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py new file mode 100644 index 00000000..e6ab3db8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py @@ -0,0 +1,520 @@ +""" +Database query helpers for invoice movements. +""" + +import logging +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional, Dict + +logger = logging.getLogger(__name__) + + +class DatabaseHelper: + """Helper for common database operations.""" + + @staticmethod + def get_database_name(db: Session) -> Optional[str]: + """ + Get the current database name from the session. + + Returns: + Database name or None if not found + """ + try: + result = db.execute(text("SELECT current_database()")).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error getting database name: {e}") + return None + + @staticmethod + def get_exchange_rate( + db: Session, + db_name: str, + fecha, + is_shelter: bool = False, + raise_on_missing: bool = False, + pedimento_number: Optional[str] = None + ) -> Optional[float]: + """ + Get exchange rate for the given date from exchange_rate table. + + Args: + db: Database session + db_name: Legacy database name (kept for compatibility, not used) + fecha: Date for exchange rate lookup + is_shelter: Shelter company flag (when True and rate not found, raises detailed error) + raise_on_missing: If True, raises ValueError when rate not found + pedimento_number: Pedimento number for error messages + + Returns: + Exchange rate as float, or None if not found + + Raises: + ValueError: When is_shelter=True and exchange rate not found + """ + if not fecha: + return None + + try: + # TODO: Verify exchange_rate table structure and column names + sql_tc = text(""" + SELECT rate + FROM a76.exchange_rate + WHERE rate_date = :fecha + ORDER BY rate_date DESC + LIMIT 1 + """) + res = db.execute(sql_tc, {"fecha": fecha}).fetchone() + if res and res[0]: + return float(res[0]) + else: + # Clarion logic: For Shelter operations with FP, missing exchange rate is an error + if is_shelter and raise_on_missing: + fecha_str = fecha.strftime('%d/%m/%Y') if hasattr(fecha, 'strftime') else str(fecha) + ped_info = f" del Pedimento: {pedimento_number}" if pedimento_number else "" + raise ValueError( + f"Falta el tipo de cambio del día {fecha_str}. " + f"Por favor regístralo en el catálogo de Tipos de Cambio." + ) + logger.warning(f"Exchange rate not found for date {fecha}") + return None + except ValueError: + raise # Re-raise validation errors + except Exception as e: + logger.error(f"Error fetching exchange rate for date {fecha}: {e}") + return None + + @staticmethod + def get_client_info( + db: Session, + db_name: str, + client_code: str, + is_supplier: bool = True + ) -> Dict[str, Optional[str]]: + """ + Get client or supplier information (name, RFC, TaxID). + + Args: + db: Database session + db_name: Database name (kept for compatibility, not used) + client_code: Client/supplier code + is_supplier: True for suppliers, False for clients + + Returns: + Dict with 'name', 'rfc', 'tax_id' keys + """ + if not client_code: + return {"name": None, "rfc": None, "tax_id": None} + + client_type = 'PROVIDER' if is_supplier else 'CLIENT' + + try: + sql = text(""" + SELECT cp.name, cp.rfc, cpp.tax_id + FROM a76.clients_and_providers cp + LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id + WHERE cp.id = :client_code AND cp.client_or_provider = :client_type + """) + result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone() + + if result: + return { + "name": result[0], + "rfc": result[1], + "tax_id": result[2] + } + else: + logger.debug(f"Client {client_code} not found as {client_type}") + return {"name": None, "rfc": None, "tax_id": None} + except Exception as e: + logger.error(f"Error fetching client info for {client_code}: {e}") + raise + + @staticmethod + def get_customs_agent_info( + db: Session, + db_name: str, + agent_code: str + ) -> Dict[str, Optional[str]]: + """ + Get customs agent information (name, license). + + Args: + db: Database session + db_name: Database name (kept for compatibility, not used) + agent_code: Customs agent code + + Returns: + Dict with 'name', 'license' keys + """ + if not agent_code: + return {"name": None, "license": None} + + try: + sql = text(""" + SELECT name, license + FROM a76.customs_brokers + WHERE id = :agent_code + LIMIT 1 + """) + result = db.execute(sql, {"agent_code": agent_code}).fetchone() + + if result: + return { + "name": result[0], + "license": result[1] + } + else: + logger.debug(f"Customs agent {agent_code} not found") + return {"name": None, "license": None} + except Exception as e: + logger.error(f"Error fetching customs agent info for {agent_code}: {e}") + raise + + @staticmethod + def get_aduana_seccion_nombre( + db: Session, + db_name: str, + aduana_seccion: str + ) -> Optional[str]: + """ + Get customs section name. + + Args: + db: Database session + db_name: Database name + aduana_seccion: Customs section code + + Returns: + Customs section name or None + """ + if not aduana_seccion: + return None + + try: + query = text(""" + SELECT section_name + FROM public.customs_sections + WHERE customs_code = :code + """) + result = db.execute(query, {"code": aduana_seccion}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching customs section name: {e}") + raise + + @staticmethod + def get_series_info( + db: Session, + db_name: str, + invoice_id: int, + linea: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for import items. + + Args: + db: Database session + db_name: Legacy database name (not used in PostgreSQL) + invoice_id: Invoice header ID + linea: Line number + is_shelter: Shelter flag (not used) + + Returns: + Formatted series string or None + """ + if not invoice_id or not linea: + return None + + try: + query = text(""" + SELECT serial_numbers, model, brand + FROM a76.item_line_series ils + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id + WHERE il.invoice_id = :invoice_id + AND il.line_number = :linea + ORDER BY ils.id + LIMIT 1 + """) + result = db.execute(query, { + "invoice_id": invoice_id, + "linea": linea + }).fetchone() + + if result: + serial_numbers, model, brand = result + parts = [] + if serial_numbers: + parts.append(serial_numbers) + if model: + parts.append(model) + if brand: + parts.append(brand) + return " / ".join(parts) if parts else None + return None + except Exception as e: + logger.error(f"Error fetching series info: {e}") + raise + + @staticmethod + def get_series_info_export( + db: Session, + db_name: str, + invoice_id: int, + linea: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for export items. + + Args: + db: Database session + db_name: Legacy database name + invoice_id: Invoice header ID + linea: LineaExpo value + is_shelter: Shelter flag + + Returns: + Formatted series string or None + """ + if not invoice_id or not linea: + return None + + try: + # Note: Postgres items table calls it expo_brad (typo in DB schema) + # ItemLineSeries FK is line_item_id, not item_line_id + query = text(""" + SELECT serial_numbers, model, expo_brad + FROM a76.item_line_series ils + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id + WHERE il.invoice_id = :invoice_id + AND il.line_number = :linea + ORDER BY ils.id + LIMIT 1 + """) + result = db.execute(query, { + "invoice_id": invoice_id, + "linea": linea + }).fetchone() + + if result: + serial_numbers, model, expo_brand = result + parts = [] + if serial_numbers: + parts.append(serial_numbers) + if model: + parts.append(model) + if expo_brand: + parts.append(expo_brand) + return " | ".join(parts) if parts else None + return None + except Exception as e: + logger.error(f"Error fetching export series info: {e}") + raise + + @staticmethod + def get_rectification_pedimento( + db: Session, + pedimento: str, + ped_rectifica: Optional[str], + is_shelter: bool = False + ) -> Optional[str]: + """ + Get final pedimento rectification number following the chain recursively. + + Clarion logic: + - IF Loc:OpcionShelter = 1 THEN: use direct field value (PedRectifica) + - ELSE: call BuscarRectificacion() - follows rectification chain recursively + + BuscarRectificacion follows the chain: + Example: A1 -> A2 -> A3 -> A4 (returns A4, the final rectification) + + Args: + db: Database session + pedimento: Original pedimento number + ped_rectifica: Initial rectification pedimento from database field + (already resolved from pedimento_rectification_origin JOIN in query) + is_shelter: Shelter company flag + + Returns: + Final rectification pedimento number in the chain, or None/empty if no rectification + """ + if is_shelter: + # Shelter: use direct value from PedRectifica field + result = ped_rectifica + else: + # Non-Shelter: implement BuscarRectificacion logic + result = DatabaseHelper._buscar_rectificacion(db, pedimento, ped_rectifica) + return result + + @staticmethod + def _buscar_rectificacion( + db: Session, + pedimento_orig: str, + ped_rec: Optional[str] + ) -> Optional[str]: + """ + BUSCA ULTIMO PEDIMENTO DE RECTIFICACION + Returns the rectification origin pedimento string already resolved by the query + builder's JOIN on pedimento_rectification_origin. + + Args: + db: Database session + pedimento_orig: Original pedimento number (e.g. "1234567") + ped_rec: Rectification pedimento origin string already computed by the SQL JOIN + (e.g. "25-470-8000-1234567") + + Returns: + The rectification origin string, or empty string if none. + """ + if not ped_rec: + return '' + + # The ped_rec value already comes from the JOIN on pedimento_rectification_origin + # in the query builder, so it is the directly stored origin pedimento. + # Return it directly without any further recursive DB lookup. + return ped_rec + + @staticmethod + def _busca_pedimento_r1( + db: Session, + pedimento: str, + visited: set + ) -> Optional[str]: + """ + BUSCA_PEDIMENTO_R1 ROUTINE - Recursive search for final rectification pedimento + using pedimento_rectification_origin table. + + Args: + db: Database session + pedimento: Current pedimento number to check + visited: Set of already visited pedimentos (prevents infinite loops) + + Returns: + Final pedimento in chain, or None if circular reference detected + """ + if pedimento in visited: + # Circular reference detected (ERRORCODE = 30 equivalent) + logger.warning(f"Circular reference detected in rectification chain: {pedimento}") + return None + + try: + # Query pedimento_rectification_origin for the next pedimento in the chain. + # NOTE: The a76.pedimentos table does NOT have a ped_rectifica column. + # Rectification data lives in pedimento_rectification_origin. + sql = text(""" + SELECT + pro.original_pedimento_year || '-' || pro.original_customs_office || + '-' || pro.original_license || '-' || pro.original_pedimento_number AS ped_origen + FROM a76.pedimento_rectification_origin pro + INNER JOIN a76.pedimentos ped ON ped.id = pro.pedimento_id + WHERE ped.pedimento_number = :pedimento + AND pro.deleted_at IS NULL + LIMIT 1 + """) + result = db.execute(sql, {"pedimento": pedimento}).fetchone() + + if result and result[0] and result[0].replace('-', '').strip(): + ped_rectifica_next = result[0] + + # Add current pedimento to visited set + visited.add(pedimento) + + # Recurse with next rectification origin + final_ped = DatabaseHelper._busca_pedimento_r1( + db, ped_rectifica_next, visited + ) + + return final_ped if final_ped else pedimento + else: + # No more rectifications, this is the final pedimento + return pedimento + + except Exception as e: + logger.error(f"Error fetching rectification origin for pedimento {pedimento}: {e}") + return None + + @staticmethod + def get_driver_badge( + db: Session, + db_name: str, + factura: str + ) -> Optional[str]: + """ + Get driver unique badge number (NUMGAFETEUNICO) for invoice. + + Clarion query: + SELECT NUMGAFETEUNICO FROM GConductor + LEFT JOIN QFacImp ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = '' + + Modern schema: + - invoice_header has invoice_number + - invoice_logistics links to invoice via invoice_id and has driver_name + - driver table has unique_badge_number and driver_name + + Args: + db: Database session + db_name: Database name (not used in modern schema) + factura: Invoice number + + Returns: + Driver unique badge number or None + """ + if not factura: + return None + + try: + # Join invoice_header -> invoice_logistics -> driver via driver_name + query = text(""" + SELECT d.unique_badge_number + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.driver d ON d.driver_name = log.driver_name + WHERE ih.invoice_number = :factura + AND d.unique_badge_number IS NOT NULL + LIMIT 1 + """) + result = db.execute(query, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching driver badge for invoice {factura}: {e}") + raise + + @staticmethod + def get_part_export_symbol( + db: Session, + db_name: str, + num_parte: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get export symbol/license for a part number. + + Args: + db: Database session + db_name: Database name (not used in PostgreSQL, kept for compatibility) + num_parte: Part number + is_shelter: Shelter flag (not used, kept for compatibility) + + Returns: + Export symbol/license or None + """ + if not num_parte: + return None + + try: + query = text(""" + SELECT exclusion_symbol + FROM a76.parts + WHERE part_number = :num_parte + LIMIT 1 + """) + result = db.execute(query, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching export symbol for part {num_parte}: {e}") + raise diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py new file mode 100644 index 00000000..dd710d24 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -0,0 +1,383 @@ +""" +Definitive import service - handles COMEX/IMPDF movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportDefinitiveFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import DefinitiveImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class DefinitiveImportService: + """Service for handling definitive import movements (COMEX/IMPDF).""" + + def get_movements( + self, + db: Session, + filters: "ImportDefinitiveFilter" + ) -> List["MovementItem"]: + """ + Get definitive import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(DefinitiveImportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} definitive import invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaImpoDef + estatus = row[3] # C4 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + invoice_id = row[16] # C35 - invoice ID + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + total_me = to_float(row[28]) # total_me from SUM aggregation + total_mn = to_float(row[29]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[30]) + sum_value_mxn = to_float(row[31]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[20]), # C51 - TipoCambio + fecha_pago=row[8], # C13 - Fecha_Pago + fecha_inicio=row[6], # C11 - Fecha_Inicio + tipo_pedimento=row[4], # C5 - ClavePed (Fix: using C5 instead of empty C59) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpoDef + row[17], # C42 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoImpoDef + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMPDF', + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C43 - EDocument + NumOperacionVU=row[19], # C44 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C53 - UsuarioCap + UsuarioAcr=row[23], # C54 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[8]), # C13 - Fecha_Pago + NumCaja=row[24], # C56 - Transporte + NumTrasporte + Pedimento18=row[25], # C57 - empty (index 25) + AduanaCru=row[15], # C39 - Aduana_Cruce + Lote=row[26] # C58 - empty (index 26) + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching definitive import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportDefinitiveFilter" + ) -> List["MovementItemDetailed"]: + """ + Get definitive import movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed definitive import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute main query + sql = text(DefinitiveImportQueries.build_main_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed definitive import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus + continue + + # Get additional detailed information + # Provider and client names now come directly from query (row[7], row[8]) + # But we still need RFC and TaxID from the helper + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[15], is_supplier=True + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[16], is_supplier=False + ) + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[17] + ) + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[38] + ) + + # Calculate values using unified method + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=row[26], # C27 - ValorImpoME + valor_mn_direct=row[24], # C25 - ValorImpoMN + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C1 entry_date + clave_ped=row[57] if len(row) > 57 else '', # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + if row[39] == 'P': # C40 - EsSubPartida + peso_neto = float(row[28]) if row[28] else 0.0 # C29 + peso_bruto = float(row[29]) if row[29] else 0.0 # C30 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44 + ) + + simbolo_ex = None + if row[48]: # C49 - Part Number + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[48], filters.is_shelter + ) + + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, row[1], row[40], filters.is_shelter # C2, C41 + ) + + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[0] # C1 + ) + + movement = MovementItemDetailed( + Linea=row[43], # C44 + Factura=row[0], # C1 + Pedimento=row[1], # C2 + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef='IMPDF', + EsCambioRegimen='N', + Regimen=row[9], # C10 + Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11 + Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12 + Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13 + Remesa=str(row[13]) if row[13] is not None else None, # C14 + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[48], # C49 + DescripcionE=StringHelper.clean_text(row[20]), # C21 + DescripcionI=StringHelper.clean_text(row[21]), # C22 + CantidadIE=float(row[22]) if row[22] else 0.0, # C23 + UniMed=row[23], # C24 + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[30], # C31 + FraccionArancelaria=row[31], # C32 + Preferencia=row[32], # C33 + Sector=row[34], # C35 + PaisOrigen=row[36], # C37 + Aduana=aduana_nombre, + Advalorem='P' if row[39] == 'P' else 'S', # C40 + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[41], # C42 + NumOperacionVU=row[42], # C43 + Series=series_info, + Marca=StringHelper.clean_text(row[44]), # C45 + Modelo=StringHelper.clean_text(row[45]), # C46 + FraccionAmericana=row[46], # C47 + ECCN=row[47], # C48 + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[50]) if row[50] else None, # C51 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[51], # C52 + UsuarioAcr=row[52], # C53 + Transportista=row[53], # C54 + NumCaja=row[54], # C55 + Pedimento18=row[55] if len(row) > 55 else '', # C56 + AduanaCru=row[37], # C38 + Lote=row[56] if len(row) > 56 else '' # C57 + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed definitive import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportDefinitiveFilter") -> str: + """Build WHERE clause for definitive imports query.""" + where_conditions = [] + + # STRICT SEPARATION: Only imports + where_conditions.append("ih.operation_type = 'imp'") + + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + # ALWAYS filter by specific invoice_type to avoid duplication with Temporary service + where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """Calculate totals for main partidas only. + + Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion). + """ + sql = text(""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND COALESCE(il.is_subpartida, false) = false + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py new file mode 100644 index 00000000..5c9f2010 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py @@ -0,0 +1,193 @@ +""" +Exchange rate calculation logic for invoice movements. +""" + +import logging +from sqlalchemy.orm import Session +from typing import Tuple, Optional +from .base import DateHelper +from .database_helpers import DatabaseHelper + +logger = logging.getLogger(__name__) + + +class ExchangeRateCalculator: + """Handles exchange rate calculations and commercial value conversions.""" + + @staticmethod + def calculate_exchange_rate_and_value( + db: Session, + db_name: str, + es_subpartida: str, + valor_me: Optional[float], + valor_mn_direct: Optional[float], + fecha_pago, + fecha_inicio, + clave_ped: str, + tipo_cambio_partida: Optional[float], + currency_type: str, + exchange_rate_type: str, + met_trans: int + ) -> Tuple[float, Optional[float]]: + """ + Unified method to calculate exchange rate and commercial value. + Eliminates duplicated logic across all import types. + + Args: + db: Database session + db_name: Database name + es_subpartida: Subpartida flag ('P' for partida, 'S' for subpartida) + valor_me: Value in foreign currency (ME) + valor_mn_direct: Direct value in local currency (MN) + fecha_pago: Payment date + fecha_inicio: Start/entry date + clave_ped: Pedimento type code + tipo_cambio_partida: Exchange rate from partida record + currency_type: "ME" or "MN" + exchange_rate_type: "FP" (payment date) or "FT" (transaction date) + met_trans: MetTrans configuration value + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio_final) + """ + # Handle subpartidas - always return zero + if es_subpartida == 'S': + return (0.0, None) + + # Handle foreign currency (ME) case + if currency_type == "ME": + valor_comercial = valor_me or 0.0 + tipo_cambio_final = tipo_cambio_partida + + # Try to get exchange rate from GTipoCambio if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=clave_ped, + use_transport_method=True, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc) + if tc_value: + tipo_cambio_final = tc_value + + # For ME, the value is always in foreign currency (USD) + return (valor_comercial, tipo_cambio_final) + + # Handle local currency (MN) case + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=clave_ped, + use_transport_method=True, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc) + + if tc_value and valor_me is not None: + # Calculate MN value from ME * payment date exchange rate + return (valor_me * tc_value, tc_value) + else: + if tc_value is None: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using partida values") + return (valor_mn_direct or 0.0, tipo_cambio_partida) + else: + # exchange_rate_type == "FT" (Invoice Date) + # Use direct MN value and partida exchange rate + return (valor_mn_direct or 0.0, tipo_cambio_partida) + + @staticmethod + def calculate_for_aggregated( + db: Session, + db_name: str, + valor_me: float, + valor_mn: float, + tipo_cambio_db: float, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + currency_type: str, + exchange_rate_type: str, + is_shelter: bool, + use_transport_method: bool, + met_trans: int + ) -> Tuple[float, Optional[float]]: + """ + Calculate exchange rate and value for aggregated (normal mode) movements. + + This method is used when movements are grouped by invoice rather than + showing individual partidas. + + Args: + db: Database session + db_name: Database name + valor_me: Aggregated value in foreign currency + valor_mn: Aggregated value in local currency + tipo_cambio_db: Exchange rate from database + fecha_pago: Payment date + fecha_inicio: Start date + tipo_pedimento: Pedimento type + currency_type: "ME" or "MN" + exchange_rate_type: "FP" or "FT" + is_shelter: Shelter company flag (kept for compatibility) + use_transport_method: Use transport method flag + met_trans: MetTrans value from config + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio) + """ + # Foreign currency case + if currency_type == "ME": + valor_comercial = valor_me + tipo_cambio = tipo_cambio_db + + # Try to get exchange rate if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + use_transport_method=use_transport_method, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate( + db, db_name, fecha_tc, + is_shelter=is_shelter, + raise_on_missing=is_shelter + ) + + if tc_value: + # For ME, the value is always in foreign currency (USD). Just return the new exchange rate. + return (valor_comercial, tc_value) + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + + return (valor_comercial, tipo_cambio) + + # Local currency case + else: + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + use_transport_method=use_transport_method, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate( + db, db_name, fecha_tc, + is_shelter=is_shelter, + raise_on_missing=is_shelter + ) + + if tc_value and valor_me is not None: + # Calculate MN value from ME * payment date exchange rate + return (valor_me * tc_value, tc_value) + else: + if tc_value is None: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + return (valor_mn, tipo_cambio_db) + else: + return (valor_mn, tipo_cambio_db) diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py new file mode 100644 index 00000000..de65c9db --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -0,0 +1,408 @@ +""" +Export service - handles export movements (EXPO DEF). +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional + +from ..schemas import ExportFilter, MovementItem, MovementItemDetailed +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import ExportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class ExportService: + """Service for handling export movements (EXPO DEF).""" + + def get_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """ + Get export movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + try: + logger.info(f"Fetching export movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + # Note: discharge_clause not used in aggregated query for exports + sql = text(ExportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} export invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaExpo + tipo_mov = row[14] # C34 - TipoFactura + + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C6 - Estatus + continue + + consecutivo = row[15] # C35 - Consecutivo + + # Totals come directly from GROUP BY query (no N+1 problem) + def to_float(val): + if val is None or val == '': return 0.0 + try: return float(val) + except (ValueError, TypeError): return 0.0 + + total_me = to_float(row[23]) # total_me from SUM aggregation + total_mn = to_float(row[24]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[26]) + sum_value_mxn = to_float(row[27]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=row[18], # C48 - TipoCambio + fecha_pago=row[7], # C11 - Fecha_Pago + fecha_inicio=row[6], # C10 - Fecha_Inicio (mapped previously to C9) + tipo_pedimento='', # Not in aggregated query + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=filters.use_transport_method, + met_trans=met_trans + ) + + # Get pedimento rectification + rectified_pedimento = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[25], # C54 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + driver_badge = self._get_driver_badge(db, filters.database_name, factura) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C6 - Estatus + ClavePed=row[4], # C7 - ClavePed + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=to_float(row[28]), + TipoExpo='EXPO DEF', + PedimentoR1=rectified_pedimento, + EDocument=row[16], # C40 - EDocument + NumOperacionVU=row[17], # C41 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=driver_badge, + UsuarioCap=row[20], # C50 - UsuarioCap + UsuarioAcr=row[21], # C51 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[7]), # C11 - Fecha_Pago + NumCaja=row[22], # C53 - Transporte + NumTrasporte + Pedimento18='', # Not in aggregated query + AduanaCru=row[13], # C33 - Aduana_Cruce + Lote='' # Not in aggregated query + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} export movements") + return movements + + except Exception as e: + logger.error(f"Error fetching export movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """ + Get export movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + try: + logger.info(f"Fetching detailed export movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Build discharge filter for main query + discharge_clause = "" + if filters.discharge_filter == "SiDes": + discharge_clause = " AND EqiPex.Descarga = 1" + elif filters.discharge_filter == "NoDes": + discharge_clause = " AND EqiPex.Descarga = 0" + + # Modify main query to include discharge filter + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(ExportQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed export partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus + continue + + # Get client/supplier information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA + ) + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[15] # C16 - AAduanal + ) + + # Get customs section name + customs_name = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[32] # C33 - Aduana_Cruce + ) + + # Calculate exchange rate and value for this partida + valor_mn, tipo_cambio_final = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[37], # C38 - EsSubPartida + valor_me=row[36], # C37 - ValorExpoME + valor_mn_direct=row[35], # C36 - ValorExpoMN + fecha_pago=row[10], # C11 - Fecha_Pago + fecha_inicio=row[8], # C9 - Fecha_Inicio + clave_ped=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[47], # C48 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values (material_type is typically 'PT' or 'MP', not just 'P') + peso_neto_final = row[24] if row[37] != 'S' else 0 # C25 - PesoNeto + peso_bruto_final = row[25] if row[37] != 'S' else 0 # C26 - PesoBruto + + # Get series information + series_info = DatabaseHelper.get_series_info_export( + db, filters.database_name, row[34], row[41], filters.is_shelter + ) + + # Get pedimento rectification + rectified_pedimento = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[38], # C39 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + driver_badge = self._get_driver_badge(db, filters.database_name, row[0]) # C1 - FacturaExpo + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[41], # C42 - LineaExpo + Factura=row[0], # C1 - FacturaExpo + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=row[2], # C3 - FechaFactura + Estatus=row[5], # C6 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef=row[31], # C34 - TipoFactura + EsCambioRegimen='N', + Regimen=row[5], # C6 - Regime (Shared index with Estatus in this query) + Fecha_Inicio=parse_yyyymmdd_date(row[6]), # C7 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[7]), # C8 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Pago + Remesa=row[9], # C12 - Remesa + TipoCambio=tipo_cambio_final, + Proveedor=proveedor_info.get("name"), + RFCProveedor=proveedor_info.get("rfc"), + ProveedorTaxID=proveedor_info.get("tax_id"), + VendidoA=vendido_info.get("name"), + VendidoARFC=vendido_info.get("rfc"), + VendidoATaxID=vendido_info.get("tax_id"), + AgenteAduanal=agente_info.get("name"), + Patente=agente_info.get("license"), + NumParte=row[17], # C18 - NumParte + DescripcionE=StringHelper.remove_commas(row[18]), # C19 - DescripcionE + DescripcionI=StringHelper.remove_commas(row[19]), # C20 - DescripcionI + CantidadIE=row[20], # C21 - CantExpo + UniMed=row[21], # C22 - UnidadMedida + ValorComercialMN=valor_mn, + PesoNeto=peso_neto_final, + PesoBruto=peso_bruto_final, + OrdenCompraVenta=row[26], # C27 - OrdenCompra + FraccionArancelaria=row[27], # C28 - FraccionExpo + Preferencia=row[28], # C29 - TipoFraccion + Sector=row[30], # C31 - Sector + PaisOrigen=row[31], # C32 - PaisOrigen + Aduana=customs_name, + Advalorem=row[29], # C30 - Advalorem + TipoExpo='EXPO DEF', + PedimentoR1=rectified_pedimento, + EDocument=row[39], # C40 - EDocument + NumOperacionVU=row[40], # C41 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[42]), # C43 - Marca + Modelo=StringHelper.clean_text(row[43]), # C44 - Modelo + FraccionAmericana=row[44], # C45 - FraccionAme + ECCN=row[45], # C46 - ECCN + FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=driver_badge, + UsuarioCap=row[49], # C50 - UsuarioCap + UsuarioAcr=row[50], # C51 - UsuarioAct + Transportista=row[51], # C52 - Carrier ID (derived from log.transport_id) + NumCaja=row[52], # C53 - log.transport_id || log.transport_num + Pedimento18=row[53], # C54 - empty + AduanaCru=row[32], # C33 - Aduana_Cruce + Lote=row[54] if len(row) > 54 else '' # C55 - Lote + ) + + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed export movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed export movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: ExportFilter) -> str: + """ + Build WHERE clause for export query. + + IMPORTANT: Returns conditions WITHOUT the WHERE keyword (already in base query) + AC (Active) = is_updated = true + NA (Not Applicable/Deactivated) = is_updated = false + """ + conditions = [] + + # STRICT SEPARATION: Only exports + conditions.append("ih.operation_type = 'exp'") + + # Exclude REP (export reports) + conditions.append("ih.invoice_type NOT IN ('REP')") + + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + if filters.movement_type.value != "ALL": + # Filter by invoice type for exports + conditions.append("ih.invoice_type IN ('EXP', 'EXREP')") + + # CRITICAL VALIDATION: AC/NA status filter + # If include_cancelled is False (checkbox unchecked), only show AC invoices + # AC (Active) = is_updated = true + # NA (Not Applicable/Deactivated) = is_updated = false + if not filters.include_cancelled: + conditions.append("ih.is_updated = true") + logger.debug("Filtering only active invoices (is_updated = true)") + else: + logger.debug("Including cancelled invoices (include_cancelled = true)") + + # Date range + date_field = "ih.invoice_date" if filters.range_type.value == "FF" else "pd.payment_date" + conditions.append(f"{date_field} >= TO_DATE('{filters.start_date}', 'YYYYMMDD')") + conditions.append(f"{date_field} <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Optional filters + if filters.provider: + conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + if filters.buyer: + conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + if filters.pedimento_code: + conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(conditions) + + def _build_discharge_clause(self, discharge_filter: str) -> str: + """Build discharge filter clause for totals query.""" + if discharge_filter == "SiDes": + return " AND il.is_discharged = true" + elif discharge_filter == "NoDes": + return " AND il.is_discharged = false" + return "" + + def _calculate_totals( + self, + db: Session, + db_name: str, + consecutivo: int, + discharge_clause: str + ) -> tuple: + """Calculate total values for an export invoice.""" + try: + sql = text(ExportQueries.build_totals_query(db_name, discharge_clause)) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating export totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str: + """Get driver's unique badge number for an export invoice.""" + if not factura: + return None + + try: + sql = text(ExportQueries.build_driver_badge_query(db_name)) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for export invoice {factura}: {e}") + return None diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py new file mode 100644 index 00000000..63704ddd --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py @@ -0,0 +1,409 @@ +""" +Export repair service - handles EXPO REP movements (repair exports). +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional + +from ..schemas import ExportRepairFilter, MovementItem, MovementItemDetailed +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import ExportRepairQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class ExportRepairService: + """Service for handling export repair movements (EXPO REP).""" + + def get_movements( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItem]: + """ + Get export repair movements (normal mode - grouped by invoice). + + Aggregated query column order (ExportRepairQueries.build_aggregated_query): + [0] C1 - invoice_number + [1] C2 - pedimento_number + [2] C3 - invoice_date + [3] C6 - estatus (AC/NA) + [4] C7 - pedimento_code + [5] C8 - regime + [6] C11 - payment_date + [7] C12 - remesa + [8] C13 - exchange_rate (fecha_pago context) + [9] C14 - provider_id + [10] C15 - sold_to_id + [11] C16 - customs_broker_id + [12] C27 - purchase_order + [13] C33 - customs_office ← AduanaCru + [14] C34 - document_type ← TipoFactura / tipo_mov + [15] C35 - id ← consecutivo + [16] C40 - edocument ← EDocument + [17] C41 - vucem_op_num ← NumOperacionVU + [18] C48 - exchange_rate ← TipoCambio + [19] C49 - emission_date + [20] C50 - capture_user ← UsuarioCap + [21] C51 - who_updated ← UsuarioAcr + [22] C52 - carrier_id ← Transportista + [23] C53 - transport ← NumCaja + [24] total_me + [25] total_mn + [26] C54 - ped_r1 ← PedimentoR1 + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + try: + logger.info(f"Fetching export repair movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(ExportRepairQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} export repair invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaExpo + tipo_mov = row[14] # C34 - TipoFactura + estatus = row[3] # C6 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[15] # C35 - Consecutivo + + # Totals come directly from GROUP BY query (no N+1 problem) + def to_float(val): + if val is None or val == '': return 0.0 + try: return float(val) + except (ValueError, TypeError): return 0.0 + + total_me = to_float(row[24]) # total_me + total_mn = to_float(row[25]) # total_mn + sum_value_usd = to_float(row[27]) + sum_value_mxn = to_float(row[28]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=row[18], # C48 - TipoCambio + fecha_pago=row[6], # C11 - Fecha_Pago + fecha_inicio='', + tipo_pedimento='', + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification (already resolved by SQL COALESCE) + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[26], # C54 - PedRectifica (pre-built by SQL) + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C6 - Estatus + ClavePed=row[4], # C7 - ClavePed + TipoMovTemDef=tipo_mov, # C34 - TipoFactura + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=to_float(row[29]), + TipoExpo='EXPO REP', + PedimentoR1=pedimento_r1, + EDocument=row[16], # C40 - EDocument ← FIXED (was 17) + NumOperacionVU=row[17], # C41 - NumOperacionVU ← FIXED (was 18) + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[20], # C50 - UsuarioCap ← FIXED (was 21) + UsuarioAcr=row[21], # C51 - UsuarioAct ← FIXED (was 22) + Fecha_Pago=parse_yyyymmdd_date(row[6]), # C11 - Fecha_Pago ← FIXED (was 8) + NumCaja=row[23], # C53 - NumCaja + Pedimento18='', + AduanaCru=row[13], # C33 - customs_office ← FIXED (was 14) + Lote='' + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} export repair movements") + return movements + + except Exception as e: + logger.error(f"Error fetching export repair movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItemDetailed]: + """ + Get export repair movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + try: + logger.info(f"Fetching detailed export repair movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Add discharge filter to WHERE clause + discharge_clause = "" + if filters.discharge_filter.value == "SiDes": + discharge_clause = " AND RepPex.Descarga = 1" + elif filters.discharge_filter.value == "NoDes": + discharge_clause = " AND RepPex.Descarga = 0" + + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(ExportRepairQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed export repair partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus + continue + + # Get provider information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor + ) + + # Get buyer information + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA + ) + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[15] # C16 - AAduanal + ) + + # Get customs section name + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[32] # C33 - Aduana_Cruce + ) + + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[37] != 'S': # C38 - EsSubPartida + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_partida( + db=db, + db_name=filters.database_name, + valor_me=row[36], # C37 - ValorExpoME + valor_mn=row[35], # C36 - ValorExpoMN + tipo_cambio_db=row[47], # C48 - TipoCambio + fecha_pago=row[10], # C11 - Fecha_Pago + fecha_inicio=row[8], # C9 - Fecha_Inicio + tipo_pedimento=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + peso_neto = float(row[24]) if row[24] else 0.0 # C25 + peso_bruto = float(row[25]) if row[25] else 0.0 # C26 + else: # Subpartida + valor_comercial = 0.0 + tipo_cambio = 0.0 + peso_neto = 0.0 + peso_bruto = 0.0 + + # Get series information + series_info = DatabaseHelper.get_series_info_export( + db, filters.database_name, row[34], row[41], filters.is_shelter # C35, C42 + ) + + # Get part export symbol + simbolo_ex = None + if row[46]: # C47 - NumParte + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[46], filters.is_shelter + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[38], # C39 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, row[0]) + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[41], # C42 - LineaExpo + Factura=row[0], # C1 - FacturaExpo + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=row[2], # C3 - FechaFactura + Estatus=row[5], # C6 - Estatus + ClavePed=row[6], # C7 - ClavePed + TipoMovTemDef=row[33], # C34 - TipoFactura + EsCambioRegimen='N', + Regimen=row[7], # C8 - Regimen + Fecha_Inicio=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[9]), # C10 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Pago + Remesa=row[11], # C12 - Remesa + Proveedor=proveedor_info.get('name'), + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=vendido_info.get('name'), + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[46], # C47 - NumParte + DescripcionE=StringHelper.clean_text(row[18]), # C19 + DescripcionI=StringHelper.clean_text(row[19]), # C20 + CantidadIE=float(row[20]) if row[20] else 0.0, # C21 + UniMed=row[21], # C22 + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[26], # C27 - OrdenCompra + FraccionArancelaria=row[27], # C28 - FraccionExpo + Preferencia=row[28], # C29 - TipoFraccion + Sector=row[30], # C31 - Sector + PaisOrigen=row[31], # C32 - PaisOrigen + Aduana=aduana_nombre, + Advalorem=row[37], # C38 - EsSubPartida + TipoExpo='EXPO REP', + PedimentoR1=pedimento_r1, + EDocument=row[39], # C40 - EDocument + NumOperacionVU=row[40], # C41 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[42]), # C43 + Modelo=StringHelper.clean_text(row[43]), # C44 + FraccionAmericana=row[44], # C45 - FraccionAme + ECCN=row[45], # C46 - ECCN + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaFactura + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[49], # C50 - UsuarioCap + UsuarioAcr=row[50], # C51 - UsuarioAct + Transportista=row[51], # C52 - Transportista + NumCaja=row[52], # C53 - Transporte + NumTrasporte + Pedimento18=row[53], # C54 - Pedimento18 + AduanaCru=row[32], # C33 - Aduana_Cruce + Lote=row[54] if len(row) > 54 else '' # C55 - Lote + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed export repair movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed export repair movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: ExportRepairFilter) -> str: + """Build WHERE clause for export repair query.""" + where_conditions = [] + + # STRICT SEPARATION: Only exports for repair + where_conditions.append("ih.operation_type = 'exp'") + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + if filters.movement_type.value == "ALL": + pass + else: + where_conditions.append("ih.invoice_type = 'REPAR'") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "AFIJO": + where_conditions.append("ih.document_type = 'AFIJO'") + elif filters.movement_type.value == "NODES": + where_conditions.append("ih.document_type = 'NODES'") + + return " AND ".join(where_conditions) + + return total_me, total_mn + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str: + """Get driver badge number for invoice.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return None \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py new file mode 100644 index 00000000..7b2d7fdb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -0,0 +1,1071 @@ +""" +SQL Query builders for invoice movement services. +Centralizes all SQL query construction logic. +""" + + +class TemporaryImportQueries: + """SQL queries for temporary imports using PostgreSQL tables.""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: db_name parameter kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE(lf_agg.sum_value_mxn, 0) AS valor_comercial_mn, + COALESCE(lf_agg.sum_value_temp_mxn, 0) AS valor_mp_temp_mn, + COALESCE(lf_agg.sum_value_added_mxn, 0) AS valor_agre_mn, + COALESCE(lf_agg.sum_value_temp_usd, 0) AS valor_mp_temp_usd, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(COALESCE(lf.value_mxn, 0)) AS sum_value_mxn, + SUM(COALESCE(lf.value_temp_material_mxn, 0)) AS sum_value_temp_mxn, + SUM(COALESCE(lf.value_added_mxn, 0)) AS sum_value_added_mxn, + SUM(COALESCE(lf.value_temp_material_usd, 0)) AS sum_value_temp_usd, + SUM(COALESCE(lf.value_usd, 0)) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + ORDER BY ih.invoice_number + """ + + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL.""" + # Note: db_name parameter is kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(prov.name, '') AS C8, + COALESCE(client.name, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(il.unit_of_measure::text, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(il.order, ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(il.material_type, 'P') AS C40, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, -- [40] rectification_id + cmp.edocument AS C42, -- [41] + cmp.vucem_operation_num AS C43, -- [42] + COALESCE(il.line_number, 0) AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, fac.eccn_code, '') AS C48, + COALESCE(prt.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, + '' AS C56, + COALESCE(ld.lot, '') AS C57, + '' AS C58, + COALESCE(lf.value_temp_material_mxn, 0) AS C59, + COALESCE(lf.value_temp_material_usd, 0) AS C60 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for an invoice.""" + return f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information.""" + return f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number.""" + return f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """ + + +class DefinitiveImportQueries: + """SQL queries for definitive imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(cmp.aduana, '') AS C39, + ih.id AS C35, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C42, + COALESCE(cmp.edocument, '') AS C43, + COALESCE(cmp.vucem_operation_num, '') AS C44, + COALESCE(fin.exchange_rate, 0) AS C51, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52, + COALESCE(ih.capture_user, '') AS C53, + COALESCE(ih.who_updated, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C56, + '' AS C57, + '' AS C58, + '' AS C59, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE') + AND {where_clause} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL for definitive imports.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(prov.name, '') AS C8, + COALESCE(client.name, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(um.code, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(il.order, ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(il.material_type, 'P') AS C40, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, + cmp.edocument AS C42, + cmp.vucem_operation_num AS C43, + COALESCE(il.line_number, 0) AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, fac.eccn_code, '') AS C48, + COALESCE(prt.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C55, + '' AS C56, + COALESCE(ld.lot, '') AS C57, + '' AS C58, + 0 AS C59, + 0 AS C60 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for a definitive import invoice.""" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for definitive imports.""" + # TODO: QSeriesDef table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for definitive imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class RepairImportQueries: + """SQL queries for repair imports (PostgreSQL schema). + + Note: Repair imports are NOT identified by invoice_type, but by having + cross-references (search_invoice field) that link them to export invoices. + These are regular import invoices (TEM, DEF, etc.) that were imported for repair. + """ + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + + return f""" + SELECT + ih.invoice_number AS C2, + COALESCE(ped.pedimento_number, '') AS C3, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5, + COALESCE(ped.pedimento_code, '') AS C6, + COALESCE(ped.regime, '') AS C7, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C9, + COALESCE(cmp.remesa::text, '') AS C10, + COALESCE(fin.exchange_rate, 0) AS C11, + COALESCE(cmp.provider_id::text, '') AS C12, + COALESCE(cmp.sold_to_id::text, '') AS C13, + COALESCE(cmp.customs_broker_id::text, '') AS C14, + COALESCE(ih.purchase_order, '') AS C24, + COALESCE(ped.customs_office, '') AS C29, + ih.id AS C30, + COALESCE(cmp.edocument, '') AS C33, + COALESCE(cmp.vucem_operation_num, '') AS C34, + COALESCE(fin.exchange_rate, 0) AS C40, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41, + COALESCE(ih.capture_user, '') AS C42, + COALESCE(ih.who_updated, '') AS C43, + COALESCE(log.carrier_id, '') AS C44, + COALESCE(log.transport_num, '') AS C45, + COALESCE(ped.pedimento_code, '') AS C47, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C48, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE 1=1 {discharge_filter} + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND COALESCE(cmp.is_regime_change, false) = false + AND EXISTS ( + SELECT 1 FROM a76.item_lines il2 + INNER JOIN a24.fa_item_lines fil2 ON fil2.id = il2.id + WHERE il2.invoice_id = ih.id AND fil2.search_invoice IS NOT NULL + {discharge_filter} + ) + {"AND " + where_str if where_str else ""} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build main SQL query for repair import data.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + il.line_number, + ih.invoice_number, + COALESCE(ped.pedimento_number, ''), + TO_CHAR(ih.invoice_date, 'YYYYMMDD'), + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END, + COALESCE(ped.pedimento_code, ''), + COALESCE(ped.regime, ''), + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), ''), + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), ''), + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), ''), + COALESCE(cmp.remesa::text, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(cmp.provider_id::text, ''), + COALESCE(cmp.sold_to_id::text, ''), + COALESCE(cmp.customs_broker_id::text, ''), + COALESCE(prt.part_number::text, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lq.quantity, 0), + COALESCE(il.unit_of_measure, 0), + COALESCE(lf.value_mxn, 0), + COALESCE(lf.value_usd, 0), + COALESCE(lq.net_weight, 0), + COALESCE(lq.gross_weight, 0), + COALESCE(il.order, ih.purchase_order, ''), + COALESCE(lc.fraction, ''), + '', + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), ''), + COALESCE(lc.origin_country, ''), + COALESCE(ped.customs_office, ''), + ih.id, + COALESCE(il.material_type, 'P'), + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ), + COALESCE(cmp.edocument, ''), + COALESCE(cmp.vucem_operation_num, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lc.american_fraction, ''), + COALESCE(prt.eccn, fac.eccn_code, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''), + COALESCE(ih.capture_user, ''), + COALESCE(ih.who_updated, ''), + COALESCE(log.carrier_id, ''), + COALESCE(log.transport_num, ''), + '', + COALESCE(ped.pedimento_code, '') + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND COALESCE(cmp.is_regime_change, false) = false + AND fil.search_invoice IS NOT NULL + {"AND " + where_str if where_str else ""} + {discharge_filter} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for a repair import invoice.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for repair imports.""" + # TODO: QSeriesImpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for repair imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportQueries: + """SQL queries for exports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """ + Build optimized query for NORMAL mode (grouped by invoice with totals). + + Args: + db_name: Database name (not used in PostgreSQL version) + where_clause: Additional WHERE conditions (without WHERE keyword) + """ + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(log.payment_receipt_num, '') AS C12, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(cmp.aduana, '') AS C33, + COALESCE(ih.invoice_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C54, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE {where_clause} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + '' AS C4, -- [3] + ped.pedimento_code AS C5, -- [4] + ped.regime AS C6, -- [5] + pd.entry_date AS C7, -- [6] + COALESCE(pd.end_date, pd.payment_date) AS C8, -- [7] + pd.payment_date AS C9, -- [8] + log.payment_receipt_num AS C12, -- [11] + '' AS C13, -- [12] + cmp.provider_id AS C14, -- [13] + cmp.sold_to_id AS C15, -- [14] + cmp.customs_broker_id AS C16, -- [15] + '' AS C17, -- [16] + COALESCE(prt.part_number::text, '') AS C18, -- [17] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, -- [18] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, -- [19] + lq.quantity AS C21, -- [20] + um.code AS C22, -- [21] + '' AS C23, -- [22] + '' AS C24, -- [23] + COALESCE(lq.net_weight, 0) AS C25, -- [24] + COALESCE(lq.gross_weight, 0) AS C26, -- [25] + COALESCE(il.order, ih.purchase_order, '') AS C27, -- [26] + COALESCE(lc.fraction, '') AS C28, -- [27] + COALESCE(lc.fraction_type, '') AS C29, -- [28] + COALESCE(lc.advalorem_numeric, 0) AS C30, -- [29] + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C31, -- [30] + COALESCE(lc.origin_country, '') AS C32, -- [31] + cmp.aduana AS C33, -- [32] + ih.invoice_type AS C34, -- [33] + ih.id AS C35, -- [34] + lf.value_mxn AS C36, -- [35] + lf.value_usd AS C37, -- [36] + il.material_type AS C38, -- [37] + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C39, -- [38] rectification_id + cmp.edocument AS C40, -- [39] + cmp.vucem_operation_num AS C41, -- [40] + il.line_number AS C42, -- [41] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, -- [42] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, -- [43] + COALESCE(cls.us_fraction, '') AS C45, -- [44] + COALESCE(prt.eccn, fac.eccn_code, '') AS C46, -- [45] + prt.id AS C47, -- [46] + fin.exchange_rate AS C48, -- [47] + ih.emission_date AS C49, -- [48] + ih.capture_user AS C50, -- [49] + ih.who_updated AS C51, -- [50] + '' AS C52, -- [51] + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, -- [52] NumCaja + '' AS C54, -- [53] Pedimento18 + COALESCE(ld.lot, '') AS C55, -- [54] Lote + '' AS C56, -- [55] TipoPedimentoTransporte + '' AS C57, -- [56] + '' AS C58, -- [57] + '' AS C59, -- [58] + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export invoice. + + Only sums partidas where is_subitem is false (main partidas, not sub-items). + """ + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for exports.""" + # TODO: QSeriesExpo table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for exports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportRepairQueries: + """SQL queries for export repairs (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C54, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE 1=1 {discharge_filter} + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'exp' + AND ih.invoice_type = 'REPAR' + {"AND " + where_str if where_str else ""} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for export repair data.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + COALESCE(fin.value_me, 0) AS C4, + COALESCE(fin.value_mn, 0) AS C5, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C9, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + '' AS C17, + COALESCE(cls.class_code, '') AS C18, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, + COALESCE(lq.quantity, 0) AS C21, + COALESCE(il.unit_of_measure, 0) AS C22, + COALESCE(lf.customs_value_mxn, 0) AS C23, + COALESCE(lf.customs_value_usd, 0) AS C24, + COALESCE(lq.net_weight, 0) AS C25, + COALESCE(lq.gross_weight, 0) AS C26, + COALESCE(il.order, ih.purchase_order, '') AS C27, + COALESCE(lc.fraction, '') AS C28, + COALESCE(lc.fraction_type, '') AS C29, + COALESCE(lc.advalorem_numeric, 0) AS C30, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C31, + COALESCE(lc.origin_country, '') AS C32, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(lf.value_mxn, 0) AS C36, + COALESCE(lf.value_usd, 0) AS C37, + COALESCE(il.material_type, 'P') AS C38, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C39, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(il.line_number, 0) AS C42, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, + COALESCE(cls.us_fraction, '') AS C45, + COALESCE(prt.eccn, fac.eccn_code, '') AS C46, + COALESCE(prt.part_number::text, '') AS C47, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + '' AS C54, + COALESCE(ld.lot, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + '' AS C59 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET') + AND UPPER(ih.invoice_type) IN ('DEF', 'REPAR', 'EXDEF', 'MATDE') + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export repair invoice.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for export repairs.""" + # TODO: QSeriesExpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for export repairs.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak new file mode 100644 index 00000000..f74e0da8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak @@ -0,0 +1,877 @@ +""" +SQL Query builders for invoice movement services. +Centralizes all SQL query construction logic. +""" + + +class TemporaryImportQueries: + """SQL queries for temporary imports using PostgreSQL tables.""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: db_name parameter kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(ped_r1.pedimento_number, '') AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, cmp.remesa, fin.exchange_rate, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, cmp.aduana, ped_r1.pedimento_number, + cmp.edocument, cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num, log.license_plate + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL.""" + # Note: db_name parameter is kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(cmp.provider_id::text, '') AS C8, + COALESCE(cmp.sold_to_id::text, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(il.unit_of_measure::text, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(lc.sector, '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + FALSE AS C40, + '' AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(il.line_number, 0) AS C44, + '' AS C45, + '' AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, '') AS C48, + COALESCE(il.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.item_lines il ON il.item_id = ( + SELECT id FROM a76.items WHERE invoice_id = ih.id LIMIT 1 + ) + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for an invoice.""" + return f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information.""" + return f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number.""" + return f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """ + + +class DefinitiveImportQueries: + """SQL queries for definitive imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(cmp.aduana, '') AS C39, + ih.id AS C35, + COALESCE(ped_r1.pedimento_number, '') AS C42, + COALESCE(cmp.edocument, '') AS C43, + COALESCE(cmp.vucem_operation_num, '') AS C44, + COALESCE(fin.exchange_rate, 0) AS C51, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52, + COALESCE(ih.capture_user, '') AS C53, + COALESCE(ih.who_updated, '') AS C54, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, + '' AS C57, + '' AS C58, + '' AS C59, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE') + AND {where_clause} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, + ih.purchase_order, cmp.aduana, ped_r1.pedimento_number, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + ped.status AS C4, -- [3] + ped.pedimento_code AS C5, -- [4] + '' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8] + ped.regime AS C10, -- [9] + log.entry_exit_date AS C11, -- [10] + log.delivery_date AS C12, -- [11] + log.payment_date AS C13, -- [12] + log.payment_receipt_num AS C14, -- [13] + '' AS C15, -- [14] + cmp.provider_id AS C16, -- [15] + cmp.sold_to_id AS C17, -- [16] + cmp.customs_broker_id AS C18, -- [17] + '' AS C19, -- [18] + prt.part_number AS C20, -- [19] + ld.description_spanish AS C21, -- [20] + ld.description_english AS C22, -- [21] + lq.quantity AS C23, -- [22] + um.code AS C24, -- [23] + lf.value_mxn AS C25, -- [24] + '' AS C26, -- [25] + lf.value_usd AS C27, -- [26] + '' AS C28, -- [27] + lq.net_weight AS C29, -- [28] + lq.gross_weight AS C30, -- [29] + ih.purchase_order AS C31, -- [30] + lc.fraction AS C32, -- [31] + '' AS C33, '' AS C34, -- [32-33] + ih.id AS C35, -- [34] + '' AS C36, '' AS C37, -- [35-36] + lc.origin_country AS C38, -- [37] + cmp.aduana AS C39, -- [38] + il.material_type AS C40, -- [39] + il.id AS C41, -- [40] + '' AS C42, -- [41] rectification_id + cmp.edocument AS C43, -- [42] + cmp.vucem_operation_num AS C44, -- [43] + il.line_number AS C45, -- [44] + ld.brand AS C46, -- [45] + ld.model AS C47, -- [46] + prt.us_fraction AS C48, -- [47] + prt.eccn AS C49, -- [48] + prt.id AS C50, -- [49] + fin.exchange_rate AS C51, -- [50] + ih.emission_date AS C52, -- [51] + ih.capture_user AS C53, -- [52] + ih.who_updated AS C54, -- [53] + '' AS C55, -- [54] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, -- [55] + '' AS C57, -- [56] Pedimento18 (row[56]) + COALESCE(ld.lot, '') AS C58, -- [57] Lote (row[57]) + '' AS C59, -- [58] TipoPed (row[58]) + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for a definitive import invoice.""" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for definitive imports.""" + # TODO: QSeriesDef table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for definitive imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class RepairImportQueries: + """SQL queries for repair imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + ih.invoice_number AS C2, + COALESCE(ped.pedimento_number, '') AS C3, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5, + COALESCE(ped.pedimento_code, '') AS C6, + COALESCE(ped.regime, '') AS C7, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C9, + COALESCE(cmp.remesa::text, '') AS C10, + COALESCE(fin.exchange_rate, 0) AS C11, + COALESCE(cmp.provider_id::text, '') AS C12, + COALESCE(cmp.sold_to_id::text, '') AS C13, + COALESCE(cmp.customs_broker_id::text, '') AS C14, + COALESCE(ih.purchase_order, '') AS C24, + COALESCE(ped.customs_office, '') AS C29, + ih.id AS C30, + COALESCE(cmp.edocument, '') AS C33, + COALESCE(cmp.vucem_operation_num, '') AS C34, + COALESCE(fin.exchange_rate, 0) AS C40, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41, + COALESCE(ih.capture_user, '') AS C42, + COALESCE(ih.who_updated, '') AS C43, + COALESCE(log.carrier_id, '') AS C44, + COALESCE(log.transport_num, '') AS C45, + COALESCE(ped.pedimento_code, '') AS C47, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build main SQL query for repair import data.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + il.line_number, + ih.invoice_number, + COALESCE(ped.pedimento_number, ''), + TO_CHAR(ih.invoice_date, 'YYYYMMDD'), + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END, + COALESCE(ped.pedimento_code, ''), + COALESCE(ped.regime, ''), + '', + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''), + COALESCE(cmp.remesa::text, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(cmp.provider_id::text, ''), + COALESCE(cmp.sold_to_id::text, ''), + COALESCE(cmp.customs_broker_id::text, ''), + COALESCE(il.part_number::text, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lq.quantity, 0), + COALESCE(il.unit_of_measure, 0), + COALESCE(lf.value_mxn, 0), + COALESCE(lf.value_usd, 0), + COALESCE(lq.net_weight, 0), + COALESCE(lq.gross_weight, 0), + COALESCE(ih.purchase_order, ''), + COALESCE(lc.fraction, ''), + '', + COALESCE(lc.sector, ''), + COALESCE(lc.origin_country, ''), + COALESCE(ped.customs_office, ''), + ih.id, + 'P', + '', + COALESCE(cmp.edocument, ''), + COALESCE(cmp.vucem_operation_num, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lc.american_fraction, ''), + COALESCE(prt.eccn, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''), + COALESCE(ih.capture_user, ''), + COALESCE(ih.who_updated, ''), + COALESCE(log.carrier_id, ''), + COALESCE(log.transport_num, ''), + '', + COALESCE(ped.pedimento_code, '') + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for a repair import invoice.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for repair imports.""" + # TODO: QSeriesImpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for repair imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportQueries: + """SQL queries for exports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """ + Build optimized query for NORMAL mode (grouped by invoice with totals). + + Args: + db_name: Database name (not used in PostgreSQL version) + where_clause: Additional WHERE conditions (without WHERE keyword) + """ + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C9, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(log.payment_receipt_num, '') AS C12, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(cmp.aduana, '') AS C33, + COALESCE(ih.invoice_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE {where_clause} + GROUP BY ih.id, ih.invoice_number, ih.is_updated, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, ih.purchase_order, cmp.aduana, + ih.invoice_type, cmp.edocument, cmp.vucem_operation_num, fin.exchange_rate, + ih.emission_date, ih.capture_user, ih.who_updated, log.transport_id, log.transport_num, + ih.invoice_date + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + '' AS C4, -- [3] + '' AS C5, -- [4] + ped.status AS C6, -- [5] + ped.pedimento_code AS C7, -- [6] + ped.regime AS C8, -- [7] + log.entry_exit_date AS C9, -- [8] + log.delivery_date AS C10, -- [9] + log.payment_date AS C11, -- [10] + log.payment_receipt_num AS C12, -- [11] + '' AS C13, -- [12] + cmp.provider_id AS C14, -- [13] + cmp.sold_to_id AS C15, -- [14] + cmp.customs_broker_id AS C16, -- [15] + '' AS C17, -- [16] + prt.part_number AS C18, -- [17] + ld.description_spanish AS C19, -- [18] + ld.description_english AS C20, -- [19] + lq.quantity AS C21, -- [20] + um.code AS C22, -- [21] + '' AS C23, -- [22] + '' AS C24, -- [23] + lq.net_weight AS C25, -- [24] + lq.gross_weight AS C26, -- [25] + ih.purchase_order AS C27, -- [26] + lc.fraction AS C28, -- [27] + '' AS C29, -- [28] + '' AS C30, -- [29] + '' AS C31, -- [30] + '' AS C32, -- [31] + cmp.aduana AS C33, -- [32] + ih.invoice_type AS C34, -- [33] + ih.id AS C35, -- [34] + lf.value_mxn AS C36, -- [35] + lf.value_usd AS C37, -- [36] + il.material_type AS C38, -- [37] + '' AS C39, -- [38] rectification_id + cmp.edocument AS C40, -- [39] + cmp.vucem_operation_num AS C41, -- [40] + il.line_number AS C42, -- [41] + ld.brand AS C43, -- [42] + ld.model AS C44, -- [43] + prt.us_fraction AS C45, -- [44] + prt.eccn AS C46, -- [45] + prt.id AS C47, -- [46] + fin.exchange_rate AS C48, -- [47] + ih.emission_date AS C49, -- [48] + ih.capture_user AS C50, -- [49] + ih.who_updated AS C51, -- [50] + '' AS C52, -- [51] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, -- [52] NumCaja + '' AS C54, -- [53] Pedimento18 + COALESCE(ld.lot, '') AS C55, -- [54] Lote + '' AS C56, -- [55] TipoPedimentoTransporte + '' AS C57, -- [56] + '' AS C58, -- [57] + '' AS C59, -- [58] + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export invoice. + + Only sums partidas where is_subitem is false (main partidas, not sub-items). + """ + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for exports.""" + # TODO: QSeriesExpo table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for exports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportRepairQueries: + """SQL queries for export repairs (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: discharge_clause temporarily disabled until is_discharged field migrated + discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'exp' + AND ih.invoice_type = 'REP' + {"AND " + where_str if where_str else ""} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, ih.document_type, + cmp.edocument, cmp.vucem_operation_num, ih.invoice_date, ih.capture_user, + ih.who_updated, log.carrier_id, log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for export repair data.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + COALESCE(fin.value_me, 0) AS C4, + COALESCE(fin.value_mn, 0) AS C5, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + '' AS C9, + '' AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + '' AS C17, + COALESCE(cls.class_code, '') AS C18, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, + COALESCE(lq.quantity, 0) AS C21, + COALESCE(il.unit_of_measure, 0) AS C22, + COALESCE(lf.customs_value_mxn, 0) AS C23, + COALESCE(lf.customs_value_usd, 0) AS C24, + COALESCE(lq.net_weight, 0) AS C25, + COALESCE(lq.gross_weight, 0) AS C26, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(lc.fraction, '') AS C28, + COALESCE(lc.fraction_type, '') AS C29, + COALESCE(lc.advalorem_numeric, 0) AS C30, + COALESCE(lc.sector, '') AS C31, + COALESCE(lc.origin_country, '') AS C32, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(lf.value_mxn, 0) AS C36, + COALESCE(lf.value_usd, 0) AS C37, + 'P' AS C38, + '' AS C39, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(il.line_number, 0) AS C42, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, + COALESCE(cls.us_fraction, '') AS C45, + COALESCE(prt.eccn, '') AS C46, + COALESCE(il.part_number::text, '') AS C47, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + '' AS C54, + COALESCE(ld.lot, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + '' AS C59 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET') + AND UPPER(ih.invoice_type) IN ('DEF', 'REP', 'EXDEF', 'MATDE') + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export repair invoice.""" + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for export repairs.""" + # TODO: QSeriesExpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for export repairs.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py new file mode 100644 index 00000000..bf89f124 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -0,0 +1,400 @@ +""" +Repair import service - handles IMPRE movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportRepairFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import RepairImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class RepairImportService: + """Service for handling repair import movements (IMPRE).""" + + def get_movements( + self, + db: Session, + filters: "ImportRepairFilter" + ) -> List["MovementItem"]: + """ + Get repair import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(RepairImportQueries.build_aggregated_query( + filters.database_name, + where_clause, + filters.discharge_filter.value + )) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} repair import invoices") + + movements = [] + + for row in results: + factura = row[0] # C2 - FacturaImpoRep + estatus = row[3] # C5 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[14] # C30 - Consecutivo + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + total_me = to_float(row[24]) # total_me from SUM aggregation + total_mn = to_float(row[25]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[27]) + sum_value_mxn = to_float(row[28]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[17]), # C40 - TipoCambio + fecha_pago=row[6], # C9 - Fecha_Pago + fecha_inicio='', # Not available in aggregated query + tipo_pedimento=row[23], # C47 - pedimento_code (used as tipo_pedimento) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C3 - PedimentoImpoRep + row[26], # C48 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C3 - PedimentoImpoRep + FechaFactura=parse_yyyymmdd_date(row[2]), # C4 - FechaFactura + Estatus=row[3], # C5 - Estatus + ClavePed=row[4], # C6 - ClavePed + TipoMovTemDef='IMPRE', + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[15], # C33 - EDocument + NumOperacionVU=row[16], # C34 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[19], # C42 - UsuarioCap + UsuarioAcr=row[20], # C43 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[6]), # C9 - Fecha_Pago + NumCaja=row[22], # C45 - Transport num + Pedimento18='', # Not in aggregated query + AduanaCru=row[13], # C29 - customs_office + Lote='' # Not in aggregated query + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching repair import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportRepairFilter" + ) -> List["MovementItemDetailed"]: + """ + Get repair import movements (detailed mode - line by line). + + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed repair import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause with discharge filter + where_clause = self._build_where_clause(filters) + + # Add discharge filter to WHERE clause + discharge_clause = "" + if filters.discharge_filter.value == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif filters.discharge_filter.value == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(RepairImportQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed repair import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[4] != 'AC': # C5 - Estatus + continue + + # Get all detailed information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[11], is_supplier=True + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[12], is_supplier=False + ) + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[13] + ) + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[28] + ) + + # Calculate values using unified method + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[30], # 'P' or 'S' + valor_me=row[21], + valor_mn_direct=row[20], + fecha_pago=row[8], + fecha_inicio=row[7], + clave_ped=row[45], + tipo_cambio_partida=row[38], + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + if row[31] == 'P': + peso_neto = float(row[22]) if row[22] else 0.0 + peso_bruto = float(row[23]) if row[23] else 0.0 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[29], row[0], filters.is_shelter + ) + + simbolo_ex = None + if row[15]: + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[15], filters.is_shelter + ) + + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, row[2], row[32], filters.is_shelter + ) + + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[1] + ) + + movement = MovementItemDetailed( + Linea=row[0], + Factura=row[1], + Pedimento=row[2], + FechaFactura=row[3], + Estatus=row[4], + ClavePed=row[5], + TipoMovTemDef='IMPRE', + EsCambioRegimen='N', + Regimen=row[6], + Fecha_Inicio=parse_yyyymmdd_date(row[7]), + Fecha_Fin=parse_yyyymmdd_date(row[8]), + Fecha_Pago=parse_yyyymmdd_date(row[9]), + Remesa=row[10], + Proveedor=proveedor_info.get('name'), + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=vendido_info.get('name'), + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[15], + DescripcionE=StringHelper.clean_text(row[16]), + DescripcionI=StringHelper.clean_text(row[17]), + CantidadIE=float(row[18]) if row[18] else 0.0, + UniMed=row[19], + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[24], + FraccionArancelaria=row[25], + Preferencia=row[26], + Sector=row[27], + PaisOrigen=row[28], + Aduana=aduana_nombre, + Advalorem='', + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[33], + NumOperacionVU=row[34], + Series=series_info, + Marca=StringHelper.clean_text(row[35]), + Modelo=StringHelper.clean_text(row[36]), + FraccionAmericana=row[37], + ECCN=row[38], + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[40]) if row[40] else None, # C41 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[41], + UsuarioAcr=row[42], + Transportista=row[43], + NumCaja=row[44], + Pedimento18=row[45], + AduanaCru=row[29], + Lote=row[54] if len(row) > 54 else '' # Not in query but mapped safely + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportRepairFilter") -> str: + """Build WHERE clause for repair imports query.""" + where_conditions = [] + + # STRICT SEPARATION: Only imports for repair + where_conditions.append("ih.operation_type = 'imp'") + # Repair imports are identified by cross-references, not invoice_type + where_conditions.append("COALESCE(cmp.is_regime_change, false) = false") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int, discharge_filter: str) -> tuple: + """Calculate totals for main partidas with discharge filter.""" + # Build discharge clause + # Note: is_discharged field not yet migrated to PostgreSQL schema + discharge_clause = "" + # Temporarily disabled until schema migration: + # if discharge_filter == "SiDes": + # discharge_clause = " AND il.is_discharged = true" + # elif discharge_filter == "NoDes": + # discharge_clause = " AND il.is_discharged = false" + + sql = text(f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND il.is_subitem = false + {discharge_clause} + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py new file mode 100644 index 00000000..1e3faa70 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -0,0 +1,459 @@ +""" +Temporary import service - handles IMTEM movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportTemporaryFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import TemporaryImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class TemporaryImportService: + """Service for handling temporary import movements (IMTEM).""" + + def get_movements( + self, + db: Session, + filters: "ImportTemporaryFilter" + ) -> List["MovementItem"]: + """ + Get temporary import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode (GROUP BY with totals) + sql = text(TemporaryImportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} temporary import invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaImpo + estatus = row[3] # C4 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[15] # C39 - Consecutivo + + # Helper to convert empty strings to None + def none_if_empty(val): + return None if val == '' else val + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + # Correct indices based on TemporaryImportQueries.build_aggregated_query + total_me = to_float(row[28]) # total_me (index 28) + total_mn = to_float(row[29]) # total_mn (index 29) + valor_comercial_mn = to_float(row[30]) # valor_comercial_mn from item_line_financials + valor_mp_temp_mn = to_float(row[31]) # valor_mp_temp_mn from item_line_financials + valor_agre_mn = to_float(row[32]) # valor_agre_mn from item_line_financials + valor_mp_temp_usd = to_float(row[33]) # valor_mp_temp_usd from item_line_financials + sum_value_usd = to_float(row[34]) # sum_value_usd from item_line_financials (line item sum avoids zero-value header bug) + + # Replace zero values with the computed sums + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = valor_comercial_mn if valor_comercial_mn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[19]), # C50 - TipoCambio + fecha_pago=row[8], # C13 - Fecha_Pago + fecha_inicio=row[6], # C11 - Fecha_Inicio + tipo_pedimento=row[4], # C5 - ClavePed (Using correct index) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, # Not used for temporary imports + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpo + row[16], # C41 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Calculate exchange rate for MPTemp explicitly decoupled from Valor Comercial + valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=to_float(row[33]), # sum_value_temp_usd + valor_mn=to_float(row[31]), # sum_value_temp_mxn + tipo_cambio_db=to_float(row[19]), + fecha_pago=row[8], + fecha_inicio=row[6], + tipo_pedimento=row[4], + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + valor_mp_temp = float(valor_mp_temp_raw) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoImpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMTEM', + EsCambioRegimen='N', + ValorMPTemp=valor_mp_temp, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=valor_agre_mn, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[17], # C42 - EDocument + NumOperacionVU=row[18], # C43 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[21], # C52 - UsuarioCap + UsuarioAcr=row[22], # C53 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(none_if_empty(row[8])), # C13 - Fecha_Pago + NumCaja=row[24], # C55 - transport_num || license_plate (index 24) + Pedimento18=row[25], # C56 - '' empty (index 25) + AduanaCru=row[14], # C38 - Aduana_Cruce (index 14) + Lote=row[26] # C57 - '' empty (index 26) + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} temporary import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching temporary import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportTemporaryFilter" + ) -> List["MovementItemDetailed"]: + """ + Get temporary import movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed temporary import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute main query + sql = text(TemporaryImportQueries.build_main_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed temporary import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus + continue + + # Provider and client names now come directly from query (C8, C9) + # No need for additional database lookups + logger.info(f"Processing invoice {row[0]}: Proveedor='{row[7]}', VendidoA='{row[8]}', CantidadIE={row[22]}, DescripcionE='{row[20][:50] if row[20] else None}'") + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[17] # C18 - AAduanal + ) + + # Get provider and client details including RFC and TaxID + provider_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[15], is_supplier=True + ) if row[15] else {} + + client_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[16], is_supplier=False + ) if row[16] else {} + + # Get customs section name + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[37] # C38 - Aduana_Cruce + ) + + # Calculate values (only for main partidas 'P', not subpartidas 'S') + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=row[26], # C27 - ValorImpoME + valor_mn_direct=row[24], # C25 - ValorImpoMN + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C11 - Fecha_Inicio + clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=float(row[59]) if row[59] else 0.0, + valor_mn_direct=float(row[58]) if row[58] else 0.0, + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C11 - Fecha_Inicio + clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + valor_mp_temp_mn = float(valor_mp_temp_raw) + + # Assign the properly converted commercial value directly + valor_comercial_mn = float(valor_comercial) + + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[39] != 'S': # C40 - EsSubPartida + peso_neto = float(row[28]) if row[28] else 0.0 # C29 + peso_bruto = float(row[29]) if row[29] else 0.0 # C30 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + # Get series information + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44 + ) + + # Get part export symbol + simbolo_ex = None + if row[48]: # C49 - NumParte + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[48], filters.is_shelter + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpo + row[40], # C41 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[0] # C1 - FacturaImpo + ) + + # Helper to convert empty strings to None for dates + def none_if_empty(val): + if val == '' or val is None: + return None + return val + + # Helper to convert to string (for Remesa, Advalorem) + def to_str(val): + if val is None or val == '': + return None + if isinstance(val, bool): + return 'P' if val else 'S' # Convert bool to P/S for Advalorem + return str(val) + + logger.error(f"DEBUG ROW: NumParte(17)='{row[17]}', Sector(30)='{row[30]}', Partida {row[43]}, Original Part ID(46)='{row[46]}'") + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[43], # C44 - LineaImpo + Factura=row[0], # C1 - FacturaImpo + Pedimento=row[1], # C2 - PedimentoImpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura (convert to datetime) + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMTEM', + EsCambioRegimen='N', + Regimen=row[9], # C10 - Regimen + Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13 - Fecha_Pago + Remesa=to_str(row[13]), # C14 - Remesa + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=provider_info.get('rfc'), + ProveedorTaxID=provider_info.get('tax_id'), + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=client_info.get('rfc'), + VendidoATaxID=client_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[48], # C49 - Part Number (from JOIN) + DescripcionE=StringHelper.clean_text(row[20]), # C21 + DescripcionI=StringHelper.clean_text(row[21]), # C22 + CantidadIE=float(row[22]) if row[22] else 0.0, # C23 + UniMed=row[23], # C24 + ValorComercialMN=valor_comercial_mn, + ValorMPTemp=valor_mp_temp_mn, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[30], # C31 - OrdenCompra + FraccionArancelaria=row[31], # C32 - Fraccion + Preferencia=row[32], # C33 - TipoFraccion + Sector=row[34], # C35 - Sector (from COALESCE) + PaisOrigen=row[36], # C37 - PaisOrigen + Aduana=aduana_nombre, + Advalorem=to_str(row[39]), # C40 - EsSubPartida (convert bool to str) + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[41], # C42 - EDocument + NumOperacionVU=row[42], # C43 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[44]), # C45 + Modelo=StringHelper.clean_text(row[45]), # C46 + FraccionAmericana=row[46], # C47 - FraccionAme + ECCN=row[47], # C48 - ECCN + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[50]), # C51 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[51], # C52 - UsuarioCap + UsuarioAcr=row[52], # C53 - UsuarioAct + Transportista=row[53], # C54 - Carrier ID + NumCaja=row[54], # C55 - Transport num + Pedimento18=row[55], # C56 - Pedimento18 + AduanaCru=row[37], # C38 - Aduana_Cruce + Lote=row[56] # C57 - LOTE + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed temporary import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed temporary import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportTemporaryFilter") -> str: + """Build WHERE clause for temporary imports query using PostgreSQL tables.""" + where_conditions = [] + + # STRICT SEPARATION: Only temporary imports + where_conditions.append("ih.operation_type = 'imp'") + # ALWAYS filter by specific invoice_type to avoid duplication with Definitive service + where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) if where_conditions else "1=1" + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """Calculate totals for main partidas only using PostgreSQL. + + Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion). + """ + sql = text(""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND COALESCE(il.is_subpartida, false) = false + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py new file mode 100644 index 00000000..66c888df --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py @@ -0,0 +1,2115 @@ +import logging +from sqlalchemy import text +from sqlalchemy.orm import Session +import configparser +import os +from typing import List, Optional +from datetime import datetime +from decimal import Decimal + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + MovementItem, + MovementItemDetailed, + RangeType +) + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Service for handling movement operations, particularly temporary import movements. + Integrates with legacy SQL Server databases for data extraction. + """ + + def _get_met_trans_config(self) -> int: + """ + Read MetTrans configuration from Scaii.ini file. + + Returns: + MetTrans value (0 or 1) + """ + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + return met_trans + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + return 0 + + def _build_where_clause_temporary(self, filters: ImportTemporaryFilter) -> tuple: + """ + Build WHERE clause and parameters for temporary imports query. + + Returns: + Tuple of (where_string, params_dict) + """ + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type.value == 'FF': + where_clauses.append("EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date") + else: + where_clauses.append("EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date") + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + return " AND ".join(where_clauses), params + + def _build_where_clause_definitive(self, filters: ImportDefinitiveFilter) -> str: + """ + Build WHERE clause for definitive imports query. + + Returns: + WHERE clause string + """ + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + return " AND ".join(where_conditions) + + def _calculate_exchange_rate_and_value( + self, + db: Session, + db_name: str, + valor_me: float, + valor_mn: float, + tipo_cambio_db: float, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + currency_type: str, + exchange_rate_type: str, + is_shelter: bool, + use_transport_method: bool, + met_trans: int + ) -> tuple: + """ + Unified method to calculate exchange rate and commercial value. + Eliminates duplicated logic across temporary and definitive imports. + + Args: + db: Database session + db_name: Database name + valor_me: Value in foreign currency + valor_mn: Value in local currency + tipo_cambio_db: Exchange rate from database + fecha_pago: Payment date + fecha_inicio: Start date + tipo_pedimento: Pedimento type + currency_type: "ME" or "MN" + exchange_rate_type: "FP" or "FF" + is_shelter: Shelter company flag + use_transport_method: Use transport method flag + met_trans: MetTrans value from config + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio) + """ + # Foreign currency case - simpler + if currency_type == "ME": + valor_comercial = valor_me + tipo_cambio = tipo_cambio_db + + # Try to get exchange rate if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + if tc_value: + tipo_cambio = tc_value + + return valor_comercial, tipo_cambio + + # Local currency case - more complex + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + + if tc_value: + return valor_me * tc_value, tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + return valor_mn, tipo_cambio_db + else: + return valor_mn, tipo_cambio_db + + def _get_fecha_tipo_cambio( + self, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + use_transport_method: bool, + met_trans: int + ): + """ + Determine which date to use for exchange rate lookup. + + Returns: + Date to use for exchange rate + """ + fecha = fecha_pago + if use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha = fecha_inicio + return fecha + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """ + Retrieve temporary import movements from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause dynamically based on filters + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Main Query (using shared query builder) + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + processed_facturas = set() + + for row in rows: + if filters.report_type == 'Normal': + C1_Factura = row[0] + + if C1_Factura in processed_facturas: + continue + + qcsv_ie = {} + processed_facturas.add(C1_Factura) + + C39_Consecutivo = row[38] + + # Totalize Items (sum values for main partidas only) + sql_total = text(f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """) + + try: + res_total = db.execute(sql_total, {"consecutivo": C39_Consecutivo}).fetchone() + val_total_me = float(res_total[0]) if res_total and res_total[0] is not None else 0.0 + val_total_mn = float(res_total[1]) if res_total and res_total[1] is not None else 0.0 + except Exception as e: + logger.warning(f"Error calculating totals for consecutivo {C39_Consecutivo}: {e}") + val_total_me = 0.0 + val_total_mn = 0.0 + + qcsv_ie['Factura'] = row[0] + qcsv_ie['Pedimento'] = row[1] + qcsv_ie['FechaFactura'] = row[2] + qcsv_ie['Estatus'] = row[3] + qcsv_ie['ClavePed'] = row[4] + qcsv_ie['TipoMovTemDef'] = 'IMTEM' + qcsv_ie['EsCambioRegimen'] = 'N' + + row_c13 = row[12] # Fecha_Pago + row_c11 = row[10] # Fecha_Inicio + row_c58 = row[57] # TIPOPEDIMENTOTRANSPORTEE + row_c50 = row[49] # TipoCambio + row_c41 = row[40] # PedRectifica + row_c2 = row[1] # PedimentoImpo + + calculated_tc = row_c50 + + # Calculate values based on currency type + if filters.currency_type == 'ME': + qcsv_ie['ValorMPTemp'] = val_total_me + qcsv_ie['ValorComercialMN'] = val_total_me + + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio( + db, db_name, row_c13, row_c11, row_c58, met_trans + ) + qcsv_ie['TipoCambio'] = calculated_tc if calculated_tc > 0 else row_c50 + else: + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio_mn(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + + qcsv_ie['ValorAgre'] = 0.0 + qcsv_ie['TipoExpo'] = '' + + if filters.is_shelter: + qcsv_ie['PedimentoR1'] = row_c41 + else: + qcsv_ie['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row_c2, row_c41) + + qcsv_ie['EDocument'] = row[41] + qcsv_ie['NumOperacionVU'] = row[42] + qcsv_ie['BaseDeDatos'] = db_name + + # Get driver badge number (gafete) + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + try: + res_gafete = db.execute(sql_gafete, {"factura": row[0]}).fetchone() + qcsv_ie['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {row[0]}: {e}") + qcsv_ie['NumGafUni'] = None + + qcsv_ie['UsuarioCap'] = row[51] + qcsv_ie['UsuarioAcr'] = row[52] + qcsv_ie['Fecha_Pago'] = row[12] + qcsv_ie['NumCaja'] = row[54] + qcsv_ie['Pedimento18'] = row[55] + qcsv_ie['AduanaCru'] = row[37] + qcsv_ie['Lote'] = row[56] + + movements.append(MovementItem(**qcsv_ie)) + + return movements + + def _obtener_tipo_cambio( + self, + db: Session, + db_name: str, + fecha, + is_shelter: bool + ) -> Optional[float]: + """ + Get exchange rate for the given date. + Simplified version that works with both shelter and non-shelter logic. + + Args: + db: Database session + db_name: Legacy database name + fecha: Date for exchange rate lookup + is_shelter: Shelter company flag (currently not used but kept for compatibility) + + Returns: + Exchange rate as float, or None if not found + """ + if not fecha: + return None + + try: + sql_tc = text(f""" + SELECT TOP 1 Valor + FROM [{db_name}].dbo.GTipoCambio + WHERE Fecha = :fecha + ORDER BY Fecha DESC + """) + res = db.execute(sql_tc, {"fecha": fecha}).fetchone() + if res and res[0]: + return float(res[0]) + else: + logger.warning(f"Exchange rate not found for date {fecha}") + return None + except Exception as e: + logger.error(f"Error fetching exchange rate for date {fecha}: {e}") + return None + + def _buscar_rectificacion( + self, + pedimento: str, + ped_rectifica: Optional[str] + ) -> Optional[str]: + """ + Search for pedimento rectification. + Simplified version - returns the rectification value from database. + + Args: + pedimento: Original pedimento number + ped_rectifica: Rectification pedimento from query + + Returns: + Rectification pedimento number or None + """ + # TODO: Implement full rectification search logic if needed + # For now, returning the value from the query + return ped_rectifica + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed temporary import movements (line by line) from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per line/partida) + """ + logger.info(f"Fetching DETAILED temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal report) + where_clauses = [] + params = {} + + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Same main query as normal report + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows for detailed processing") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + + # Process each row individually (detailed mode) + for row in rows: + item = {} + + # Basic invoice info + item['Linea'] = row[43] # C44 - LineaImpo + item['Factura'] = row[0] # C1 + item['Pedimento'] = row[1] # C2 + item['FechaFactura'] = row[2] # C3 + item['Estatus'] = row[3] # C4 + item['ClavePed'] = row[4] # C5 + item['TipoMovTemDef'] = 'IMTEM' + item['EsCambioRegimen'] = 'N' + item['Regimen'] = row[9] # C10 + item['Fecha_Inicio'] = row[10] # C11 + item['Fecha_Fin'] = row[11] # C12 + item['Fecha_Pago'] = row[12] # C13 + item['Remesa'] = row[13] # C14 + + # Get provider information + provider_code = row[15] # C16 - Proveedor + provider_info = self._get_client_provider_info(db, db_name, provider_code, filters.is_shelter) + item['Proveedor'] = provider_info.get('nombre') + item['RFCProveedor'] = provider_info.get('rfc') + item['ProveedorTaxID'] = provider_info.get('tax_id') + + # Get buyer information + buyer_code = row[16] # C17 - VendidoA + buyer_info = self._get_client_buyer_info(db, db_name, buyer_code, filters.is_shelter) + item['VendidoA'] = buyer_info.get('nombre') + item['VendidoARFC'] = buyer_info.get('rfc') + item['VendidoATaxID'] = buyer_info.get('tax_id') + + # Get customs broker info + customs_broker_code = row[17] # C18 - AAduanal + broker_info = self._get_customs_broker_info(db, db_name, customs_broker_code) + item['AgenteAduanal'] = broker_info.get('nombre') + item['Patente'] = broker_info.get('patente') + + # Item details + item['NumParte'] = row[19] # C20 - Clase (NumParte) + item['DescripcionE'] = self._clean_text(row[20]) # C21 - Already cleaned in query + item['DescripcionI'] = self._clean_text(row[21]) # C22 - Already cleaned in query + item['CantidadIE'] = float(row[22]) if row[22] else 0.0 # C23 + item['UniMed'] = row[23] # C24 + + # Values and exchange rate (only for main partidas, not subpartidas) + if row[39] == 'P': # C40 - EsSubPartida == 'P' + # Calculate value based on currency type + if filters.currency_type == 'MN': + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row[12]: # C13 - Fecha_Pago + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + else: + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + elif filters.currency_type == 'ME': + item['ValorComercialMN'] = float(row[26]) if row[26] else 0.0 # C27 + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['TipoCambio'] = tc if tc > 0 else float(row[49]) if row[49] else 0.0 + else: + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + + item['PesoNeto'] = float(row[28]) if row[28] else 0.0 # C29 + item['PesoBruto'] = float(row[29]) if row[29] else 0.0 # C30 + elif row[39] == 'S': # Subpartida + item['ValorComercialMN'] = 0.0 + item['PesoNeto'] = 0.0 + item['PesoBruto'] = 0.0 + item['TipoCambio'] = 0.0 + + # Additional fields + item['OrdenCompraVenta'] = row[30] # C31 + item['FraccionArancelaria'] = row[31] # C32 + item['Preferencia'] = row[32] # C33 + item['Sector'] = row[34] # C35 + item['PaisOrigen'] = row[36] # C37 + + # Get customs office name + aduana_code = row[37] # C38 - Aduana_Cruce + aduana_name = self._get_customs_office_name(db, db_name, aduana_code) + item['Aduana'] = aduana_name + + item['Advalorem'] = row[39] # C40 + item['TipoExpo'] = '' + + # Rectification + if filters.is_shelter: + item['PedimentoR1'] = row[40] # C41 + else: + item['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row[1], row[40]) + + item['EDocument'] = row[41] # C42 + item['NumOperacionVU'] = row[42] # C43 + + # Get series information + consecutivo = row[38] # C39 + linea_impo = row[43] # C44 + series_info = self._get_series_info(db, db_name, consecutivo, linea_impo, filters.is_shelter) + item['Series'] = series_info + + item['Marca'] = row[44] # C45 + item['Modelo'] = row[45] # C46 + item['FraccionAmericana'] = row[46] # C47 + item['ECCN'] = row[47] # C48 + + # Get export symbol from parts + num_parte = row[48] # C49 + if num_parte: + simbolo_ex = self._get_part_export_symbol(db, db_name, num_parte, filters.is_shelter) + item['SimboloEx'] = simbolo_ex + else: + item['SimboloEx'] = None + + item['FechaEmision'] = row[50] # C51 + item['BaseDeDatos'] = db_name + + # Get driver badge + factura = row[0] # C1 + try: + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + res_gafete = db.execute(sql_gafete, {"factura": factura}).fetchone() + item['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {factura}: {e}") + item['NumGafUni'] = None + + item['UsuarioCap'] = row[51] # C52 + item['UsuarioAcr'] = row[52] # C53 + item['Transportista'] = row[53] # C54 + item['NumCaja'] = row[54] # C55 + item['Pedimento18'] = row[55] # C56 + item['AduanaCru'] = row[37] # C38 + item['Lote'] = row[56] # C57 + + movements.append(MovementItemDetailed(**item)) + + logger.info(f"Processed {len(movements)} detailed movement items") + return movements + + def _build_main_query(self, db_name: str, where_str: str) -> str: + """Build the main SQL query for fetching invoice data""" + return f""" + SELECT + EqiFim.FacturaImpo AS C1, + EqiFim.PedimentoImpo AS C2, + EqiFim.FechaFactura AS C3, + EqiFim.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFim.ValorImpoME AS C6, + EqiFim.ValorImpoMN AS C7, + EqiFim.Proveedor AS C8, + EqiFim.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFim.Remesa AS C14, + EqiFim.TipoCambio AS C15, + EqiFim.Proveedor AS C16, + EqiFim.VendidoA AS C17, + EqiFim.AAduanal AS C18, + '' AS C19, + EqiPim.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(ClaAct.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EqiPim.CantImpo AS C23, + EqiPim.UnidadMedida AS C24, + EqiPim.ValorImpoMN AS C25, + EqiPim.ValorAduanasMN AS C26, + EqiPim.ValorImpoME AS C27, + EqiPim.ValorAduanasME AS C28, + EqiPim.PesoNeto AS C29, + EqiPim.PesoBruto AS C30, + EqiPim.OrdenCompra AS C31, + EqiPim.Fraccion AS C32, + EqiPim.TipoFraccion AS C33, + EqiPim.AdvImpo AS C34, + EqiPim.Sector AS C35, + EqiPim.MontoIgi AS C36, + EqiPim.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFim.Consecutivo AS C39, + EqiPim.EsSubPartida AS C40, + EqiPed.PedRectifica AS C41, + EqiFim.EDocument AS C42, + EqiFim.NumOperacionVU AS C43, + EqiPim.LineaImpo AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + ClaAct.FraccionAme AS C47, + ClaAct.ECCN AS C48, + EqiPim.NumParte AS C49, + EqiFim.TipoCambio AS C50, + EqiFim.FechaEmision AS C51, + EqiFim.UsuarioCap AS C52, + EqiFim.UsuarioAct AS C53, + EqiFim.Transportista AS C54, + EqiFim.Transporte + ' ' + EqiFim.NumTrasporte AS C55, + EqiPed.Pedimento18 AS C56, + EqiPim.LOTE AS C57, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C58 + FROM [{db_name}].dbo.QFacImp EqiFim + LEFT JOIN [{db_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFim.PedimentoImpo + LEFT JOIN [{db_name}].dbo.QEqiMaq EqiPim + ON EqiPim.Consecutivo = EqiFim.Consecutivo + LEFT JOIN [{db_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = EqiPim.Clase + WHERE {where_str} + """ + + def _clean_text(self, text: Optional[str]) -> Optional[str]: + """Clean text by removing special characters""" + if not text: + return None + return text.strip() + + def _get_client_provider_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get provider/client information""" + if not client_code: + return {"nombre": None, "rfc": None, "tax_id": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, RFC, TaxID + FROM [{db_name}].dbo.GClientesPro + WHERE Cliente = :cliente + """) + result = db.execute(sql, {"cliente": client_code}).fetchone() + + if result: + return { + "nombre": self._clean_text(result[0]), + "rfc": result[1], + "tax_id": result[2] + } + except Exception as e: + logger.warning(f"Error fetching provider info for {client_code}: {e}") + + return {"nombre": None, "rfc": None, "tax_id": None} + + def _get_client_buyer_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get buyer information (same structure as provider)""" + return self._get_client_provider_info(db, db_name, client_code, is_shelter) + + def _get_customs_broker_info(self, db: Session, db_name: str, broker_code: str) -> dict: + """Get customs broker information""" + if not broker_code: + return {"nombre": None, "patente": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, Patente + FROM [{db_name}].dbo.GAAduanal + WHERE ClaveAA = :clave + """) + result = db.execute(sql, {"clave": broker_code}).fetchone() + + if result: + return { + "nombre": result[0], + "patente": result[1] + } + except Exception as e: + logger.warning(f"Error fetching customs broker info for {broker_code}: {e}") + + return {"nombre": None, "patente": None} + + def _get_customs_office_name(self, db: Session, db_name: str, aduana_code: str) -> Optional[str]: + """Get customs office name""" + if not aduana_code: + return None + + try: + sql = text(f""" + SELECT TOP 1 REPLACE(REPLACE(REPLACE(REPLACE(Nombre, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') + FROM [{db_name}].dbo.GAduanaSec + WHERE AduanaSeccion = :aduana + """) + result = db.execute(sql, {"aduana": aduana_code}).fetchone() + return result[0] if result else None + except Exception as e: + logger.warning(f"Error fetching customs office name for {aduana_code}: {e}") + return None + + def _get_series_info(self, db: Session, db_name: str, consecutivo: str, linea_impo: str, is_shelter: bool) -> Optional[str]: + """Get series information for an item""" + if not consecutivo or not linea_impo: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea_impo}).fetchall() + + if not result: + return None + + # Build series string + series_parts = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_parts.append(serie_str) + + return " | ".join(series_parts) if series_parts else None + + except Exception as e: + logger.warning(f"Error fetching series for {consecutivo}/{linea_impo}: {e}") + return None + + def _get_part_export_symbol(self, db: Session, db_name: str, num_parte: str, is_shelter: bool) -> Optional[str]: + """Get export symbol/license for a part number""" + if not num_parte: + return None + + try: + sql = text(f""" + SELECT TOP 1 SimboloExcLic + FROM [{db_name}].dbo.QPartes + WHERE NumParte = :num_parte + """) + result = db.execute(sql, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching export symbol for part {num_parte}: {e}") + return None + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """ + Retrieve definitive import movements from legacy database (LLENADODEFINITIVO - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: # FP + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + # If ALL, no filter added + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFid.ValorImpoME AS C6, + EqiFid.ValorImpoMN AS C7, + EqiFid.Proveedor AS C8, + EqiFid.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.AAduanal AS C18, + EqiFid.Consecutivo AS C40, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + prov_impo_def_cr = row[28] # C39 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Use factura + tipo_mov as key + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C40 + fecha_pago = row[12] # C13 + tipo_pedimento = row[29] # C59 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice + valor_me, valor_mn = self._calculate_definitive_totals( + db, filters.database_name, consecutivo + ) + + # Calculate exchange rate and values + tipo_cambio = row[20] # C51 + valor_mp_temp = 0.0 + valor_comercial_mn = 0.0 + + if filters.currency_type.value == "ME": + valor_mp_temp = float(valor_me or 0) + valor_comercial_mn = float(valor_me or 0) + tipo_cambio = float(tipo_cambio or 1.0) + else: # MN + if filters.is_shelter: + # Shelter logic with exchange rate calculation + if filters.exchange_rate_type.value == "FP" and fecha_pago: + # Check if special transport method logic applies + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using values from DB") + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + # Non-shelter logic + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C42 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_mp_temp, + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C43 + NumOperacionVU=row[19], # C44 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C53 + UsuarioAcr=row[23], # C54 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C56 + Pedimento18=row[26], # C57 + AduanaCru=row[27], # C38 + Lote=None # Lote comes from EpiDef table, not available in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching definitive import movements: {e}", exc_info=True) + raise + + def _calculate_definitive_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """ + Calculate total values for a definitive import invoice. + Sums up all partidas (items) excluding sub-partidas. + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + sql = text(f""" + SELECT SUM(EqiPdf.ValorME), SUM(EqiPdf.ValorMN) + FROM [{db_name}].dbo.QEqiDef EqiPdf + WHERE EqiPdf.Consecutivo = :consecutivo + AND EqiPdf.EsSubpartida = 'P' + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number (NUMGAFETEUNICO) for a definitive import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpDef + ON QFacImpDef.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpoDef = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for invoice {factura}: {e}") + return None + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed definitive import movements from legacy database (LLENADODEFINITIVO - DETALLADO). + Returns each line/partida as a separate record with full details. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per partida/line) + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching DETAILED definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal mode) + where_conditions = [] + + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + where_clause = " AND ".join(where_conditions) + + # Build detailed SQL query (includes partida/line details) + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.Proveedor AS C16, + EqiFid.VendidoA AS C17, + EqiFid.AAduanal AS C18, + EpiDef.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(EqiCla.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EpiDef.CantImpoDef AS C23, + EpiDef.UnidadMedida AS C24, + EpiDef.ValorMN AS C25, + EpiDef.ValorME AS C27, + EpiDef.PesoNeto AS C29, + EpiDef.PesoBruto AS C30, + EpiDef.OrdenCompra AS C31, + EpiDef.Fraccion AS C32, + EpiDef.TipoFraccion AS C33, + EpiDef.Sector AS C35, + EpiDef.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiFid.Consecutivo AS C40, + EpiDef.EsSubPartida AS C41, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EpiDef.LineaImpoDef AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C47, + EqiCla.FraccionAme AS C48, + EqiCla.ECCN AS C49, + EpiDef.NumParte AS C50, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EpiDef.Lote AS C58, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + LEFT JOIN [{filters.database_name}].dbo.QEqiDef EpiDef + ON EpiDef.Consecutivo = EqiFid.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct EqiCla + ON EqiCla.Clase = EpiDef.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements = [] + + for row in result: + linea = row[31] # C45 + factura = row[0] # C1 + pedimento = row[1] # C2 + prov_impo_def_cr = row[25] # C39 + es_subpartida = row[27] # C41 + fecha_pago = row[8] # C13 + tipo_pedimento = row[44] # C59 + fecha_inicio = row[6] # C11 + consecutivo = row[26] # C40 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Get provider info + proveedor_info = self._get_client_provider_info( + db, filters.database_name, row[11], filters.is_shelter # C16 + ) + + # Get buyer info + buyer_info = self._get_client_buyer_info( + db, filters.database_name, row[12], filters.is_shelter # C17 + ) + + # Get customs broker info + broker_info = self._get_customs_broker_info( + db, filters.database_name, row[13] # C18 + ) + + # Calculate commercial value and exchange rate + tipo_cambio = float(row[10] or 1.0) # C15 + valor_comercial_mn = 0.0 + peso_neto = 0.0 + peso_bruto = 0.0 + + # Only process values if it's a Partida (not Subpartida) + if es_subpartida == 'P': + if filters.currency_type.value == "MN": + if filters.is_shelter: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: # ME + valor_comercial_mn = float(row[20] or 0) # C27 + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + tipo_cambio = tc_value + else: + tipo_cambio = float(row[37] or 1.0) # C51 + else: + tipo_cambio = float(row[37] or 1.0) # C51 + + peso_neto = float(row[21] or 0) # C29 + peso_bruto = float(row[22] or 0) # C30 + # If es_subpartida == 'S', values remain 0 + + # Get customs office name + aduana_nombre = self._get_customs_office_name( + db, filters.database_name, row[24] # C38 + ) + + # Get series information + series_info = self._get_definitive_series_info( + db, filters.database_name, consecutivo, linea, filters.is_shelter + ) + + # Get export symbol + symbolo_ex = self._get_part_export_symbol( + db, filters.database_name, row[36], filters.is_shelter # C50 + ) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(pedimento, row[28]) # C42 + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Create detailed movement item + movement = MovementItemDetailed( + Linea=linea, + Factura=factura, + Pedimento=pedimento, + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + Regimen=row[5], # C10 + Fecha_Inicio=row[6], # C11 + Fecha_Fin=row[7], # C12 + Fecha_Pago=row[8], # C13 + Remesa=row[9], # C14, + Proveedor=proveedor_info.get("nombre"), + RFCProveedor=proveedor_info.get("rfc"), + ProveedorTaxID=proveedor_info.get("tax_id"), + VendidoA=buyer_info.get("nombre"), + VendidoARFC=buyer_info.get("rfc"), + VendidoATaxID=buyer_info.get("tax_id"), + AgenteAduanal=broker_info.get("nombre"), + Patente=broker_info.get("patente"), + NumParte=row[14], # C20 + DescripcionE=row[15], # C21 + DescripcionI=row[16], # C22 + CantidadIE=float(row[17] or 0), # C23 + UniMed=row[18], # C24 + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[23], # C31 + FraccionArancelaria=row[29], # C32 + Preferencia=row[30], # C33 + Sector=row[32], # C35 + PaisOrigen=row[33], # C37 + Aduana=aduana_nombre, + Advalorem=row[27], # C41 + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[34], # C43 + NumOperacionVU=row[35], # C44 + Series=series_info, + Marca=row[40], # C46 + Modelo=row[41], # C47 + FraccionAmericana=row[42], # C48 + ECCN=row[43], # C49 + SimboloEx=symbolo_ex, + FechaEmision=row[38], # C52 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[39], # C53 + UsuarioAcr=row[40], # C54 + Transportista=row[41], # C55 + NumCaja=row[42], # C56 + Pedimento18=row[43], # C57 + AduanaCru=row[24], # C38 + Lote=row[44] # C58 + ) + + movements.append(movement) + + logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed definitive movements: {e}", exc_info=True) + raise + + def _get_definitive_series_info( + self, + db: Session, + db_name: str, + consecutivo: int, + linea: int, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for a definitive import partida from QSeriesDef table. + Returns formatted string with series, model, and part info. + """ + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesDef + WHERE Consecutivo = :consecutivo + AND LineaImpoDef = :linea + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not result: + return None + + series_list = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching definitive series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """ + Retrieve repair import movements from legacy database (LLENADOIMP_REPARACION - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}") + + # Get configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"FimRep.FechaFactura >= '{filters.start_date}' AND FimRep.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("FimRep.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"FimRep.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"FimRep.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Discharge filter for repair imports + if filters.discharge_filter.value == "SiDes": + where_conditions.append("RepPim.Descarga = 1") + elif filters.discharge_filter.value == "NoDes": + where_conditions.append("RepPim.Descarga = 0") + # If ALL, no filter added + + # Exclude regime changes + where_conditions.append("FimRep.EsCambioRegimen <> 'S'") + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query for repair imports + sql_query = text(f""" + SELECT + FimRep.FacturaImpo AS C1, + FimRep.PedimentoImpo AS C2, + FimRep.FechaFactura AS C3, + FimRep.Estatus AS C4, + EqiPed.ClavePed AS C5, + FimRep.ValorImpoME AS C6, + FimRep.ValorImpoMN AS C7, + FimRep.Proveedor AS C8, + FimRep.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + FimRep.Remesa AS C14, + FimRep.TipoCambio AS C15, + FimRep.AAduanal AS C18, + FimRep.Consecutivo AS C39, + EqiPed.PedRectifica AS C41, + FimRep.EDocument AS C42, + FimRep.NumOperacionVU AS C43, + FimRep.TipoCambio AS C49, + FimRep.FechaEmision AS C50, + FimRep.UsuarioCap AS C51, + FimRep.UsuarioAct AS C52, + FimRep.Transportista AS C53, + FimRep.Transporte + ' ' + FimRep.NumTrasporte AS C54, + EqiPed.Pedimento18 AS C55, + EqiPed.Aduana_Cruce AS C38, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C56 + FROM [{filters.database_name}].dbo.QFacImpRep FimRep + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = FimRep.PedimentoImpo + LEFT JOIN [{filters.database_name}].dbo.QEqiMaqRep RepPim + ON RepPim.Consecutivo = FimRep.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = RepPim.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + + # Use factura + IMPRE as key + tipo_mov = "IMPRE" + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C39 + fecha_pago = row[12] # C13 + tipo_pedimento = row[28] # C56 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice (with discharge filter) + valor_me, valor_mn = self._calculate_repair_totals( + db, filters.database_name, consecutivo, filters.discharge_filter.value + ) + + # Calculate exchange rate and values using unified method + valor_comercial, tipo_cambio = self._calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + valor_me=valor_me, + valor_mn=valor_mn, + tipo_cambio_db=float(row[20] or 1.0), # C49 + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=filters.use_transport_method, + met_trans=met_trans + ) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge_repair(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C41 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C42 + NumOperacionVU=row[19], # C43 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C51 + UsuarioAcr=row[23], # C52 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C54 + Pedimento18=row[26], # C55 + AduanaCru=row[27], # C38 + Lote=None # Repair imports don't have Lote in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching repair import movements: {e}", exc_info=True) + raise + + def _calculate_repair_totals( + self, + db: Session, + db_name: str, + consecutivo: int, + discharge_filter: str + ) -> tuple: + """ + Calculate total values for a repair import invoice. + Sums up all partidas (items) excluding sub-partidas, with optional discharge filter. + + Args: + db: Database session + db_name: Database name + consecutivo: Invoice consecutive number + discharge_filter: "SiDes", "NoDes", or "ALL" + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + # Build discharge filter clause + discharge_clause = "" + if discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + sql = text(f""" + SELECT SUM(RepPim.ValorImpoME), SUM(RepPim.ValorImpoMN) + FROM [{db_name}].dbo.QEqiMaqRep RepPim + WHERE RepPim.Consecutivo = :consecutivo + AND RepPim.EsSubpartida = 'P' + {discharge_clause} + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating repair totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge_repair(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number for a repair import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpRep + ON QFacImpRep.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for repair invoice {factura}: {e}") + return None + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed repair import movements from legacy database (LLENADOIMP_REPARACION - DETALLADO). + Returns individual partida lines with full detail. + + Args: + db: Database session + filters: Filter parameters including date range, discharge filter, etc. + + Returns: + List of detailed movement items + """ + try: + logger.info(f"Fetching detailed repair import movements with filters: {filters}") + + # Get database name + db_name = self._get_database_name(db) + if not db_name: + logger.error("Could not determine database name") + return [] + + # Get MetTrans configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause_repair(filters) + + # Build discharge filter for main query + discharge_clause = "" + if filters.discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif filters.discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + # Build main SQL query with all required fields for detailed mode + sql = text(f""" + SELECT + RepPim.LineaImpo, -- C44: Linea + Rep.FacturaImpo, -- C1: Factura + Ped.PedNumero, -- C2: Pedimento + Rep.FechaFacImpo, -- C3: FechaFactura + Rep.Estatus, -- C4: Estatus + Ped.ClavePedImpo, -- C5: ClavePed + Ped.Regimen, -- C10: Regimen + Ped.FechaEntrada, -- C11: Fecha_Inicio + Ped.FechaPago, -- C13: Fecha_Pago + Rep.Remesa, -- C14: Remesa + Rep.TipoCambio, -- C15: TipoCambio (from header) + Rep.Cliente, -- C16: Cliente/Proveedor + Rep.VendidoA, -- C17: VendidoA + Rep.AgenteAduanal, -- C18: AgenteAduanal clave + RepPim.NumParteImpo, -- C20: NumParte + RepPim.DescripcionE, -- C21: DescripcionE + RepPim.DescripcionI, -- C22: DescripcionI + RepPim.CantidadImpo, -- C23: CantidadIE + RepPim.UnidadMedImpo, -- C24: UniMed + RepPim.ValorImpoMN, -- C25: ValorComercialMN (direct) + RepPim.ValorImpoME, -- C27: ValorComercialME + RepPim.PesoNetoImpo, -- C29: PesoNeto + RepPim.PesoBrutoImpo, -- C30: PesoBruto + RepPim.OrdenCompraVta, -- C31: OrdenCompraVenta + RepPim.FraccionImpo, -- C32: FraccionArancelaria + RepPim.Preferencia, -- C33: Preferencia + RepPim.Sector, -- C35: Sector + RepPim.PaisOrigenImpo, -- C37: PaisOrigen + RepPim.Aduana, -- C38: Aduana seccion + Rep.Consecutivo, -- C39: Consecutivo + RepPim.EsSubpartida, -- C40: Advalorem/EsSubpartida + Ped.PedRectifica, -- C41: PedRectifica + Rep.eDocument, -- C42: EDocument + Rep.NumOperacionVU, -- C43: NumOperacionVU + RepPim.Marca, -- C45: Marca + RepPim.Modelo, -- C46: Modelo + RepPim.FraccionAmericana, -- C47: FraccionAmericana + RepPim.ECCN, -- C48: ECCN + RepPim.TipoCambio AS TipoCambioPartida, -- C49: TipoCambio (from partida) + Rep.FechaEmbarque, -- C50: FechaEmision + Rep.UsuarioCap, -- C51: UsuarioCap + Rep.UsuarioAct, -- C52: UsuarioAcr + Rep.Transportista, -- C53: Transportista + Rep.NumCaja, -- C54: NumCaja + Ped.Pedimento18, -- C55: Pedimento18 + Ped.ClavePedImpo -- C56: ClavePedImpo (for MetTrans check) + FROM [{db_name}].dbo.QFacImpRep Rep + LEFT JOIN [{db_name}].dbo.QPedimentos Ped ON Rep.Pedimento = Ped.PedNumero + LEFT JOIN [{db_name}].dbo.QEqiMaqRep RepPim ON Rep.Consecutivo = RepPim.Consecutivo + WHERE Rep.EsCambioRegimen <> 'S' + {where_clause} + {discharge_clause} + ORDER BY Rep.FacturaImpo, RepPim.LineaImpo + """) + + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed repair import partidas") + + movements = [] + for row in results: + # Extract all fields from query + linea = row[0] + factura = row[1] + pedimento = row[2] + fecha_factura = row[3] + estatus = row[4] + clave_ped = row[5] + regimen = row[6] + fecha_inicio = row[7] + fecha_pago = row[8] + remesa = row[9] + tipo_cambio_header = row[10] + cliente = row[11] + vendido_a = row[12] + agente_aduanal_clave = row[13] + num_parte = row[14] + descripcion_e = row[15] + descripcion_i = row[16] + cantidad = row[17] + uni_med = row[18] + valor_mn_direct = row[19] + valor_me = row[20] + peso_neto = row[21] + peso_bruto = row[22] + orden_compra = row[23] + fraccion = row[24] + preferencia = row[25] + sector = row[26] + pais_origen = row[27] + aduana_seccion = row[28] + consecutivo = row[29] + es_subpartida = row[30] + ped_rectifica = row[31] + e_document = row[32] + num_operacion_vu = row[33] + marca = row[34] + modelo = row[35] + fraccion_americana = row[36] + eccn = row[37] + tipo_cambio_partida = row[38] + fecha_emision = row[39] + usuario_cap = row[40] + usuario_acr = row[41] + transportista = row[42] + num_caja = row[43] + pedimento_18 = row[44] + clave_ped_mettrans = row[45] + + # Get client/supplier information (Proveedor) + proveedor_info = self._get_client_info(db, db_name, cliente, is_supplier=True) + + # Get sold-to client information (VendidoA) + vendido_info = self._get_client_info(db, db_name, vendido_a, is_supplier=False) + + # Get customs agent information + agente_info = self._get_customs_agent_info(db, db_name, agente_aduanal_clave) + + # Get customs section name + aduana_nombre = self._get_aduana_seccion_nombre(db, db_name, aduana_seccion) + + # Calculate exchange rate and commercial value + valor_mn, tipo_cambio_final = self._calculate_exchange_rate_and_value( + db=db, + db_name=db_name, + es_subpartida=es_subpartida, + valor_me=valor_me, + valor_mn_direct=valor_mn_direct, + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + clave_ped=clave_ped_mettrans, + tipo_cambio_partida=tipo_cambio_partida, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + peso_neto_final = peso_neto if es_subpartida == 'P' else 0 + peso_bruto_final = peso_bruto if es_subpartida == 'P' else 0 + + # Get series information for this partida + series = self._get_series_info_repair(db, db_name, consecutivo, linea) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(db, db_name, pedimento, ped_rectifica) + + # Get driver badge unique number + num_gaf_uni = self._get_driver_badge_repair(db, db_name, factura) + + # Build movement item + movement = MovementItemDetailed( + linea=linea, + factura=factura, + pedimento=pedimento, + fecha_factura=fecha_factura, + estatus=estatus, + clave_ped=clave_ped, + tipo_mov_tem_def="IMPRE", + es_cambio_regimen="N", + regimen=regimen, + fecha_inicio=fecha_inicio, + fecha_fin=None, # Not available for repair imports + fecha_pago=fecha_pago, + remesa=remesa, + tipo_cambio=tipo_cambio_final, + proveedor=proveedor_info.get("nombre"), + rfc_proveedor=proveedor_info.get("rfc"), + proveedor_tax_id=proveedor_info.get("tax_id"), + vendido_a=vendido_info.get("nombre"), + vendido_a_rfc=vendido_info.get("rfc"), + vendido_a_tax_id=vendido_info.get("tax_id"), + agente_aduanal=agente_info.get("nombre"), + patente=agente_info.get("patente"), + num_parte=num_parte, + descripcion_e=self._remove_commas(descripcion_e), + descripcion_i=self._remove_commas(descripcion_i), + cantidad_ie=cantidad, + uni_med=uni_med, + valor_comercial_mn=valor_mn, + peso_neto=peso_neto_final, + peso_bruto=peso_bruto_final, + orden_compra_venta=orden_compra, + fraccion_arancelaria=fraccion, + preferencia=preferencia, + sector=sector, + pais_origen=pais_origen, + aduana=aduana_nombre, + advalorem=es_subpartida, + tipo_expo=None, + pedimento_r1=pedimento_r1, + e_document=e_document, + num_operacion_vu=num_operacion_vu, + series=series, + marca=marca, + modelo=modelo, + fraccion_americana=fraccion_americana, + eccn=eccn, + fecha_emision=fecha_emision, + base_de_datos=db_name, + num_gaf_uni=num_gaf_uni, + usuario_cap=usuario_cap, + usuario_acr=usuario_acr, + transportista=transportista, + num_caja=num_caja, + pedimento_18=pedimento_18, + aduana_cru=aduana_seccion + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True) + raise + + def _get_series_info_repair(self, db: Session, db_name: str, consecutivo: int, linea: int) -> Optional[str]: + """Get series information for repair import partida""" + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpoRep + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY Renglon + """) + results = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not results: + return None + + series_list = [] + for idx, row in enumerate(results, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching repair series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def _build_where_clause_repair(self, filters: ImportRepairFilter) -> str: + """Build WHERE clause for repair imports query""" + conditions = [] + + # Date range filter + if filters.range_type == RangeType.INVOICE_DATE: + conditions.append(f"Rep.FechaFacImpo BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.PAYMENT_DATE: + conditions.append(f"Ped.FechaPago BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.ENTRY_DATE: + conditions.append(f"Ped.FechaEntrada BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + + return " AND " + " AND ".join(conditions) if conditions else "" + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py new file mode 100644 index 00000000..47114e98 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -0,0 +1,108 @@ + +import base64 +import logging +import traceback +from typing import Dict, Any + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.email import EmailService +from datetime import datetime + +from .movement_service import movement_service +from .schemas import AllMovementsFilter +from .csv_utils import generate_csv_from_movements + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True, name="generate_invoice_movements_async") +def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_email: str = None): + """ + Async task to generate invoice movements report. + FETCHES data -> GENERATES CSV -> SENDS EMAIL (optional) -> RETURNS CSV (base64) + """ + db = CoreSessionLocal() + try: + # 1. Update Progress + self.update_state(state='PROCESSING', meta={'current': 10, 'total': 100, 'status': 'Inicializando reporte...'}) + + # 2. Reconstruct Filter + filters = AllMovementsFilter(**filter_data) + + # 3. Fetch Data + self.update_state(state='PROCESSING', meta={'current': 30, 'total': 100, 'status': 'Obteniendo movimientos de base de datos...'}) + logger.info(f"Async Task: Fetching movements for {filters}") + + movements = movement_service.get_all_movements(db=db, filters=filters) + + self.update_state(state='PROCESSING', meta={'current': 70, 'total': 100, 'status': f'Procesando {len(movements)} registros...'}) + + # 4. Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + filters=filters + ) + + # 5. Send Email if requested + email_sent = False + if filters.send_email and user_email: + self.update_state(state='PROCESSING', meta={'current': 90, 'total': 100, 'status': 'Enviando correo electrónico...'}) + try: + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email (using the new async wrapper or run_until_complete if needed, + # but since we are in a sync celery task we might need to be careful with async/await. + # Actually EmailService.send_report_email is async. + # We need to run it synchronously here or make the task async. + # Celery tasks are sync by default. We can use asgiref.sync.async_to_sync + + import asyncio + from asgiref.sync import async_to_sync + + # Helper to run async method + result = async_to_sync(EmailService.send_report_email)( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if result: + email_sent = True + logger.info(f"Async Task: Email sent to {user_email}") + else: + logger.warning(f"Async Task: Failed to send email to {user_email}") + + except Exception as e: + logger.error(f"Async Task: Email error: {str(e)}") + + # 6. Encode and Return + self.update_state(state='PROCESSING', meta={'current': 95, 'total': 100, 'status': 'Finalizando...'}) + + # Convert string csv to bytes then base64 + pdf_b64 = base64.b64encode(csv_content.encode('utf-8')).decode('utf-8') + + return { + 'status': 'success', + 'file_name': f"reporte_facturas_{datetime.now().strftime('%Y%m%d')}.csv", + 'content': pdf_b64, + 'media_type': 'text/csv', + 'email_sent': email_sent, + 'total_records': len(movements) + } + + except Exception as e: + logger.error(f"Error in generate_invoice_movements_async: {str(e)}", exc_info=True) + self.update_state( + state='FAILURE', + meta={ + 'exc_type': type(e).__name__, + 'exc_message': str(e), + 'custom': 'Error generating report' + } + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 70e3ac38..5f5d23bd 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -35,6 +35,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout from .reports.importacion.consolidados.routes import router as consolidated_reports_router from .reports.importacion.packing_list.routes import router as packing_list_router from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router +from .reports.movements.invoices.routes import router as movement_invoices_router from .reports.exportacion.descargo.routes import router as discharge_reports_router from .manifests.manifest.routes import router as manifests_router from .manifests.driver.routes import router as manifest_drivers_router @@ -44,7 +45,6 @@ from .reports.importacion.transmission.temporal.MAINX30.routes import router as from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router - # Router principal router = APIRouter() @@ -101,6 +101,13 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + + movement_invoices_router, + prefix="/a76/reports/movements/invoices", + tags=["a76 / reports"] +) + router.include_router( discharge_reports_router, prefix="/a76/reports/exportacion/descargo", @@ -145,4 +152,4 @@ router.include_router( # Registrar router de bitácora from .audit_log.router import router as audit_log_router -router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) \ No newline at end of file +router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/backend/api/v1/modules/a76/transportation/trailers/services.py b/backend/api/v1/modules/a76/transportation/trailers/services.py index 43332b64..4ffb7c30 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/services.py +++ b/backend/api/v1/modules/a76/transportation/trailers/services.py @@ -25,6 +25,10 @@ class TrailerService: # Apply filters if provided if filters: + if filters.get("trailer_number"): + query = query.filter( + models.Trailer.trailer_number.ilike(f"%{filters['trailer_number']}%") + ) if filters.get("plate_number"): query = query.filter( models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%") @@ -75,8 +79,8 @@ class TrailerService: db: Session, trailer_number: str, tenant_id: int, - company_id: int, trailer_data: dto.TrailerUpdateDTO, + company_id: int, ) -> Optional[models.Trailer]: """Update a trailer""" trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/transportation/transporters/models.py b/backend/api/v1/modules/a76/transportation/transporters/models.py index cc355dbf..9f555c79 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/models.py +++ b/backend/api/v1/modules/a76/transportation/transporters/models.py @@ -9,7 +9,7 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin): {"schema": "a76"}, ) - transporter_key = Column(String(5), primary_key=True, nullable=False) + transporter_key = Column(String(23), primary_key=True, nullable=False) name = Column(String(256), nullable=True) short_name = Column(String(10), nullable=True) responsible = Column(String(100), nullable=True) @@ -27,4 +27,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin): ftp_user = Column(String(200), nullable=True) ftp_password = Column(String(100), nullable=True) ftp_directory = Column(String(1000), nullable=True) - filler_code = Column(String(4), nullable=True) + filler_code = Column(String(20), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index 1af393e5..bbf76e69 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -25,6 +25,10 @@ class TransporterService: # Apply filters if provided if filters: + if filters.get("transporter_key"): + query = query.filter( + models.Transporter.transporter_key.ilike(f"%{filters['transporter_key']}%") + ) if filters.get("name"): query = query.filter( models.Transporter.name.ilike(f"%{filters['name']}%") @@ -75,8 +79,8 @@ class TransporterService: db: Session, transporter_key: str, tenant_id: int, - company_id: int, transporter_data: dto.TransporterUpdateDTO, + company_id: int, ) -> Optional[models.Transporter]: """Update a transporter""" transporter = TransporterService.get_by_id( diff --git a/backend/api/v1/modules/a76/transportation/vehicles/services.py b/backend/api/v1/modules/a76/transportation/vehicles/services.py index d21bd36f..2b9801ac 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/services.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/services.py @@ -25,6 +25,10 @@ class VehicleService: # Apply filters if provided if filters: + if filters.get("vehicle_key"): + query = query.filter( + models.Vehicle.vehicle_key.ilike(f"%{filters['vehicle_key']}%") + ) if filters.get("plate_number"): query = query.filter( models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%") @@ -75,8 +79,8 @@ class VehicleService: db: Session, vehicle_key: str, tenant_id: int, - company_id: int, vehicle_data: dto.VehicleUpdateDTO, + company_id: int, ) -> Optional[models.Vehicle]: """Update a vehicle""" vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id) diff --git a/backend/api/v1/modules/core/help_center/models.py b/backend/api/v1/modules/core/help_center/models.py new file mode 100644 index 00000000..e7df432c --- /dev/null +++ b/backend/api/v1/modules/core/help_center/models.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime, timezone +from sqlalchemy import Column, String, Text, DateTime, Integer +from sqlalchemy.dialects.postgresql import UUID +from core.database import Base + +class HelpArticle(Base): + """ + Modelo para los artículos de ayuda (Base de Conocimientos). + Sincronizado entre Servidor Central y Clientes. + """ + __tablename__ = "help_articles" + + uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True) + slug = Column(String(255), unique=True, index=True, nullable=False) + title = Column(String(255), nullable=False) + content = Column(Text, nullable=False) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + last_editor = Column(String(255), nullable=False) + + # Library Mode Fields + category = Column(String(255), nullable=True, default="General") + order = Column(Integer, nullable=True, default=0) + + # Removed missing fields to avoid 500 errors (No migration approach) + # content_type = Column(String(50), nullable=False, default="article") + # file_url = Column(String(512), nullable=True) + # file_size = Column(Integer, nullable=True) + # mime_type = Column(String(100), nullable=True) + + def __repr__(self): + return f"" diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py new file mode 100644 index 00000000..29f576b5 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -0,0 +1,227 @@ +import shutil +import os +import uuid +from datetime import datetime +from typing import List, Optional, Dict, Any +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File +from sqlalchemy.orm import Session +from core.database import get_core_db +from core.config import settings +from core.security import get_current_user, has_role +from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse +from .services import HelpCenterService +from .tasks import sync_single_article_task + +router = APIRouter(prefix="/help-center", tags=["Help Center"]) + +def verify_sync_token(x_sync_token: str = Header(...)): + if x_sync_token != settings.SYNC_SECRET_TOKEN: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Sync Token" + ) + +def trigger_sync_or_broadcast(article_uuid: UUID): + """ + Helper function to handle synchronization logic. + - If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync. + - If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast. + """ + import logging + logger = logging.getLogger(__name__) + + try: + logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}") + logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + # 1. Upstream Sync (Client -> Hub) + if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""': + logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}") + sync_single_article_task.delay(str(article_uuid)) + + # 2. Downstream Broadcast (Hub -> Spokes) + # Only if we are the Hub (no upstream) and have spokes configured. + elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS: + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}") + # origin_client_uuid is None because this change originated on the Hub itself + broadcast_help_update.delay(str(article_uuid), None) + else: + logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)") + + except Exception as e: + logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True) + # We don't re-raise here to avoid returning 500 to the user if the save was successful + +@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)]) +def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)): + """ + Endpoint de sincronización inteligente para artículos de ayuda. + Requiere X-Sync-Token en los headers. + """ + result = HelpCenterService.sync_article(db, sync_data) + + # Broadcast to other spokes (Hub logic) + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'") + + if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS: + # We are the Hub (no central server to push to) and have Spokes configured + from .tasks import broadcast_help_update + logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}") + broadcast_help_update.delay( + str(sync_data.article_uuid), + str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None + ) + else: + logger.info("DEBUG: Broadcast skipped (Condition failed)") + + return result + +@router.post("/upload-image/") +def upload_help_image( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Sube una imagen para usar en los artículos.""" + try: + file_ext = os.path.splitext(file.filename)[1] + new_filename = f"{uuid.uuid4()}{file_ext}" + file_location = f"uploads/help/{new_filename}" + + # Ensure directory exists + os.makedirs("uploads/help", exist_ok=True) + + with open(file_location, "wb+") as buffer: + shutil.copyfileobj(file.file, buffer) + + return {"url": f"/api/uploads/help/{new_filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/upload-asset/") +def upload_help_asset( + file: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" + try: + file_ext = os.path.splitext(file.filename)[1].lower() + new_filename = f"{uuid.uuid4()}{file_ext}" + + # Guardar en una carpeta segun el tipo o general + folder = "uploads/help/assets" + if file_ext in ['.pdf']: + folder = "uploads/help/pdfs" + elif file_ext in ['.mp4', '.mov', '.avi']: + folder = "uploads/help/videos" + + file_location = f"{folder}/{new_filename}" + + # Ensure directory exists + os.makedirs(folder, exist_ok=True) + + with open(file_location, "wb+") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Get file size + file_size = os.path.getsize(file_location) + + return { + "url": f"/api/{file_location}", + "filename": file.filename, + "size": file_size, + "mime_type": file.content_type + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/articles/", response_model=List[HelpArticleInDB]) +def list_articles( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Lista todos los artículos de ayuda.""" + return HelpCenterService.get_all(db) + +@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)]) +def get_modifications(since: datetime, db: Session = Depends(get_core_db)): + """Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token.""" + return HelpCenterService.get_modifications(db, since) + +@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def get_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Obtiene un artículo por UUID.""" + article = HelpCenterService.get_by_uuid(db, article_uuid) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + return article + +@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED) +def create_article( + article: HelpArticleCreate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Crea un nuevo artículo.""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}") + + # Fill last_editor with admin username + if current_user.get('preferred_username'): + article.last_editor = current_user.get('preferred_username') + + new_article = HelpCenterService.create(db, article) + logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}") + + trigger_sync_or_broadcast(new_article.uuid) + + return new_article + +@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB) +def update_article( + article_uuid: UUID, + article_data: HelpArticleUpdate, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Actualiza un artículo.""" + if current_user.get('preferred_username'): + article_data.last_editor = current_user.get('preferred_username') + + article = HelpCenterService.update(db, article_uuid, article_data) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + + trigger_sync_or_broadcast(article.uuid) + + return article + +@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_article( + article_uuid: UUID, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(has_role("admin")) +): + """Elimina un artículo.""" + if not HelpCenterService.delete(db, article_uuid): + raise HTTPException(status_code=404, detail="Article not found") + + # Broadcast or Sync the deletion? + # Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone. + # For now, let's at least trigger the logic. + # WARNING: sync_single_article_task expects the article to exist to send it. + # If we deleted it locally, sync_single_article_task will fail or send nothing. + # We need a dedicated 'sync_deletion' task or similar. + # Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync + # logic right now to avoid breaking things, but I'll add the hook for completeness. + # Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs. + + return None diff --git a/backend/api/v1/modules/core/help_center/schemas.py b/backend/api/v1/modules/core/help_center/schemas.py new file mode 100644 index 00000000..5fc67a37 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/schemas.py @@ -0,0 +1,66 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + +class HelpArticleBase(BaseModel): + slug: str + title: str + content: str + last_editor: str + category: Optional[str] = "General" + order: Optional[int] = 0 + content_type: str = "article" + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None + +class HelpArticleCreate(HelpArticleBase): + pass + +class HelpArticleUpdate(BaseModel): + slug: Optional[str] = None + title: Optional[str] = None + content: Optional[str] = None + last_editor: Optional[str] = None + category: Optional[str] = None + order: Optional[int] = None + content_type: Optional[str] = None + file_url: Optional[str] = None + file_size: Optional[int] = None + mime_type: Optional[str] = None + +class HelpArticleInDB(HelpArticleBase): + uuid: UUID + updated_at: datetime + + class Config: + from_attributes = True + +class HelpSyncRequest(BaseModel): + article_uuid: UUID + client_updated_at: datetime + client_content: str + client_title: str + client_slug: str + last_editor: str + client_category: Optional[str] = "General" + client_order: Optional[int] = 0 + client_content_type: str = "article" + client_file_url: Optional[str] = None + client_file_size: Optional[int] = None + client_mime_type: Optional[str] = None + +class HelpSyncResponse(BaseModel): + status: str + server_updated_at: Optional[datetime] = None + server_content: Optional[str] = None + server_title: Optional[str] = None + server_slug: Optional[str] = None + server_category: Optional[str] = None + server_order: Optional[int] = None + server_content_type: Optional[str] = None + server_file_url: Optional[str] = None + server_file_size: Optional[int] = None + server_mime_type: Optional[str] = None + message: str diff --git a/backend/api/v1/modules/core/help_center/services.py b/backend/api/v1/modules/core/help_center/services.py new file mode 100644 index 00000000..8f6f36cb --- /dev/null +++ b/backend/api/v1/modules/core/help_center/services.py @@ -0,0 +1,240 @@ +import json +import re +from datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID +from sqlalchemy.orm import Session +from .models import HelpArticle +from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse + +class HelpCenterService: + @staticmethod + def _inject_metadata(article: HelpArticle) -> HelpArticle: + if not article or not article.content: + return article + + # Look for + match = re.search(r'', article.content, re.DOTALL) + if match: + try: + metadata = json.loads(match.group(1)) + article.content_type = metadata.get("content_type", "article") + article.file_url = metadata.get("file_url") + article.file_size = metadata.get("file_size") + article.mime_type = metadata.get("mime_type") + # Remove metadata from content for clean display if needed, + # but usually better to leave it and let parser handle it or hide it here. + # For now, we just set the attributes. + except Exception: + pass + else: + article.content_type = "article" + article.file_url = None + article.file_size = None + article.mime_type = None + + return article + + @staticmethod + def _extract_metadata(content: str, data: dict) -> str: + # Remove existing metadata block if any + content = re.sub(r'\n\n', '', content, flags=re.DOTALL) + + metadata = { + "content_type": data.get("content_type", "article"), + "file_url": data.get("file_url"), + "file_size": data.get("file_size"), + "mime_type": data.get("mime_type") + } + + # Only append if there's something meaningful beyond "article" + if metadata["content_type"] != "article" or metadata["file_url"]: + content += f"\n\n" + + return content + + @staticmethod + def get_all(db: Session) -> List[HelpArticle]: + articles = db.query(HelpArticle).all() + return [HelpCenterService._inject_metadata(a) for a in articles] + + @staticmethod + def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + return HelpCenterService._inject_metadata(article) + + @staticmethod + def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]: + article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first() + return HelpCenterService._inject_metadata(article) + + @staticmethod + def get_modifications(db: Session, since: datetime) -> List[HelpArticle]: + # Ensure timezone awareness + if since.tzinfo is None: + since = since.replace(tzinfo=timezone.utc) + articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() + return [HelpCenterService._inject_metadata(a) for a in articles] + + @staticmethod + def create(db: Session, article: HelpArticleCreate) -> HelpArticle: + data = article.model_dump() + # Move metadata into content + data["content"] = HelpCenterService._extract_metadata(data["content"], data) + # Remove virtual fields from data to avoid SQLAlchemy errors + virtual_fields = ["content_type", "file_url", "file_size", "mime_type"] + for f in virtual_fields: + if f in data: + del data[f] + + db_article = HelpArticle(**data) + db.add(db_article) + db.commit() + db.refresh(db_article) + return HelpCenterService._inject_metadata(db_article) + + @staticmethod + def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return None + + # Inject metadata to existing article to get current virtual fields + db_article = HelpCenterService._inject_metadata(db_article) + + update_data = article_data.model_dump(exclude_unset=True) + + # Handle metadata update + if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]): + # Merge existing metadata with new updates + current_meta = { + "content_type": getattr(db_article, "content_type", "article"), + "file_url": getattr(db_article, "file_url", None), + "file_size": getattr(db_article, "file_size", None), + "mime_type": getattr(db_article, "mime_type", None) + } + # Update with new data if present + for f in ["content_type", "file_url", "file_size", "mime_type"]: + if f in update_data: + current_meta[f] = update_data[f] + + # Use current content or new content + content = update_data.get("content", db_article.content) + update_data["content"] = HelpCenterService._extract_metadata(content, current_meta) + + # Remove virtual fields from data + virtual_fields = ["content_type", "file_url", "file_size", "mime_type"] + for f in virtual_fields: + if f in update_data: + del update_data[f] + + for key, value in update_data.items(): + setattr(db_article, key, value) + + db.commit() + db.refresh(db_article) + return HelpCenterService._inject_metadata(db_article) + + @staticmethod + def delete(db: Session, article_uuid: UUID) -> bool: + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not db_article: + return False + db.delete(db_article) + db.commit() + return True + + @staticmethod + def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse: + """ + Lógica de sincronización "Smart Sync" (Last Write Wins). + """ + db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first() + + client_updated_at = sync_data.client_updated_at + if client_updated_at.tzinfo is None: + client_updated_at = client_updated_at.replace(tzinfo=timezone.utc) + + if not db_article: + # Caso A: Artículo nuevo desde el cliente + # Store metadata in content + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type + } + content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) + + new_article = HelpArticle( + uuid=sync_data.article_uuid, + slug=sync_data.client_slug, + title=sync_data.client_title, + content=content_with_meta, + updated_at=client_updated_at, + last_editor=sync_data.last_editor, + category=sync_data.client_category, + order=sync_data.client_order + ) + db.add(new_article) + db.commit() + + # Download assets if needed (Images in content and main file) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + + return HelpSyncResponse(status="OK", message="Article created on server.") + + server_updated_at = db_article.updated_at + if server_updated_at.tzinfo is None: + server_updated_at = server_updated_at.replace(tzinfo=timezone.utc) + + # Caso A: Cliente es más nuevo + if client_updated_at > server_updated_at: + client_meta = { + "content_type": sync_data.client_content_type, + "file_url": sync_data.client_file_url, + "file_size": sync_data.client_file_size, + "mime_type": sync_data.client_mime_type + } + db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta) + db_article.title = sync_data.client_title + db_article.slug = sync_data.client_slug + db_article.updated_at = client_updated_at + db_article.last_editor = sync_data.last_editor + db_article.category = sync_data.client_category + db_article.order = sync_data.client_order + db.commit() + + # Download assets if needed (Images in content) + from .utils import download_file_from_hub, sync_assets_from_content + if sync_data.client_file_url: + download_file_from_hub(sync_data.client_file_url) + sync_assets_from_content(sync_data.client_content) + + return HelpSyncResponse(status="OK", message="Server updated with client data.") + + # Caso B: Servidor es más nuevo + elif server_updated_at > client_updated_at: + # Inject metadata for response + db_article = HelpCenterService._inject_metadata(db_article) + return HelpSyncResponse( + status="UPDATE_REQUIRED", + server_updated_at=server_updated_at, + server_content=db_article.content, + server_title=db_article.title, + server_slug=db_article.slug, + server_category=db_article.category, + server_order=db_article.order, + server_content_type=getattr(db_article, "content_type", "article"), + server_file_url=getattr(db_article, "file_url", None), + server_file_size=getattr(db_article, "file_size", None), + server_mime_type=getattr(db_article, "mime_type", None), + message="Client is outdated. Update required." + ) + + # Caso C: Iguales + else: + return HelpSyncResponse(status="OK", message="Already in sync.") diff --git a/backend/api/v1/modules/core/help_center/tasks.py b/backend/api/v1/modules/core/help_center/tasks.py new file mode 100644 index 00000000..e9c2c6ab --- /dev/null +++ b/backend/api/v1/modules/core/help_center/tasks.py @@ -0,0 +1,269 @@ +import logging +import httpx +from uuid import UUID +from celery import shared_task +from datetime import datetime, timezone +from core.database import CoreSessionLocal +from core.config import settings +from .models import HelpArticle +from .schemas import HelpSyncRequest, HelpSyncResponse + +logger = logging.getLogger(__name__) + +@shared_task(name="sync_all_articles_task") +def sync_all_articles_task(): + """ + Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central. + Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke). + """ + if not settings.CENTRAL_SERVER_URL: + logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).") + return + db = CoreSessionLocal() + try: + articles = db.query(HelpArticle).all() + for article in articles: + sync_single_article(article.uuid) + except Exception as e: + logger.error(f"Error in sync_all_articles_task: {e}") + finally: + db.close() + +@shared_task(name="sync_single_article_task") +def sync_single_article_task(article_uuid_str: str): + """ + Sincroniza un único artículo inmediatamente después de una edición local. + """ + sync_single_article(article_uuid_str) + + +@shared_task(name="broadcast_help_update") +def broadcast_help_update(article_uuid_str: str): + """ + Difunde una actualización de artículo a todos los spokes configurados. + """ + if not settings.SPOKE_URLS: + logger.info("No SPOKE_URLS configured. Skipping broadcast.") + return + + spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()] + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first() + if not article: + logger.error(f"Article {article_uuid_str} not found for broadcast.") + return + + sync_payload = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + client_category=article.category, + client_order=article.order + ).model_dump(mode='json') + + with httpx.Client() as client: + for spoke_url in spokes: + # Loop Prevention: Skip if the spoke is the origin + try: + logger.info(f"Broadcasting update to {spoke_url}") + response = client.post( + spoke_url, + json=sync_payload, + headers=headers, + timeout=5.0 + ) + if response.status_code != 200: + logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}") + except Exception as e: + logger.error(f"Error broadcasting to {spoke_url}: {e}") + + except Exception as e: + logger.error(f"Broadcast error: {e}") + finally: + db.close() + + +def sync_single_article(article_uuid): + """ + Lógica compartida para sincronizar un artículo con el servidor central. + """ + logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})") + + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + # Enhanced check to catch literal empty quotes if they slip through + return + + db = CoreSessionLocal() + try: + article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() + if not article: + return + + sync_data = HelpSyncRequest( + article_uuid=article.uuid, + client_updated_at=article.updated_at, + client_content=article.content, + client_title=article.title, + client_slug=article.slug, + last_editor=article.last_editor, + client_category=article.category, + client_order=article.order + ) + + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + + with httpx.Client() as client: + response = client.post( + settings.CENTRAL_SERVER_URL, + json=sync_data.model_dump(mode='json'), + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + result = HelpSyncResponse(**response.json()) + if result.status == "UPDATE_REQUIRED": + # El servidor tiene una versión más nueva, actualizamos localmente + article.content = result.server_content + article.title = result.server_title + article.slug = result.server_slug + article.updated_at = result.server_updated_at + db.commit() + logger.info(f"Article {article.uuid} updated from server.") + + # Download assets if needed + from .utils import download_file_from_hub, sync_assets_from_content + if result.server_file_url: + download_file_from_hub(result.server_file_url) + sync_assets_from_content(result.server_content) + else: + logger.info(f"Article {article.uuid} sync OK: {result.message}") + else: + logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error syncing article {article.uuid}: {e}") + finally: + db.close() + +from sqlalchemy import func + +@shared_task(name="sync_from_hub_task") +def sync_from_hub_task(): + """ + Tarea de POLLING que el Cliente ejecuta periódicamente. + Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde + la última actualización local. + """ + if not settings.CENTRAL_SERVER_URL: + return + + db = CoreSessionLocal() + try: + # 1. Obtener la fecha de la última actualización local + last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar() + if not last_local_update: + # Si no hay datos, traer todo desde el principio de los tiempos + last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc) + + # Asegurar timezone awareness + if last_local_update.tzinfo is None: + last_local_update = last_local_update.replace(tzinfo=timezone.utc) + + logger.info(f"Polling Hub for updates since {last_local_update}") + + # 2. Consultar al Hub + headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN} + # CENTRAL_SERVER_URL es ".../help-center/sync/" + # Queremos ".../help-center/modifications/" + hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/") + + with httpx.Client() as client: + response = client.get( + hub_url, + params={"since": last_local_update.isoformat()}, + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + articles_data = response.json() + if not articles_data: + logger.info("No updates found.") + return + + logger.info(f"Found {len(articles_data)} updates from Hub. Applying...") + + # 3. Aplicar actualizaciones + for art_data in articles_data: + try: + # Logic similar to sync_article but simpler (Force Update from Hub) + # We assume Hub is Truth in this Polling flow + + # Try to find by UUID + local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first() + + # Fallback: find by Slug if UUID doesn't match + if not local_article: + local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first() + + server_updated_at = datetime.fromisoformat(art_data['updated_at']) + if server_updated_at.tzinfo is None: + server_updated_at = server_updated_at.replace(tzinfo=timezone.utc) + + if not local_article: + new_article = HelpArticle( + uuid=art_data['uuid'], + slug=art_data['slug'], + title=art_data['title'], + content=art_data['content'], + updated_at=server_updated_at, + last_editor=art_data['last_editor'], + category=art_data.get('category', "General"), + order=art_data.get('order', 0) + ) + db.add(new_article) + logger.info(f"Created new article: {art_data['slug']}") + else: + # Update existing article + # If UUID changed in Hub but slug is the same, we update UUID too + local_article.uuid = art_data['uuid'] + local_article.slug = art_data['slug'] + local_article.title = art_data['title'] + local_article.content = art_data['content'] + local_article.updated_at = server_updated_at + local_article.last_editor = art_data['last_editor'] + local_article.category = art_data.get('category', "General") + local_article.order = art_data.get('order', 0) + logger.info(f"Updated article: {art_data['slug']}") + + db.commit() # Commit each article to avoid bulk failure + except Exception as e: + db.rollback() + logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}") + + + # Download assets after bulk update (Polling) + from .utils import download_file_from_hub, sync_assets_from_content + for art_data in articles_data: + # art_data contains the virtual fields because it was dumped via HelpArticleInDB + if "file_url" in art_data and art_data['file_url']: + download_file_from_hub(art_data['file_url']) + + sync_assets_from_content(art_data.get('content', '')) + + logger.info("Polling sync completed successfully.") + + else: + logger.error(f"Polling failed: {response.status_code} - {response.text}") + + except Exception as e: + logger.error(f"Error in sync_from_hub_task: {e}") + finally: + db.close() diff --git a/backend/api/v1/modules/core/help_center/utils.py b/backend/api/v1/modules/core/help_center/utils.py new file mode 100644 index 00000000..b5fe4c58 --- /dev/null +++ b/backend/api/v1/modules/core/help_center/utils.py @@ -0,0 +1,76 @@ +import os +import re +import httpx +import logging +import uuid +from pathlib import Path +from core.config import settings + +logger = logging.getLogger(__name__) + +def download_file_from_hub(relative_path: str) -> bool: + """ + Downloads a file from the Hub to the local storage. + relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png' + """ + if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': + return False + + # Clean the path + clean_path = relative_path.replace("/api/uploads/", "uploads/") + if clean_path.startswith("/"): + clean_path = clean_path[1:] + + # Check if it starts with uploads + if not clean_path.startswith("uploads/"): + # If it doesn't start with uploads, it might just be the filename or a subpath + # We assume it's relative to /app/ + pass + + local_path = Path(clean_path) + if local_path.exists(): + logger.info(f"File {clean_path} already exists, skipping download.") + return True + + # Ensure directories exist + local_path.parent.mkdir(parents=True, exist_ok=True) + + # Resolve Hub Base URL + # CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/ + # We want http://hub:8000/api/ + base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0] + # The file in backend is served usually under /api/uploads/... + # But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path + hub_file_url = f"{base_url}/{clean_path}" + + logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}") + + try: + with httpx.Client() as client: + response = client.get(hub_file_url, timeout=30.0) + if response.status_code == 200: + with open(local_path, "wb") as f: + f.write(response.content) + logger.info(f"Successfully downloaded {clean_path}") + return True + else: + logger.warning(f"Failed to download {clean_path}: Status {response.status_code} URL: {hub_file_url}") + return False + except Exception as e: + logger.error(f"Error downloading {clean_path}: {str(e)}") + return False + +def sync_assets_from_content(content: str): + """ + Parses markdown content for image URLs and downloads them if they are local references. + Example: ![alt text](/api/uploads/help/uuid.png) + """ + if not content: + return + + # Regex for markdown images: ![...](/api/uploads/...) + image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)' + matches = re.findall(image_pattern, content) + + for asset_url in matches: + download_file_from_hub(asset_url) diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index 861eecaa..be36fde0 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -5,6 +5,7 @@ from .tenants.routes import router as tenants_router from .user_tenant.routes import router as user_tenant_router from .users.routes import router as users_router from .dashboard.routes import router as dashboard_router +from .help_center.routes import router as help_center_router from fastapi import APIRouter router = APIRouter() @@ -16,3 +17,4 @@ router.include_router(users_router, prefix="/core", tags=["core / users"]) router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) router.include_router(permissions_router, prefix="/core", tags=["core / permissions"]) router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"]) +router.include_router(help_center_router, prefix="/core", tags=["core / help-center"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 6929e5f7..c9d7c252 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -14,23 +14,31 @@ from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem # noqa: F401 from api.v1.modules.a76.items.models import LineItem # noqa: F401 valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") +print(f"DEBUG: Celery Broker URL: {valkey_url}") +# Configurar broker y backend explícitamente en el constructor celery_app = Celery( "anexo76_tasks", broker=valkey_url, backend=valkey_url, +) +celery_app.set_default() + +celery_app.conf.update( include=[ "api.v1.modules.a76.reports.importacion.facturas.task", "api.v1.modules.a76.reports.importacion.consolidados.task", "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", + "api.v1.modules.a76.reports.movements.invoices.tasks", "api.v1.modules.a76.reports.exportacion.descargo.task", "api.v1.modules.a76.imports.tasks", "api.v1.modules.a76.customs_brokers.imports.tasks", "api.v1.modules.a76.clients_and_providers.imports.tasks", "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task", - "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task" + "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task", + "api.v1.modules.core.help_center.tasks" ] # Ruta al módulo donde están las tareas ) @@ -44,5 +52,12 @@ celery_app.conf.update( enable_utc=True, ) +celery_app.conf.beat_schedule = { + "sync-from-hub-every-minute": { + "task": "sync_from_hub_task", + "schedule": 60.0, # Run every 60 seconds + }, +} + if __name__ == "__main__": celery_app.start() diff --git a/backend/core/config.py b/backend/core/config.py index 31919a35..495600f8 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -5,6 +5,7 @@ Configuración centralizada de la aplicación usando Pydantic Settings import os from typing import List +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -39,6 +40,11 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + # Synchronization + SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" + CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" + SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only) + # CORS CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" @@ -50,6 +56,14 @@ class Settings(BaseSettings): SITAR_API_USER: str = "" SITAR_API_PASSWORD: str = "" + # SMTP Email Configuration + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_NAME: str = "Sistema Anexo76" + SMTP_USE_TLS: bool = True + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, @@ -57,6 +71,13 @@ class Settings(BaseSettings): env_file_encoding="utf-8", ) + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", mode="before") + @classmethod + def strip_quotes(cls, v: str) -> str: + if v: + return v.strip().strip('"').strip("'") + return v + @property def core_database_url(self) -> str: """URL de conexión a la base de datos core""" diff --git a/backend/core/email.py b/backend/core/email.py new file mode 100644 index 00000000..41b81a67 --- /dev/null +++ b/backend/core/email.py @@ -0,0 +1,117 @@ +""" +Email service for sending reports via SMTP. +""" +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from typing import List +import logging +from datetime import datetime + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails with attachments.""" + + @staticmethod + async def send_report_email( + recipient_email: str, + subject: str, + body_text: str, + csv_content: str, + filename: str + ) -> bool: + """ + Send a report email with CSV attachment. + + Args: + recipient_email: Email address of recipient + subject: Email subject line + body_text: Plain text email body + csv_content: CSV file content as string + filename: Name for the CSV attachment + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Create message + msg = MIMEMultipart() + msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg['To'] = recipient_email + msg['Subject'] = subject + + # Email body + html_body = f""" + + +
+

+ Reporte de Facturas - Sistema Anexo76 +

+

{body_text}

+

+ El reporte se encuentra adjunto en formato CSV. +

+
+

+ Este es un correo generado automáticamente. Por favor no responder. +

+

+ Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')} +

+
+ + + """ + msg.attach(MIMEText(html_body, 'html')) + + # CSV attachment + attachment = MIMEBase('text', 'csv') + attachment.set_payload(csv_content.encode('utf-8')) + encoders.encode_base64(attachment) + attachment.add_header( + 'Content-Disposition', + f'attachment; filename="{filename}"' + ) + msg.attach(attachment) + + # Create SSL context that ignores certificate errors + import ssl + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Send email + if settings.SMTP_PORT == 465: + # Port 465 uses implicit SSL + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=True, # Implicit SSL + tls_context=context + ) as smtp: + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + # Port 587 uses STARTTLS + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + tls_context=context + ) as smtp: + await smtp.starttls(tls_context=context) + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + + logger.info(f"Email sent successfully to {recipient_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {recipient_email}: {str(e)}") + return False diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 8a7065fd..36705ca5 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -109,16 +109,19 @@ async def integrity_error_handler( }, ) - # Intentar extraer información útil del error - error_message = "Error de integridad en la base de datos" - orig_msg = str(exc.orig).lower() - if "unique constraint" in orig_msg or "duplicate key" in orig_msg: - error_message = "El registro ya existe. Verifica los campos únicos." - elif "foreign key" in orig_msg: - error_message = "Referencia inválida a otro registro." - elif "not null" in orig_msg: - error_message = "Falta un campo requerido." + + # Check for unique/duplicate key violations (English and Spanish) + if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]): + error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)." + # Check for foreign key violations (English and Spanish) + elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]): + error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados." + # Check for not null violations (English and Spanish) + elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]): + error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios." + else: + error_message = "Error de integridad en la base de datos" response = JSONResponse( status_code=status.HTTP_409_CONFLICT, diff --git a/backend/core/middleware.py b/backend/core/middleware.py index d4c9c594..edd39f75 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -21,6 +21,7 @@ class TenantMiddleware(BaseHTTPMiddleware): "/api/health", "/api/", "/uploads", + "/api/v1/core/help-center", ] path = request.url.path @@ -86,6 +87,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): "/api/v1/status", "/api/health", "/api/", + "/api/v1/core/help-center", ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/backend/core/security.py b/backend/core/security.py index 0bcde710..7fb90728 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -106,6 +106,10 @@ def has_role(required_role: str): user_roles = current_user.get("realm_access", {}).get("roles", []) if required_role not in user_roles: + logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}") + # Also check client roles as a debug fallback + client_roles = current_user.get("resource_access", {}) + logger.warning(f"User client roles: {client_roles}") raise HTTPException( status_code=403, detail=f"User does not have required role: {required_role}", diff --git a/backend/main.py b/backend/main.py index 34505840..db63a12d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ """ Anexo76 - Aplicación SaaS para gestión de comercio exterior Backend API con FastAPI + Keycloak + SQLAlchemy -""" + """ import logging import subprocess @@ -63,6 +63,11 @@ from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.a76.general_catalogs.company.models import Company +# Transportation Modules +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle + # Core Modules & Transactional Models from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie @@ -80,6 +85,7 @@ from api.v1.modules.a76.audit_log.events import register_audit_listeners # Core Modules (Secondary) +import core.celery_app # Initialize Celery App from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db @@ -181,6 +187,137 @@ def run_migrations(): subprocess.run(["alembic", "upgrade", "head"], check=True) +def create_transportation_tables(): + """Crea las tablas de transporte directamente si no existen. + Se usa en lugar de una migración Alembic para evitar gestionar versiones. + """ + from sqlalchemy import text + from core.database import core_engine + + ddl_statements = [ + """ + CREATE TABLE IF NOT EXISTS a76.transporter ( + transporter_key VARCHAR(23) PRIMARY KEY, + name VARCHAR(256), + short_name VARCHAR(10), + responsible VARCHAR(100), + rfc VARCHAR(30), + streets VARCHAR(100), + postal_code VARCHAR(15), + city VARCHAR(30), + state VARCHAR(30), + country VARCHAR(3), + loader_code VARCHAR(9), + caat_code VARCHAR(49), + transport_code VARCHAR(8), + transport_interface_type VARCHAR(20), + ftp_server VARCHAR(200), + ftp_user VARCHAR(200), + ftp_password VARCHAR(100), + ftp_directory VARCHAR(1000), + filler_code VARCHAR(20), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS a76.trailer ( + trailer_number VARCHAR(20) PRIMARY KEY, + ace_trailer_number VARCHAR(10), + trailer_type_key VARCHAR(2), + seal VARCHAR(15), + entity_code VARCHAR(1), + plate_number VARCHAR(17), + state VARCHAR(30), + country VARCHAR(3), + container_key VARCHAR(3), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS a76.vehicle ( + vehicle_key VARCHAR(14) PRIMARY KEY, + ace_vehicle_key VARCHAR(10), + transporter_key VARCHAR(23), + transport_identifier VARCHAR(30), + transport_type VARCHAR(2), + entity_code VARCHAR(1), + transponder_number VARCHAR(16), + dot_number VARCHAR(8), + plate_number VARCHAR(17), + city VARCHAR(30), + state VARCHAR(30), + country VARCHAR(3), + seal VARCHAR(49), + insurance_company_name VARCHAR(30), + insurance_number VARCHAR(20), + insurance_amount NUMERIC(13, 2), + insurance_date INTEGER, + box_number VARCHAR(300), + brand VARCHAR(20), + year VARCHAR(4), + series VARCHAR(30), + description VARCHAR(100), + engine_number VARCHAR(50), + sct_permission VARCHAR(40), + color VARCHAR(20), + container_key VARCHAR(3), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + ] + + with core_engine.connect() as conn: + for stmt in ddl_statements: + conn.execute(text(stmt)) + # Ampliar columnas que pudieron haberse creado con tamaño incorrecto + conn.execute(text(""" + DO $$ + BEGIN + -- Fix transporter_key si fue creada como VARCHAR(5) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='a76' AND table_name='transporter' + AND column_name='transporter_key' + AND character_maximum_length < 23 + ) THEN + ALTER TABLE a76.transporter ALTER COLUMN transporter_key TYPE VARCHAR(23); + END IF; + -- Fix filler_code si fue creada como VARCHAR(4) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='a76' AND table_name='transporter' + AND column_name='filler_code' + AND character_maximum_length < 20 + ) THEN + ALTER TABLE a76.transporter ALTER COLUMN filler_code TYPE VARCHAR(20); + END IF; + + -- Eliminar constraint de trailer_type_key en trailer si existe + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name='trailer_trailer_type_key_fkey' + AND table_schema='a76' AND table_name='trailer' + ) THEN + ALTER TABLE a76.trailer DROP CONSTRAINT trailer_trailer_type_key_fkey; + END IF; + END$$; + """)) + conn.commit() + logger.info("Tablas de transporte verificadas/creadas correctamente.") + + # Inicializar la base de datos @app.on_event("startup") async def on_startup(): @@ -188,6 +325,7 @@ async def on_startup(): logger.info("Iniciando la aplicación Anexo76...") init_db() run_migrations() + create_transportation_tables() logger.info("Base de datos inicializada correctamente.") @@ -301,6 +439,10 @@ def register_audit(): CustomsBroker, Part, Company, + # Transportation Modules + Trailer, + Transporter, + Vehicle, # Reference Data Country, CurrencyType, diff --git a/backend/requirements.txt b/backend/requirements.txt index c169f2ca..7480be9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,6 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1 \ No newline at end of file +pdf417gen==0.8.1 +asgiref==3.8.1 +aiosmtplib==3.0.1 diff --git a/backend/test_debug.py b/backend/test_debug.py new file mode 100644 index 00000000..cfb342be --- /dev/null +++ b/backend/test_debug.py @@ -0,0 +1,44 @@ +import sys +import os +sys.path.append('/app') +sys.path.append('/home/josmar/dev/anexo76/backend') + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import asyncio +import logging + +# Disable logging to keep output clean +logging.basicConfig(level=logging.ERROR) + +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.service import TariffFractionService +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.dto import TariffFractionResponseDTO + +async def test(): + # Use the database URL from the environment or default + db_url = "postgresql://postgres:postgres@anexo76-postgres-a76:5432/anexo76_core" + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db = Session() + + try: + print("Starting test for catalog='american'...") + items, total = await TariffFractionService.get_all( + db, skip=0, limit=10, filters=None, catalog="american", tenant_id=1, company_id=1 + ) + print(f"Service Success! Total: {total}") + + print("Validating items with TariffFractionResponseDTO...") + for item in items: + dto = TariffFractionResponseDTO.model_validate(item) + print(f"DTO: ID={dto.id}, Code={dto.code}, Fraction={dto.fraction}") + + except Exception as e: + print(f"Error caught: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + finally: + db.close() + +if __name__ == "__main__": + asyncio.run(test()) diff --git a/backend/test_user_info.py b/backend/test_user_info.py new file mode 100644 index 00000000..17f2cc2b --- /dev/null +++ b/backend/test_user_info.py @@ -0,0 +1,39 @@ +import sys +import os +sys.path.append('/app') +sys.path.append('/home/josmar/dev/anexo76/backend') + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +def test(): + db_url = "postgresql://postgres:postgres@anexo76-postgres-a76:5432/anexo76_core" + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db = Session() + + try: + print("Checking users and tenants...") + # Check users + users = db.execute(text("SELECT id, email, tenant_id FROM a76.users")).fetchall() + for u in users: + print(f"User ID: {u.id} | Email: {u.email} | Tenant ID: {u.tenant_id}") + + # Check companies + companies = db.execute(text("SELECT id, name, tenant_id FROM a76.companies")).fetchall() + for c in companies: + print(f"Company ID: {c.id} | Name: {c.name} | Tenant ID: {c.tenant_id}") + + # Check fractions + fractions = db.execute(text("SELECT id, code, tenant_id, company_id FROM a76.us_tariff_fractions")).fetchall() + print(f"Total US Fractions in DB: {len(fractions)}") + for f in fractions: + print(f"Fraction ID: {f.id} | Code: {f.code} | Tenant ID: {f.tenant_id} | Company ID: {f.company_id}") + + except Exception as e: + print(f"Error: {e}") + finally: + db.close() + +if __name__ == "__main__": + test() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4eabf108..9b422bfa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,7 +17,7 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1"] + test: [ "CMD-SHELL", "pg_isready -U postgres -d anexo76_core || exit 1" ] interval: 5s timeout: 3s retries: 10 @@ -37,7 +37,7 @@ services: # PostgreSQL - Base de datos Keycloak postgres-keycloak: - image: postgres:18-alpine + image: postgres:16-alpine container_name: anexo76-postgres-keycloak environment: POSTGRES_DB: keycloak @@ -54,7 +54,7 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1"] + test: [ "CMD-SHELL", "pg_isready -U postgres -d keycloak || exit 1" ] interval: 5s timeout: 3s retries: 10 @@ -80,10 +80,10 @@ services: KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_DB: postgres - KC_DB_URL_HOST: postgres-keycloak + KC_DB_URL_HOST: anexo76-postgres-keycloak KC_DB_URL_PORT: "5432" KC_DB_URL_DATABASE: keycloak - KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak + KC_DB_URL: jdbc:postgresql://anexo76-postgres-keycloak:5432/keycloak KC_DB_USERNAME: postgres KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} KC_DB_SCHEMA: public @@ -96,12 +96,12 @@ services: KC_HEALTH_ENABLED: "true" KC_METRICS_ENABLED: "true" KC_HOSTNAME_PATH: /kcauth - KC_LOG_LEVEL: INFO + KC_LOG_LEVEL: INFO JAVA_OPTS_APPEND: "-Xms256m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true" - command: + command: - start - --db=postgres - - --db-url-host=postgres-keycloak + - --db-url-host=anexo76-postgres-keycloak - --http-relative-path=/kcauth - --db-url-port=5432 - --db-url-database=keycloak @@ -123,7 +123,19 @@ services: - backend-net restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r\nhost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"] + test: + [ + "CMD-SHELL", + "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r + + host: 127.0.0.1\r + + Connection: close\r + + \r + + ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1" + ] interval: 10s timeout: 5s retries: 30 @@ -162,6 +174,13 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - EXTERNAL_API_URL=${EXTERNAL_API_URL} + - EXTERNAL_API_USER=${EXTERNAL_API_USER} + - EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD} + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} ports: - "3467:8000" depends_on: @@ -176,24 +195,10 @@ services: - backend-net - frontend-net restart: unless-stopped - entrypoint: ["/entrypoint.sh"] - command: - [ - "gunicorn", - "main:app", - "-k", - "uvicorn.workers.UvicornWorker", - "-w", - "${WEB_CONCURRENCY:-1}", - "-b", - "0.0.0.0:8000", - "--log-level", - "info", - "--forwarded-allow-ips", - "*" - ] + entrypoint: [ "/entrypoint.sh" ] + command: [ "gunicorn", "main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "${WEB_CONCURRENCY:-1}", "-b", "0.0.0.0:8000", "--log-level", "info", "--forwarded-allow-ips", "*" ] healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + test: [ "CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1" ] interval: 15s timeout: 5s retries: 5 @@ -216,7 +221,15 @@ services: container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: - - VALKEY_URL=redis://valkey:6379/0 + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} depends_on: - backend - valkey @@ -252,7 +265,7 @@ services: depends_on: backend: condition: service_healthy - entrypoint: ["/frontend-entrypoint.sh"] + entrypoint: [ "/frontend-entrypoint.sh" ] volumes: - ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro networks: @@ -260,9 +273,9 @@ services: - backend-net - auth-net restart: unless-stopped - command: ["pnpm", "start"] + command: [ "pnpm", "start" ] healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1"] + test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5173/ || exit 1" ] interval: 15s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 041c1bdf..5ae2e394 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,7 +127,7 @@ services: "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r - host: 127.0.0.1\r + Host: localhost\r Connection: close\r @@ -178,6 +178,13 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - EXTERNAL_API_URL=${EXTERNAL_API_URL} + - EXTERNAL_API_USER=${EXTERNAL_API_USER} + - EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD} + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} ports: - "8000:8000" depends_on: @@ -234,6 +241,7 @@ services: - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} + - VITE_HUB_MODE=${VITE_HUB_MODE:-true} ports: - "5173:5173" depends_on: @@ -266,16 +274,62 @@ services: memory: 1G reservations: memory: 512M + # celery celery_worker: build: ./backend container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-development} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - VALKEY_URL=redis://valkey:6379/0 depends_on: - backend - valkey + volumes: + - ./backend:/app + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads + networks: + - backend-net + + celery_beat: + build: ./backend + container_name: celery_beat + command: celery -A core.celery_app beat --loglevel=info + environment: + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0} + - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} + - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} + - SPOKE_URLS=${SPOKE_URLS:-""} + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + depends_on: + - backend + - valkey + volumes: + - ./backend:/app + - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads networks: - backend-net diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bd5ede6d..8ccfa4e2 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,7 +15,7 @@ RUN npm config set strict-ssl false RUN npm install -g pnpm # Instalar dependencias -RUN pnpm install --frozen-lockfile +RUN pnpm install # Copiar código COPY . . diff --git a/frontend/package.json b/frontend/package.json index ca906270..b21c873b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,8 +57,12 @@ "vitest-browser-svelte": "^1.1.0" }, "dependencies": { + "@types/dompurify": "^3.2.0", + "@types/marked": "^6.0.0", + "dompurify": "^3.0.9", "keycloak-js": "^26.2.1", "lucide-svelte": "^0.553.0", + "marked": "^12.0.0", "svelte-sonner": "^1.0.7" } -} +} \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index bfafc5e5..4933f90b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,12 +8,24 @@ importers: .: dependencies: + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 + '@types/marked': + specifier: ^6.0.0 + version: 6.0.0 + dompurify: + specifier: ^3.0.9 + version: 3.3.1 keycloak-js: specifier: ^26.2.1 version: 26.2.1 lucide-svelte: specifier: ^0.553.0 version: 0.553.0(svelte@5.40.2) + marked: + specifier: ^12.0.0 + version: 12.0.2 svelte-sonner: specifier: ^1.0.7 version: 1.0.7(svelte@5.40.2) @@ -505,56 +517,67 @@ packages: resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.4': resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.4': resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.4': resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.4': resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.4': resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.4': resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.4': resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.4': resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.4': resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.4': resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.4': resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} @@ -675,24 +698,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.14': resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.14': resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.14': resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.14': resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} @@ -758,18 +785,29 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/marked@6.0.0': + resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} + deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. + '@types/node@20.19.22': resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.46.1': resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1054,6 +1092,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -1368,24 +1409,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -1432,6 +1477,11 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + marked@12.0.2: + resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} + engines: {node: '>= 18'} + hasBin: true + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2547,16 +2597,27 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.1 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} + '@types/marked@6.0.0': + dependencies: + marked: 12.0.2 + '@types/node@20.19.22': dependencies: undici-types: 6.21.0 '@types/resolve@1.20.2': {} + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2853,6 +2914,10 @@ snapshots: dom-accessibility-api@0.5.16: {} + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -3212,6 +3277,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@12.0.2: {} + merge2@1.4.1: {} micromatch@4.0.8: diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 7e328ea8..9cffaf74 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -13,4 +13,4 @@ declare global { } } -export {}; +export { }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 61ff1f21..10aff063 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -270,7 +270,7 @@ async function fetchApi( } return { - error: data.message || data.detail || 'Error en la petición', + error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición', status: response.status }; } diff --git a/frontend/src/lib/api/dashboard/a24/fa_classes.ts b/frontend/src/lib/api/dashboard/a24/fa_classes.ts index 91359f52..e65f4625 100644 --- a/frontend/src/lib/api/dashboard/a24/fa_classes.ts +++ b/frontend/src/lib/api/dashboard/a24/fa_classes.ts @@ -98,7 +98,7 @@ export const faClassesApi = { * Actualizar una clase de activo fijo existente */ update: (id: number, data: FAClassUpdate, company_id: number): Promise> => { - return api.put(`/v1/a24/fa/classes/${id}?company_id=${company_id}`, data); + return api.put(`/v1/a24/fa/classes/${id}/?company_id=${company_id}`, data); }, /** diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index 236c2cdb..de2a8ca5 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -17,6 +17,7 @@ export interface A76Class { sub_key: string; physical_review: number; iva_exempt_fraction: string; + eccn_code?: string | null; is_active?: boolean; // Agregado para el switch del formulario created_at: string; updated_at: string; @@ -35,6 +36,7 @@ export interface A76ClassCreate { sub_key?: string | null; physical_review?: number | null; iva_exempt_fraction?: string | null; + eccn_code?: string | null; is_active?: boolean; } diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 981ef884..1362a11a 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -1,4 +1,5 @@ import { api } from '$lib/api'; +import { companyStore } from '$lib/stores/company.svelte'; // <--- NUEVO: Importamos el store para el fallback import type { ApiResponse } from '$lib/api'; export interface CustomsBroker { @@ -22,9 +23,12 @@ export interface CustomsBroker { contact?: string | null; tenant_id: string; company_id: string; + vu?: CustomsBrokerVU | null; } export interface CustomsBrokerVU { + tenant_id?: string | null; + company_id?: string | null; certificate_path?: string | null; key_path?: string | null; access_key?: string | null; @@ -94,7 +98,9 @@ export interface CustomsBrokerListResponse { */ export const customsBrokersApi = { list: (companyId: string, page = 1, pageSize = 50) => { - return api.get(`/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}`); + return api.get( + `/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}` + ); }, get: (brokerKey: string, companyId: string) => { @@ -110,20 +116,47 @@ export const customsBrokersApi = { */ update: (brokerKey: string, data: CreateCustomsBrokerData) => { const companyId = data.company_id; - return api.put(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data); + return api.put( + `/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, + data + ); }, /** * Elimina un agente aduanal */ delete: (brokerKey: string, companyId: string) => { - return api.delete(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`); + return api.delete( + `/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}` + ); }, - - + /** + * Actualiza la información de Ventanilla Única (VU) + */ updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => { - return api.put(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data); + // LOGICA DE RESCATE: + // Si companyId llega nulo/undefined, intentamos obtenerlo del store global + let finalCompanyId = companyId; + + if (!finalCompanyId && companyStore.activeCompany?.id) { + finalCompanyId = companyStore.activeCompany.id.toString(); + console.warn("WARN: companyId no fue provisto a updateVU, usando companyStore:", finalCompanyId); + } + + // Aseguramos que el payload tenga los IDs + const payload = { + ...data, + company_id: finalCompanyId, + tenant_id: finalCompanyId + }; + + console.log('[DEBUG] Enviando payload VU:', payload); + + return api.put( + `/v1/a76/customs-broker-vu/${brokerKey}?company_id=${finalCompanyId}`, + payload + ); }, updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel, companyId: string) => { diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 586fedf7..11b037a1 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -477,11 +477,13 @@ export async function uploadCompanyLogo(id: number, file: File): Promise> { +export async function uploadCompanyCertificate(id: number, type: string, file: File, password?: string): Promise> { const formData = new FormData(); formData.append('file', file); + if (password) { + formData.append('password', password); + } - // Add certificate type query param const token = localStorage.getItem('access_token'); const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); diff --git a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts new file mode 100644 index 00000000..5424bc4b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -0,0 +1,203 @@ +/** + * API Client para Reportes de Movimientos de Facturas + */ +import { api } from '$lib/api'; + +// ===== TYPES ===== + +export type RangeType = 'FF' | 'FP'; // FF = fecha factura, FP = fecha pago +export type ReportType = 'Normal' | 'Detallado'; +export type CurrencyType = 'ME' | 'MN'; // ME = moneda extranjera, MN = moneda nacional +export type ExchangeRateType = 'FP' | 'FF'; +export type MovementTypeFilter = 'COMEX' | 'IMPDF' | 'ALL'; +export type DischargeFilter = 'SiDes' | 'NoDes' | 'ALL'; +export type ExportMovementType = 'AFIJO' | 'NODES' | 'SCRAP' | 'REEXP' | 'DONAC' | 'VEMEX' | 'ALL'; + +export interface BaseFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + report_type: ReportType; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + database_name: string; +} + +export interface ImportTemporaryFilter extends BaseFilter { } + +export interface ImportDefinitiveFilter extends BaseFilter { + movement_type: MovementTypeFilter; +} + +export interface ImportRepairFilter extends BaseFilter { + discharge_filter: DischargeFilter; +} + +export interface ExportFilter extends BaseFilter { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; + use_transport_method: boolean; +} + +export interface ExportRepairFilter extends Omit { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; +} + +export interface AllMovementsFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + report_type: ReportType; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + operation_type?: 'imp' | 'exp' | null; + send_email?: boolean; + // Granular flags + import_temp?: boolean; + import_def?: boolean; + import_rep?: boolean; + export_def?: boolean; + export_rep?: boolean; + export_types?: string[]; + discharge_filter?: DischargeFilter; +} + +export interface MovementItem { + Factura: string; + Pedimento: string; + FechaFactura: string | null; + Estatus: string; + ClavePed: string; + TipoMovTemDef: string; + EsCambioRegimen: string; + ValorMPTemp: number; + ValorComercialMN: number; + TipoCambio: number; + ValorAgre: number; + TipoExpo: string; + PedimentoR1: string | null; + EDocument: string | null; + NumOperacionVU: string | null; + BaseDeDatos: string; + NumGafUni: string | null; + UsuarioCap: string | null; + UsuarioAcr: string | null; + Fecha_Pago: string | null; + NumCaja: string | null; + Pedimento18: string | null; + AduanaCru: string | null; + Lote: string | null; +} + +export interface MovementItemDetailed extends MovementItem { + Linea: number; + Regimen: string | null; + Fecha_Inicio: string | null; + Fecha_Fin: string | null; + Remesa: string | null; + Proveedor: string | null; + RFCProveedor: string | null; + ProveedorTaxID: string | null; + VendidoA: string | null; + VendidoARFC: string | null; + VendidoATaxID: string | null; + AgenteAduanal: string | null; + Patente: string | null; + NumParte: string | null; + DescripcionE: string | null; + DescripcionI: string | null; + CantidadIE: number; + UniMed: string | null; + PesoNeto: number; + PesoBruto: number; + OrdenCompraVenta: string | null; + FraccionArancelaria: string | null; + Preferencia: string | null; + Sector: string | null; + PaisOrigen: string | null; + Aduana: string | null; + Advalorem: string | null; + Series: string | null; + Marca: string | null; + Modelo: string | null; + FraccionAmericana: string | null; + ECCN: string | null; + SimboloEx: string | null; + FechaEmision: string | null; + Transportista: string | null; +} + +// ===== API METHODS ===== + +export const invoiceMovementsApi = { + // Temporary Imports + getTemporaryImports: (filters: ImportTemporaryFilter) => + api.post('/v1/a76/reports/movements/invoices/temporary', filters), + + getTemporaryImportsDetailed: (filters: ImportTemporaryFilter) => + api.post( + '/v1/a76/reports/movements/invoices/temporary-detailed', + filters + ), + + // Definitive Imports + getDefinitiveImports: (filters: ImportDefinitiveFilter) => + api.post('/v1/a76/reports/movements/invoices/definitive', filters), + + getDefinitiveImportsDetailed: (filters: ImportDefinitiveFilter) => + api.post( + '/v1/a76/reports/movements/invoices/definitive-detailed', + filters + ), + + // Repair Imports + getRepairImports: (filters: ImportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/repair', filters), + + getRepairImportsDetailed: (filters: ImportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/repair-detailed', + filters + ), + + // Exports + getExports: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export', filters), + + getExportsDetailed: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export-detailed', filters), + + // Export Repairs + getExportRepairs: (filters: ExportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/export-repair', filters), + + getExportRepairsDetailed: (filters: ExportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/export-repair-detailed', + filters + ), + + // All Movements + getAllMovements: (filters: AllMovementsFilter) => + api.post('/v1/a76/reports/movements/invoices/all', filters), + + // Async Generation + generateReportAsync: (filters: AllMovementsFilter) => + api.post<{ task_id: string }>('/v1/a76/reports/movements/invoices/generate', filters), + + getTaskStatus: (taskId: string) => + api.get<{ task_id: string; status: string; result?: any; meta?: any }>( + `/v1/a76/reports/movements/invoices/task/${taskId}` + ) +}; diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index ea5c2194..3e537e6d 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -319,6 +319,7 @@ export interface UpdateInvoiceData { cfdi_uuid?: string | null; path_pdf?: string | null; path_xml?: string | null; + is_updated?: boolean | null; compliance_mx?: Partial | null; financials?: Partial | null; logistics?: Partial[] | null; diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 2b10b734..28e77fcd 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -18,7 +18,7 @@ export interface LineCustoms { destination_country?: string; advalorem?: string; advalorem_numeric?: number; - advalorem_american?: number; + advalorem_american?: number; advalorem_tlcan?: number; rate?: string; depreciation_rate?: number; @@ -33,7 +33,7 @@ export interface LineFinancials { unit_cost_mxn?: number; unit_cost_capture?: number; unit_cost_commercial_usd?: number; - + // Values value_mc?: number; value_usd?: number; @@ -49,7 +49,7 @@ export interface LineQuantities { line_item_id?: number; quantity?: number; unit_of_measure?: string; - + // Special quantities quantity_temp_export?: number; quantity_returned?: number; @@ -92,7 +92,7 @@ export interface FaLineItem { id?: number; tenant_id?: number; company_id?: number; - + // Asset information (SCAF specific) asset_number?: string; asset_photo?: string; @@ -101,44 +101,46 @@ export interface FaLineItem { return_import_invoice?: string; return_import_date?: number; movement_type_import?: string; - + // Cross-references for import repair search_invoice?: string; search_line?: number; - + // Search type search_type?: string; - - // Subitems - is_subitem?: boolean; - contains_subitems?: boolean; - subitem_number?: number; + + // Subitems + is_subitem?: boolean; + contains_subitems?: boolean; + subitem_number?: number; // Special flags download?: boolean; own_equipment?: boolean; - omit_annex31?: boolean; - + omit_annex31?: boolean; + // Timestamps created_at?: string; updated_at?: string; } export interface Item { - id?: number; - invoice_id: number; + id?: number; + invoice_id: number; line_number: number; - + // Identification part_number?: string; + part_number_id?: number; component_part_number?: string; + component_part_number_id?: number; class_id?: number; identifier?: string; // Unit of Measure unit_of_measure?: number; alternate_unit?: number; - + // Permits permit_number?: string; page_line?: string; @@ -151,29 +153,29 @@ export interface Item { includes_subitems?: boolean; tax_payment?: boolean; is_military_mcia?: boolean; - + // Payment payment_method?: string; igi_amount?: number; - + // Additional notes wildcard_field?: string; // Computed fields from class_info relation class_code?: string; class_description?: string; - + // Computed field from unit_of_measure_info relation - unit_of_measure_code?: string; - reference_number?: string; - order?: string; - guide_number?: string; - depreciation_date?: number; - rectification?: number; - warehouse?: string; - location?: string; - created_at?: string; - updated_at?: string; + unit_of_measure_code?: string; + reference_number?: string; + order?: string; + guide_number?: string; + depreciation_date?: number; + rectification?: number; + warehouse?: string; + location?: string; + created_at?: string; + updated_at?: string; // Nested relations (Singular names to match backend Pydantic models) customs?: LineCustoms; @@ -185,86 +187,86 @@ export interface Item { } export interface ItemListResponse { - items: Item[]; - total: number; - skip: number; - limit: number; + items: Item[]; + total: number; + skip: number; + limit: number; } export interface CreateItemData extends Omit { - invoice_id: number; + invoice_id: number; } -export interface UpdateItemData extends Partial> {} +export interface UpdateItemData extends Partial> { } /** * 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() - }); + /** + * 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()); - } + if (invoiceId) { + params.append('invoice_id', invoiceId.toString()); + } - return api.get(`/v1/a76/items/?${params.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()}`); - }, + /** + * 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()}`); - }, + /** + * 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); - }, + /** + * 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); - }, + /** + * 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()}`); - } + /** + * 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/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index a5069c9f..8b9d46f6 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -10,12 +10,12 @@ export interface PedimentoDates { pedimento_date?: string | null; payment_date?: string | null; rectification_payment_date?: string | null; - extraction_date?: string | null; + extraction_date?: string | null; submission_date?: string | null; eucan_date?: string | null; - original_date?: string | null; - start_date?: string | null; - end_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; } export interface PedimentoPayments { @@ -33,12 +33,19 @@ export interface PedimentoPayments { } export interface PedimentoTransportMeans { + id?: number; destination?: number | null; entry_exit?: string | null; arrival?: string | null; departure?: string | null; } +export interface PedimentoCustomsOffices { + id?: number; + dispatch_customs?: string | null; + entry_exit_customs?: string | null; +} + export interface PedimentoValidation { validator?: string | null; validation_ack?: string | null; @@ -180,6 +187,7 @@ export interface Pedimento { pedimento_incrementables?: PedimentoIncrementables | null; pedimento_decrementables?: PedimentoDecrementables | null; pedimento_indexes?: PedimentoIndexes | null; + pedimento_customs_offices?: PedimentoCustomsOffices | null; pedimento_config_additional?: PedimentoConfigAdditional | null; pedimento_config_calculations?: PedimentoConfigCalculations | null; pedimento_config_surcharges?: PedimentoConfigSurcharges | null; @@ -279,9 +287,9 @@ export const pedimentosApi = { * @param filters - Filtros opcionales * @param companyId - ID de la compañía (por defecto 1) */ - list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId = 1) => { + list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId?: number) => { let url = `/v1/a76/pedimentos/?company_id=${companyId}&page=${page}&page_size=${pageSize}`; - + if (filters?.status) { url += `&status=${encodeURIComponent(filters.status)}`; } @@ -291,7 +299,7 @@ export const pedimentosApi = { if (filters?.year) { url += `&year=${encodeURIComponent(filters.year)}`; } - + return api.get(url); }, @@ -300,14 +308,14 @@ export const pedimentosApi = { * @param id - ID del pedimento * @param companyId - ID de la compañía (por defecto 1) */ - get: (id: number, companyId = 1) => api.get(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`), + get: (id: number, companyId?: number) => api.get(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`), /** * Crea un nuevo pedimento * @param data - Datos del pedimento a crear * @param companyId - ID de la compañía (por defecto 1) */ - create: (data: CreatePedimentoData, companyId = 1) => + create: (data: CreatePedimentoData, companyId?: number) => api.post(`/v1/a76/pedimentos/?company_id=${companyId}`, data), /** @@ -316,7 +324,7 @@ export const pedimentosApi = { * @param data - Datos a actualizar * @param companyId - ID de la compañía (por defecto 1) */ - update: (id: number, data: UpdatePedimentoData, companyId = 1) => + update: (id: number, data: UpdatePedimentoData, companyId?: number) => api.put(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`, data), /** @@ -324,5 +332,5 @@ export const pedimentosApi = { * @param id - ID del pedimento a eliminar * @param companyId - ID de la compañía (por defecto 1) */ - delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) + delete: (id: number, companyId?: number) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) }; diff --git a/frontend/src/lib/api/dashboard/a76/trailers.ts b/frontend/src/lib/api/dashboard/a76/trailers.ts index df739414..5d74aa2d 100644 --- a/frontend/src/lib/api/dashboard/a76/trailers.ts +++ b/frontend/src/lib/api/dashboard/a76/trailers.ts @@ -2,10 +2,17 @@ import { api, type ApiResponse } from '$lib/api'; export interface Trailer { trailer_number: string; - plate_number?: string; + ace_trailer_number?: string; trailer_type_key?: string; - is_active: boolean; - tenant_id?: string; + seal?: string; + entity_code?: string; + plate_number?: string; + state?: string; + country?: string; + container_key?: string; + is_active?: boolean; + company_id?: number | string; + tenant_id?: number | string; } export interface TrailerResponse { @@ -16,7 +23,7 @@ export interface TrailerResponse { } class TrailersApi { - private baseUrl = '/v1/a76/trailers'; + private baseUrl = '/v1/a76/transportation/trailers'; async list( companyId: string | number, @@ -35,6 +42,27 @@ class TrailersApi { }); return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); } + + async create(data: Trailer, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Trailer, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } } export const trailersApi = new TrailersApi(); diff --git a/frontend/src/lib/api/dashboard/a76/transporters.ts b/frontend/src/lib/api/dashboard/a76/transporters.ts index 6383828b..70ecbcdd 100644 --- a/frontend/src/lib/api/dashboard/a76/transporters.ts +++ b/frontend/src/lib/api/dashboard/a76/transporters.ts @@ -2,10 +2,26 @@ import { api, type ApiResponse } from '$lib/api'; export interface Transporter { transporter_key: string; - name: string; + name?: string; + short_name?: string; + responsible?: string; rfc?: string; - is_active: boolean; - tenant_id?: string; + streets?: string; + postal_code?: string; + city?: string; + state?: string; + country?: string; + loader_code?: string; + caat_code?: string; + transport_code?: string; + transport_interface_type?: string; + ftp_server?: string; + ftp_user?: string; + ftp_password?: string; + ftp_directory?: string; + filler_code?: string; + company_id?: number | string; + tenant_id?: number | string; } export interface TransporterResponse { @@ -35,6 +51,27 @@ class TransportersApi { }); return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); } + + async create(data: Transporter, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Transporter, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } } export const transportersApi = new TransportersApi(); diff --git a/frontend/src/lib/api/dashboard/a76/vehicles.ts b/frontend/src/lib/api/dashboard/a76/vehicles.ts index a0c864e4..3fd7d67a 100644 --- a/frontend/src/lib/api/dashboard/a76/vehicles.ts +++ b/frontend/src/lib/api/dashboard/a76/vehicles.ts @@ -1,32 +1,84 @@ -import { api } from '$lib/api'; +import { api, type ApiResponse } from '$lib/api'; export interface Vehicle { vehicle_key: string; - brand?: string; - plate_number?: string; - description?: string; + ace_vehicle_key?: string; + transporter_key?: string; + transport_identifier?: string; transport_type?: string; + entity_code?: string; + transponder_number?: string; + dot_number?: string; + plate_number?: string; + city?: string; + state?: string; + country?: string; + seal?: string; + insurance_company_name?: string; + insurance_number?: string; + insurance_amount?: number; + insurance_date?: number; + box_number?: string; + brand?: string; year?: string; + series?: string; + description?: string; + engine_number?: string; + sct_permission?: string; + color?: string; + container_key?: string; + company_id?: number | string; + tenant_id?: number | string; } -export interface VehicleListResponse { +export interface VehicleResponse { items: Vehicle[]; total: number; + page: number; + page_size: number; } -/** - * API para Vehículos - */ -export const vehiclesApi = { - list: (companyId: string, page = 1, pageSize = 50) => { - return api.get( - `/v1/a76/transportation/vehicles?company_id=${companyId}&page=${page}&page_size=${pageSize}` - ); - }, +class VehiclesApi { + private baseUrl = '/v1/a76/transportation/vehicles'; - get: (vehicleKey: string, companyId: string) => { - return api.get( - `/v1/a76/transportation/vehicles/${vehicleKey}?company_id=${companyId}` - ); + async list( + companyId: string | number, + params?: Record + ): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString(), + ...params + }); + return api.get(`${this.baseUrl}?${queryParams.toString()}`); } -}; + + async get(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } + + async create(data: Vehicle, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Vehicle, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } +} + +export const vehiclesApi = new VehiclesApi(); diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts new file mode 100644 index 00000000..70693704 --- /dev/null +++ b/frontend/src/lib/api/help.ts @@ -0,0 +1,125 @@ +import { getToken, authStore } from '$lib/auth'; +import { get } from 'svelte/store'; + +const api_url = import.meta.env.VITE_API_URL; +const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`; + +function getAuthToken(): string | null { + // 1. First try getToken() which checks Keycloak and localStorage + let token = getToken(); + + // 2. If somehow empty, explicitly check authStore value + if (!token) { + const auth = get(authStore); + token = auth.token; + } + + return token; +} + +function getHeaders() { + const token = getAuthToken(); + return { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }; +} + +export interface HelpArticle { + uuid: string; + slug: string; + title: string; + content: string; + updated_at: string; + last_editor: string; + category?: string; + order?: number; + content_type: string; + file_url?: string; + file_size?: number; + mime_type?: string; +} + +export const helpApi = { + async listArticles(): Promise { + const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() }); + if (!response.ok) throw new Error('Failed to fetch articles'); + return response.json(); + }, + + async getArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() }); + if (!response.ok) throw new Error('Failed to fetch article'); + return response.json(); + }, + + async updateArticle(uuid: string, data: Partial): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'PATCH', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al guardar cambios'); + return response.json(); + }, + + async createArticle(data: Partial): Promise { + const response = await fetch(`${BASE_URL}/articles/`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(data) + }); + if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al crear el artículo'); + return response.json(); + }, + + async deleteArticle(uuid: string): Promise { + const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { + method: 'DELETE', + headers: getHeaders() + }); + if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al eliminar'); + }, + + async triggerSync(): Promise { + // Opcional: endpoint para forzar sync desde UI si es necesario + }, + + async uploadImage(file: File): Promise<{ url: string }> { + const formData = new FormData(); + formData.append('file', file); + + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/upload-image/`, { + method: 'POST', + // No Content-Type header for FormData, browser sets it with boundary + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }, + body: formData + }); + if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir imagen'); + return response.json(); + }, + + async uploadAsset(file: File): Promise<{ url: string, filename: string, size: number, mime_type: string }> { + const formData = new FormData(); + formData.append('file', file); + + const token = getAuthToken(); + const response = await fetch(`${BASE_URL}/upload-asset/`, { + method: 'POST', + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + }, + body: formData + }); + if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)'); + if (!response.ok) throw new Error('Error al subir archivo'); + return response.json(); + } +}; diff --git a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts index 1e770110..add7af3f 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts +++ b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts @@ -42,8 +42,12 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ cell: ({ row }) => { const typeSnippet = createRawSnippet<[{ type: string | null | undefined }]>((getType) => { const { type } = getType(); + let display = type || '-'; + if (type === 'MEX') display = 'Agente Aduanal Mexicano'; + else if (type === 'USA') display = 'Agente Aduanal Americano (Broker)'; + return { - render: () => `
${type || '-'}
` + render: () => `
${display}
` }; }); return renderSnippet(typeSnippet, { type: row.original.type }); @@ -82,8 +86,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const postalSnippet = createRawSnippet<[{ postal: string | null | undefined }]>((getPostal) => { const { postal } = getPostal(); return { - render: () => - postal + render: () => + postal ? `${postal}` : `-` }; @@ -150,8 +154,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const licenseSnippet = createRawSnippet<[{ license: string | null | undefined }]>((getLicense) => { const { license } = getLicense(); return { - render: () => - license + render: () => + license ? `${license}` : `-` }; @@ -189,9 +193,9 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ id: "actions", header: "Acciones", cell: ({ row }) => { - return renderComponent(DataTableActions, { + return renderComponent(DataTableActions, { broker: row.original, - onSuccess + onSuccess }); } } diff --git a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte index ded462ce..f19312c0 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte @@ -166,7 +166,7 @@
-

+

{title}

@@ -232,7 +232,7 @@ /> {#if isMissingRateContext}

Requerida
@@ -241,28 +241,12 @@
-
- - -
+
$
@@ -270,6 +254,7 @@ id="value" type="number" step="0.0001" + lang="en" bind:value={formData.value} disabled={loading} required @@ -278,18 +263,30 @@ autofocus />
-

Ej. 24.1234

+
+ +

Ej. 24.1234

+
- - - + - + + diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte index 93eea293..9e0a6b62 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionFormDialog.svelte @@ -66,6 +66,13 @@ } }); + // Sync code with fractionFormatted for US catalog + $effect(() => { + if (catalog === 'usa' || catalog === 'american') { + code = fractionFormatted.replace(/\./g, '').replace(/-/g, ''); + } + }); + async function handleSubmit() { const companyId = companyStore.activeCompany?.id; if (!companyId) return; @@ -126,17 +133,23 @@
- - - {#if fraction} -

- El código no se puede modificar una vez creado. -

- {/if} + + +

+ {catalog === 'mex' + ? 'El código se genera automáticamente.' + : 'La clave se deriva de la fracción sin puntos.'} +

- - + +
diff --git a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte index 15e75d87..ba3ea1cf 100644 --- a/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte +++ b/frontend/src/lib/components/dashboard/goods/fractions/TariffFractionList.svelte @@ -310,7 +310,7 @@ fraction={selectedFraction} {catalog} onSuccess={() => { - loadFractions(); + loadFractions(true); isFormDialogOpen = false; }} /> diff --git a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte index 546ccfa4..c6a4e708 100644 --- a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte @@ -1,15 +1,20 @@ - + - {isEditing ? "Editar Factura" : "Nueva Factura"} + {isEditing ? 'Editar Factura' : 'Nueva Factura'} - {isEditing - ? "Modifica los datos de la factura" - : "Ingresa los datos de la nueva factura"} + {isEditing ? 'Modifica los datos de la factura' : 'Ingresa los datos de la nueva factura'} @@ -397,24 +406,29 @@
-
- - { - if (v) formData.operation_type = v as "imp" | "exp"; - }} - > - - {formData.operation_type === 'imp' ? 'Importación' : formData.operation_type === 'exp' ? 'Exportación' : 'Seleccionar tipo'} - - - Importación - Exportación - - -
+
+ + { + if (v) formData.operation_type = v as 'imp' | 'exp'; + }} + > + + {formData.operation_type === 'imp' + ? 'Importación' + : formData.operation_type === 'exp' + ? 'Exportación' + : 'Seleccionar tipo'} + + + Importación + Exportación + + +
+
- +
@@ -470,11 +480,7 @@
- +
@@ -514,11 +520,7 @@
- +
@@ -548,7 +550,7 @@ />
-
+
- +
@@ -696,34 +694,31 @@ {#if error} -
+
{error}
{/if} - - {/* Optional: maybe refresh something or just let user continue */}} + onSuccess={() => { + /* Optional: maybe refresh something or just let user continue */ + }} /> diff --git a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte index 82adf098..a9cfd904 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte @@ -74,6 +74,7 @@ is_pedimento_pending: false, pedimento_id: invoice?.compliance_mx?.pedimento_id || '', remesa: invoice?.compliance_mx?.remesa || '', + invoice_number: invoice?.invoice_number || '', invoice_date: invoice?.invoice_date || new Date().toISOString().split('T')[0], emission_date: new Date().toISOString().split('T')[0], diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 8bc0f930..af2950b8 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -5,14 +5,14 @@ import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; import PartNumberDialog from './part-number-dialog.svelte'; - import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; - let { + let { lineItem = $bindable(), - descriptions = $bindable() - }: { + descriptions = $bindable() + }: { lineItem: Partial; - descriptions: LineDescriptions; + descriptions: LineDescriptions; } = $props(); let showPartDialog = $state(false); @@ -22,30 +22,30 @@ lineItem.fa_data = {}; } - // Helper to map boolean to string for RadioGroup - let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); - function setIsSubPartida(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.is_subitem = val === 'subpartida'; - } + // Helper to map boolean to string for RadioGroup + let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); + function setIsSubPartida(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.is_subitem = val === 'subpartida'; + } - let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); - function setContinueSubPartidas(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.contains_subitems = val === 'si'; - } + let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); + function setContinueSubPartidas(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.contains_subitems = val === 'si'; + } - // Helper for subitem_number binding - let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); - function setSubitemNumber(val: number) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.subitem_number = val; - } + // Helper for subitem_number binding + let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); + function setSubitemNumber(val: number) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.subitem_number = val; + } function handlePartSelect(part: any) { lineItem.part_number = part.id; // Store part number for display - (lineItem as any).part_number = part.part_number; + (lineItem as any).part_number_display = part.part_number; (lineItem as any).part_description_es = part.description_spanish; (lineItem as any).part_description_en = part.description_english; } @@ -53,15 +53,12 @@ -
+
-
- Is - +
+ Is +
@@ -73,12 +70,15 @@
{#if isSubPartidaValue === 'partida'} -
- Contains Sub-Items - + Contains Sub-Items + + class="flex gap-3" + >
@@ -90,31 +90,33 @@
{:else if isSubPartidaValue === 'subpartida'} -
- Main Item Number - + Main Item Number + setSubitemNumber(e.currentTarget.valueAsNumber || 0)} - class="h-7 text-xs" + class="h-7 text-xs" placeholder="Enter main item number" /> -
- {/if} +
+ {/if}
-
+
- (showPartDialog = true)} /> @@ -124,29 +126,33 @@ class="h-7 w-7 shrink-0" onclick={() => (showPartDialog = true)} > - +
{#if (lineItem as any).part_description_es} -

{(lineItem as any).part_description_es}

+

+ {(lineItem as any).part_description_es} +

{/if}
- -
-
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte new file mode 100644 index 00000000..81f228e8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte @@ -0,0 +1,153 @@ + + + + + + CATALOGO DE ESTADOS + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+ + + + + + + + + + + {#each filteredItems as item} + handleSelect(item)} + > + + + + + + {/each} + {#if filteredItems.length === 0} + + + + {/if} + +
Clave M3Clave MexClave AmeDescripción
{item.m3_key || ''}{item.mex_key || ''}{item.ame_key || ''}{item.description || ''}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index a4a10bb3..13b20bf5 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -2,14 +2,16 @@ import * as Sheet from '$lib/components/ui/sheet'; import * as Tabs from '$lib/components/ui/tabs'; import { Input } from '$lib/components/ui/input'; + import { Textarea } from '$lib/components/ui/textarea'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { Loader2, FileText } from 'lucide-svelte'; + import { Loader2, FileText, Folder } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import type { Item } from '$lib/api/dashboard/a76/items'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosPestanasItemInv } from '$lib/config/shortcuts/dashboard/invoices/item/inventory'; + import PartNumberDialog from '../fa/part-number-dialog.svelte'; let { open = $bindable(), @@ -34,6 +36,7 @@ } = $props(); let activeTab = $state('general'); + let showPartDialog = $state(false); const tabMapping: Record = { tab1: 'general', @@ -42,6 +45,19 @@ tab4: 'otros' }; + function handlePartSelect(part: any) { + editingItem.part_number = part.id; + // Store part number for display + (editingItem as any).part_number_display = part.part_number; + if (editingItem.description) { + editingItem.description.description_spanish = part.description_spanish; + editingItem.description.description_english = part.description_english; + } + if (editingItem.customs) { + editingItem.customs.fraction = part.fraction; + } + } + useShortcuts( 'Invoice Item Form (Inventory)', obtenerAtajosPestanasItemInv({ @@ -60,18 +76,16 @@ // Initialize missing nested objects if they don't exist $effect(() => { if (open && editingItem) { - if (editingItem && !editingItem.quantity) - editingItem.quantity = {} as any; - if (editingItem && !editingItem.financial) - editingItem.financial = {} as any; - if (editingItem && !editingItem.customs) - editingItem.customs = {} as any; - if (editingItem && !editingItem.description) - editingItem.description = {} as any; + if (editingItem && !editingItem.quantity) editingItem.quantity = {} as any; + if (editingItem && !editingItem.financial) editingItem.financial = {} as any; + if (editingItem && !editingItem.customs) editingItem.customs = {} as any; + if (editingItem && !editingItem.description) editingItem.description = {} as any; } }); + +
{#if line} - +
+ (showPartDialog = true)} + /> + +
{/if}
@@ -227,20 +249,20 @@
{#if line?.description} - {/if}
{#if line?.description} - {/if}
@@ -363,7 +385,7 @@ {/if}
@@ -377,7 +399,7 @@ id="imported_quantity" type="number" placeholder="0" - bind:value={(line.quantity as any).quantity_imported} + bind:value={(line.quantity as any).quantity_imported} /> {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index a8e24e06..8bc9b75a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -20,7 +20,7 @@ } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; - import { itemsApi, type Item } from '$lib/api/dashboard/a76/items'; + import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items'; import { companyStore } from '$lib/stores/company.svelte'; import ItemSheetFa from './fa/item-sheet-fa.svelte'; import ItemSheetInv from './inv/item-sheet-inv.svelte'; @@ -43,7 +43,7 @@ } = $props(); // 1. Core State - let items = $state([]); + let items = $state([]); let displayedItems = $state([]); let imported = $state(0); let net_weight = $state(0); @@ -87,6 +87,7 @@ item?.description?.description_english || '', unit_of_measure_code: item?.quantity?.unit_of_measure || item?.unit_of_measure, + part_number_display: (item as any).part_number_display || item?.part_number, fa_data: item?.fa_data || {}, warehouse: item?.warehouse, full_item: item @@ -128,9 +129,9 @@ let showItemSheet = $state(false); let isEditMode = $state(false); let showDeleteDialog = $state(false); - let selectedItem = $state(null); - let originalItemData = $state | null>(null); - let editingItem = $state>({ + let selectedItem = $state(null); + let originalItemData = $state | null>(null); + let editingItem = $state>({ invoice_id: undefined, reference_number: '', order: '', @@ -261,11 +262,11 @@ payment_method: undefined, igi_amount: undefined, is_military_mcia: false, - wildcard_field: undefined, + wildcard_field: undefined, reference_number: '', order: invoice?.purchase_order || '', warehouse: '', - location: '', + location: '', // Nested relations financial: { unit_cost_usd: undefined, @@ -314,7 +315,7 @@ }, reference: { serie_id: undefined - } + } }; } @@ -399,7 +400,7 @@ return cleanLineData({ ...rest }); } - function cloneItemForPreset(item: Item) { + function cloneItemForPreset(item: InvoiceItem) { const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; return { ...sanitizeLineForPreset(rest), @@ -422,8 +423,7 @@ quantity: Number(draft.quantity) || 0 }, financial: { - unit_cost_usd: - draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined + unit_cost_usd: draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined } }); } @@ -502,7 +502,7 @@ isSavingPreset = true; try { // We group everything as items for the template - const lines = builderItems.map((item: Item, idx: number) => { + const lines = builderItems.map((item: InvoiceItem, idx: number) => { return { ...cleanLineData(item), line_number: item.line_number || idx + 1, // Ensure line_number is present @@ -552,7 +552,7 @@ } // Enrich item with descriptive data for display - async function enrichItemData(item: Partial) { + async function enrichItemData(item: Partial) { if (!item || !activeCompanyId) return; // Load class data @@ -574,15 +574,15 @@ } // Load part number data - if (item.part_number) { + if (item.part_number_id) { try { const response = await fetch( - `/api-sveltekit/parts/${item.part_number}?company_id=${activeCompanyId}`, + `/api-sveltekit/parts/${item.part_number_id}?company_id=${activeCompanyId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } } ); if (response.ok) { const partData = await response.json(); - (item as any).part_number = partData.part_number; + (item as any).part_number_display = partData.part_number; (item as any).part_description_es = partData.description_spanish; (item as any).part_description_en = partData.description_english; } @@ -661,7 +661,8 @@ if (Array.isArray(packages)) { const pkg = packages.find((p: any) => p.id === packageId); if (pkg) { - (item.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key; + (item.quantity as any).package_description = + pkg.description_es || pkg.description_en || pkg.key; (item.quantity as any).package_key = pkg.key; (item.quantity as any).package_weight_unit = pkg.weight_unit || 0; } @@ -696,7 +697,7 @@ } // Normalize numeric values from strings to numbers - function normalizeItemData(item: Partial): Partial { + function normalizeItemData(item: Partial): Partial { if (item) { const normalizedItem = { ...item }; @@ -990,15 +991,15 @@
-
-
+
+

Items de la Factura

Carga partidas, crea o aplica plantillas sin salir de esta vista.

-
+
@@ -1037,7 +1038,7 @@ Preferencia Contiene Subpartida Partida Principal - Acciones + Acciones @@ -1066,7 +1067,7 @@ {item.is_subitem ? 'S' : 'P'} {item.quantity?.quantity || '0'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'} {item.is_subitem ? 'S' : 'P'} {item.quantity?.quantity || '0'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'} {item.fa_data?.search_line || '-'} {item.is_subitem ? 'S' : 'P'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'}
@@ -1249,10 +1250,10 @@ }} > -
+
Usar plantilla @@ -1267,7 +1268,7 @@ disabled={isLoadingPresets} class="h-8 text-muted-foreground" > - + Actualizar
-
+
-
+
@@ -1303,47 +1304,47 @@
{#if isLoadingPresets}
- + Cargando...
{:else if filteredPresets.length === 0}
- +

No se encontraron plantillas

{:else} {#each filteredPresets as preset} @@ -1496,7 +1495,11 @@
- +
@@ -1510,18 +1513,18 @@
-
+
{builderItems.length} items/líneas
-
-
- +
+ Items de la plantilla
@@ -1531,7 +1534,7 @@ # Descripción Cant. - Acciones + Acciones @@ -1539,7 +1542,7 @@ Usa el botón "Agregar Item/Línea" para definir el contenido de la plantilla. @@ -1552,7 +1555,7 @@
- + {item?.[0]?.description?.description_spanish || 'Sin descripción'} {item?.[0]?.quantity?.quantity || 0} - +
@@ -1597,14 +1600,14 @@ diff --git a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte index b53cc53f..8f80a09a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte @@ -47,6 +47,8 @@ adendas: invoice.compliance_mx?.addendum_vu || '', observations_vu: invoice.vu_observations || '', certified_number: invoice.compliance_mx?.certificate_number || '', + entry_exit_date: invoice.logistics?.entry_exit_date || '', + payment_date: invoice.logistics?.payment_date || '', // New Export fields bill_number: invoice.logistics?.bill_number || '', guide_number: invoice.logistics?.guide_number || '', @@ -80,6 +82,8 @@ adendas: '', observations_vu: '', certified_number: '', + entry_exit_date: '', + payment_date: '', // New Export fields bill_number: '', guide_number: '', @@ -145,8 +149,8 @@
- (formData.is_mixed = v === 'true')} class="flex gap-4" > @@ -297,7 +301,6 @@
-
@@ -317,6 +320,16 @@
+ +
+ + +
+
+ + +
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index aa8070a8..141a1fbd 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -114,6 +114,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined, document_type: generalFormData?.document_type || undefined, invoice_number: InvoiceTopFieldsFormData?.invoice_number || undefined, + purchase_order: InvoiceTopFieldsFormData?.purchase_order || continuationFormData?.purchase_order || undefined, invoice_date: InvoiceTopFieldsFormData?.invoice_date || undefined, emission_date: InvoiceTopFieldsFormData?.emission_date || undefined, proforma_number: observationFormData?.proforma_number || undefined, @@ -126,7 +127,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI vu_observations: othersFormData?.observations_vu || undefined, comments_status: othersFormData?.comments_status || undefined, option_iv18: othersFormData?.option_iv18 || undefined, - purchase_order: continuationFormData?.purchase_order || undefined, + payment_terms: continuationFormData?.payment_terms || undefined, handling_fees: continuationFormData?.handling_fees || undefined, cfdi_uuid: continuationFormData?.cfdi_uuid || undefined, @@ -234,8 +235,9 @@ function buildComplianceMxData(invoiceType: string, InvoiceTopFieldsFormData: an function buildFinancialsData(generalFormData: any, observationFormData: any, othersFormData: any, InvoiceTopFieldsFormData?: any) { return { // Currency from generalFormData - currency_type: generalFormData?.currency_type || null, + currency_type: generalFormData?.currency_type || (generalFormData?.currency === 'foreign' ? 'USD' : null), currency: generalFormData?.currency || null, + exchange_rate: generalFormData?.exchange_rate ? Number(generalFormData.exchange_rate) : null, iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? String(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null, // Costs & increments from observationFormData freight: observationFormData?.freight || null, @@ -255,9 +257,11 @@ function buildLogisticsData(generalFormData: any, observationFormData: any, othe // Retornar logistics como objeto único con datos de continuación return { carrier_id: generalFormData?.carrier_id || null, + transport_id: generalFormData?.transport_id || null, transport_type: generalFormData?.transport_type || 'none', driver_name: generalFormData?.driver_name || null, - vehicle_num: generalFormData?.transport_num || continuationFormData?.numero_tipo_transporte || null, + license_plate: generalFormData?.transport_num || null, + vehicle_num: continuationFormData?.numero_tipo_transporte || null, incoterm: observationFormData?.incoterm || null, // Campos de continuación mapeados a logistics transport_num: continuationFormData?.numero_tipo_transporte || null, @@ -285,6 +289,10 @@ function buildLogisticsData(generalFormData: any, observationFormData: any, othe green_light_us: continuationFormData?.semaforo_verde_aduana_americana || false, red_light_mx: continuationFormData?.semaforo_rojo_aduana_mexicana || false, red_light_us: continuationFormData?.semaforo_rojo_aduana_americana || false, + // Fechas Logísticas de OthersTabForm (se pasan en othersFormData) + entry_exit_date: othersFormData?.entry_exit_date || null, + payment_date: othersFormData?.payment_date || null, + vehicle_data: continuationFormData?.vehicle_data || null, }; } diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/contributions-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/contributions-tab-form.svelte index d14f69f6..61c3c86b 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/contributions-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/contributions-tab-form.svelte @@ -1,4 +1,5 @@ - +
-
+
{#if activeTab === 'DTA'}
@@ -397,7 +405,7 @@
{:else if activeTab === 'PREV'} -
+
-
+
@@ -497,7 +505,7 @@ {#if !formData?.contribuciones || formData.contribuciones.length === 0} - + No hay contribuciones registradas @@ -555,7 +563,7 @@ onCheckedChange={(checked: boolean | 'indeterminate') => formData && (formData.calculo_manual = checked === true)} /> - @@ -567,7 +575,7 @@ onCheckedChange={(checked: boolean | 'indeterminate') => formData && (formData.operaciones_regla_31_40 = checked === true)} /> - @@ -609,7 +617,7 @@ - + {editingIndex !== null ? 'Editar Contribución' : 'Insertando'} @@ -619,8 +627,8 @@
-

Tasas Pedimento - Registro 509

- +

Tasas Pedimento - Registro 509

+
{currentContribucion.tipo_tasa || 'Seleccionar'} - Porcentual - Específico - Cuota minima (DTA) - Cuota fija (DTA) - Tasa de descuento sobre ad valorem - Factor de aplicación sobre tigie. - Al millar (DTA) - Tasa de descuento sobre el arancel específico - Tasa especifica sobre precios de referencia - Tasa especifica sobre precios de referencia con UM - - - + Porcentual + Específico + Cuota minima (DTA) + Cuota fija (DTA) + Tasa de descuento sobre ad valorem + Factor de aplicación sobre tigie. + Al millar (DTA) + Tasa de descuento sobre el arancel específico + Tasa especifica sobre precios de referencia + Tasa especifica sobre precios de referencia con UM + + + +
+
+ +
+ +
-
- - -
- + +
+

Contribuciones Generales - Registro 510

- -
-

Contribuciones Generales - Registro 510

- -
+
@@ -696,7 +714,7 @@ placeholder="0.00000" {#if !currentContribucion.contribuciones_generales || currentContribucion.contribuciones_generales.length === 0} - + Sin registros @@ -704,9 +722,9 @@ placeholder="0.00000" {#each currentContribucion.contribuciones_generales as contribGen, index} {contribGen.clave_contribucion} - {contribGen.clave_forma_pago} - - {contribGen.importe_contribucion.toLocaleString('es-MX', { + {contribGen.clave_forma_pago} + + {contribGen.importe_contribucion.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} @@ -720,13 +738,13 @@ placeholder="0.00000"
@@ -767,11 +785,7 @@ placeholder="0.00000"
- +
diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte index adf9f65b..aa8d3f8e 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte @@ -1,5 +1,5 @@ @@ -381,18 +428,18 @@
Información General - - Edita los datos principales del pedimento - + Edita los datos principales del pedimento
- + -
+
{formData.fecha_captura}
-
+
{formData.hora_captura} @@ -401,39 +448,39 @@
-
-
+
+
- +
- formData.customs_office = v ?? ''} + onValueChange={(v: string) => (formData.customs_office = v ?? '')} > {formData.customs_office ? formData.customs_office.substring(0, 2) : 'Sel...'} - + {#if customsSections.length === 0}
No hay aduanas disponibles @@ -441,7 +488,10 @@ {:else} {#each customsSections as section} - + {section.customs_code} - {section.section_name} @@ -452,23 +502,23 @@
- +
- formData.license = v || null} + onValueChange={(v: string | undefined) => (formData.license = v || null)} > {formData.license || 'Sel...'} - - {@const validBrokers = customsBrokers.filter(b => b.license)} + + {@const validBrokers = customsBrokers.filter((b) => b.license)} {#if customsBrokers.length === 0}
No hay agentes aduanales registrados @@ -481,7 +531,10 @@ {#each validBrokers as broker} {@const displayName = broker.name || broker.broker_key || ''} - + {displayName ? `${displayName} - ${broker.license}` : broker.license} @@ -492,11 +545,13 @@
- +
- +
-
+
- formData.pedimento_code = v ?? ''} + onValueChange={(v: string) => (formData.pedimento_code = v ?? '')} > - {pedimentoCodes.find(o => o.code === formData.pedimento_code)?.code || 'Sel...'} + {pedimentoCodes.find((o) => o.code === formData.pedimento_code)?.code || 'Sel...'} - + {#each pedimentoCodes as code}
@@ -552,13 +607,14 @@
- formData.operation_type = v || null} + onValueChange={(v: string) => (formData.operation_type = v || null)} > - {operationOptions.find(o => o.value === formData.operation_type)?.label || 'Seleccionar...'} + {operationOptions.find((o) => o.value === formData.operation_type)?.label || + 'Seleccionar...'} {#each filteredOperationTypes as option} @@ -571,11 +627,11 @@
- {#if hasMultipleRegimens} - formData.regime = v ?? ''} + onValueChange={(v: string) => (formData.regime = v ?? '')} > @@ -583,9 +639,12 @@ - {#each filteredRegimens as regimen} + {#each filteredRegimens as regimen} - + {regimen.code} @@ -605,18 +664,18 @@
-
+
- formData.client_id = v ? Number(v) : null} + onValueChange={(v: string) => (formData.client_id = v ? Number(v) : null)} > - {clients.find(c => c.id === formData.client_id)?.name || 'Seleccionar cliente...'} + {clients.find((c) => c.id === formData.client_id)?.name || 'Seleccionar cliente...'} @@ -627,7 +686,10 @@ {:else} {#each clients as client} - + {client.name} @@ -640,13 +702,14 @@
- formData.pedimento_type = v ?? ''} + onValueChange={(v: string) => (formData.pedimento_type = v ?? '')} > - {pedimentoTypeOptions.find(o => o.value === formData.pedimento_type)?.label || 'Seleccionar...'} + {pedimentoTypeOptions.find((o) => o.value === formData.pedimento_type)?.label || + 'Seleccionar...'} {#each pedimentoTypeOptions as option} @@ -655,12 +718,9 @@
-
- - -
+
@@ -700,145 +760,237 @@
- + + (formData.pedimento_customs_offices.dispatch_customs = v || '')} + > + + {formData.pedimento_customs_offices.dispatch_customs || 'Sel...'} + + + {#each customsSections.filter((s) => !formData.customs_office || s.customs_code.startsWith(formData.customs_office.substring(0, 2))) as section} + + {section.customs_code} - {section.section_name} + + {/each} + + +
+ + +
+ + + (formData.pedimento_transport_means.destination = v ? Number(v) : null)} + > + + {formData.pedimento_transport_means.destination === 1 + ? '1 - REGION FRONTERIZA' + : formData.pedimento_transport_means.destination === 8 + ? '8 - RECTIFICACION REGION FRONTERIZA' + : formData.pedimento_transport_means.destination === 9 + ? '9 - INTERIOR DEL PAIS' + : 'Seleccionar...'} + + + 1 - REGION FRONTERIZA + 8 - RECTIFICACION REGION FRONTERIZA + 9 - INTERIOR DEL PAIS + +
- +
- +
- - -
+ + (formData.pedimento_transport_means.entry_exit = v || '')} + > + + + {transportModes.find((m) => m.key === formData.pedimento_transport_means.entry_exit) + ?.name || + transportTypes.find( + (t) => t.transport_code === formData.pedimento_transport_means.entry_exit + )?.description || + formData.pedimento_transport_means.entry_exit || + 'Seleccionar...'} + + + + {#each transportModes as mode} + {mode.key} - {mode.name} + {/each} + + +
+ + +
+ + (formData.pedimento_transport_means.arrival = v || '')} + > + + + {transportModes.find((m) => m.key === formData.pedimento_transport_means.arrival) + ?.name || + transportTypes.find( + (t) => t.transport_code === formData.pedimento_transport_means.arrival + )?.description || + formData.pedimento_transport_means.arrival || + 'Seleccionar...'} + + + + {#each transportModes as mode} + {mode.key} - {mode.name} + {/each} + + +
+ + +
+ + (formData.pedimento_transport_means.departure = v || '')} + > + + + {transportModes.find((m) => m.key === formData.pedimento_transport_means.departure) + ?.name || + transportTypes.find( + (t) => t.transport_code === formData.pedimento_transport_means.departure + )?.description || + formData.pedimento_transport_means.departure || + 'Seleccionar...'} + + + + {#each transportModes as mode} + {mode.key} - {mode.name} + {/each} + + +
-
-
- - - - - - + + + + + + +
-
{#if activeSection === 'fechas'} -
+
- +
{#if formData.pedimento_type === 'consolidated'}
- +
{/if}
- +
- +
- +
{#if formData.pedimento_code === 'R1'} @@ -856,125 +1008,118 @@
- -
+ +
{:else if activeSection === 'incrementables'}
-
- Factor: 1.00000 -
+
Factor: 1.00000
-
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- +
+
-
- - -
-
- - + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
-
{:else if activeSection === 'identificadores'} {:else if activeSection === 'indices'} -
+
- formData.tipo_factor = v ?? ''} + onValueChange={(v: string) => (formData.tipo_factor = v ?? '')} > - {formData.tipo_factor === 'INPC' ? 'I.N.P.C' : formData.tipo_factor === 'variacion_cambiaria' ? 'Variación Cambiaria' : 'Seleccionar'} + {formData.tipo_factor === 'INPC' + ? 'I.N.P.C' + : formData.tipo_factor === 'variacion_cambiaria' + ? 'Variación Cambiaria' + : 'Seleccionar'} @@ -998,7 +1143,7 @@
-
+
{:else if activeSection === 'adicional'} -
+
@@ -1023,14 +1168,14 @@ bind:value={formData.anio_impresion} placeholder="25" maxlength={2} - class="text-center w-40 focus-visible:ring-0 focus-visible:ring-offset-0" + class="w-40 text-center focus-visible:ring-0 focus-visible:ring-offset-0" />
-
+
-
+
-
+
-
+
-
+
-
+
-
- Factor: 0.05000 -
+
Factor: 0.05000
-
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- -
- + +
+ + -
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
@@ -1220,9 +1348,11 @@ - {/* Optional: maybe refresh something or just let user continue */}} + onSuccess={() => { + /* Optional: maybe refresh something or just let user continue */ + }} /> diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte index a7a749f6..340f0d4b 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte @@ -1,4 +1,5 @@ -
+
{#if !showBitacora && !showPartesII && !showRectificacion && !showRectificacionII && !showNotas && !showSeleccionAutomatizada && !showMultas} @@ -748,7 +770,7 @@ -
+

Parámetros de cálculo:

@@ -792,49 +814,49 @@ id="dta_operacion" bind:checked={formData.dta_por_operacion_ag_facturas} /> -
-
- +
-
-
+
-
-
-
@@ -844,7 +866,7 @@ id="cuota_fija_veh" bind:checked={formData.cuota_fija_adicional_vehiculo} /> -
@@ -870,7 +892,7 @@
-
@@ -888,7 +910,7 @@
-
@@ -909,7 +931,7 @@
-
+

@@ -918,35 +940,35 @@
-
-
-
-
-
@@ -959,35 +981,35 @@
-
-
-
-
-
@@ -997,7 +1019,7 @@ id="calc_recargos" bind:checked={formData.calcular_recargos_diferencias} /> -
@@ -1006,7 +1028,7 @@
-
+

Parámetro de cambio de DTA:

@@ -1015,15 +1037,15 @@ id="prov_nacional" bind:checked={formData.proveedor_nacional_modifico_dta} /> -
-
+
-
@@ -1033,7 +1055,7 @@ id="calc_2dec" bind:checked={formData.calculo_2_decimales_valor_unitario} /> -
@@ -1043,7 +1065,7 @@ id="calc_base_part" bind:checked={formData.calcular_valor_aduana_base_partidas} /> -
@@ -1058,42 +1080,42 @@
-
-
-
-
- +
-
+
@@ -1126,7 +1148,7 @@ Registro de movimientos sobre el Pedimento -
+

@@ -1141,13 +1163,13 @@ {#if bitacoraMovimientos.length === 0} - + No hay movimientos registrados {:else if paginatedBitacoraMovimientos.length === 0} - + No hay datos en esta página @@ -1175,7 +1197,7 @@ variant="outline" onclick={goToBitacoraFirstPage} disabled={currentBitacoraPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1184,11 +1206,11 @@ variant="outline" onclick={goToBitacoraPreviousPage} disabled={currentBitacoraPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > - + Página {currentBitacoraPage + 1} de {totalBitacoraPages || 1} @@ -1205,7 +1227,7 @@ variant="outline" onclick={goToBitacoraLastPage} disabled={currentBitacoraPage >= totalBitacoraPages - 1} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1223,14 +1245,14 @@ {:else if showPartesII} -
+
Embarques Parciales -
+
@@ -1249,13 +1271,13 @@ {#if embarquesParciales.length === 0} - + No hay embarques registrados {:else if paginatedEmbarquesParciales.length === 0} - + No hay datos en esta página @@ -1297,7 +1319,7 @@ variant="outline" onclick={goToPartesIIFirstPage} disabled={currentPartesIIPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1306,11 +1328,11 @@ variant="outline" onclick={goToPartesIIPreviousPage} disabled={currentPartesIIPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > - + Página {currentPartesIIPage + 1} de {totalPartesIIPages || 1} @@ -1327,7 +1349,7 @@ variant="outline" onclick={goToPartesIILastPage} disabled={currentPartesIIPage >= totalPartesIIPages - 1} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1374,42 +1396,42 @@ -
+
- +
-
+
- +

Partidas del Pedimento

-
+
- +
-
+
- +

Embarques

-
+
- +
-
+
- +
-
+
- +
@@ -1455,21 +1477,21 @@ Rectificación -
+
{ - if (v) rectificacionFormData.es_rectificacion = v; + if (v) formData.es_rectificacion = v; }} > - {rectificacionFormData.es_rectificacion === 'si' + {formData.es_rectificacion === 'si' ? 'Sí' - : rectificacionFormData.es_rectificacion === 'no' + : formData.es_rectificacion === 'no' ? 'No' : 'Seleccionar'} @@ -1485,7 +1507,8 @@
@@ -1496,7 +1519,8 @@ >
@@ -1505,7 +1529,7 @@
@@ -1513,7 +1537,8 @@
@@ -1521,13 +1546,14 @@
- +
@@ -1535,10 +1561,9 @@
{ - rectificacionFormData.utilizar_fecha_pago_original = - !rectificacionFormData.utilizar_fecha_pago_original; + formData.utilizar_fecha_pago_original = !formData.utilizar_fecha_pago_original; }} />
-
-
+
+

Cuadro de liquidación para diferencias en contribuciones

{ - rectificacionFormData.calculo_manual_contribuciones = - !rectificacionFormData.calculo_manual_contribuciones; + formData.calculo_manual_contribuciones = + !formData.calculo_manual_contribuciones; }} />
-
+
@@ -1580,14 +1605,14 @@ - {#if liquidacionDiferencias.length === 0} + {#if formData.liquidacion_diferencias.length === 0} No hay registros {:else} - {#each liquidacionDiferencias as item} + {#each formData.liquidacion_diferencias as item} {item.gravamen} {item.forma_pago} @@ -1631,8 +1656,8 @@ @@ -1641,8 +1666,8 @@ @@ -1659,7 +1684,7 @@ -
+
@@ -1668,16 +1693,16 @@ Comentario - + Fecha Hora - + {#if notasPedimento.length === 0} - + No hay notas registradas @@ -1696,7 +1721,7 @@ -
+
- + Página {currentNotasPage + 1} de {totalNotasPages || 1}
@@ -1829,7 +1854,7 @@
@@ -1873,7 +1898,7 @@
- +
-
+
# Descripción de Mercancía - Cantidad UMC - Cantidad UMT - Peso + Cantidad UMC + Cantidad UMT + Peso {#if mercancias.length === 0} - + No hay mercancías registradas @@ -2086,17 +2111,17 @@ -
- - - -
@@ -2109,7 +2134,7 @@ type="number" bind:value={currentEmbarque.cantidad_transportes} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2119,7 +2144,7 @@ type="number" bind:value={currentEmbarque.cantidad_partidas} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2130,7 +2155,7 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2140,7 +2165,7 @@ type="number" bind:value={currentEmbarque.cantidad_embarques} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2150,7 +2175,7 @@ type="number" bind:value={currentEmbarque.cantidad_mercancias} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2161,13 +2186,13 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc_embarques.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> -
+
@@ -2190,7 +2215,7 @@ - + Mercancías del Embarque Parcial @@ -2201,7 +2226,7 @@
@@ -2311,7 +2336,7 @@
diff --git a/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte new file mode 100644 index 00000000..dffe17af --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte @@ -0,0 +1,226 @@ + + + + + + Seleccionar Sector PROSEC + + Seleccione el sector del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron sectores.

+
+ {:else} + + + + Clave + Descripción + Autorizado + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.key} + +
+
+ + {item.description} + + + + {item.authorized ? 'Sí' : 'No'} + + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte new file mode 100644 index 00000000..eb6f9c4c --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte @@ -0,0 +1,224 @@ + + + + + + Seleccionar Estado + + Seleccione el estado del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron estados.

+
+ {:else} + + + + Clave M3 + Descripción + MEX + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.m3_key} + +
+
+ + {item.description} + + + {item.mex_key || '-'} + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts b/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts new file mode 100644 index 00000000..e00011c6 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Trailers + */ +import type { Trailer } from '$lib/api/dashboard/a76/trailers'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'trailer_number', + header: 'Número de Trailer', + cell: ({ row }) => { + return row.original.trailer_number; + } + }, + { + accessorKey: 'plate_number', + header: 'Placas', + cell: ({ row }) => { + return row.original.plate_number || '-'; + } + }, + { + accessorKey: 'trailer_type_key', + header: 'Tipo de Trailer', + cell: ({ row }) => { + return row.original.trailer_type_key || '-'; + } + }, + { + accessorKey: 'container_key', + header: 'Contenedor', + cell: ({ row }) => { + return row.original.container_key || '-'; + } + }, + { + accessorKey: 'state', + header: 'Estado', + cell: ({ row }) => { + return row.original.state || '-'; + } + }, + { + accessorKey: 'country', + header: 'País', + cell: ({ row }) => { + return row.original.country || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte new file mode 100644 index 00000000..6959aed2 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte @@ -0,0 +1,207 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del trailer' + : 'Completa los datos para crear un nuevo trailer'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte new file mode 100644 index 00000000..6dce1ab9 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte @@ -0,0 +1,111 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte new file mode 100644 index 00000000..0cc296fd --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -0,0 +1,306 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del transportista' + : 'Completa los datos para crear un nuevo transportista'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+ +
+

Información General

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Códigos de Transporte

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Dirección

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Configuración FTP

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte new file mode 100644 index 00000000..8715b474 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte @@ -0,0 +1,114 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts b/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts new file mode 100644 index 00000000..32e69333 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Transportistas + */ +import type { Transporter } from '$lib/api/dashboard/a76/transporters'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'transporter_key', + header: 'Clave', + cell: ({ row }) => { + return row.original.transporter_key; + } + }, + { + accessorKey: 'name', + header: 'Nombre', + cell: ({ row }) => { + return row.original.name || '-'; + } + }, + { + accessorKey: 'short_name', + header: 'Nombre Corto', + cell: ({ row }) => { + return row.original.short_name || '-'; + } + }, + { + accessorKey: 'rfc', + header: 'RFC', + cell: ({ row }) => { + return row.original.rfc || '-'; + } + }, + { + accessorKey: 'caat_code', + header: 'CAAT', + cell: ({ row }) => { + return row.original.caat_code || '-'; + } + }, + { + accessorKey: 'transport_code', + header: 'Código Transporte', + cell: ({ row }) => { + return row.original.transport_code || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts new file mode 100644 index 00000000..2bacea83 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Vehículos + */ +import type { Vehicle } from '$lib/api/dashboard/a76/vehicles'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'vehicle_key', + header: 'Clave', + cell: ({ row }) => { + return row.original.vehicle_key; + } + }, + { + accessorKey: 'brand', + header: 'Marca', + cell: ({ row }) => { + return row.original.brand || '-'; + } + }, + { + accessorKey: 'year', + header: 'Año', + cell: ({ row }) => { + return row.original.year || '-'; + } + }, + { + accessorKey: 'plate_number', + header: 'Placas', + cell: ({ row }) => { + return row.original.plate_number || '-'; + } + }, + { + accessorKey: 'transporter_key', + header: 'Transportista', + cell: ({ row }) => { + return row.original.transporter_key || '-'; + } + }, + { + accessorKey: 'transport_type', + header: 'Tipo Transporte', + cell: ({ row }) => { + return row.original.transport_type || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte new file mode 100644 index 00000000..313dd45b --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -0,0 +1,317 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del vehículo' + : 'Completa los datos para crear un nuevo vehículo de transporte'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+ +
+

Identificación del Vehículo

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+

Datos de Transporte

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Seguro y Otros

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+

Ubicación y Detalles

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+ + + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte new file mode 100644 index 00000000..9ec4eb00 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte @@ -0,0 +1,111 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte new file mode 100644 index 00000000..6a557aed --- /dev/null +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -0,0 +1,229 @@ + + + + + + + + + + {#if selectedArticle} + + {/if} + Base de Conocimientos + + + +
+ {#if !selectedArticle} +
+
+

Artículos Disponibles

+ {#if isAdmin} + + {/if} +
+ {#if isLoading} +

Cargando...

+ {:else if articles.length === 0} +

+ No hay artículos de ayuda disponibles. +

+ {/if} +
+ {#each articles as article} + + {/each} +
+
+ {:else} +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} +
+
+

{selectedArticle.title}

+ {#if isAdmin} + + {/if} +
+
+ {@html renderMarkdown(selectedArticle.content)} +
+
+ {/if} +
+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte index cd7288cb..35f52052 100644 --- a/frontend/src/lib/components/keyboard/KeyboardManager.svelte +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -117,6 +117,7 @@ const authenticated = isAuthenticated(); const { key, altKey, ctrlKey, metaKey, shiftKey } = event; + if (!key) return; const lowerKey = key.toLowerCase(); // Ignore standalone modifiers diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 90c86c47..5a00a88d 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -5,6 +5,7 @@ import { BadgeCheck, ChartPie, Database, + FileSearch, FileText, Frame, GalleryVerticalEnd, @@ -13,9 +14,9 @@ import { Package, Settings2, Shield, - Users, Ship, - MoreHorizontal, + Truck, + Users, } from 'lucide-svelte'; import * as m from "$lib/paraglide/messages.js"; import { Title } from '../ui/alert'; @@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData { title: m["sidebar.general_catalogs.identifiers"](), url: "/dashboard/general_catalogs/identifiers", }, + // ------------------------------------- { title: m["sidebar.general_catalogs.incoterms"](), url: "/dashboard/reference_data/incoterms", @@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData { }, ], }, + { + title: "Transportes", + url: "#", + icon: Truck, + items: [ + { + title: "Transportistas", + url: "/dashboard/general_catalogs/transporters", + }, + { + title: "Trailers", + url: "/dashboard/general_catalogs/trailers", + }, + { + title: "Vehículos", + url: "/dashboard/general_catalogs/vehicles", + }, + ], + }, { title: m["sidebar.goods.title"](), url: "#", @@ -473,6 +494,17 @@ export function getSidebarData(): SidebarData { icon: BadgeCheck, items: [], }, + { + title: "Reportes", + url: "#", + icon: FileSearch, + items: [ + { + title: "Facturas Impo/Expo", + url: "/dashboard/reports/invoices", + }, + ], + }, { title: m["sidebar.reference_data.configuracion"](), url: "#", @@ -503,7 +535,7 @@ export function getSidebarData(): SidebarData { }, { name: m["sidebar.reference_data.ayuda"](), - url: "#", + url: "/dashboard/help-center", icon: Frame, }, ], @@ -511,4 +543,4 @@ export function getSidebarData(): SidebarData { } // Exportar también como constante para compatibilidad (deprecado) -export const sidebarData: SidebarData = getSidebarData(); +export const sidebarData: SidebarData = getSidebarData(); \ No newline at end of file diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index f67a27e2..15f67eb9 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -1,15 +1,16 @@ - + + + diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts index 01d40c80..94a584fb 100644 --- a/frontend/src/lib/components/ui/sheet/index.ts +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -1,4 +1,5 @@ -import { Dialog as SheetPrimitive } from "bits-ui"; +import { Dialog } from "bits-ui"; +const SheetPrimitive = Dialog; import Trigger from "./sheet-trigger.svelte"; import Close from "./sheet-close.svelte"; import Overlay from "./sheet-overlay.svelte"; diff --git a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts index dc716087..8902cccb 100644 --- a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts +++ b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts @@ -4,6 +4,7 @@ export const obtenerAtajosEdicionAgente = (acciones: { irGeneral: () => void; irContacto: () => void; irDireccion: () => void; + irVU: () => void; guardar: () => void; cancelar: () => void; }): ShortcutDef[] => [ @@ -22,6 +23,11 @@ export const obtenerAtajosEdicionAgente = (acciones: { description: 'Tab Dirección', action: acciones.irDireccion }, + { + key: 'Alt+Digit4', + description: 'Tab Ventanilla Única', + action: acciones.irVU + }, { key: 'Ctrl+S', description: 'Guardar', diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts index 71c7d963..e21c9f25 100644 --- a/frontend/src/lib/date-utils.ts +++ b/frontend/src/lib/date-utils.ts @@ -22,7 +22,9 @@ export function prepareDateForBackend(dateStr: string, timeStr: string = '00:00' // Combinar fecha y hora usando la zona horaria local const dateTime = toCalendarDateTime(date, time); const zonedDateTime = dateTime.toDate(localTimeZone); - return zonedDateTime.toISOString(); + // Convertir a objeto Date nativo de JavaScript para obtener ISO string correcto + const jsDate = new Date(zonedDateTime.toString()); + return jsDate.toISOString(); } catch (e) { console.error('Error parsing date:', e); return null; diff --git a/frontend/src/lib/stores/help.svelte.ts b/frontend/src/lib/stores/help.svelte.ts new file mode 100644 index 00000000..19fe53e0 --- /dev/null +++ b/frontend/src/lib/stores/help.svelte.ts @@ -0,0 +1,9 @@ +// Store to control the Help Drawer state globally +// Using Svelte 5 runes + +export const helpStore = $state({ + isOpen: false, + open() { this.isOpen = true; }, + close() { this.isOpen = false; }, + toggle() { this.isOpen = !this.isOpen; } +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 69b575c0..50904564 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,15 +4,15 @@ import { Toaster } from 'svelte-sonner'; import { page } from '$app/stores'; import { handleApiError } from '$lib/utils/error-handler'; - import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte'; - + import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte'; + import HelpDrawer from '$lib/components/help/HelpDrawer.svelte'; let { children } = $props(); // Detectar errores de CUALQUIER página (layout o page) $effect(() => { - const pageData = $page.data as any; - if (pageData?.error) { + const pageData = $page.data as any; + if (pageData?.error) { handleApiError(pageData.error); } }); @@ -24,7 +24,14 @@ + - console.log('Global Search Focused')} /> + console.log('Global Search Focused')} +/> {@render children?.()} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 95c9163b..adc8e934 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -8,6 +8,7 @@ import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import { companyStore } from '$lib/stores/company.svelte'; import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte'; + import { page } from '$app/state'; let { data, children }: { data: LayoutData; children: any } = $props(); @@ -58,9 +59,11 @@
- {@render children()} + {@render children?.()}
- +{#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')} + +{/if} diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index ba7e4207..991a2507 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -22,10 +22,29 @@ FileText, Settings, User, - Trash2 + Trash2, + Search, + Globe, + MapPin as MapPinIcon, + Factory, + Calendar, + Hash, + ShieldCheck, + Award, + Fingerprint, + Briefcase } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + // Componentes Compartidos (Modales) + import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte'; + import StateSelectorDialog from '$lib/components/dashboard/shared/modals/state-selector-dialog.svelte'; + import SectorSelectorDialog from '$lib/components/dashboard/shared/modals/sector-selector-dialog.svelte'; + + import { type Country } from '$lib/api/dashboard/reference_data/countries'; + import { type State } from '$lib/api/dashboard/reference_data/states'; + import { type Sector } from '$lib/api/dashboard/reference_data/sectors'; + // API & Stores import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; @@ -73,17 +92,30 @@ program: '', program_number: '', authorization_date_str: '', // String para input date - prosec: 0, + prosec: '', manufacturer_id: '', tax_id: '', ctpat_svi: '', - is_certified_company: '0' + is_certified_company: false }); let formData = $state(getEmptyForm()); let loading = $state(false); let error = $state(null); + // --- ESTADO PARA MODALES --- + let countryModalOpen = $state(false); + let stateModalOpen = $state(false); + let sectorModalOpen = $state(false); + + const scaiiPrograms = [ + { value: 'IMMEX', label: 'IMMEX' }, + { value: 'PROSEC', label: 'PROSEC' }, + { value: 'ALTEX', label: 'ALTEX' }, + { value: 'ECEX', label: 'ECEX' }, + { value: 'DRAWBACK', label: 'DRAWBACK' } + ]; + // --- UTILIDADES --- const intDateToString = (d?: number | null) => d @@ -138,11 +170,11 @@ program: prog.program || '', program_number: prog.program_number || '', authorization_date_str: intDateToString(prog.secon_auth_date), - prosec: prog.prosec || 0, + prosec: prog.prosec ? String(prog.prosec) : '', manufacturer_id: prog.manufacturer_id || '', tax_id: prog.tax_id || '', ctpat_svi: prog.ctpat_svi || '', - is_certified_company: prog.is_certified_company || '0' + is_certified_company: prog.is_certified_company === '1' }; } } catch (e: any) { @@ -202,11 +234,11 @@ program: clean(formData.program), program_number: clean(formData.program_number), secon_auth_date: stringDateToInt(formData.authorization_date_str), - prosec: Number(formData.prosec) || null, + prosec: clean(formData.prosec), manufacturer_id: clean(formData.manufacturer_id), tax_id: clean(formData.tax_id), ctpat_svi: clean(formData.ctpat_svi), - is_certified_company: clean(formData.is_certified_company) + is_certified_company: formData.is_certified_company ? '1' : '0' } }; @@ -233,8 +265,6 @@ import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosEdicionSocio } from '$lib/config/shortcuts/dashboard/clients_and_providers/edit'; - // ... previous imports / code ... - function handleCancel() { goto('/dashboard/clients_and_providers'); } @@ -271,7 +301,7 @@ Nuevo {/if}
-

+

{isEditing ? 'Edita la información del cliente o proveedor' : 'Registra un nuevo cliente o proveedor en el sistema'} @@ -314,11 +344,11 @@

- (formData.client_or_provider = v)} - > + (formData.client_or_provider = v)} + > {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} @@ -331,8 +361,8 @@
-
-
+
+
- (formData.type_nat_foreign = v)} - > + (formData.type_nat_foreign = v)} + > {formData.type_nat_foreign === 'N' ? 'Nacional' @@ -403,7 +433,7 @@ Ubicación fiscal y datos de contacto. -
+
-
+
-
+
- +
+ + +
- +
+ + +
-
+
@@ -511,77 +568,160 @@ >Información sobre IMMEX, PROSEC y otras certificaciones. - -
-
- - + + +
+
+ + Programas de Fomento
-
- - + +
+
+ + (formData.program = v)} + disabled={loading} + > + + {formData.program || 'Selecciona un programa'} + + + {#each scaiiPrograms as prog} + + {prog.label} + + {/each} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
-
-
- - + +
+
+ + Identificación Industrial
-
- - -
-
- - + +
+
+ + +
+
+ + +
-
-
- - + +
+
+ + Certificaciones y Seguridad
-
- - + +
+
+ + +
+
+ +
+ +

+ Indica si cuenta con certificación de empresa +

+
+
@@ -597,7 +737,7 @@
-
+
@@ -606,7 +746,7 @@

-
+
-
+
@@ -672,3 +812,24 @@
+ + + (formData.country = country.m3_key)} +/> + + { + formData.state = state.description; + if (state.m3_key && !formData.country) { + formData.country = state.m3_key; + } + }} +/> + + (formData.prosec = sector.key)} +/> diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/+page.svelte index 5a92b518..611ce839 100644 --- a/frontend/src/routes/dashboard/customs_brokers/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/+page.svelte @@ -187,8 +187,7 @@ const brokerColumns = createBrokerColumns(handleActionSuccess); -
- +

GESTIÓN ADUANAL

@@ -196,17 +195,17 @@
- - + + Agentes Aduanales Secciones Aduanales @@ -214,13 +213,11 @@ - -
- -
-
+
+
+

Filtros

Busque por nombre o patente @@ -244,23 +241,20 @@ oninput={handleSearch} />
-
- -
+
- -
-
+
+

Listado

{filteredItems.length} registros
@@ -275,9 +269,8 @@ idField="broker_key" />
- {#if totalItems > pageSize} -
+
- + Página {currentPage} de {Math.ceil(totalItems / pageSize)}
-
-
-

+

+

Detalles del Agente

{selectedItem?.name || '---'}

-
- + Patente: {selectedItem?.broker_key || ''}
-
+
{#if selectedItem}
@@ -338,21 +330,21 @@ {#if selectedItem.tax_id}
-

{selectedItem.tax_id}

+

{selectedItem.tax_id}

{/if} -
+
-
+

{selectedItem.address || ''}

{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} @@ -363,9 +355,9 @@

-
+
@@ -391,9 +383,9 @@
{:else}
- +

Selecciona un agente

{/if} @@ -401,9 +393,8 @@
- - - + +
-
+
{#if activeTab === 'brokers'} - {:else} {/if} diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 264e7d8a..801303a2 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -8,6 +8,8 @@ } from '$lib/api/dashboard/a76/customs-brokers'; // UI Components + import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte'; + import StateDialog from '$lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; @@ -15,6 +17,7 @@ import { Badge } from '$lib/components/ui/badge'; import * as Tabs from '$lib/components/ui/tabs'; import * as Card from '$lib/components/ui/card'; + import * as Select from '$lib/components/ui/select'; import { ArrowLeft, Loader2, @@ -24,7 +27,18 @@ MapPin, Settings, FileText, - Hash + Hash, + FileKey, + Key, + Globe, + Folder, + Mail, + UserRound, + Fingerprint, + Lock, + Signature, + Archive, + ShieldCheck } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; @@ -40,6 +54,8 @@ let activeTab = $state('general'); let error = $state(null); let dataLoaded = $state(false); + let showCountryDialog = $state(false); + let showStateDialog = $state(false); let formData = $state({ broker_key: '', @@ -62,6 +78,28 @@ company_id: '' }); + let vuData = $state({ + certificate_path: '', + key_path: '', + xml_files_path: '', + fiel_access_key: '', + doda_web_service_user: '', + doda_web_service_access_key: '', + doda_certificate_path: '', + doda_key_path: '', + doda_fiel_access_key: '', + doda_xml_files_path: '', + web_service_user: '', + web_service_access_key: '', + query_tax_id: '', + vu_email: '', + vu_figure_type: '', + fiel_format: '', + access_key: '', + signature_read_path: '', + archive_path: '' + }); + let brokerKeyError = $state(false); let licenseError = $state(false); let brokerKeyTimeout: ReturnType; @@ -77,6 +115,16 @@ } }); + // --- 5. FUNCIONES --- + function handleLocalFileSelect(event: Event, targetKey: keyof typeof vuData) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + vuData[targetKey] = file.name; + toast.success(`Archivo ${file.name} seleccionado`); + } + } + async function loadBrokerData(key: string, cId: string) { if (!key || key === 'undefined') return; loading = true; @@ -106,9 +154,32 @@ company: d.company || '', company_id: cId }; + if (d.vu) { + vuData = { + certificate_path: d.vu.certificate_path || '', + key_path: d.vu.key_path || '', + xml_files_path: d.vu.xml_files_path || '', + fiel_access_key: d.vu.fiel_access_key || '', + doda_web_service_user: d.vu.doda_web_service_user || '', + doda_web_service_access_key: d.vu.doda_web_service_access_key || '', + doda_certificate_path: d.vu.doda_certificate_path || '', + doda_key_path: d.vu.doda_key_path || '', + doda_fiel_access_key: d.vu.doda_fiel_access_key || '', + doda_xml_files_path: d.vu.doda_xml_files_path || '', + web_service_user: d.vu.web_service_user || '', + web_service_access_key: d.vu.web_service_access_key || '', + query_tax_id: d.vu.query_tax_id || '', + vu_email: d.vu.vu_email || '', + vu_figure_type: d.vu.vu_figure_type || '', + fiel_format: d.vu.fiel_format || '', + access_key: d.vu.access_key || '', + signature_read_path: d.vu.signature_read_path || '', + archive_path: d.vu.archive_path || '' + }; + } dataLoaded = true; } else if (d.error) { - error = d.error; + error = d.error as string; toast.error(error); } } catch (e: any) { @@ -119,6 +190,18 @@ } } + function handleCountrySelect(country: any) { + const nextCountry = country.m3_key || country.mex_key || country.ame_key; + if (formData.country !== nextCountry) { + formData.state = ''; + } + formData.country = nextCountry; + } + + function handleStateSelect(state: any) { + formData.state = state.m3_key || state.mex_key || state.ame_key; + } + // --- 4. GUARDADO --- async function handleSave() { if (!companyStore.activeCompany) { @@ -151,15 +234,31 @@ formData.company_id = cId; const res = isEdit - ? await customsBrokersApi.update(routeId!, formData, cId) + ? await customsBrokersApi.update(routeId!, formData) : await customsBrokersApi.create(formData, cId); if ((res as any).error) throw new Error((res as any).error); + // UPSERT VU + try { + const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId); + if ((vuRes as any).error) { + toast.error( + 'Agente guardado, pero ocurrió un error guardando Ventanilla Única: ' + + (vuRes as any).error + ); + return; // Avoid triggering success redirection + } + } catch (vuErr: any) { + toast.error('Agente guardado, pero ocurrió un error guardando Ventanilla Única.'); + console.error(vuErr); + return; + } + toast.success(isEdit ? 'Agente actualizado' : 'Agente creado'); goto('/dashboard/customs_brokers'); } catch (e: any) { - error = e.message || 'Error al procesar la solicitud'; + error = (e.message || 'Error al procesar la solicitud') as string; toast.error(error); } finally { loading = false; @@ -176,6 +275,7 @@ irGeneral: () => (activeTab = 'general'), irContacto: () => (activeTab = 'contact'), irDireccion: () => (activeTab = 'address'), + irVU: () => (activeTab = 'vu'), guardar: handleSave, cancelar: handleCancel }) @@ -197,7 +297,7 @@ {isEdit ? 'Edición' : 'Nuevo'}
-

+

{isEdit ? 'Modifica la información del agente aduanal' : 'Registra un nuevo agente aduanal en el sistema'} @@ -224,7 +324,31 @@ Identificación oficial del agente y patente. -

+
+
+ + (formData.type = v)} + disabled={loading} + > + + {formData.type === 'MEX' + ? 'Agente Aduanal Mexicano' + : formData.type === 'USA' + ? 'Agente Aduanal Americano (Broker)' + : 'Selecciona un tipo...'} + + + Agente Aduanal Mexicano + Agente Aduanal Americano (Broker) + + +
+
+ +
{#if brokerKeyError} @@ -281,8 +405,8 @@ } }} placeholder="Ej. 3421" - maxlength="5" - class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''} + maxlength={5} + class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} disabled={loading} /> {#if licenseError} @@ -297,16 +421,22 @@
- +
-
+
@@ -315,6 +445,7 @@ bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} + class="h-10" />
@@ -330,13 +461,14 @@ Datos para comunicación con el agente. -
+
@@ -345,24 +477,26 @@ bind:value={formData.position} placeholder="Ej. Gerente Comercial" disabled={loading} + class="h-10" />
-
+
- +
@@ -371,6 +505,7 @@ bind:value={formData.email} placeholder="correo@empresa.com" disabled={loading} + class="h-10" />
@@ -392,26 +527,501 @@ bind:value={formData.address} placeholder="Dirección completa" disabled={loading} + class="h-10" />
-
+
- +
- +
-
+
- +
+ (showStateDialog = true)} + /> + +
- +
+ (showCountryDialog = true)} + /> + +
+
+
+ + + + + + + + + + Ventanilla Única / Web Services + Certificados y credenciales para integración con DODA/PITA. + + +
+
+ +
+ + handleLocalFileSelect(e, 'certificate_path')} + id="vu-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'key_path')} + id="vu-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ + + + {vuData.vu_figure_type || 'Seleccionar tipo de figura'} + + + AGENTE ADUANAL + APODERADO ADUANAL + MANDATARIO + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'xml_files_path')} + id="vu-cove-file" + /> + +
+
+
+ + + +
+

+ Configuración Adicional +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + DODA-PITA + Configuración de servicios DODA / PITA. + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'doda_certificate_path')} + id="doda-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'doda_key_path')} + id="doda-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ +
+ + handleLocalFileSelect(e, 'doda_xml_files_path')} + id="doda-xml-file" + /> + +
+
+
+
+
+
+ + + + ANAM + Configuración de acceso para ANAM. + + +
+
+ + +
+
+ +
@@ -424,22 +1034,19 @@
-
+
- - - General - - - Contacto - - - Dirección - + + General + Contacto + Domicilio + VU + DODA + ANAM
diff --git a/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte new file mode 100644 index 00000000..596c3d13 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte @@ -0,0 +1,117 @@ + + +
+
+
+

Trailers

+

Gestión del catálogo de trailers de la compañía

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando trailers... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte new file mode 100644 index 00000000..150e006b --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte @@ -0,0 +1,117 @@ + + +
+
+
+

Transportistas

+

Gestión del catálogo de líneas transportistas

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando transportistas... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte new file mode 100644 index 00000000..1bcc8916 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte @@ -0,0 +1,119 @@ + + +
+
+
+

Vehículos (Transporte)

+

+ Gestión del catálogo de camiones y vehículos de transporte +

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando vehículos... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index ebde6753..35ccccde 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -14,9 +14,17 @@ // Tipo extendido que combina A76Class y FAClass interface FixedAssetClassExtended extends A76Class { fa_class_id?: number; + import_tariff_code?: string | null; + import_tariff_type?: string | null; + export_tariff_code?: string | null; + export_tariff_type?: string | null; depreciation_rate?: number | null; fda_code?: string | null; + eccn_code?: string | null; class_enabled?: boolean | null; + // Virtual fields for form compatibility + annual_depreciation_rate?: number | string | null; + fda_key?: string | null; } // Estado de la lista de clases @@ -42,6 +50,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }); @@ -117,227 +128,13 @@ fraction: cls.fraction || '', us_fraction: cls.us_fraction || '', unit_measure_trade: '', + depreciation_rate: (cls as FixedAssetClassExtended).depreciation_rate || null, + fda_code: (cls as FixedAssetClassExtended).fda_code || '', + eccn_code: (cls as FixedAssetClassExtended).eccn_code || '', bom: '' }; } - async function saveFixedAssetClass(formData: any) { - const companyId = companyStore.activeCompany?.id; - - // CAMBIO: Usar $state.snapshot para obtener una copia real, no reactiva - const data = $state.snapshot(formData); - - if (!companyId) { - toast.error('No hay empresa seleccionada'); - throw new Error('No hay empresa seleccionada'); - } - - // Validar campos obligatorios - const missingFields: string[] = []; - - if (!data.class_code?.trim()) { - missingFields.push('Código de clase'); - } - if (!data.description_es?.trim()) { - missingFields.push('Descripción en español'); - } - if (!data.material_key?.trim()) { - missingFields.push('Tipo de activo fijo'); - } - if (!data.unit_of_measure?.trim()) { - missingFields.push('Unidad de medida comercial'); - } - if (!data.fraction?.trim()) { - missingFields.push('Fracción arancelaria'); - } - - if (missingFields.length > 0) { - const fieldsList = missingFields.join(', '); - validationError = `Debe completar los siguientes campos obligatorios: ${fieldsList}`; - toast.error(validationError, { - duration: 8000 - }); - throw new Error(`Campos obligatorios faltantes: ${fieldsList}`); - } - - // Limpiar error de validación si todo está bien - validationError = ''; - - try { - // Usar el endpoint combinado /fa que crea ambos registros en una transacción - const payload = { - class_code: data.class_code.trim(), - description_es: data.description_es.trim(), - description_en: data.description_en?.trim() || '', - material_key: data.material_key.trim(), - unit_of_measure: data.unit_of_measure.trim(), - fraction: data.fraction.trim(), - us_fraction: data.us_fraction?.trim() || '', - sub_key: data.sub_key || '', - physical_review: data.physical_review ? 1 : 0, - iva_exempt_fraction: data.iva_exempt_fraction || '', - // FA-specific fields - import_tariff_code: data.import_tariff_code || null, - import_tariff_type: data.import_tariff_type || null, - export_tariff_code: data.export_tariff_code || null, - export_tariff_type: data.export_tariff_type || null, - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null, - eccn_code: data.eccn_code || null, - class_enabled: true - }; - - const response = await classesApi.createFA(payload, companyId); - - if (response.error) { - console.error('Server error:', response.error); - - // Manejar diferentes formatos de error - let errorMessage = response.error; - let isDuplicateError = false; - - // Detectar si es un error de código duplicado - if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) { - isDuplicateError = true; - } - - // Mensaje más específico para errores de duplicado - if (isDuplicateError) { - validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`; - } else { - validationError = `⚠️ ${errorMessage}`; - } - - toast.error(errorMessage, { duration: 8000 }); - throw new Error(errorMessage); - } - - validationError = ''; - toast.success('✅ Clase de activo fijo creada correctamente'); - return response.data; - } catch (error: any) { - console.error('Error saving fixed asset class:', error); - // El toast ya se mostró arriba, solo re-lanzar el error - throw error; - } - } - - async function updateFixedAssetClass(formData: any) { - const companyId = companyStore.activeCompany?.id; - - // CAMBIO 1: Usar $state.snapshot para obtener una copia real, no reactiva - // Esto garantiza que aunque el hijo borre el formulario, 'data' mantenga los valores - const data = $state.snapshot(formData); - - if (!companyId || !selectedClass) { - toast.error('No hay empresa o clase seleccionada'); - return; - } - - // CAMBIO 2: Validar sobre 'data' (la copia muerta) - const missingFields: string[] = []; - if (!data.class_code?.trim()) missingFields.push('Código de clase'); - if (!data.description_es?.trim()) missingFields.push('Descripción en español'); - if (!data.material_key?.trim()) missingFields.push('Tipo de activo fijo'); - if (!data.unit_of_measure?.trim()) missingFields.push('Unidad de medida comercial'); - if (!data.fraction?.trim()) missingFields.push('Fracción arancelaria'); - - if (missingFields.length > 0) { - const errorMsg = `Campos obligatorios faltantes: ${missingFields.join(', ')}`; - validationError = `⚠️ ${errorMsg}`; - toast.error(errorMsg); - // Lanzamos el error para que el 'onSave' del Dialog no cierre la ventana - throw new Error(errorMsg); - } - - validationError = ''; - - try { - // CAMBIO 3: Usar siempre 'data' para los payloads - const a76Response = await classesApi.update( - selectedClass.id, - { - class_code: data.class_code.trim(), - description_es: data.description_es.trim(), - description_en: data.description_en?.trim() || '', - material_key: data.material_key.trim(), - unit_of_measure: data.unit_of_measure.trim(), - fraction: data.fraction.trim(), - us_fraction: data.us_fraction || '', - physical_review: data.physical_review ? 1 : 0, - iva_exempt_fraction: data.iva_exempt_fraction || '' - }, - companyId - ); - - if (selectedClass.fa_class_id) { - await faClassesApi.update( - selectedClass.fa_class_id, - { - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null - }, - companyId - ); - } else { - await faClassesApi.create( - { - class_id: selectedClass.id, - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null, - class_enabled: true - }, - companyId - ); - } - - toast.success('Clase actualizada correctamente'); - return { a76: a76Response.data }; - } catch (error: any) { - console.error('Error updating fixed asset class:', error); - console.error('Error response:', error?.response); - console.error('Error response data:', error?.response?.data); - console.error('Error response detail:', error?.response?.data?.detail); - console.error('Error type:', typeof error?.response?.data?.detail); - - let errorMessage = 'Error al actualizar la clase'; - let isDuplicateError = false; - - // Extract error message from response - if (error?.response?.data?.detail) { - if (Array.isArray(error.response.data.detail)) { - errorMessage = error.response.data.detail - .map((e: any) => `${e.loc ? e.loc.join(' → ') : ''}: ${e.msg || e}`) - .join(', '); - } else if (typeof error.response.data.detail === 'string') { - errorMessage = error.response.data.detail; - // Detectar si es un error de código duplicado - if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) { - isDuplicateError = true; - } - } else { - errorMessage = JSON.stringify(error.response.data.detail); - } - } else if (error?.message) { - errorMessage = error.message; - } - - console.error('Final error message:', errorMessage); - console.error('Is duplicate error:', isDuplicateError); - - // Mensaje más específico para errores de duplicado - if (isDuplicateError) { - validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`; - } else { - validationError = `⚠️ ${errorMessage}`; - } - - toast.error(errorMessage, { duration: 8000 }); - - console.error('Toast shown, about to throw error'); - throw error; - } - } function handleNew() { selectedClass = null; formData = { @@ -349,6 +146,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }; } @@ -397,6 +197,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }; } catch (error) { @@ -629,6 +432,18 @@ {formData.fraction || '0000.00.00'}

+ + +
+ +

+ {formData.eccn_code || '---'} +

+
@@ -752,6 +567,52 @@ companyId ); + // También actualizar la extensión FA + if (selectedClass.fa_class_id) { + await faClassesApi.update( + selectedClass.fa_class_id, + { + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + import_tariff_code: cleanData.import_tariff_code || null, + import_tariff_type: cleanData.import_tariff_type || null, + export_tariff_code: cleanData.export_tariff_code || null, + export_tariff_type: cleanData.export_tariff_type || null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null + }, + companyId + ); + } else { + await faClassesApi.create( + { + class_id: selectedClass.id, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, + class_enabled: true + }, + companyId + ); + } + // ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status } if (response.error) { console.error('❌ Error en respuesta de actualización:', response); @@ -770,8 +631,18 @@ sub_key: cleanData.sub_key || '', physical_review: cleanData.physical_review ? 1 : 0, iva_exempt_fraction: cleanData.iva_exempt_fraction || '', - depreciation_rate: cleanData.depreciation_rate || null, - fda_code: cleanData.fda_code || null, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, class_enabled: true }; diff --git a/frontend/src/routes/dashboard/help-center/+page.svelte b/frontend/src/routes/dashboard/help-center/+page.svelte new file mode 100644 index 00000000..38828eeb --- /dev/null +++ b/frontend/src/routes/dashboard/help-center/+page.svelte @@ -0,0 +1,266 @@ + + +
+ +
+
+

+ + Biblioteca de Conocimiento +

+

Manuales, Guías y Documentación del Sistema.

+
+
+ {#if HUB_MODE} + + {/if} +
+
+ + +
+ + +
+ + +
+ {#if loading} +
+ +
+ {:else if Object.keys(groupedArticles).length === 0} +
+ +

La biblioteca está vacía.

+
+ {:else} + {#each Object.entries(groupedArticles) as [category, groupArticles]} +
+

+ {category} +

+
+ {#each groupArticles as article} + +
+ + + {#if article.content_type === 'pdf'} + + {:else if article.content_type === 'video'} + + + {new Date(article.updated_at).toLocaleDateString()} + + + + {#if article.content_type === 'article'} +
+ {@html (article.content || '').replace(/<[^>]*>?/gm, '').substring(0, 150)}... +
+ {:else} +
+

Formato: {article.mime_type || 'Desconocido'}

+ {#if article.file_size} +

Tamaño: {(article.file_size / 1024 / 1024).toFixed(2)} MB

+ {/if} +
+ {/if} +
+ + +
+ {#if HUB_MODE} + + + {/if} +
+
+
+ {/each} +
+
+ {/each} + {/if} +
+
+ + + + + + ¿Eliminar capítulo? + + Se eliminará permanentemente "{selectedArticle?.title}". + + + + (showDeleteDialog = false)}>Cancelar + + {#if processing}{/if} + Eliminar + + + + diff --git a/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte new file mode 100644 index 00000000..0cee42c6 --- /dev/null +++ b/frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte @@ -0,0 +1,276 @@ + + +
+ +
+ +
+ {#if article} + + + {article.category || 'General'} + + {/if} +
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

Error al cargar el capítulo

+

{error}

+
+ {:else if article} +
+ +
+
+ +
+

+ {article.title} +

+
+ + + {article.last_editor} + + + + {new Date(article.updated_at).toLocaleDateString(undefined, { + dateStyle: 'long' + })} + +
+
+ + + {#if article.content_type === 'article'} +
+ {@html renderMarkdown(article.content)} +
+ {:else if article.content_type === 'pdf'} +
+
+
+ +
+

Documento PDF

+

+ {article.file_size ? (article.file_size / 1024 / 1024).toFixed(2) : '??'} MB +

+
+
+
+ + +
+
+ + +
+ {:else if article.content_type === 'video'} +
+ +
+

Información del archivo

+

{article.mime_type}

+
+
+ {:else} +
+
+ +
+
+

Archivo para descargar

+

+ Este archivo no tiene vista previa directa. +

+
+ +
+ {/if} +
+
+ + + {#if toc.length > 0} + + {/if} +
+ {/if} +
+
diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte new file mode 100644 index 00000000..2455fea8 --- /dev/null +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -0,0 +1,493 @@ + + +
+ +
+
+ +

+ {title || 'Sin Título'} +

+
+
+ + +
+
+ + +
+ + + + +
+ {#if content_type === 'article'} + +
+ +
+ + +
+ + +
+ + + +
+ + + + + +
+ + + {#if showPreview} +
+
+ {@html renderMarkdown(content)} +
+
+ {/if} + {:else} + +
+
+
+ {#if content_type === 'pdf'} +
+ +
+

Configuración de PDF

+ {:else if content_type === 'video'} +
+ +
+

Configuración de Video

+ {:else} +
+ +
+

Configuración de Documento

+ {/if} +

Sube el archivo que deseas asociar a este título.

+
+ + {#if file_url} +
+
+
+ +
+
+

Archivo Cargado

+

{file_url}

+

+ {mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB +

+
+
+
+ + +
+
+ {:else} + + {/if} +
+
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index c09cd9e3..e6a1f74c 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -190,7 +190,7 @@ selectedInvoiceId = null; } else { selectedInvoiceId = invoice.id; - } + } } const selectedInvoice = $derived( @@ -584,6 +584,36 @@ reloadData(); } + async function handleUpdateStatus(status: boolean) { + if (!selectedInvoice || !companyStore.activeCompany) { + toast.info('Seleccione una factura para cambiar su estatus'); + return; + } + + loading = true; + try { + const companyId = companyStore.activeCompany.id; + const response = await invoicesApi.update(selectedInvoice.id, companyId, { + id: selectedInvoice.id, + is_updated: status + }); + + if (response.error) { + toast.error( + `Error al ${status ? 'actualizar' : 'desactualizar'} factura: ${response.error}` + ); + } else { + toast.success(`Factura ${status ? 'actualizada' : 'desactualizada'} correctamente`); + reloadData(); + } + } catch (e) { + console.error('Error updating status:', e); + toast.error('Error inesperado al cambiar el estatus'); + } finally { + loading = false; + } + } + // Opciones de tipo de operación para el filtro const operationTypeOptions = [ { value: '', label: 'Todas' }, @@ -785,11 +815,21 @@
- - diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 506ec342..df9e4fec 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -225,7 +225,7 @@ // Función para mapear la factura existente a los formData function mapInvoiceToTopFields(invoice: any) { if (!invoice) return topFieldsSkeleton; - + let operationType: string | null = null; if (invoice.operation_type) { operationType = invoice.operation_type; @@ -251,7 +251,7 @@ function mapInvoiceToGeneral(invoice: any) { if (!invoice) return generalSkeleton; - + return { provider_header: invoice.compliance_mx?.provider_header || 'proveedor', provider_id: invoice.compliance_mx?.provider_id || null, @@ -278,7 +278,7 @@ function mapInvoiceToObservations(invoice: any) { if (!invoice) return observationSkeleton; - + return { observation_es: invoice.observation_es || '', observation_en: invoice.observation_en || '', @@ -300,7 +300,7 @@ function mapInvoiceToItems(invoice: any) { if (!invoice) return ensureItemsFormData(null); - + return { items: invoice.items || [] }; @@ -308,7 +308,7 @@ function mapInvoiceToOthers(invoice: any) { if (!invoice) return othersSkeleton; - + return { comments_status: invoice.comments_status || '', transport_mode: invoice.logistics?.transport_mode || 'TRUCK', @@ -337,7 +337,7 @@ function mapInvoiceToContinuation(invoice: any) { if (!invoice) return continuationSkeleton; - + return { numero_tipo_transporte: invoice.logistics?.numero_tipo_transporte || '', es_ferrocarril: invoice.logistics?.es_ferrocarril || 'no', @@ -351,10 +351,13 @@ funge_como_cd: invoice.logistics?.acts_as_cd || false, llego_pedimento: invoice.compliance_mx?.llego_pedimento || false, errores_facturacion: invoice.errores_facturacion || [], - semaforo_verde_aduana_mexicana: invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, - semaforo_verde_aduana_americana: invoice.compliance_mx?.semaforo_verde_aduana_americana || false, + semaforo_verde_aduana_mexicana: + invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, + semaforo_verde_aduana_americana: + invoice.compliance_mx?.semaforo_verde_aduana_americana || false, semaforo_rojo_aduana_mexicana: invoice.compliance_mx?.semaforo_rojo_aduana_mexicana || false, - semaforo_rojo_aduana_americana: invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, + semaforo_rojo_aduana_americana: + invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, is_mixed: invoice.compliance_mx?.is_mixed || false, reason_export: invoice.compliance_mx?.reason_export || '1', purchase_order: invoice.purchase_order || '', @@ -405,8 +408,8 @@ !data.isCreate ? !!data.invoice : !!data.defaultSettings?.observationFormData ); let itemsExists = $state( - !data.isCreate - ? !!(data.invoice?.items && data.invoice.items.length > 0) + !data.isCreate + ? !!(data.invoice?.items && data.invoice.items.length > 0) : !!data.defaultSettings?.itemsFormData?.items?.length ); let othersExists = $state( @@ -484,7 +487,7 @@ const actualResponse = response as any; const items = actualResponse.data?.items || []; - if (items.length === 0) { + if (items.length === 0) { if (!uiStore.isExchangeRateDialogOpen) { missingExchangeRateDate = date; showExchangeRateDialog = true; diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index dd9613e9..6898d88d 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -85,7 +85,7 @@ function handleRowClick(pedimento: Pedimento) { // Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar - selectedId = selectedId === pedimento.id ? null : pedimento.id; + selectedId = selectedId === pedimento.id ? null : pedimento.id; } function handleEditSelected() { @@ -149,6 +149,11 @@ try { const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -194,7 +199,12 @@ error = null; try { - const companyId = companyStore.activeCompany?.id || 1; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -248,7 +258,12 @@ error = null; try { - const companyId = companyStore.activeCompany.id; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -334,17 +349,17 @@ -
+
{ + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + /> +
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + /> +
+
+ + + +
+ +
+ +
+ {#each Object.keys(types.import) as key} +
+ + +
+ {/each} +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#each Object.keys(types.export.additional) as key} +
+ + +
+ {/each} +
+
+
+ + +
+ +
+ {#each Object.keys(types.other) as key} +
+ { + if (key === 'TODAS') handleTodasChange(v as boolean); + }} + /> + +
+ {/each} +
+
+
+ + + + + + + + Filtros e Identificadores + + + +
+ {#each [{ label: 'Proveedor', key: 'provider' as const }, { label: 'Vendido a', key: 'soldTo' as const }, { label: 'Clave de Pedimento', key: 'pedimentoKey' as const }] as item} +
+ +
+ + +
+
+ {/each} +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + Configuración Final + + + +
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + +
+
+ + + { + if (!v) showResults = false; + }} + > + + +
+
+ + + {reportTitle} + + + {results.length} registros encontrados • {currencyLabel} + +
+
+ + + + Cerrar + +
+
+
+ +
+
+
+ + + {#each config.reportType === 'normal' ? ['PEDIMENTO', 'CLAVE', 'FACTURA', 'FECHA FACT', 'VALOR COM.', 'TIPO OPER.', 'ESTATUS', 'PROYECTO'] : ['PEDIMENTO', 'FACTURA', 'FECHA FACT', 'PROVEEDOR', 'CLIENTE', 'CANTIDAD', 'DESC. ESPAÑOL', 'TIPO OPER.'] as header} + + {/each} + + + + {#each results as row} + + {#if config.reportType === 'normal'} + + + + + + + + + {:else} + {@const detailRow = row as MovementItemDetailed} + + + + + + + + + {/if} + + {/each} + +
+ {header} +
{row.Pedimento || '-'}{row.ClavePed || '-'}{row.Factura}{formatDateFromYYYYMMDD(row.FechaFactura)}${formatCurrency(row.ValorComercialMN)} + + {row.TipoMovTemDef} + + + + {row.Estatus || 'A'} + + {row.BaseDeDatos}{detailRow.Pedimento || '-'}{detailRow.Factura}{formatDateFromYYYYMMDD(detailRow.FechaFactura)}{detailRow.Proveedor || '-'}{detailRow.VendidoA || '-'}{detailRow.CantidadIE || '0'}{detailRow.DescripcionE || '-'} + + {detailRow.TipoMovTemDef} + +
+
+
+ + +
+ + + + + + + Seleccionar {dialogType === 'pedimentoKey' + ? 'Clave de Pedimento' + : dialogType === 'provider' + ? 'Proveedor' + : 'Cliente'} + + + Busca y selecciona {dialogType === 'pedimentoKey' + ? 'una clave de pedimento' + : dialogType === 'provider' + ? 'un proveedor' + : 'un cliente'} de la lista + + + +
+
+ + +
+ +
+ {#if dialogType === 'pedimentoKey'} + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)} + > + + + + + {/each} + {/if} + +
CódigoDescripciónAcción
+ No se encontraron resultados +
{item.code || '-'}{item.description || '-'} + +
+ {:else} + + + + + + + + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)} + > + + + + + + + + + + + + {/each} + {/if} + +
ClaveNombreTipoRFCCallesNúm. ExtCPColoniaCiudadAcción
+ No se encontraron resultados +
{item.id || '-'}{item.name || '-'} + + {item.client_or_provider === 'provider' + ? 'P' + : item.client_or_provider === 'client' + ? 'C' + : 'A'} + + {item.rfc || '-'}{item.address?.streets || '-'}{item.address?.exterior_number || '-'}{item.address?.postal_code || '-'}{item.address?.neighborhood || '-'}{item.address?.city || '-'} + +
+ {/if} +
+
+ + + + +
+
+ + + + +{#if contextMenu.open} +
+ +
+{/if} diff --git a/frontend/src/svelte-shims.d.ts b/frontend/src/svelte-shims.d.ts new file mode 100644 index 00000000..185afbcb --- /dev/null +++ b/frontend/src/svelte-shims.d.ts @@ -0,0 +1,7 @@ +// Ambient type declarations for .svelte files +// This must be a script (no top-level import/export) to be globally ambient +declare module "*.svelte" { + import type { Component } from "svelte"; + const component: Component; + export default component; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a39edb9d..e741ea69 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,13 @@ export default defineConfig({ allowedHosts: [ 'anexo76-dev.aduanasoft.com', // 'otro-host.com' si necesitas más - ], + ], + proxy: { + '/api/uploads': { + target: 'http://backend:8000', + changeOrigin: true + } + } }, plugins: [ tailwindcss(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 47270527..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,157 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lucide-svelte: - specifier: ^0.552.0 - version: 0.552.0(svelte@5.43.2) - -packages: - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@sveltejs/acorn-typescript@1.0.6': - resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} - peerDependencies: - acorn: ^8.9.0 - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - - esrap@2.1.2: - resolution: {integrity: sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - lucide-svelte@0.552.0: - resolution: {integrity: sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w==} - peerDependencies: - svelte: ^3 || ^4 || ^5.0.0-next.42 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - svelte@5.43.2: - resolution: {integrity: sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==} - engines: {node: '>=18'} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - -snapshots: - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': - dependencies: - acorn: 8.15.0 - - '@types/estree@1.0.8': {} - - acorn@8.15.0: {} - - aria-query@5.3.2: {} - - axobject-query@4.1.0: {} - - clsx@2.1.1: {} - - esm-env@1.2.2: {} - - esrap@2.1.2: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - locate-character@3.0.0: {} - - lucide-svelte@0.552.0(svelte@5.43.2): - dependencies: - svelte: 5.43.2 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - svelte@5.43.2: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@types/estree': 1.0.8 - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - clsx: 2.1.1 - esm-env: 1.2.2 - esrap: 2.1.2 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.4 - - zimmerframe@1.1.4: {} diff --git a/reinicio.sh b/reinicio.sh old mode 100755 new mode 100644 diff --git a/scripts/frontend-entrypoint.sh b/scripts/frontend-entrypoint.sh index bb2f0a33..9a431440 100755 --- a/scripts/frontend-entrypoint.sh +++ b/scripts/frontend-entrypoint.sh @@ -40,5 +40,12 @@ echo "==========================================" echo "Iniciando aplicación SvelteKit..." echo "==========================================" +# Instalar dependencias nuevas si package.json ha cambiado +if [ "$NODE_ENV" = "development" ]; then + echo "Instalando dependencias (development mode)..." + # CI=true evita el error ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY + CI=true pnpm install +fi + # Ejecutar el comando que se pasó al contenedor exec "$@" diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index b9dacab2..c03a12f9 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -14,7 +14,9 @@ # 8. Licencia Enterprise para el tenant (ilimitada, 1 año de vigencia) # # Requisitos: -# - Keycloak y PostgreSQL corriendo (p. ej. docker compose up -d) +# - Keycloak y PostgreSQL corriendo (por ejemplo: docker compose up -d) +# - Keycloak corriendo en http://localhost:8080 +# - PostgreSQL corriendo en localhost:5432 o 5939 # - Base de datos anexo76_core creada # - jq instalado (para procesamiento JSON) # @@ -66,6 +68,17 @@ exec_pg_sql() { -t -c "${sql}" 2>&1 } +# Ejecutar SQL en el PostgreSQL del Cliente (Simulación) +exec_pg_sql_client() { + local sql="$1" + # Solo ejecutar si el contenedor existe y está corriendo + if docker ps --format '{{.Names}}' | grep -q "^anexo76-postgres-client$"; then + docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-client \ + psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "anexo76_client" \ + -t -c "${sql}" 2>/dev/null || true + fi +} + # Crear mapper de tenant_id para un cliente create_tenant_mapper() { local client_id="$1" @@ -118,7 +131,11 @@ KEYCLOAK_URL="http://localhost:18080/kcauth" KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}" KEYCLOAK_REALM="${KEYCLOAK_REALM:-master}" +<<<<<<< HEAD KEYCLOACK_ADMIN_URL="http://localhost:19000/kcauth" +======= +KEYCLOAK_ADMIN_URL="${KEYCLOAK_ADMIN_URL:-http://localhost:9000/kcauth}" +>>>>>>> origin/development POSTGRES_HOST="${POSTGRES_HOST:-localhost}" POSTGRES_PORT="${POSTGRES_PORT:-5432}" @@ -149,7 +166,7 @@ MAX_RETRIES=30 RETRY_COUNT=0 while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - if curl -s -f "${KEYCLOACK_ADMIN_URL}/health/ready" > /dev/null 2>&1; then + if curl -s -f "${KEYCLOAK_ADMIN_URL}/health/ready" > /dev/null 2>&1; then echo -e "${GREEN}✓ Keycloak está listo${NC}" break fi @@ -338,12 +355,52 @@ FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK -H "Content-Type: application/json") if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then - echo -e "${YELLOW}⚠ Cliente Frontend ya existe${NC}" + echo -e "${YELLOW}⚠ Cliente Frontend ya existe, actualizando configuración...${NC}" FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id') + + # Actualizar configuración del cliente existente para incluir puertos nuevos + UPDATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "anexo76-frontend", + "name": "Anexo76 Frontend", + "description": "Aplicación web frontend para el sistema Anexo76", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "directAccessGrantsEnabled": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:5173", + "baseUrl": "http://localhost:5173", + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:5174/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:3000" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }') + + HTTP_CODE=$(echo "$UPDATE_FRONTEND" | tail -n1) + if [ "$HTTP_CODE" = "204" ] || [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Cliente Frontend actualizado (Puertos actualizados)${NC}" + else + echo -e "${RED}✗ Error al actualizar cliente Frontend (HTTP ${HTTP_CODE})${NC}" + fi + else CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ +<<<<<<< HEAD -d "{ \"clientId\": \"anexo76-frontend\", \"name\": \"Anexo76 Frontend\", @@ -363,6 +420,29 @@ else \"webOrigins\": [ \"http://localhost:15173\", \"http://localhost:13000\" +======= + -d '{ + "clientId": "anexo76-frontend", + "name": "Anexo76 Frontend", + "description": "Aplicación web frontend para el sistema Anexo76", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "directAccessGrantsEnabled": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "rootUrl": "http://localhost:5173", + "baseUrl": "http://localhost:5173", + "redirectUris": [ + "http://localhost:5173/*", + "http://localhost:5174/*", + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:3000" +>>>>>>> origin/development ], \"attributes\": { \"pkce.code.challenge.method\": \"S256\" @@ -517,8 +597,9 @@ if [ $PG_RETRY_COUNT -eq $MAX_PG_RETRIES ]; then fi # Insertar o actualizar tenant -echo "Insertando tenant en PostgreSQL..." +echo "Insertando tenant en PostgreSQL (Hub y Cliente)..." exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null +exec_pg_sql_client "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null # Obtener el ID del tenant con mejor manejo de errores echo "Obteniendo ID del tenant..." @@ -553,7 +634,8 @@ COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs) if [ "$COMPANY_EXISTS" = "0" ]; then exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null - echo -e "${GREEN}✓ Company creada${NC}" + exec_pg_sql_client "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null + echo -e "${GREEN}✓ Company creada (Hub y Cliente)${NC}" else echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}" fi @@ -584,9 +666,14 @@ echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}" # Agregar relación usuario-tenant en la base de datos (usar company_id real) echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}" +<<<<<<< HEAD exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, ${COMPANY_ID}, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null +======= +exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null +exec_pg_sql_client "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null +>>>>>>> origin/development -echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos${NC}" +echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos (Hub y Cliente)${NC}" ############################################################################### # 9. Crear licencia Enterprise para el tenant @@ -609,7 +696,8 @@ if [ "$LICENSE_EXISTS" = "0" ]; then set -e # Reactivar exit on error if [ $LICENSE_CREATE_STATUS -eq 0 ]; then - echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente${NC}" + exec_pg_sql_client "INSERT INTO core.licenses (tenant_id, plan, status, max_users, max_storage_gb, max_monthly_operations, feature_api_access, feature_advanced_reports, feature_integrations, feature_dedicated_support, starts_at, expires_at, created_at, updated_at) VALUES (${TENANT_ID}, 'ENTERPRISE', 'ACTIVE', 999999, 999999, 999999, true, true, true, true, '${LICENSE_START_DATE}'::timestamp, '${LICENSE_EXPIRE_DATE}'::timestamp, now(), now());" >/dev/null 2>&1 || true + echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente (Hub y Cliente)${NC}" echo -e "${GREEN} Plan: Enterprise${NC}" echo -e "${GREEN} Usuarios: Ilimitados${NC}" echo -e "${GREEN} Almacenamiento: Ilimitado${NC}" @@ -629,11 +717,12 @@ else # Actualizar licencia existente a Enterprise set +e # Desactivar exit on error temporalmente exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1 + exec_pg_sql_client "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1 || true LICENSE_UPDATE_STATUS=$? set -e # Reactivar exit on error if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then - echo -e "${GREEN}✓ Licencia actualizada a Enterprise${NC}" + echo -e "${GREEN}✓ Licencia actualizada a Enterprise (Hub y Cliente)${NC}" else echo -e "${RED}✗ Error al actualizar la licencia${NC}" exit 1 diff --git a/start.sh b/start.sh index f3e1a213..5f13c9b2 100755 --- a/start.sh +++ b/start.sh @@ -60,14 +60,15 @@ fi echo -e "${GREEN}✓ Docker está instalado y corriendo${NC}" echo "" -# 2. Crear archivo .env si no existe +# 2. Configurar variables de entorno echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}" + if [ ! -f .env ]; then if [ -f .env.example ]; then cp .env.example .env - echo -e "${GREEN}✓ Archivo .env creado${NC}" + echo -e "${GREEN}✓ Archivo .env creado desde .env.example${NC}" else - echo -e "${YELLOW}⚠ .env.example no existe, creando .env con valores por defecto${NC}" + echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}" cat > .env </dev/null || echo "dev-sync-token-$(date +%s)") + read -p "Ingrese el Token de Sincronización Secreto [Presione Enter para generar uno aleatorio: $GENERATED_TOKEN]: " SYNC_TOKEN + SYNC_TOKEN=${SYNC_TOKEN:-$GENERATED_TOKEN} + + if [ "$SERVER_ROLE" == "1" ]; then + echo -e "${GREEN}Configurando como HUB...${NC}" + CENTRAL_URL="" + SPOKE_URLS="" + HUB_MODE="true" + else + echo -e "${GREEN}Configurando como CLIENTE...${NC}" + HUB_MODE="false" + read -p "Ingrese la URL del Hub [http://100.78.6.108:8000/api/v1/core/help-center/sync/]: " CENTRAL_URL + CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/} + SPOKE_URLS="" + fi + + # Aplicar configuraciones + sed -i "s|^CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env 2>/dev/null || echo "CENTRAL_SERVER_URL=$CENTRAL_URL" >> .env + sed -i "s|^SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env 2>/dev/null || echo "SPOKE_URLS=$SPOKE_URLS" >> .env + if ! grep -q "SYNC_SECRET_TOKEN=" .env; then + echo "SYNC_SECRET_TOKEN=$SYNC_TOKEN" >> .env + fi + + if grep -q "VITE_HUB_MODE=" .env; then + sed -i "s|^VITE_HUB_MODE=.*|VITE_HUB_MODE=$HUB_MODE|" .env + else + echo "VITE_HUB_MODE=$HUB_MODE" >> .env + fi + echo -e "${GREEN}✓ Configuración de rol aplicada al .env${NC}" fi else - echo -e "${YELLOW}⚠ .env ya existe, no se sobrescribirá${NC}" + echo -e "${YELLOW}⚠ .env ya contiene configuración de rol. Para cambiarlo, ejecuta con RECONFIGURE=true${NC}" fi + echo "" # 3. Limpiar contenedores previos si existen