feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo

- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-29 13:18:07 -06:00
parent ef7e69ed57
commit 206ff450f8
6 changed files with 712 additions and 2 deletions

View File

@@ -0,0 +1,60 @@
"""crm quote_settings (marca por tenant) + quotes.pdf_file_key
Revision ID: a0b1c2d3e4f5
Revises: f8a9b0c1d2e3
Create Date: 2026-07-29 00:00:00.000000
PDF de cotización con formato maestro + branding por tenant + envío por correo.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a0b1c2d3e4f5"
down_revision: Union[str, None] = "f8a9b0c1d2e3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA)
op.create_table(
"quote_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("emitter_name", sa.String(length=255), nullable=True),
sa.Column("emitter_rfc", sa.String(length=13), nullable=True),
sa.Column("emitter_address", sa.Text(), nullable=True),
sa.Column("emitter_phone", sa.String(length=60), nullable=True),
sa.Column("emitter_email", sa.String(length=255), nullable=True),
sa.Column("emitter_website", sa.String(length=255), nullable=True),
sa.Column("logo_file_key", sa.String(length=512), nullable=True),
sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")),
sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")),
sa.Column("default_terms", sa.Text(), nullable=True),
sa.Column("footer_note", sa.Text(), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA)
# Una configuración por compañía
op.create_index(
"uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"],
unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_table("quote_settings", schema=SCHEMA)
op.drop_column("quotes", "pdf_file_key", schema=SCHEMA)