Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/generacion-xml-nuevos

This commit is contained in:
2026-06-02 09:11:34 -05:00
5 changed files with 384 additions and 30 deletions

View File

@@ -2,17 +2,27 @@
Rutas para gestión de firmas
"""
import mimetypes
import os
from datetime import datetime
from typing import Any, Dict
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
from fastapi import Depends, HTTPException, Query, status
from fastapi import Depends, File, HTTPException, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.config import settings
from core.database import get_core_db
from core.s3_keys import signature_photo_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
from .dto import SignatureCreateDTO, SignatureResponseDTO, SignatureUpdateDTO
from .service import SignatureService
# Constantes
ALLOWED_PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_PHOTO_SIZE = 5 * 1024 * 1024 # 5 MB
# Create router using TenantCRUDRoutes factory
signature_crud = TenantCRUDRoutes(
service=SignatureService,
@@ -81,3 +91,119 @@ async def delete_signature(
detail="Signature not found",
)
return None
@router.post(
"/{signature_id}/upload-photo",
response_model=dict,
summary="Upload signature photo",
)
async def upload_signature_photo(
signature_id: int,
company_id: int = Query(..., description="Company ID"),
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency),
):
"""
Sube la foto de la firma electrónica a MinIO/S3 y actualiza photo_path en la firma.
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp — máx 5 MB.
"""
tenant_id = validate_access_to_resource(
db, company_id, current_user, ["cat_signatures.edit"]
)
# Verificar que la firma existe
sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id)
if not sig:
raise HTTPException(status_code=404, detail="Signature not found")
# Validar extensión
file_ext = os.path.splitext(file.filename or "")[1].lower()
if file_ext not in ALLOWED_PHOTO_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Tipo de archivo no permitido. Permitidos: {', '.join(ALLOWED_PHOTO_EXTENSIONS)}",
)
# Leer y validar tamaño
content = await file.read()
if len(content) > MAX_PHOTO_SIZE:
raise HTTPException(
status_code=400,
detail=f"Archivo demasiado grande. Máximo: {MAX_PHOTO_SIZE // 1024 // 1024} MB",
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
try:
if settings.use_s3_object_storage:
# Eliminar foto anterior si existe en S3
if sig.photo_path and sig.photo_path.startswith("tenants/"):
delete_object_if_exists(sig.photo_path)
key = signature_photo_key(tenant_id, company_id, signature_id, timestamp, file_ext)
ct = mimetypes.guess_type(file.filename or "photo")[0] or "image/jpeg"
put_object_bytes(key, content, content_type=ct)
file_path = key
else:
# Fallback: almacenamiento local
upload_dir = os.path.join("uploads", "signatures", str(signature_id))
os.makedirs(upload_dir, exist_ok=True)
filename = f"photo_{timestamp}{file_ext}"
file_path = os.path.join(upload_dir, filename)
with open(file_path, "wb") as f:
f.write(content)
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Error al guardar la foto: {str(exc)}",
) from exc
# Actualizar photo_path en la firma
from .dto import SignatureUpdateDTO as UpdateDTO
SignatureService.update(
db, signature_id, tenant_id, UpdateDTO(photo_path=file_path), company_id
)
return {
"message": "Foto subida correctamente",
"photo_path": file_path,
"signature_id": signature_id,
}
@router.get(
"/{signature_id}/photo",
summary="Get signature photo image",
)
async def get_signature_photo(
signature_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency),
):
"""Devuelve los bytes de la foto de la firma desde MinIO/S3 para preview."""
tenant_id = validate_access_to_resource(
db, company_id, current_user, ["cat_signatures.view"]
)
sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id)
if not sig or not sig.photo_path:
raise HTTPException(status_code=404, detail="Foto no encontrada")
if sig.photo_path.startswith("tenants/"):
try:
data = get_object_bytes(sig.photo_path)
except Exception:
raise HTTPException(status_code=404, detail="Foto no encontrada en el almacenamiento")
media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg"
return Response(content=data, media_type=media_type)
# Fallback: archivo local
if not os.path.isfile(sig.photo_path):
raise HTTPException(status_code=404, detail="Foto no encontrada en el sistema de archivos")
with open(sig.photo_path, "rb") as f:
data = f.read()
media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg"
return Response(content=data, media_type=media_type)

View File

@@ -32,8 +32,8 @@ def list_customs_sections(
search_filter = f"%{search}%"
query = query.filter(
or_(
CustomsSection.code.ilike(search_filter),
CustomsSection.description.ilike(search_filter)
CustomsSection.customs_code.ilike(search_filter),
CustomsSection.section_name.ilike(search_filter)
)
)
items = query.offset(skip).limit(page_size).all()

View File

@@ -396,6 +396,26 @@ def help_public_api_path(relative_under_help: str) -> str:
return f"/api/v1/core/help-center/files/{rel}"
def signature_photo_key(
tenant_id: Union[int, str],
company_id: int,
signature_id: int,
timestamp: str,
file_ext: str,
) -> str:
"""
Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``.
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp
"""
sid = _segment(signature_id, "signature_id")
ts = _segment(timestamp, "timestamp")
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
if ext not in allowed:
raise ValueError(f"signature photo extension not allowed: {ext}")
return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}"
def system_help_object_key(relative_path: str) -> str:
"""
Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...).

View File

@@ -92,5 +92,76 @@ export const signaturesApi = {
get: (id: number, companyId: number) => getSignature(id, companyId),
create: (data: SignatureCreate, companyId: number) => createSignature(data, companyId),
update: (id: number, data: SignatureUpdate, companyId: number) => updateSignature(id, data, companyId),
delete: (id: number, companyId: number) => deleteSignature(id, companyId)
delete: (id: number, companyId: number) => deleteSignature(id, companyId),
uploadPhoto: (id: number, file: File, companyId: number) => uploadSignaturePhoto(id, file, companyId),
getPhotoUrl: (id: number, companyId: number) => getSignaturePhotoUrl(id, companyId),
};
export interface SignaturePhotoUploadResponse {
message: string;
photo_path: string;
signature_id: number;
}
/**
* Sube la foto de la firma a MinIO vía multipart/form-data.
* Usa fetch directo porque api.post() siempre JSON.stringify el body,
* lo que destruye el FormData. Replica la auth del cliente api.ts.
*/
export async function uploadSignaturePhoto(
signatureId: number,
file: File,
companyId: number
): Promise<ApiResponse<SignaturePhotoUploadResponse>> {
const { getToken } = await import('$lib/auth');
const { browser } = await import('$app/environment');
const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '');
const token = getToken();
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
// Replicar X-Tenant-Override igual que fetchApi / buildAuthHeaders
if (browser) {
const tenantPub = document.cookie
.split('; ')
.find((c) => c.startsWith('sso_tenant_pub='))
?.split('=')[1];
if (tenantPub) headers['X-Tenant-Override'] = tenantPub;
const activeSystem = document.cookie
.split('; ')
.find((c) => c.startsWith('active_system='))
?.split('=')[1];
if (activeSystem) headers['X-Active-System'] = activeSystem;
}
try {
const res = await fetch(
`${apiBase}/v1/a76/signatures/${signatureId}/upload-photo?company_id=${companyId}`,
{ method: 'POST', headers, credentials: 'include', body: formData }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return { error: data.detail || data.message || 'Error al subir la foto', status: res.status };
}
return { data: data as SignaturePhotoUploadResponse, status: res.status };
} catch {
return { error: 'Error de red al subir la foto', status: 0 };
}
}
/**
* Devuelve la URL del endpoint de foto para usar en <img src=...>.
* El backend sirve los bytes directamente desde MinIO con autenticación Bearer.
* NOTA: Esta URL no es pública, el componente debe agregarla via fetch+blob o
* usar el patrón de token en query param si se necesita en <img>.
*/
export function getSignaturePhotoUrl(signatureId: number, companyId: number): string {
return `/api/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`;
}

View File

@@ -3,14 +3,16 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea'; // Para que quepa más texto en la firma
// 👇 Verifica tu ruta de importación
import { Textarea } from '$lib/components/ui/textarea';
import { ImagePlus, Trash2, Loader2 } from 'lucide-svelte';
import {
createSignature,
updateSignature,
uploadSignaturePhoto,
type Signature
} from '$lib/api/dashboard/a76/general_catalogs/signatures';
import { companyStore } from '$lib/stores/company.svelte';
import { getToken } from '$lib/auth';
import { obtenerAtajosFormularioFirmas } from '$lib/config/shortcuts/dashboard/general_catalogs/signatures/edit';
let {
@@ -23,8 +25,6 @@
onSuccess?: () => void;
} = $props();
// Atajos
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Firma' : 'Nueva Firma');
@@ -38,6 +38,12 @@
let loading = $state(false);
let error = $state<string | null>(null);
// Estado de la foto
let selectedFile = $state<File | null>(null);
let previewUrl = $state<string | null>(null);
let photoLoading = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
// Cargar datos al abrir
$effect(() => {
if (open) {
@@ -47,17 +53,87 @@
signature: item.signature || '',
photo_path: item.photo_path || ''
};
// Cargar preview si hay foto
if (item.photo_path && item.id) {
loadPhotoPreview(item.id);
} else {
previewUrl = null;
}
} else {
formData = {
code: '',
signature: '',
photo_path: ''
};
formData = { code: '', signature: '', photo_path: '' };
previewUrl = null;
}
selectedFile = null;
error = null;
} else {
// Al cerrar, limpiar la blob URL para liberar memoria
if (previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
previewUrl = null;
selectedFile = null;
}
});
/**
* Carga la imagen desde el backend (autenticado) y crea una blob URL para el <img>.
* Usa la misma base URL y headers que el cliente api.ts.
*/
async function loadPhotoPreview(signatureId: number) {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
photoLoading = true;
try {
const token = getToken();
// Misma URL base que usa fetchApi en api.ts
const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '');
const url = `${apiBase}/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
// Agregar X-Tenant-Override si existe (igual que buildAuthHeaders)
const tenantPub = document.cookie
.split('; ')
.find((c) => c.startsWith('sso_tenant_pub='))
?.split('=')[1];
if (tenantPub) headers['X-Tenant-Override'] = tenantPub;
const res = await fetch(url, { headers, credentials: 'include' });
if (!res.ok) {
previewUrl = null;
return;
}
const blob = await res.blob();
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
} catch {
previewUrl = null;
} finally {
photoLoading = false;
}
}
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0] ?? null;
selectedFile = file;
if (file) {
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(file);
}
}
function clearPhoto() {
selectedFile = null;
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
previewUrl = null;
formData.photo_path = '';
if (fileInput) fileInput.value = '';
}
async function handleSubmit() {
loading = true;
error = null;
@@ -65,11 +141,8 @@
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('No hay una compañía seleccionada');
// Validaciones
if (!formData.code.trim()) throw new Error('El código es requerido');
// Preparar datos
const dataToSend = {
code: formData.code.trim(),
signature: formData.signature.trim() || null,
@@ -77,12 +150,26 @@
};
let response;
let savedSignatureId: number | undefined;
// 👇 companyId por fuera (Argumento separado)
if (isEdit && item) {
// Primero actualizar los campos de texto
response = await updateSignature(item.id, dataToSend, companyId);
savedSignatureId = item.id;
} else {
// Crear la firma (sin foto aún)
response = await createSignature(dataToSend, companyId);
savedSignatureId = (response as Signature)?.id;
}
// Si hay un archivo nuevo seleccionado, subirlo ahora que tenemos el ID
if (selectedFile && savedSignatureId) {
const uploadResult = await uploadSignaturePhoto(savedSignatureId, selectedFile, companyId);
if (uploadResult.error) {
// La firma se guardó pero la foto falló: informar sin bloquear
error = `Firma guardada, pero hubo un error al subir la foto: ${uploadResult.error}`;
// Aún así cerrar y recargar
}
}
open = false;
@@ -115,9 +202,9 @@
{/if}
<div class="grid gap-4">
<!-- Código -->
<div class="grid grid-cols-4 items-center gap-4">
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label
>
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label>
<div class="col-span-3">
<Input
id="code"
@@ -127,10 +214,11 @@
disabled={loading || isEdit}
required
/>
<p class="text-[10px] text-muted-foreground mt-1">Máximo 10 caracteres.</p>
<p class="mt-1 text-[10px] text-muted-foreground">Máximo 10 caracteres.</p>
</div>
</div>
<!-- Firma / Nombre -->
<div class="grid grid-cols-4 items-center gap-4">
<Label for="signature" class="text-right">Firma / Nombre</Label>
<div class="col-span-3">
@@ -144,17 +232,66 @@
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="photo_path" class="text-right">Ruta Foto</Label>
<div class="col-span-3">
<Input
id="photo_path"
bind:value={formData.photo_path}
placeholder="Ej: /uploads/firmas/juan.png"
maxlength={1000}
<!-- Foto de firma -->
<div class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right">Foto</Label>
<div class="col-span-3 flex flex-col gap-2">
<!-- Preview -->
{#if photoLoading}
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if previewUrl}
<div class="relative w-fit">
<img
src={previewUrl}
alt="Vista previa de firma"
class="h-28 max-w-full rounded-md border object-contain shadow-sm"
/>
{#if !loading}
<button
type="button"
onclick={clearPhoto}
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow"
title="Eliminar foto"
>
<Trash2 class="h-3 w-3" />
</button>
{/if}
</div>
{:else}
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30 text-muted-foreground">
<span class="text-xs">Sin foto</span>
</div>
{/if}
<!-- Selector de archivo -->
<input
bind:this={fileInput}
type="file"
id="photo_file"
accept=".jpg,.jpeg,.png,.gif,.webp"
class="hidden"
onchange={handleFileChange}
disabled={loading}
/>
<p class="text-[10px] text-muted-foreground mt-1">Ruta del archivo (Texto).</p>
<Button
type="button"
variant="outline"
size="sm"
class="w-fit"
onclick={() => fileInput?.click()}
disabled={loading}
>
<ImagePlus class="mr-2 h-4 w-4" />
{previewUrl ? 'Cambiar foto' : 'Seleccionar foto'}
</Button>
<p class="text-[10px] text-muted-foreground">
JPG, PNG, GIF o WebP — máx. 5 MB.
{#if !isEdit}
La foto se sube al guardar.
{/if}
</p>
</div>
</div>
</div>