From e3205880d7be14333472b1a247237b9de640ee66 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 26 May 2026 10:02:17 -0600 Subject: [PATCH] feature/funcionalidades-click-derecho-facturas --- backend/api/v1/modules/a76/invoices/routes.py | 248 ++- .../api/v1/modules/a76/invoices/services.py | 1675 +++++++++++++++++ backend/requirements.txt | 1 + .../src/lib/api/dashboard/a76/invoices.ts | 45 + .../dashboard/invoices/data-table.svelte | 3 + .../context-menu/context-menu-content.svelte | 25 + .../ui/context-menu/context-menu-group.svelte | 7 + .../ui/context-menu/context-menu-item.svelte | 27 + .../ui/context-menu/context-menu-label.svelte | 19 + .../ui/context-menu/context-menu-root.svelte | 7 + .../context-menu-separator.svelte | 17 + .../context-menu-sub-content.svelte | 20 + .../context-menu-sub-trigger.svelte | 27 + .../ui/context-menu/context-menu-sub.svelte | 7 + .../context-menu/context-menu-trigger.svelte | 7 + .../lib/components/ui/context-menu/index.ts | 31 + .../routes/dashboard/invoices/+page.svelte | 436 ++++- 17 files changed, 2598 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-content.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-group.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-item.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-label.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-root.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte create mode 100644 frontend/src/lib/components/ui/context-menu/index.ts diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 54fb72d0..04028763 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Any, Optional +from typing import Dict, Any, Literal, Optional from core.config import settings from core.database import get_core_db @@ -255,6 +255,252 @@ def delete_invoice( success = services.InvoiceService.delete(db, invoice_id, tenant_id, company_id) return {"success": success} +@router.post("/invoices/{invoice_id}/copy", response_model=schemas.InvoiceHeaderResponse) +def copy_invoice( + invoice_id: int = Path(..., description="ID de la factura a copiar"), + 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) + original = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not original: + raise HTTPException(status_code=404, detail="Factura no encontrada") + + perm_base = get_invoice_permission_base(original.operation_type, original.invoice_type) + validate_access_to_resource( + db, company_id, current_user, required_permissions=[f"{perm_base}.create"] + ) + + try: + return services.InvoiceService.copy(db, invoice_id, tenant_id, company_id) + except HTTPException: + raise + except Exception as e: + logger.exception("copy_invoice failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al copiar factura: {str(e)}") + + +@router.post("/invoices/{invoice_id}/copy-header", response_model=schemas.InvoiceHeaderResponse) +def copy_invoice_header( + invoice_id: int = Path(..., description="ID de la factura"), + 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) + original = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not original: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(original.operation_type, original.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"]) + try: + return services.InvoiceService.copy_header_only(db, invoice_id, tenant_id, company_id) + except HTTPException: + raise + except Exception as e: + logger.exception("copy_invoice_header failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al copiar encabezado: {str(e)}") + + +@router.get("/invoices/{invoice_id}/export") +def export_invoice( + invoice_id: int = Path(..., description="ID de la factura"), + format: Literal['csv', 'xlsx', 'txt'] = Query(..., description="Formato: csv | xlsx | txt"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_items(db, invoice, format) + except HTTPException: + raise + except Exception as e: + logger.exception("export_invoice failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al exportar factura: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/gm-transport") +def interface_gm_transport( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_gm_transport(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_gm_transport failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz GM Transport: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/carta-porte") +def interface_carta_porte( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_carta_porte(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_carta_porte failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/tfc") +def interface_tfc( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_tfc(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_tfc failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz TFC: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/carta-porte-consolidada") +def interface_carta_porte_consolidada( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_carta_porte_consolidada(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_carta_porte_consolidada failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte Consolidada: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/aviso-cruce") +def interface_aviso_cruce( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_aviso_cruce(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_aviso_cruce failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz Aviso Cruce: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/aaduanal-rs") +def interface_aaduanal_rs( + invoice_id: int = Path(..., description="ID de la factura"), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_aaduanal_rs(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_aaduanal_rs failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz AAduanal_RS: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/caaarem") +def interface_caaarem( + invoice_id: int = Path(...), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_caaarem(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_caaarem failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz CAAAREM: {str(e)}") + + +@router.get("/invoices/{invoice_id}/interfaces/cp-genesis") +def interface_cp_genesis( + invoice_id: int = Path(...), + company_id: int = Query(...), + 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) + invoice = services.InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id) + if not invoice: + raise HTTPException(status_code=404, detail="Factura no encontrada") + perm_base = get_invoice_permission_base(invoice.operation_type, invoice.invoice_type) + validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.view"]) + try: + return services.InvoiceService.export_cp_genesis(db, invoice) + except HTTPException: + raise + except Exception as e: + logger.exception("interface_cp_genesis failed: %s", e) + raise HTTPException(status_code=500, detail=f"Error al generar interfaz Carta Porte Genesis: {str(e)}") + # --- RUTA DE LISTADO (FILTROS) --- diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 030640c9..368d8b04 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -766,3 +766,1678 @@ class InvoiceService: db.commit() return True return False + + @staticmethod + def copy( + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + ) -> models.InvoiceHeader: + """Duplica una factura con todas sus tablas relacionadas. + + El número de factura de la copia lleva sufijo '-COPIA' (o '-COPIA-N' si ya existe). + Estado reseteado a 'pending', sin pedimento ni datos de procesamiento. + """ + original = ( + db.query(models.InvoiceHeader) + .filter( + models.InvoiceHeader.id == invoice_id, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not original: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Factura no encontrada") + + # Generar número de factura único para la copia + base_number = original.invoice_number or "" + candidate = f"{base_number}-COPIA" + counter = 1 + while db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.invoice_number == candidate, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ).first(): + counter += 1 + candidate = f"{base_number}-COPIA-{counter}" + new_invoice_number = candidate + + username = _get_current_username() + + # Columnas a excluir en la copia del encabezado + EXCLUDE_COLS = { + "id", "invoice_number", "status", "capture_date", "capture_user", + "who_processed", "processed_date", "process_log", "status_rec", + "status_rep", "comments_status", "vu_observations", "cfdi_uuid", + "path_pdf", "path_xml", "created_at", "updated_at", + } + + header_data = { + c.name: getattr(original, c.name) + for c in models.InvoiceHeader.__table__.columns + if c.name not in EXCLUDE_COLS + } + header_data["invoice_number"] = new_invoice_number + header_data["status"] = models.InvoiceStatus.PENDING + header_data["capture_user"] = username + header_data["who_processed"] = username + + new_invoice = models.InvoiceHeader(**header_data) + db.add(new_invoice) + db.flush() + + # Copiar compliance_mx (sin pedimento ni datos de procesamiento aduanal) + comp = db.query(models.InvoiceComplianceMx).filter( + models.InvoiceComplianceMx.invoice_id == invoice_id + ).first() + if comp: + EXCLUDE_COMP = { + "invoice_id", "pedimento_id", "vucem_operation_num", + "electronic_signature", "certificate_number", "niu_number", + "code_signature", "edocument", "created_at", "updated_at", + } + comp_data = { + c.name: getattr(comp, c.name) + for c in models.InvoiceComplianceMx.__table__.columns + if c.name not in EXCLUDE_COMP + } + comp_data["invoice_id"] = new_invoice.id + db.add(models.InvoiceComplianceMx(**comp_data)) + + # Copiar financials + fin = db.query(models.InvoiceFinancials).filter( + models.InvoiceFinancials.invoice_id == invoice_id + ).first() + if fin: + EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"} + fin_data = { + c.name: getattr(fin, c.name) + for c in models.InvoiceFinancials.__table__.columns + if c.name not in EXCLUDE_FIN + } + fin_data["invoice_id"] = new_invoice.id + fin_data["tenant_id"] = tenant_id + fin_data["company_id"] = company_id + db.add(models.InvoiceFinancials(**fin_data)) + + # Copiar logistics + log_entries = db.query(models.InvoiceLogistics).filter( + models.InvoiceLogistics.invoice_id == invoice_id + ).all() + for log in log_entries: + EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"} + log_data = { + c.name: getattr(log, c.name) + for c in models.InvoiceLogistics.__table__.columns + if c.name not in EXCLUDE_LOG + } + log_data["invoice_id"] = new_invoice.id + log_data["tenant_id"] = tenant_id + log_data["company_id"] = company_id + db.add(models.InvoiceLogistics(**log_data)) + + # Copiar sales details + details = db.query(models.InvoiceSalesDetails).filter( + models.InvoiceSalesDetails.invoice_id == invoice_id + ).all() + for det in details: + EXCLUDE_DET = {"id", "invoice_id", "created_at", "updated_at"} + det_data = { + c.name: getattr(det, c.name) + for c in models.InvoiceSalesDetails.__table__.columns + if c.name not in EXCLUDE_DET + } + det_data["invoice_id"] = new_invoice.id + det_data["tenant_id"] = tenant_id + det_data["company_id"] = company_id + db.add(models.InvoiceSalesDetails(**det_data)) + + # Copiar collections + collections = db.query(models.InvoiceCollections).filter( + models.InvoiceCollections.invoice_id == invoice_id + ).all() + for col in collections: + EXCLUDE_COL = {"id", "invoice_id", "created_at", "updated_at"} + col_data = { + c.name: getattr(col, c.name) + for c in models.InvoiceCollections.__table__.columns + if c.name not in EXCLUDE_COL + } + col_data["invoice_id"] = new_invoice.id + col_data["tenant_id"] = tenant_id + col_data["company_id"] = company_id + db.add(models.InvoiceCollections(**col_data)) + + db.commit() + db.refresh(new_invoice) + return new_invoice + + @staticmethod + def export_items( + db: Session, + invoice: "models.InvoiceHeader", + fmt: str, + ): + """Genera StreamingResponse con partidas de la factura. + + Replica el OF 2 del POPUP legacy (QEqiDef → CSV/TXT/XLSX). + Formatos: csv (,) | txt (|) | xlsx (openpyxl). + Incluye sección de series al final si existen. + """ + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction + + def _strip_newlines(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + # --- Query principal: patrón joinedload igual que ItemService.get_all --- + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.financial), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.class_info), + joinedload(LineItem.part_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.series), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + # --- Lookup bulk de UMCLAVE (TariffFraction.umt por código de fracción) --- + fractions = { + item.customs.fraction + for item in items + if item.customs and item.customs.fraction + } + umt_map: dict = {} + if fractions: + umt_map = { + r.code: r.umt + for r in db.query(TariffFraction.code, TariffFraction.umt) + .filter(TariffFraction.code.in_(fractions)) + .all() + } + + # --- Delimitadores y media type --- + fmt = fmt.lower() + if fmt == 'xlsx': + return InvoiceService._export_items_xlsx(invoice, items, umt_map) + + delim = ',' if fmt == 'csv' else '|' + media_type = 'text/csv; charset=utf-8' if fmt == 'csv' else 'text/plain; charset=utf-8' + ext = fmt + + HEADERS = [ + 'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI', + 'CANTIDAD', 'UNIMED', 'COSTO UNITARIO', + 'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS', + 'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA', + 'ORDEN COMPRA', 'NUM PARTE', + 'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE', + 'VALORIMPOMN', 'VALORIMPOME', 'LOTE', + 'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA', + ] + SER_HEADERS = ['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID'] + + def _item_to_list(item: LineItem): + q = item.quantity + f = item.financial + c = item.customs + d = item.description + cls = item.class_info + part = item.part_info + uom = item.unit_of_measure_info + pais = c.origin_country if c else '' + fraccion = (c.octave_fraction or c.fraction) if c else '' + umclave = umt_map.get(c.fraction, '') if c and c.fraction else '' + return [ + invoice.invoice_number or '', + item.line_number or '', + 'TEM', + invoice.invoice_number or '', + item.line_number or '', + 'SI', + q.quantity if q else '', + uom.code if uom else '', + f.unit_cost_capture if f else '', + q.net_weight if q else '', + q.gross_weight if q else '', + q.package_quantity if q else '', + q.package_id if q else '', + pais or '', + fraccion, + c.fraction_type if c else '', + c.sector if c else '', + c.american_fraction if c else '', + invoice.purchase_order or '', + part.part_number if part else '', + _strip_newlines(d.description_spanish if d else None), + _strip_newlines(d.description_english if d else None), + d.brand if d else '', + d.model if d else '', + cls.class_code if cls else '', + umclave, + f.value_mxn if f else '', + f.value_usd if f else '', + d.lot if d else '', + _strip_newlines(d.extra_description if d else None), + invoice.capture_user or '', + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=delim, lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0); buf.truncate(0) + writer.writerow(_item_to_list(item)) + yield buf.getvalue() + # Sección de series al final + has_series = any(item.series for item in items) + if has_series: + buf.seek(0); buf.truncate(0) + writer.writerow([]) + writer.writerow(SER_HEADERS) + yield buf.getvalue() + for item in items: + for s in sorted(item.series, key=lambda x: x.row): + buf.seek(0); buf.truncate(0) + writer.writerow([ + invoice.invoice_number or '', + item.line_number or '', + s.row or '', + s.serial_numbers or '', + s.model or '', + s.number_id or '', + ]) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_export.{ext}" + return StreamingResponse( + generate(), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def _export_items_xlsx(invoice, items, umt_map: dict): + """Genera el archivo XLSX con openpyxl.""" + import io + import re + from fastapi.responses import StreamingResponse + try: + import openpyxl + except ImportError: + raise ImportError("openpyxl no está instalado. Agrega 'openpyxl' a requirements.txt") + + def _strip_newlines(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + def _f(v): + return float(v) if v is not None else '' + + HEADERS = [ + 'FACTURA', 'LINEA', 'PROCEDENCIA', 'FACTURA', 'LINEA', 'SI', + 'CANTIDAD', 'UNIMED', 'COSTO UNITARIO', + 'PESO NETO', 'PESO BRUTO', 'CANT BULTOS', 'CLAVE BULTOS', + 'PAIS', 'FRACCION', 'PREFERENCIA', 'SECTOR', 'FRAC AMERICANA', + 'ORDEN COMPRA', 'NUM PARTE', + 'DESCRIP ESP', 'DESCRIP ING', 'MARCA', 'MODELO', 'CLASE', 'UMCLAVE', + 'VALORIMPOMN', 'VALORIMPOME', 'LOTE', + 'DESCRIPCIÓN EXTRA EN ESPAÑOL', 'USUARIO QUE CAPTURO LA FACTURA', + ] + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Partidas" + ws.append(HEADERS) + for item in items: + q = item.quantity + f = item.financial + c = item.customs + d = item.description + cls = item.class_info + part = item.part_info + uom = item.unit_of_measure_info + pais = c.origin_country if c else '' + fraccion = (c.octave_fraction or c.fraction) if c else '' + umclave = umt_map.get(c.fraction, '') if c and c.fraction else '' + ws.append([ + invoice.invoice_number or '', + item.line_number or '', + 'TEM', + invoice.invoice_number or '', + item.line_number or '', + 'SI', + _f(q.quantity if q else None), + uom.code if uom else '', + _f(f.unit_cost_capture if f else None), + _f(q.net_weight if q else None), + _f(q.gross_weight if q else None), + _f(q.package_quantity if q else None), + str(q.package_id or '') if q else '', + str(pais or ''), + str(fraccion), + str(c.fraction_type or '') if c else '', + str(c.sector or '') if c else '', + str(c.american_fraction or '') if c else '', + str(invoice.purchase_order or ''), + str(part.part_number or '') if part else '', + _strip_newlines(d.description_spanish if d else None), + _strip_newlines(d.description_english if d else None), + str(d.brand or '') if d else '', + str(d.model or '') if d else '', + str(cls.class_code or '') if cls else '', + str(umclave), + _f(f.value_mxn if f else None), + _f(f.value_usd if f else None), + str(d.lot or '') if d else '', + _strip_newlines(d.extra_description if d else None), + str(invoice.capture_user or ''), + ]) + has_series = any(item.series for item in items) + if has_series: + ws.append([]) + ws.append(['FACTURA', 'LINEA', 'RENGLON', 'SERIE', 'MODELO', 'NUMID']) + for item in items: + for s in sorted(item.series, key=lambda x: x.row): + ws.append([ + invoice.invoice_number or '', + item.line_number or '', + s.row or '', + s.serial_numbers or '', + s.model or '', + s.number_id or '', + ]) + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_export.xlsx" + return StreamingResponse( + iter([buf.read()]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_gm_transport( + db: Session, + invoice: "models.InvoiceHeader", + ): + """Genera CSV para interfaz GM Transport (OF 5 del POPUP legacy). + + Replica las 21 columnas del formato GM Transport. Pedimento formateado + como: AÑO ADUANA PATENTE NUMERO con espacios dobles entre campos. + """ + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + # Datos de pedimento y compliance son por factura (no por partida) + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + pedimento = None + ped_date = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + if pedimento: + ped_date_row = ( + db.query(PedimentoDates) + .filter(PedimentoDates.pedimento_id == pedimento.id) + .first() + ) + ped_date = ped_date_row.entry_date if ped_date_row else None + + def _format_pedimento() -> str: + if not pedimento or not pedimento.pedimento_number: + return '' + return f"{pedimento.year or ''} {pedimento.customs_office or ''} {pedimento.license or ''} {pedimento.pedimento_number or ''}" + + # Partidas con sus sub-modelos cargados (joinedload — mismo patrón que ItemService) + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.class_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + ped_str = _format_pedimento() + fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else '' + aduana_str = compliance.aduana if compliance else '' + + HEADERS = [ + 'CANTIDAD', 'ID UNIDAD EMBALAJE', 'DESC. MATERIAL CARGA', 'PESO', 'ID UNIDAD PESO', + 'CODIGO DE PRODUCTO Y SERVICIO', 'CLAVE UNIDAD DE MEDIDA Y EMBALAJE', 'CLAVE UNIDAD', + 'CLAVE FRACCIÓN ARANCELARIA', 'UUID COMERCIO EXTERIOR', 'ES MATERIAL PELIGROSO?', + 'CLAVE MATERIAL PELIGROSO', 'TIPO EMBALAJE', 'DESCRIPCIÓN EMBALAJE', + 'APLICA TARIFA', 'TARIFA', 'IMPORTE', 'IMPORTE BASE', + 'NÚMERO DE PEDIMENTO', 'FECHA', 'ADUANA', + ] + + def _item_to_list(item: LineItem): + q = item.quantity + c = item.customs + cls = item.class_info + return [ + q.quantity if q else '', + '', + _strip(cls.description_es if cls else None), + q.gross_weight if q else '', + '', + cls.fraction if cls else '', + '', + '', + ("'" + c.fraction) if c and c.fraction else '', + '', + 'NO', + '', '', '', '', '', '', '', + ped_str, + fecha_str, + aduana_str, + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0); buf.truncate(0) + writer.writerow(_item_to_list(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_gm_transport.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_carta_porte(db: Session, invoice) -> "StreamingResponse": + """ + Genera el CSV de Interfaz Carta Porte (OF-4). + 24 columnas: datos de partida + RFC proveedor/enviado a/importador + pedimento + régimen. + """ + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction + from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + # Datos fijos por factura: compliance, pedimento, RFCs + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + pedimento = None + ped_date = None + regime = '' + aduana_str = compliance.aduana if compliance else '' + provider_rfc = shipped_to_rfc = sold_to_rfc = '' + + if compliance: + if compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + if pedimento: + regime = pedimento.regime or '' + ped_date_row = ( + db.query(PedimentoDates) + .filter(PedimentoDates.pedimento_id == pedimento.id) + .first() + ) + ped_date = ped_date_row.entry_date if ped_date_row else None + + def _get_rfc(client_id): + if not client_id: + return '' + row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() + return row.rfc if row else '' + + provider_rfc = _get_rfc(compliance.provider_id) + shipped_to_rfc = _get_rfc(compliance.shipped_to_id) + sold_to_rfc = _get_rfc(compliance.sold_to_id) + + def _format_pedimento() -> str: + if not pedimento or not pedimento.pedimento_number: + return '' + return ( + f"{pedimento.year or ''} {pedimento.customs_office or ''}" + f" {pedimento.license or ''} {pedimento.pedimento_number or ''}" + ) + + # Partidas con joinedload (mismo patrón que export_items) + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.financial), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + # Bulk lookups: UMT por fracción aduanera, CartaPorte por fracción de clase + customs_fractions = { + item.customs.fraction for item in items if item.customs and item.customs.fraction + } + class_fractions = { + item.class_info.fraction for item in items + if item.class_info and item.class_info.fraction + } + umt_map = ( + {r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt) + .filter(TariffFraction.code.in_(customs_fractions)).all()} + if customs_fractions else {} + ) + cp_map = ( + {r.code: r for r in db.query(CartaPorte) + .filter(CartaPorte.code.in_(class_fractions)).all()} + if class_fractions else {} + ) + + ped_str = _format_pedimento() + fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else '' + + HEADERS = [ + 'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO', + 'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT', + 'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME', + 'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO', + 'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA', + 'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO', + ] + + def _item_to_list(item: LineItem): + q = item.quantity + fin = item.financial + c = item.customs + desc = item.description + cls = item.class_info + uom = item.unit_of_measure_info + customs_frac = c.fraction if c else '' + class_frac = cls.fraction if cls else None + cp = cp_map.get(class_frac) if class_frac else None + return [ + invoice.invoice_number or '', + cls.class_code if cls else '', + _strip(desc.description_spanish if desc else None), + cp.code if cp else '', + cp.description if cp else '', + q.quantity if q else '', + uom.code if uom else '', + umt_map.get(customs_frac, ''), + q.net_weight if q else '', + q.gross_weight if q else '', + customs_frac, + fin.value_mxn if fin else '', + fin.value_usd if fin else '', + provider_rfc, + shipped_to_rfc, + ped_str, + fecha_str, + 'NO', + aduana_str, + cls.material_key if cls else '', + '', # DESCRIPCION DE LA MATERIA — tabla material_types no mapeada + '01', # TIPO DE DOCUMENTO + sold_to_rfc, + regime, + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0); buf.truncate(0) + writer.writerow(_item_to_list(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_carta_porte.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_tfc(db: Session, invoice) -> "StreamingResponse": + """Interfaz TFC (OF-6). 17 columnas. Pedimento desglosado en 4 campos.""" + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + ped_year = pedimento.year or '' if pedimento else '' + ped_office = pedimento.customs_office or '' if pedimento else '' + ped_license = pedimento.license or '' if pedimento else '' + ped_number = pedimento.pedimento_number or '' if pedimento else '' + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.class_info), + joinedload(LineItem.description), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + HEADERS = [ + 'ID DESTINO', 'BIENES TRANSPORTADOS', 'CANTIDAD MERCANCIA', 'CLAVE DE UNIDAD', + 'PESO EN KG', 'DESCRIPCION DE MERCANCIA', 'MATERIAL PELIGROSO', 'CLAVE DE MATERIAL', + 'CLAVE EMBALAJE (SOLO SI ES MATERIAL PELIGROSO)', 'FRACCION ARANCELARIA', + 'UUID DE COMERCIO EXTERIOR', 'DESCRIPCION GUIA DE IDENTIFICACION', + 'PESO KG GUIA DE IDENTIFICACION', 'PEDIMENTO - VALIDACION', 'PEDIMENTO - ADUANA', + 'PEDIMENTO - PATENTE', 'PEDIMENTO NUMERACION PROGRESIVA', + ] + + def _item_to_list(item: LineItem): + q = item.quantity + c = item.customs + cls = item.class_info + d = item.description + return [ + '1', + cls.fraction if cls else '', + q.quantity if q else '', + '', + q.net_weight if q else '', + _strip(d.description_spanish if d else None), + 'NO', + '', '', + c.fraction if c else '', + '', '', '', + ped_year, + ped_office, + ped_license, + ped_number, + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0); buf.truncate(0) + writer.writerow(_item_to_list(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_tfc.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_carta_porte_consolidada(db: Session, invoice) -> "StreamingResponse": + """Interfaz Carta Porte Consolidada (OF-8). 24 cols, igual que OF-4 pero + partidas agrupadas por clase: SUM de cantidades/pesos/valores.""" + import io + import csv + import re + from decimal import Decimal + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction + from api.v1.modules.public.reference_data.carta_porte_codes.models import CartaPorte + + UOM_SAT = { + 'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR', + 'JGO': 'SET', 'GR': 'GRM', 'M2': 'MTK', 'PAR': 'PR', 'CAJA': 'XBX', + } + MAT_DESC = { + '01': 'Materia prima', + '02': 'Materia procesada', + '03': 'Materia terminada(producto terminado)', + '04': 'Materia para la industria manufacturera', + '05': 'Otra', + } + MATERIAL_EXCLUIDOS = {'TERR', 'INSTA', 'EDIF'} + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + def _dec(v): + if v is None: + return Decimal('0') + return Decimal(str(v)) + + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + pedimento = None + ped_date = None + regime = '' + aduana_str = compliance.aduana if compliance else '' + provider_rfc = shipped_to_rfc = sold_to_rfc = '' + + if compliance: + if compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + if pedimento: + regime = pedimento.regime or '' + ped_date_row = ( + db.query(PedimentoDates) + .filter(PedimentoDates.pedimento_id == pedimento.id) + .first() + ) + ped_date = ped_date_row.entry_date if ped_date_row else None + + def _get_rfc(client_id): + if not client_id: + return '' + row = db.query(ClientProvider.rfc).filter(ClientProvider.id == client_id).first() + return row.rfc if row else '' + + provider_rfc = _get_rfc(compliance.provider_id) + shipped_to_rfc = _get_rfc(compliance.shipped_to_id) + sold_to_rfc = _get_rfc(compliance.sold_to_id) + + def _format_pedimento() -> str: + if not pedimento or not pedimento.pedimento_number: + return '' + return ( + f"{pedimento.year or ''} {pedimento.customs_office or ''}" + f" {pedimento.license or ''} {pedimento.pedimento_number or ''}" + ) + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.financial), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.class_info), + joinedload(LineItem.unit_of_measure_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + customs_fractions = { + item.customs.fraction for item in items if item.customs and item.customs.fraction + } + class_fractions = { + item.class_info.fraction for item in items + if item.class_info and item.class_info.fraction + } + umt_map = ( + {r.code: r.umt for r in db.query(TariffFraction.code, TariffFraction.umt) + .filter(TariffFraction.code.in_(customs_fractions)).all()} + if customs_fractions else {} + ) + cp_map = ( + {r.code: r for r in db.query(CartaPorte) + .filter(CartaPorte.code.in_(class_fractions)).all()} + if class_fractions else {} + ) + + # Agrupación por class_code + groups: dict = {} + group_order: list = [] + for item in items: + key = item.class_info.class_code if item.class_info else '' + if key not in groups: + group_order.append(key) + cls = item.class_info + c = item.customs + uom = item.unit_of_measure_info + class_frac = cls.fraction if cls else None + cp = cp_map.get(class_frac) if class_frac else None + mat_key = cls.material_key if cls else '' + tipo_mat = '' if mat_key in MATERIAL_EXCLUIDOS else '05' + groups[key] = { + 'class_code': key, + 'description': _strip(item.description.description_spanish if item.description else None), + 'cp_code': cp.code if cp else '', + 'cp_desc': cp.description if cp else '', + 'uom': uom.code if uom else '', + 'uom_sat': UOM_SAT.get(uom.code if uom else '', ''), + 'customs_frac': c.fraction if c else '', + 'tipo_mat': tipo_mat, + 'mat_desc': MAT_DESC.get(tipo_mat, ''), + 'quantity': _dec(item.quantity.quantity if item.quantity else None), + 'net_weight': _dec(item.quantity.net_weight if item.quantity else None), + 'gross_weight': _dec(item.quantity.gross_weight if item.quantity else None), + 'value_mxn': _dec(item.financial.value_mxn if item.financial else None), + 'value_usd': _dec(item.financial.value_usd if item.financial else None), + } + else: + g = groups[key] + g['quantity'] += _dec(item.quantity.quantity if item.quantity else None) + g['net_weight'] += _dec(item.quantity.net_weight if item.quantity else None) + g['gross_weight'] += _dec(item.quantity.gross_weight if item.quantity else None) + g['value_mxn'] += _dec(item.financial.value_mxn if item.financial else None) + g['value_usd'] += _dec(item.financial.value_usd if item.financial else None) + + ped_str = _format_pedimento() + fecha_str = ped_date.strftime('%d/%m/%Y') if ped_date else '' + + HEADERS = [ + 'FACTURA', 'CLASE', 'DESCRIPCION CLASE', 'CODIGO DE PRODUCTO Y SERVICIO', + 'DESCRIPCION CÓDIGO', 'CANTIDAD', 'UNIDAD DE MEDIDA', 'UNIDAD DE MEDIDA SAT', + 'PESO NETO', 'PESO BRUTO', 'FRACCION', 'VALOR MN', 'VALOR ME', + 'RFC PROVEEDOR', 'RFC ENVIADO A:', 'PEDIMENTO', 'FECHA INICIO PEDIMENTO', + 'MATERIAL PELIGROSO', 'ADUANA', 'TIPO DE MATERIAL', 'DESCRIPCION DE LA MATERIA', + 'TIPO DE DOCUMENTO', 'RFC IMPORTADOR', 'REGIMEN ADUANERO', + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for key in group_order: + g = groups[key] + buf.seek(0); buf.truncate(0) + writer.writerow([ + invoice.invoice_number or '', + g['class_code'], + g['description'], + g['cp_code'], + g['cp_desc'], + g['quantity'], + g['uom'], + g['uom_sat'], + g['net_weight'], + g['gross_weight'], + g['customs_frac'], + g['value_mxn'], + g['value_usd'], + provider_rfc, + shipped_to_rfc, + ped_str, + fecha_str, + 'NO', + aduana_str, + g['tipo_mat'], + g['mat_desc'], + '01', + sold_to_rfc, + regime, + ]) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_carta_porte_consolidada.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_aviso_cruce(db: Session, invoice) -> "StreamingResponse": + """Interfaz Aviso Cruce (OF-7). 4 columnas: descripción, UMC, cantidad, valor USD.""" + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.description), + joinedload(LineItem.quantity), + joinedload(LineItem.financial), + joinedload(LineItem.unit_of_measure_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + HEADERS = ['DESCRIPCIÓN', 'UMC', 'CANTIDAD', 'VALOR DÓLARES'] + + def _item_to_list(item: LineItem): + d = item.description + q = item.quantity + f = item.financial + uom = item.unit_of_measure_info + return [ + _strip(d.description_spanish if d else None), + uom.code if uom else '', + q.quantity if q else '', + f.value_usd if f else '', + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0) + buf.truncate(0) + writer.writerow(_item_to_list(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_aviso_cruce.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def copy_header_only( + db: Session, + invoice_id: int, + tenant_id: int, + company_id: int, + ) -> "models.InvoiceHeader": + """Copia el encabezado sin partidas, zeroeando totales acumulados. + Equivale a COPIA_ENC_A_SCAII del legacy Clarion.""" + original = db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.id == invoice_id, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ).first() + if not original: + raise HTTPException(status_code=404, detail="Factura no encontrada") + + base_num = original.invoice_number or str(invoice_id) + candidate = f"{base_num}-ENC" + count = 1 + while db.query(models.InvoiceHeader).filter( + models.InvoiceHeader.invoice_number == candidate, + models.InvoiceHeader.tenant_id == tenant_id, + models.InvoiceHeader.company_id == company_id, + ).first(): + candidate = f"{base_num}-ENC-{count}" + count += 1 + + EXCLUDE_COLS = { + "id", "invoice_number", "status", "capture_date", "capture_user", + "who_processed", "processed_date", "process_log", "status_rec", + "status_rep", "comments_status", "vu_observations", "cfdi_uuid", + "path_pdf", "path_xml", "created_at", "updated_at", + } + header_data = { + c.name: getattr(original, c.name) + for c in models.InvoiceHeader.__table__.columns + if c.name not in EXCLUDE_COLS + } + header_data["invoice_number"] = candidate + header_data["status"] = models.InvoiceStatus.PENDING + header_data["party_count"] = 0 + new_invoice = models.InvoiceHeader(**header_data) + db.add(new_invoice) + db.flush() + + comp = db.query(models.InvoiceComplianceMx).filter( + models.InvoiceComplianceMx.invoice_id == invoice_id + ).first() + if comp: + EXCLUDE_COMP = { + "id", "invoice_id", "pedimento_id", "pedimento_r1", "pedimento_k1", + "vucem_operation_num", "electronic_signature", "certificate_number", + "niu_number", "code_signature", "edocument", "created_at", "updated_at", + } + comp_data = { + c.name: getattr(comp, c.name) + for c in models.InvoiceComplianceMx.__table__.columns + if c.name not in EXCLUDE_COMP + } + comp_data["invoice_id"] = new_invoice.id + comp_data["tenant_id"] = tenant_id + comp_data["company_id"] = company_id + db.add(models.InvoiceComplianceMx(**comp_data)) + + for log in db.query(models.InvoiceLogistics).filter( + models.InvoiceLogistics.invoice_id == invoice_id + ).all(): + EXCLUDE_LOG = {"id", "invoice_id", "created_at", "updated_at"} + log_data = { + c.name: getattr(log, c.name) + for c in models.InvoiceLogistics.__table__.columns + if c.name not in EXCLUDE_LOG + } + log_data["invoice_id"] = new_invoice.id + log_data["tenant_id"] = tenant_id + log_data["company_id"] = company_id + db.add(models.InvoiceLogistics(**log_data)) + + fin = db.query(models.InvoiceFinancials).filter( + models.InvoiceFinancials.invoice_id == invoice_id + ).first() + if fin: + EXCLUDE_FIN = {"id", "invoice_id", "created_at", "updated_at"} + ZERO_FIN = { + "value_mn", "value_me", "value_mc", + "customs_value_mn", "customs_value_me", + "raw_material_value_mn", "raw_material_value_me", + "aggregate_value_mn", "aggregate_value_me", "aggregate_value_mc", + "mexican_value_mn", "mexican_value_me", "mexican_value_mc", + "national_packaging_mn", "national_packaging_me", "national_packaging_mc", + "iva_mn", "iva_me", "iva_mc", "tax_value_me", + "total_quantity", "total_packages", "bundle_count", + "gross_weight", "net_weight", + } + fin_data = {} + for c in models.InvoiceFinancials.__table__.columns: + if c.name in EXCLUDE_FIN: + continue + fin_data[c.name] = 0 if c.name in ZERO_FIN else getattr(fin, c.name) + fin_data["invoice_id"] = new_invoice.id + fin_data["tenant_id"] = tenant_id + fin_data["company_id"] = company_id + db.add(models.InvoiceFinancials(**fin_data)) + + db.commit() + db.refresh(new_invoice) + return new_invoice + + @staticmethod + def export_aaduanal_rs(db: Session, invoice) -> "StreamingResponse": + """Interfaz AAduanal_RS (OF-10). 16 columnas, headers en inglés.""" + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + def _fmt_frac(frac): + if not frac or len(frac) < 7: + return frac or '' + return f"{frac[0:2]}.{frac[2:4]}.{frac[4:6]}.{frac[6:]}" + + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + ped_num = '' + if pedimento: + ped_num = ( + (pedimento.customs_office or '') + + (pedimento.license or '') + + (pedimento.pedimento_number or '') + ) + + inv_date = invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else '' + edocument = (compliance.edocument or '') if compliance else '' + vu_obs = re.sub(r'[\t\r\n]+', '', invoice.vu_observations or '') if invoice.vu_observations else '' + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.financial), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.part_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + HEADERS = [ + 'indPedNum', 'ihdInvNum', 'ihdInvDate', 'ihdPartNum', 'ihdQty', + 'ihdPartSKU', 'ihdusunitVal', 'ihdustotalval', 'ihdmexwt', 'ihdPartCtryOrig', + 'ihdPgmCode', 'ihdTariffNum', 'partSpanDesc', 'cpartRegla8', 'COVE', 'e-document', + ] + + def _row(item: LineItem): + q = item.quantity + c = item.customs + d = item.description + f = item.financial + uom = item.unit_of_measure_info + part = item.part_info + return [ + ped_num, + invoice.invoice_number or '', + inv_date, + part.part_number if part else '', + q.quantity if q else '', + (uom.code or '')[:2] if uom else '', + f.unit_cost_usd if f else '', + f.value_usd if f else '', + q.gross_weight if q else '', + c.origin_country if c else '', + 'REGLA-8' if (c and c.octave_fraction) else '', + _fmt_frac(c.fraction if c else None), + _strip(d.description_spanish if d else None), + _fmt_frac(c.octave_fraction if c else None), + edocument, + vu_obs, + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0) + buf.truncate(0) + writer.writerow(_row(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_aaduanal_rs.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_caaarem(db: Session, invoice) -> "StreamingResponse": + """Interfaz CAAAREM (OF-9). 47 columnas desnormalizadas: fila 1 = encabezado + partida 1, filas 2+ = 27 vacíos + partida.""" + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.general_catalogs.company.models import Company + + def _strip(text): + if not text: + return '' + return re.sub(r'[\r\n]+', ' ', str(text)).strip() + + def _vinculacion(v): + # Clarion: CASE 0→'0', 1/2→'1' + if not v or v == '0': + return '0' + return '1' + + def _currency_to_country(currency): + # Clarion: USD→'USA', MXP→'MEX', else '' + if currency == 'USD': + return 'USA' + if currency in ('MXP', 'MXN'): + return 'MEX' + return '' + + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + financials = invoice.financials + logistics = invoice.logistics + + company = db.query(Company).filter( + Company.tenant_id == invoice.tenant_id, + Company.id == invoice.company_id, + ).first() + + provider = None + if compliance and compliance.provider_id: + provider = ( + db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter( + ClientProvider.id == compliance.provider_id, + ClientProvider.tenant_id == invoice.tenant_id, + ) + .first() + ) + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.financial), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.part_info), + joinedload(LineItem.series), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + HEADERS = [ + 'TIPO OPERACION', 'CLIENTE', 'PROVEEDOR', 'E-DOCUMENT', 'SUBDIVISION', + 'NUMERO DE FACTURA', 'NUMERO DE EXPORTADOR', 'FECHA FACTURA', 'VALOR TOTAL FACTURA', + 'CERTIFICADO DE ORIGEN', 'MONEDA FACTURACION', 'OBSERVACION', 'VINCULACION FACTURA', + 'INCOTERM', 'PAIS DE FACTURACION', 'PESO TOTAL FACTURA', 'GUIA MASTER', 'GUIA HOUSE', + 'BULTOS TOTAL', 'SEGURO', 'MONEDA SEGURO', 'FLETE', 'MONEDA FLETE', + 'EMBALAJE', 'MONEDA EMBALAJE', 'OTROS', 'MONEDA OTROS', + 'NUMERO DE PARTE', 'FRACCION ARANCELARIA', 'DESCRIPCION PEDIMENTO', 'CANTIDAD', + 'U.M. COMERCIAL', 'VALOR TOTAL', 'CANTIDAD TARIFA', 'U.M. TARIFA', + 'PAIS ORIGEN / DESTINO', 'PAIS COMPRADOR / VENDEDOR', 'VINCULACION', 'VALORACION', + 'PESO', 'BULTOS', 'VALOR AGREGADO', 'MONEDA VALOR AGREGADO', + 'SERIE', 'MARCA', 'MODELO', 'SUBMODELO', + ] + + def _header_vals(): + currency = financials.currency if financials else '' + niu = compliance.niu_number if compliance else '' + is_rail = bool(logistics and logistics.is_rail) + return [ + '1', + compliance.sold_to_header or '' if compliance else '', + compliance.provider_header or '' if compliance else '', + compliance.edocument or '' if compliance else '', + '1' if (compliance and compliance.subdivision) else '0', + invoice.invoice_number or '', + company.manufacturer_id or '' if company else '', + invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else '', + financials.value_me if financials else '', + '1' if (compliance and compliance.acts_as) else '0', + currency, + _strip(invoice.vu_observations), + _vinculacion(provider.linking if provider else None), + logistics.incoterm or '' if logistics else '', + _currency_to_country(currency), + financials.gross_weight if financials else '', + niu if is_rail else '', + niu if is_rail else '', + financials.total_packages if financials else '', + financials.insurance if financials else '', + currency, + financials.freight if financials else '', + currency, + financials.packaging if financials else '', + currency, + financials.other_increments if financials else '', + currency, + ] + + def _item_vals(item: LineItem): + q = item.quantity + c = item.customs + d = item.description + f = item.financial + uom = item.unit_of_measure_info + part = item.part_info + first_serie = item.series[0] if item.series else None + return [ + _strip(part.part_number if part else ''), + c.fraction if c else '', + _strip(d.description_spanish if d else ''), + q.quantity if q else '', + uom.customs_code if uom else '', + f.value_usd if f else '', + q.net_weight if q else '', # CANTIDAD TARIFA simplificado (caso UMClave=1) + uom.customs_code if uom else '', # U.M. TARIFA simplificado + c.origin_country if c else '', + provider.address.country if (provider and provider.address) else '', + _vinculacion(provider.linking if provider else None), + item.valuation_method or '', + q.net_weight if q else '', + q.package_quantity if q else '', + '0', + 'USD', + first_serie.serial_numbers if first_serie else 'S/S', + d.brand or 'S/M' if d else 'S/M', + d.model or 'S/M' if d else 'S/M', + first_serie.sub_model if (first_serie and first_serie.sub_model) else '', + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for idx, item in enumerate(items): + buf.seek(0) + buf.truncate(0) + if idx == 0: + writer.writerow(_header_vals() + _item_vals(item)) + else: + writer.writerow([''] * 27 + _item_vals(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_caaarem.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @staticmethod + def export_cp_genesis(db: Session, invoice) -> "StreamingResponse": + """Interfaz Carta Porte Genesis (OF-11). 31 columnas, una fila por partida.""" + import io + import csv + import re + from fastapi.responses import StreamingResponse + from sqlalchemy.orm import joinedload + from api.v1.modules.a76.invoices.models import InvoiceComplianceMx + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.classes.models import Class + + UOM_MAP = { + 'PZA': 'H87', 'KGS': 'KGM', 'MT': 'MTR', 'LT': 'LTR', + 'JGO': 'XKI', 'LB': 'LBR', 'GAL': 'GLL', 'FT': 'LF', + } + + def _uom_to_sat(code): + return UOM_MAP.get(code or '', '') + + def _rfc_or_taxid(cp): + # Clarion: TIPOEXTNAC N→RFC, E→TAXID truncado a 9 chars + if not cp: + return '' + if cp.type_nat_foreign == 'E': + return (cp.rfc or '')[:9] + return cp.rfc or '' + + def _proceso(prov): + # Dirección de tráfico según ciudad del proveedor + if not prov or not prov.address: + return '' + city = (prov.address.city or '').upper() + if 'JUAREZ' in city: + return 'EXPORTACION' + if 'EL PASO' in city or 'ELPASO' in city: + return 'IMPORTACION' + return '' + + def _ped_map(ped): + # (IDocAdu, ClaveTM, DescripMP, CDocAdu) + if not ped: + return ('', '', '', '') + MAP = { + 'AF': ('ITR', '05', 'Otra', '18'), + 'V1': ('ITR', '05', 'Otra', '18'), + 'A1': ('IMD', '05', 'Otra', '01'), + 'A3': ('IMD', '05', 'Otra', '01'), + } + return MAP.get(ped.pedimento_code or '', ('', '', '', '')) + + def _fmt_ped(ped): + if not ped: + return '' + y = str(ped.year or '').zfill(2) + co = (ped.customs_office or '')[:2] + lic = (ped.license or '').zfill(4) + num = (ped.pedimento_number or '').zfill(7) + return f"{y} {co} {lic} {num}" + + compliance = ( + db.query(InvoiceComplianceMx) + .filter(InvoiceComplianceMx.invoice_id == invoice.id) + .first() + ) + financials = invoice.financials + + company = db.query(Company).filter( + Company.tenant_id == invoice.tenant_id, + Company.id == invoice.company_id, + ).first() + + provider = None + if compliance and compliance.provider_id: + provider = ( + db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter( + ClientProvider.id == compliance.provider_id, + ClientProvider.tenant_id == invoice.tenant_id, + ) + .first() + ) + + shipped_to = None + if compliance and compliance.shipped_to_id: + shipped_to = ( + db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter( + ClientProvider.id == compliance.shipped_to_id, + ClientProvider.tenant_id == invoice.tenant_id, + ) + .first() + ) + + pedimento = None + if compliance and compliance.pedimento_id: + pedimento = db.query(Pedimentos).filter(Pedimentos.id == compliance.pedimento_id).first() + + items = ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.financial), + joinedload(LineItem.class_info).joinedload(Class.unit_of_measure_info), + ) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .order_by(LineItem.line_number) + .all() + ) + + HEADERS = [ + 'ORIGEN', 'NombreRemitente', 'RFC o NumRegIdtrib Remitente', 'ResidenciaFiscal Remitente', + 'DESTINO', 'NombreDestinatario', 'RFC o NumRegIdtrib Destinatario', 'ResidenciaFiscal Destinatario', + 'BienesTransp', 'Descripcion', 'Cantidad', 'ClaveUnidad', 'Unidad', + 'MaterialPeligroso', 'CveMaterialPeligroso', 'Embalaje', 'DescripEmbalaje', + 'PesoEnKg', 'ValorMercancia', 'Moneda', 'TranspInternac', + 'FraccionArancelaria', 'UUIDComercioExt', 'RegimenAduanero', 'TipoMateria', + 'DescripcionMateria', 'TipoDocumento', 'Numero de Pedimento', 'IdentDocAduanero', + 'RFCImpo', 'PaisOrigenDestino', + ] + + ped_fmt = _fmt_ped(pedimento) + idoc, clave_tm, descrip_mp, cdoc = _ped_map(pedimento) + currency = financials.currency if financials else '' + proceso = _proceso(provider) + + def _row(item: LineItem): + q = item.quantity + c = item.customs + f = item.financial + ci = item.class_info + uom_info = ci.unit_of_measure_info if ci else None + val_mer = f.value_mc if (f and currency == 'USD') else '' + return [ + provider.address.city if (provider and provider.address) else '', + provider.name if provider else '', + _rfc_or_taxid(provider), + 'USA', + shipped_to.address.city if (shipped_to and shipped_to.address) else '', + shipped_to.name if shipped_to else '', + _rfc_or_taxid(shipped_to), + 'MEX', + c.fraction if c else '', + ci.description_es if ci else '', + q.quantity if q else '', + _uom_to_sat(ci.unit_of_measure if ci else ''), + uom_info.description if uom_info else '', + '', '', # MaterialPeligroso, CveMaterialPeligroso + '', '', # Embalaje, DescripEmbalaje + q.net_weight if q else '', + val_mer, + currency, + proceso, + ci.fraction if ci else '', + '', # UUIDComercioExt + idoc, + clave_tm, + descrip_mp, + cdoc, + ped_fmt, + ped_fmt, + company.rfc if company else '', + provider.address.country if (provider and provider.address) else '', + ] + + def generate(): + buf = io.StringIO() + writer = csv.writer(buf, delimiter=',', lineterminator='\r\n') + writer.writerow(HEADERS) + yield buf.getvalue() + for item in items: + buf.seek(0) + buf.truncate(0) + writer.writerow(_row(item)) + yield buf.getvalue() + + safe_num = re.sub(r'[^\w\-]', '_', invoice.invoice_number or 'factura') + filename = f"{safe_num}_cp_genesis.csv" + return StreamingResponse( + generate(), + media_type='text/csv; charset=utf-8', + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2b326129..0001d2f2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -44,6 +44,7 @@ pylint==4.0.2 # reportes Jinja2==3.1.6 pdfkit==1.0.0 +openpyxl==3.1.5 # Desarrollo en seguno plano celery==5.3.6 diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index b37dc8a5..5c9d9fa0 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -596,5 +596,50 @@ export const invoicesApi = { return api.getBlob( `/v1/a76/factura-cove/invoices/${invoiceId}/cove/acuse?${params.toString()}` ); + }, + + copyInvoice: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.post(`/v1/a76/invoices/${invoiceId}/copy?${params.toString()}`, {}); + }, + copyInvoiceHeader: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.post(`/v1/a76/invoices/${invoiceId}/copy-header?${params.toString()}`, {}); + }, + exportItems: (invoiceId: number, companyId: number, format: 'csv' | 'xlsx' | 'txt'): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString(), format }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/export?${params.toString()}`); + }, + downloadGmTransport: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/gm-transport?${params.toString()}`); + }, + downloadCartaPorte: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/carta-porte?${params.toString()}`); + }, + downloadTfc: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/tfc?${params.toString()}`); + }, + downloadCartaPorteConsolidada: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/carta-porte-consolidada?${params.toString()}`); + }, + downloadAvisoCruce: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/aviso-cruce?${params.toString()}`); + }, + downloadAaduanalRs: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/aaduanal-rs?${params.toString()}`); + }, + downloadCaaarem: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/caaarem?${params.toString()}`); + }, + downloadCpGenesis: (invoiceId: number, companyId: number): Promise => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + return api.getBlob(`/v1/a76/invoices/${invoiceId}/interfaces/cp-genesis?${params.toString()}`); } }; diff --git a/frontend/src/lib/components/dashboard/invoices/data-table.svelte b/frontend/src/lib/components/dashboard/invoices/data-table.svelte index b1e955ab..c47fa68c 100644 --- a/frontend/src/lib/components/dashboard/invoices/data-table.svelte +++ b/frontend/src/lib/components/dashboard/invoices/data-table.svelte @@ -14,6 +14,7 @@ // Props para selección selectedIds?: number[]; onRowClick?: (row: TData) => void; + onContextMenu?: (event: MouseEvent, row: TData) => void; compact?: boolean; sorting?: import("@tanstack/table-core").SortingState; onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void; @@ -27,6 +28,7 @@ loadMore, selectedIds = [], onRowClick, + onContextMenu, compact = false, sorting = [], onSortingChange @@ -211,6 +213,7 @@ class="group/inv-list cursor-pointer {row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row'}" onclick={() => onRowClick && onRowClick(row.original)} ondblclick={() => handleRowDoubleClick(row)} + oncontextmenu={(e) => { e.preventDefault(); onContextMenu?.(e, row.original); }} > {#each visibleCells as cell (cell.id)} {@const colId = cell.column.id} diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte new file mode 100644 index 00000000..896255f5 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-content.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte new file mode 100644 index 00000000..a332ef06 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte new file mode 100644 index 00000000..9b08f025 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte new file mode 100644 index 00000000..f8072cf9 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-label.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte new file mode 100644 index 00000000..e42e9694 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-root.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte new file mode 100644 index 00000000..e5fbb873 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte new file mode 100644 index 00000000..5db5aa4d --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte new file mode 100644 index 00000000..4668f403 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub-trigger.svelte @@ -0,0 +1,27 @@ + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte new file mode 100644 index 00000000..b8c349b2 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte b/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte new file mode 100644 index 00000000..5b435d59 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/context-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/lib/components/ui/context-menu/index.ts b/frontend/src/lib/components/ui/context-menu/index.ts new file mode 100644 index 00000000..4ac11666 --- /dev/null +++ b/frontend/src/lib/components/ui/context-menu/index.ts @@ -0,0 +1,31 @@ +import { ContextMenu as ContextMenuPrimitive } from "bits-ui"; +import Content from "./context-menu-content.svelte"; +import Group from "./context-menu-group.svelte"; +import Item from "./context-menu-item.svelte"; +import Label from "./context-menu-label.svelte"; +import Separator from "./context-menu-separator.svelte"; +import Sub from "./context-menu-sub.svelte"; +import SubContent from "./context-menu-sub-content.svelte"; +import SubTrigger from "./context-menu-sub-trigger.svelte"; +import Trigger from "./context-menu-trigger.svelte"; +import Root from "./context-menu-root.svelte"; + +const CheckboxItem = ContextMenuPrimitive.CheckboxItem; +const RadioGroup = ContextMenuPrimitive.RadioGroup; +const RadioItem = ContextMenuPrimitive.RadioItem; + +export { + Root, + Trigger, + Content, + Item, + Label, + Separator, + Group, + Sub, + SubTrigger, + SubContent, + CheckboxItem, + RadioGroup, + RadioItem, +}; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 49913591..fa5ca177 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -20,6 +20,7 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import * as RadioGroup from '$lib/components/ui/radio-group'; import type { PageData } from './$types'; import { browser } from '$app/environment'; import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser'; @@ -54,7 +55,14 @@ ArrowRightLeft, Database, ChevronUp, - Mail + Mail, + Copy, + Clipboard, + FileOutput, + Truck, + Route, + FileSpreadsheet, + Building2 } from 'lucide-svelte'; import DetailsDialog from '$lib/components/dashboard/invoices/details-dialog.svelte'; import DeleteDialog from '$lib/components/dashboard/invoices/delete-dialog.svelte'; @@ -95,6 +103,9 @@ let isDownloadModalOpen = $state(false); let isTransferenciaModalOpen = $state(false); let isRevertConfirmOpen = $state(false); + let exportItemsDialogOpen = $state(false); + let exportItemsInvoice = $state(null); + let exportItemsFormat = $state<'csv' | 'xlsx' | 'txt'>('csv'); // Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL $effect(() => { @@ -241,17 +252,27 @@ // Estado para selección de filas (múltiple) let selectedInvoiceIds = $state([]); + + // Estado para menú contextual (clic derecho en fila) + let contextMenuOpen = $state(false); + let contextMenuPosition = $state({ x: 0, y: 0 }); + let contextMenuInvoice = $state(null); + let contextMenuEl = $state(null); // Estado para los diálogos de acciones let showDetailsDialog = $state(false); let showDeleteDialog = $state(false); let reportesMenuOpen = $state(false); let masAccionesMenuOpen = $state(false); + let copiasInterfacesMenuOpen = $state(false); $effect(() => { - if (reportesMenuOpen) masAccionesMenuOpen = false; + if (reportesMenuOpen) { masAccionesMenuOpen = false; copiasInterfacesMenuOpen = false; } }); $effect(() => { - if (masAccionesMenuOpen) reportesMenuOpen = false; + if (masAccionesMenuOpen) { reportesMenuOpen = false; copiasInterfacesMenuOpen = false; } + }); + $effect(() => { + if (copiasInterfacesMenuOpen) { reportesMenuOpen = false; masAccionesMenuOpen = false; } }); function handleRowClick(invoice: Invoice) { @@ -265,6 +286,72 @@ } } + async function handleRowContextMenu(event: MouseEvent, invoice: Invoice) { + if (!selectedInvoiceIds.includes(invoice.id)) { + selectedInvoiceIds = [invoice.id]; + } + contextMenuInvoice = invoice; + contextMenuPosition = { x: event.clientX, y: event.clientY }; + contextMenuOpen = true; + await tick(); + if (contextMenuEl) { + const { innerWidth, innerHeight } = window; + const rect = contextMenuEl.getBoundingClientRect(); + let { x, y } = contextMenuPosition; + if (x + rect.width > innerWidth) x = innerWidth - rect.width - 8; + if (y + rect.height > innerHeight) y = innerHeight - rect.height - 8; + if (x < 8) x = 8; + if (y < 8) y = 8; + contextMenuPosition = { x, y }; + } + } + + async function handleCopyToClipboard(invoice: Invoice, fromContextMenu = true) { + const parts = [ + invoice.invoice_number, + invoice.invoice_date, + invoice.operation_type?.toUpperCase(), + invoice.compliance_mx?.pedimento?.pedimento_number + ].filter(Boolean); + await navigator.clipboard.writeText(parts.join(' | ')); + toast.success('Factura copiada al portapapeles'); + if (fromContextMenu) contextMenuOpen = false; + } + + function openExportItemsDialog(inv: Invoice) { + exportItemsInvoice = inv; + exportItemsFormat = 'csv'; + exportItemsDialogOpen = true; + } + + function handleExportItemsConfirm() { + if (!exportItemsInvoice) return; + const inv = exportItemsInvoice; + const ext = exportItemsFormat; + downloadBlob( + invoicesApi.exportItems(inv.id, companyStore.activeCompany!.id, ext), + `${inv.invoice_number ?? 'factura'}_export.${ext}` + ); + exportItemsDialogOpen = false; + exportItemsInvoice = null; + } + + async function downloadBlob(blobPromise: Promise, filename: string) { + try { + const blob = await blobPromise; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + toast.error('Error al descargar el archivo'); + } + } + const selectedInvoice = $derived( selectedInvoiceIds.length === 1 ? allItems.find((i) => i.id === selectedInvoiceIds[0]) : null ); @@ -1614,6 +1701,7 @@ {loadMore} selectedIds={selectedInvoiceIds} onRowClick={handleRowClick} + onContextMenu={handleRowContextMenu} {sorting} onSortingChange={(newSorting) => (sorting = newSorting)} /> @@ -1810,6 +1898,121 @@ + {#if contextMenuOpen && contextMenuInvoice} + + + +
+

{contextMenuInvoice.invoice_number ?? 'Factura'}

+
+ {#snippet cmItem(icon: any, label: string, action: () => void)} + {@const Icon = icon} + + {/snippet} + {@render cmItem(Copy, 'Copiar Factura', () => { + invoicesApi.copyInvoice(contextMenuInvoice!.id, companyStore.activeCompany!.id) + .then((res) => { + if (res.error) { toast.error(res.error); return; } + toast.success('Factura copiada'); + reloadData(); + }); + })} + {@render cmItem(Download, 'Exportar Partidas', () => { + openExportItemsDialog(contextMenuInvoice!); + })} + {@render cmItem(ArrowRightLeft, 'Copiar Encabezado a SCAII', () => { + const invId = contextMenuInvoice!.id; + invoicesApi.copyInvoiceHeader(invId, companyStore.activeCompany!.id) + .then((res) => { + if (res.error) { toast.error(res.error); return; } + toast.success('Encabezado copiado'); + reloadData(); + }); + })} +
+ {@render cmItem(Route, 'Interfaz Carta Porte', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadCartaPorte(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_carta_porte.csv` + ); + })} + {@render cmItem(Truck, 'Interfaz GM Transport', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadGmTransport(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_gm_transport.csv` + ); + })} + {@render cmItem(Truck, 'Interfaz TFC', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadTfc(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_tfc.csv` + ); + })} + {@render cmItem(FileText, 'Interfaz Aviso Traslado', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadAvisoCruce(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_aviso_cruce.csv` + ); + })} + {@render cmItem(Route, 'Interfaz Carta Porte Consolidada', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadCartaPorteConsolidada(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_carta_porte_consolidada.csv` + ); + })} + {@render cmItem(Building2, 'Interfaz CAAAREM', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadCaaarem(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_caaarem.csv` + ); + })} + {@render cmItem(ArrowRightLeft, 'Interfaz AAduanal_RS', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadAaduanalRs(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_aaduanal_rs.csv` + ); + })} + {@render cmItem(Route, 'Interfaz Carta Porte Genesis', () => { + const inv = contextMenuInvoice!; + downloadBlob( + invoicesApi.downloadCpGenesis(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_cp_genesis.csv` + ); + })} +
+ {@render cmItem(Clipboard, 'Copiar Factura (Portapapeles)', () => handleCopyToClipboard(contextMenuInvoice!))} +
+ {/if} + {#if showVuSubmenu} + {/snippet} + + + + Copias + { + if (!selectedInvoice) return; + const invId = selectedInvoice.id; + copiasInterfacesMenuOpen = false; + invoicesApi.copyInvoice(invId, companyStore.activeCompany!.id) + .then((res) => { + if (res.error) { toast.error(res.error); return; } + toast.success('Factura copiada'); + reloadData(); + }); + }} + > + + Copiar Factura + + { + if (!selectedInvoice) return; + copiasInterfacesMenuOpen = false; + openExportItemsDialog(selectedInvoice); + }} + > + + Exportar Partidas + + { + if (!selectedInvoice) return; + const invId = selectedInvoice.id; + copiasInterfacesMenuOpen = false; + invoicesApi.copyInvoiceHeader(invId, companyStore.activeCompany!.id) + .then((res) => { + if (res.error) { toast.error(res.error); return; } + toast.success('Encabezado copiado'); + reloadData(); + }); + }} + > + + Copiar Encabezado a SCAII + + { copiasInterfacesMenuOpen = false; if (selectedInvoice) handleCopyToClipboard(selectedInvoice, false); }} + > + + Copiar Factura (Portapapeles) + + + + + Interfaces + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadCartaPorte(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_carta_porte.csv` + ); + }}> + + Carta Porte + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadGmTransport(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_gm_transport.csv` + ); + }}> + + GM Transport + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadTfc(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_tfc.csv` + ); + }}> + + TFC + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadAvisoCruce(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_aviso_cruce.csv` + ); + }}> + + Aviso Traslado + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadCartaPorteConsolidada(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_carta_porte_consolidada.csv` + ); + }}> + + Carta Porte Consolidada + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadCaaarem(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_caaarem.csv` + ); + }}> + + CAAAREM + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadAaduanalRs(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_aaduanal_rs.csv` + ); + }}> + + AAduanal_RS + + { + if (!selectedInvoice) return; + const inv = selectedInvoice; + copiasInterfacesMenuOpen = false; + downloadBlob( + invoicesApi.downloadCpGenesis(inv.id, companyStore.activeCompany!.id), + `${inv.invoice_number ?? 'factura'}_cp_genesis.csv` + ); + }}> + + Carta Porte Genesis + + + + +
{/if} @@ -2166,6 +2549,53 @@ {/if} + + + + Exportar a + + Selecciona el formato para + {exportItemsInvoice?.invoice_number ?? 'la factura'}. + + +
+ (exportItemsFormat = v as 'csv' | 'xlsx' | 'txt')} + class="flex flex-col gap-3" + > +
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ {#if showDetailsDialog && selectedInvoice} (showDetailsDialog = false)} /> {/if}