feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)
- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido" (catálogo medio_contacto). Backend leads.preferred_contact_method + migración f0a1b2c3d4e5 reversible. - Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al host interno minio:9000. RelatedManager.openDoc usa blob→objectURL. Suite backend en verde (109). svelte-check sin errores nuevos. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Medio de contacto preferido en el prospecto (lead)
|
||||
|
||||
Revision ID: f0a1b2c3d4e5
|
||||
Revises: e4f5a6b7c8d9
|
||||
Create Date: 2026-08-07 01:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f0a1b2c3d4e5"
|
||||
down_revision: Union[str, None] = "e4f5a6b7c8d9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)
|
||||
@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
status: str = Field("new", max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
|
||||
phone: str | None
|
||||
company_name: str | None
|
||||
source: str | None
|
||||
preferred_contact_method: str | None = None
|
||||
status: str
|
||||
estimated_value: Decimal | None
|
||||
owner_user_id: str | None
|
||||
|
||||
@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
|
||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Origen: web | referido | evento | llamada | email | otro
|
||||
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
|
||||
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Estado: new | contacted | qualified | unqualified | converted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
|
||||
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
|
||||
@@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
||||
|
||||
from core.security import get_current_user
|
||||
from core.storage_s3 import presigned_get_url, put_object_bytes
|
||||
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -61,3 +61,29 @@ def get_upload_url(
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
return {"url": presigned_get_url(key)}
|
||||
|
||||
|
||||
@router.get("/uploads/download")
|
||||
def download_file(
|
||||
key: str = Query(..., description="Object key del archivo en el almacén"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
|
||||
|
||||
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
|
||||
"""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
try:
|
||||
data = get_object_bytes(key)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
|
||||
filename = key.rsplit("/", 1)[-1]
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface Lead {
|
||||
phone: string | null;
|
||||
company_name: string | null;
|
||||
source: string | null;
|
||||
preferred_contact_method: string | null;
|
||||
status: LeadStatus;
|
||||
estimated_value: number | null;
|
||||
owner_user_id: string | null;
|
||||
|
||||
@@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise<str
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!.url;
|
||||
}
|
||||
|
||||
/** Descarga el archivo por el backend (sin exponer MinIO) y devuelve un blob. */
|
||||
export async function downloadBlob(fileKey: string, companyId: number): Promise<Blob> {
|
||||
return (api as any).getBlob(
|
||||
`/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
|
||||
) as Promise<Blob>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
||||
import { uploadFile, downloadBlob } from '$lib/api/uploads';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
onMount(() => {
|
||||
@@ -62,9 +62,17 @@
|
||||
async function openDoc(d: Document) {
|
||||
if (!companyId) return;
|
||||
try {
|
||||
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
||||
if (url) window.open(url, '_blank', 'noopener');
|
||||
else toast.error('El documento no tiene archivo');
|
||||
if (d.file_key) {
|
||||
// Descarga por el backend (evita exponer MinIO / host interno)
|
||||
const blob = await downloadBlob(d.file_key, companyId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
} else if (d.file_url) {
|
||||
window.open(d.file_url, '_blank', 'noopener');
|
||||
} else {
|
||||
toast.error('El documento no tiene archivo');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
|
||||
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
onMount(() => void crmCatalogs.ensure('medio_contacto'));
|
||||
|
||||
let items = $state<Lead[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
@@ -233,6 +237,13 @@
|
||||
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Medio de contacto preferido</span>
|
||||
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.preferred_contact_method}>
|
||||
<option value={undefined}>—</option>
|
||||
{#each crmCatalogs.options('medio_contacto') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Estado</span>
|
||||
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>
|
||||
|
||||
Reference in New Issue
Block a user