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)
|
phone: str | None = Field(None, max_length=40)
|
||||||
company_name: str | None = Field(None, max_length=255)
|
company_name: str | None = Field(None, max_length=255)
|
||||||
source: str | None = Field(None, max_length=60)
|
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)
|
status: str = Field("new", max_length=20)
|
||||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||||
owner_user_id: str | None = Field(None, max_length=64)
|
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)
|
phone: str | None = Field(None, max_length=40)
|
||||||
company_name: str | None = Field(None, max_length=255)
|
company_name: str | None = Field(None, max_length=255)
|
||||||
source: str | None = Field(None, max_length=60)
|
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)
|
status: str | None = Field(None, max_length=20)
|
||||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||||
owner_user_id: str | None = Field(None, max_length=64)
|
owner_user_id: str | None = Field(None, max_length=64)
|
||||||
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
|
|||||||
phone: str | None
|
phone: str | None
|
||||||
company_name: str | None
|
company_name: str | None
|
||||||
source: str | None
|
source: str | None
|
||||||
|
preferred_contact_method: str | None = None
|
||||||
status: str
|
status: str
|
||||||
estimated_value: Decimal | None
|
estimated_value: Decimal | None
|
||||||
owner_user_id: str | 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)
|
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
# Origen: web | referido | evento | llamada | email | otro
|
# Origen: web | referido | evento | llamada | email | otro
|
||||||
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
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
|
# Estado: new | contacted | qualified | unqualified | converted
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
|
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)
|
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 re
|
||||||
import uuid
|
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.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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -61,3 +61,29 @@ def get_upload_url(
|
|||||||
if not key.startswith(prefix):
|
if not key.startswith(prefix):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||||
return {"url": presigned_get_url(key)}
|
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;
|
phone: string | null;
|
||||||
company_name: string | null;
|
company_name: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
preferred_contact_method: string | null;
|
||||||
status: LeadStatus;
|
status: LeadStatus;
|
||||||
estimated_value: number | null;
|
estimated_value: number | null;
|
||||||
owner_user_id: string | 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);
|
if (res.error) throw new Error(res.error);
|
||||||
return res.data!.url;
|
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 { DOC_TYPES, labelOf } from '$lib/components/crm/format';
|
||||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
import { uploadFile, downloadBlob } from '$lib/api/uploads';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -62,9 +62,17 @@
|
|||||||
async function openDoc(d: Document) {
|
async function openDoc(d: Document) {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
try {
|
try {
|
||||||
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
if (d.file_key) {
|
||||||
if (url) window.open(url, '_blank', 'noopener');
|
// Descarga por el backend (evita exponer MinIO / host interno)
|
||||||
else toast.error('El documento no tiene archivo');
|
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) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
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 * as Table from '$lib/components/ui/table';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
|
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
|
||||||
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
|
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';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
onMount(() => void crmCatalogs.ensure('medio_contacto'));
|
||||||
|
|
||||||
let items = $state<Lead[]>([]);
|
let items = $state<Lead[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
@@ -233,6 +237,13 @@
|
|||||||
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="font-medium">Estado</span>
|
<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}>
|
<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