diff --git a/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py new file mode 100644 index 0000000..02a030a --- /dev/null +++ b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py @@ -0,0 +1,158 @@ +"""Campos del documento maestro de cotización en la solicitud + folios del ciclo comercial + +Revision ID: b1c2d3e4f5a6 +Revises: a0b1c2d3e4f5 +Create Date: 2026-08-03 00:00:00.000000 + +Amplía crm.service_requests con los campos que exige el documento maestro de +cotización, agrega los back-links y la dirección impo/expo del ciclo +Oportunidad→Solicitud→Cotización→Operación, y crea crm.folio_counters para los +folios auto-generados ({LETRA}{AAAA}-{MM}-{NNN}-{DIR}). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b1c2d3e4f5a6" +down_revision: Union[str, None] = "a0b1c2d3e4f5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + +# Columnas nuevas de crm.service_requests (nombre, tipo, kwargs). +_SR_COLUMNS = [ + ("contact_id", sa.Integer(), {}), + ("request_date", sa.Date(), {}), + ("currency", sa.String(length=3), {}), + ("priority", sa.String(length=20), {}), + ("origin_country", sa.String(length=3), {}), + ("origin_city", sa.String(length=120), {}), + ("origin_port", sa.String(length=20), {}), + ("destination_country", sa.String(length=3), {}), + ("destination_city", sa.String(length=120), {}), + ("destination_port", sa.String(length=20), {}), + ("pickup_location", sa.String(length=255), {}), + ("delivery_location", sa.String(length=255), {}), + ("estimated_shipment_date", sa.Date(), {}), + ("cargo_value", sa.Numeric(14, 2), {}), + ("insurance_required", sa.Boolean(), {"server_default": sa.text("false")}), + ("hs_code", sa.String(length=20), {}), + ("goods_origin_country", sa.String(length=3), {}), + ("hazardous_imo", sa.Boolean(), {"server_default": sa.text("false")}), + ("refrigerated", sa.Boolean(), {"server_default": sa.text("false")}), + ("stackable", sa.Boolean(), {"server_default": sa.text("false")}), + ("pieces_count", sa.Integer(), {}), + ("boxes_count", sa.Integer(), {}), + ("pallets_count", sa.Integer(), {}), + ("net_weight", sa.Numeric(14, 3), {}), + ("length_cm", sa.Numeric(10, 2), {}), + ("width_cm", sa.Numeric(10, 2), {}), + ("height_cm", sa.Numeric(10, 2), {}), + ("measurement_unit", sa.String(length=20), {}), + ("container_count", sa.Integer(), {}), + ("packaging_type", sa.String(length=20), {}), + ("oversized", sa.Boolean(), {"server_default": sa.text("false")}), + ("weight_per_pallet", sa.Numeric(14, 3), {}), + ("volume_per_pallet", sa.Numeric(14, 3), {}), + ("additional_services", sa.JSON(), {}), + ("payment_method", sa.String(length=20), {}), + ("client_notes", sa.Text(), {}), + ("internal_notes", sa.Text(), {}), +] + + +def upgrade() -> None: + # ----- crm.service_requests: campos del documento maestro de cotización ----- + for name, col_type, kwargs in _SR_COLUMNS: + nullable = "server_default" not in kwargs # los boolean quedan NOT NULL con default false + op.add_column( + "service_requests", + sa.Column(name, col_type, nullable=nullable, **kwargs), + schema=SCHEMA, + ) + op.create_foreign_key( + "fk_crm_service_requests_contact_id", "service_requests", "contacts", + ["contact_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_service_requests_contact_id", "service_requests", ["contact_id"], schema=SCHEMA + ) + + # ----- crm.documents: adjuntos de una solicitud ----- + op.add_column( + "documents", sa.Column("service_request_id", sa.Integer(), nullable=True), schema=SCHEMA + ) + op.create_foreign_key( + "fk_crm_documents_service_request_id", "documents", "service_requests", + ["service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_documents_service_request_id", "documents", ["service_request_id"], schema=SCHEMA + ) + + # ----- crm.opportunities: dirección impo/expo + folio + back-link a la solicitud ----- + op.add_column("opportunities", sa.Column("operation_type", sa.String(length=20), nullable=True), schema=SCHEMA) + op.add_column("opportunities", sa.Column("reference", sa.String(length=40), nullable=True), schema=SCHEMA) + op.add_column( + "opportunities", + sa.Column("converted_service_request_id", sa.Integer(), nullable=True), + schema=SCHEMA, + ) + op.create_foreign_key( + "fk_crm_opportunities_converted_sr", "opportunities", "service_requests", + ["converted_service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_opportunities_reference", "opportunities", ["reference"], schema=SCHEMA + ) + + # ----- crm.quotes: variante FCL/LCL para la comparación "Ambas" ----- + op.add_column("quotes", sa.Column("load_type", sa.String(length=10), nullable=True), schema=SCHEMA) + + # ----- crm.folio_counters: consecutivo mensual por compañía y entidad ----- + op.create_table( + "folio_counters", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entity", sa.String(length=4), nullable=False), + sa.Column("period", sa.String(length=7), nullable=False), + sa.Column("last_number", sa.Integer(), nullable=False, server_default=sa.text("0")), + 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.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.UniqueConstraint( + "tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope" + ), + schema=SCHEMA, + ) + op.create_index("ix_crm_folio_counters_id", "folio_counters", ["id"], schema=SCHEMA) + op.create_index("ix_crm_folio_counters_tenant_id", "folio_counters", ["tenant_id"], schema=SCHEMA) + op.create_index("ix_crm_folio_counters_company_id", "folio_counters", ["company_id"], schema=SCHEMA) + + +def downgrade() -> None: + op.drop_index("ix_crm_folio_counters_company_id", table_name="folio_counters", schema=SCHEMA) + op.drop_index("ix_crm_folio_counters_tenant_id", table_name="folio_counters", schema=SCHEMA) + op.drop_index("ix_crm_folio_counters_id", table_name="folio_counters", schema=SCHEMA) + op.drop_table("folio_counters", schema=SCHEMA) + + op.drop_column("quotes", "load_type", schema=SCHEMA) + + op.drop_index("ix_crm_opportunities_reference", table_name="opportunities", schema=SCHEMA) + op.drop_constraint("fk_crm_opportunities_converted_sr", "opportunities", schema=SCHEMA, type_="foreignkey") + op.drop_column("opportunities", "converted_service_request_id", schema=SCHEMA) + op.drop_column("opportunities", "reference", schema=SCHEMA) + op.drop_column("opportunities", "operation_type", schema=SCHEMA) + + op.drop_index("ix_crm_documents_service_request_id", table_name="documents", schema=SCHEMA) + op.drop_constraint("fk_crm_documents_service_request_id", "documents", schema=SCHEMA, type_="foreignkey") + op.drop_column("documents", "service_request_id", schema=SCHEMA) + + op.drop_index("ix_crm_service_requests_contact_id", table_name="service_requests", schema=SCHEMA) + op.drop_constraint("fk_crm_service_requests_contact_id", "service_requests", schema=SCHEMA, type_="foreignkey") + for name, _col_type, _kwargs in reversed(_SR_COLUMNS): + op.drop_column("service_requests", name, schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index 9f970bd..d2e4f49 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -766,3 +766,85 @@ TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece', 'puerto': 'Puertos donde opera', 'aeropuerto': 'Aeropuertos donde opera', 'aduana': 'Aduanas donde opera'} + +# --------------------------------------------------------------------------- +# Catálogos del proceso comercial (Solicitud de servicio → Cotización). +# Alimentan los selects de la solicitud y del ciclo Oportunidad→Cotización. +# is_system = catálogos base que el cliente no puede borrar (sólo activar/desactivar). +# --------------------------------------------------------------------------- +GLOBAL_CATALOGS.update({ + 'tipo_operacion': {'label': 'Tipo de operación', + 'is_system': True, + 'items': [{'code': 'importacion', 'label': 'Importación'}, + {'code': 'exportacion', 'label': 'Exportación'}]}, + 'medio_transporte': {'label': 'Medio de transporte', + 'is_system': True, + 'items': [{'code': 'maritimo', 'label': 'Marítimo'}, + {'code': 'aereo', 'label': 'Aéreo'}, + {'code': 'terrestre', 'label': 'Terrestre'}, + {'code': 'ferroviario', 'label': 'Ferroviario'}, + {'code': 'multimodal', 'label': 'Multimodal'}]}, + 'tipo_servicio': {'label': 'Tipo de servicio', + 'is_system': True, + 'items': [{'code': 'puerto_puerto', 'label': 'Puerto a puerto'}, + {'code': 'puerto_puerta', 'label': 'Puerto a puerta'}, + {'code': 'puerta_puerto', 'label': 'Puerta a puerto'}, + {'code': 'puerta_puerta', 'label': 'Puerta a puerta'}]}, + 'prioridad': {'label': 'Prioridad', + 'is_system': False, + 'items': [{'code': 'baja', 'label': 'Baja'}, + {'code': 'normal', 'label': 'Normal'}, + {'code': 'alta', 'label': 'Alta'}, + {'code': 'urgente', 'label': 'Urgente'}]}, + 'tipo_mercancia': {'label': 'Tipo de mercancía', + 'is_system': False, + 'items': [{'code': 'general', 'label': 'Carga general'}, + {'code': 'perecedera', 'label': 'Perecedera'}, + {'code': 'peligrosa', 'label': 'Peligrosa (IMO)'}, + {'code': 'refrigerada', 'label': 'Refrigerada'}, + {'code': 'granel', 'label': 'Granel'}, + {'code': 'sobredimensionada', 'label': 'Sobredimensionada'}, + {'code': 'valiosa', 'label': 'Valiosa'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'unidad_medida': {'label': 'Unidad de medida', + 'is_system': False, + 'items': [{'code': 'cm', 'label': 'Centímetros (cm)'}, + {'code': 'm', 'label': 'Metros (m)'}, + {'code': 'in', 'label': 'Pulgadas (in)'}, + {'code': 'ft', 'label': 'Pies (ft)'}, + {'code': 'kg', 'label': 'Kilogramos (kg)'}, + {'code': 'lb', 'label': 'Libras (lb)'}, + {'code': 'm3', 'label': 'Metros cúbicos (m³)'}]}, + 'tipo_embalaje': {'label': 'Tipo de embalaje', + 'is_system': False, + 'items': [{'code': 'caja', 'label': 'Caja'}, + {'code': 'pallet', 'label': 'Pallet'}, + {'code': 'tarima', 'label': 'Tarima'}, + {'code': 'huacal', 'label': 'Huacal'}, + {'code': 'saco', 'label': 'Saco'}, + {'code': 'tambor', 'label': 'Tambor'}, + {'code': 'rollo', 'label': 'Rollo'}, + {'code': 'atado', 'label': 'Atado'}, + {'code': 'granel', 'label': 'Granel'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'servicio_adicional': {'label': 'Servicios adicionales', + 'is_system': False, + 'items': [{'code': 'seguro', 'label': 'Seguro de la mercancía'}, + {'code': 'despacho_aduanal', 'label': 'Despacho aduanal'}, + {'code': 'transporte_terrestre', 'label': 'Transporte terrestre'}, + {'code': 'almacenaje', 'label': 'Almacenaje'}, + {'code': 'maniobras', 'label': 'Maniobras'}, + {'code': 'custodia', 'label': 'Custodia'}, + {'code': 'revalidacion', 'label': 'Revalidación'}, + {'code': 'inspeccion', 'label': 'Inspección'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'tipo_documento': {'label': 'Tipo de documento', + 'is_system': False, + 'items': [{'code': 'factura_comercial', 'label': 'Factura comercial'}, + {'code': 'packing_list', 'label': 'Packing list'}, + {'code': 'certificado_origen', 'label': 'Certificado de origen'}, + {'code': 'hoja_seguridad_msds', 'label': 'Hoja de seguridad (MSDS)'}, + {'code': 'ficha_tecnica', 'label': 'Ficha técnica'}, + {'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'}, + {'code': 'otro', 'label': 'Otro'}]}, +}) diff --git a/backend/api/v1/modules/crm/common/__init__.py b/backend/api/v1/modules/crm/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py new file mode 100644 index 0000000..281b058 --- /dev/null +++ b/backend/api/v1/modules/crm/common/folios.py @@ -0,0 +1,92 @@ +"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación). + +Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``): +- LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque. +- ``AAAA-MM``: año-mes de creación. +- ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes). +- ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección). + +El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar +duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error; +la unicidad la garantiza el índice único (tenant, company, entity, period). +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Integer, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin +from core.database import Base + +# Entidades válidas y su letra de folio. +ENTITIES = ("O", "S", "C", "OP") +# Mapa dirección de operación → sufijo del folio. +_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} + + +class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin): + """Consecutivo mensual por compañía y entidad para armar los folios del ciclo.""" + + __tablename__ = "folio_counters" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope" + ), + {"schema": "crm"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP + period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM' + last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + + +def direction_suffix(direction: str | None) -> str: + """Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida.""" + return _DIRECTION_SUFFIX.get(direction or "", "X") + + +def next_folio( + db, + tenant_id: int, + company_id: int, + entity: str, + direction: str | None, + on_date: date | None = None, +) -> str: + """Genera el siguiente folio de una entidad, incrementando su consecutivo mensual. + + Reserva el número dentro de la transacción activa (no hace commit): el ``create_*`` + que lo invoca es quien confirma junto con la fila recién creada. + """ + if entity not in ENTITIES: + raise ValueError(f"Entidad de folio inválida: {entity!r}") + on_date = on_date or date.today() + period = on_date.strftime("%Y-%m") + + counter = ( + db.query(FolioCounter) + .filter( + FolioCounter.tenant_id == tenant_id, + FolioCounter.company_id == company_id, + FolioCounter.entity == entity, + FolioCounter.period == period, + ) + .with_for_update() + .first() + ) + if counter is None: + counter = FolioCounter( + tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0 + ) + db.add(counter) + db.flush() + + counter.last_number = (counter.last_number or 0) + 1 + db.flush() + + sequence = f"{counter.last_number:03d}" + return f"{entity}{period}-{sequence}-{direction_suffix(direction)}" diff --git a/backend/api/v1/modules/crm/documents/models.py b/backend/api/v1/modules/crm/documents/models.py index b07b20d..4cb4dfd 100644 --- a/backend/api/v1/modules/crm/documents/models.py +++ b/backend/api/v1/modules/crm/documents/models.py @@ -22,6 +22,10 @@ class Document(Base, TenantScopedMixin, TimestampMixin): supplier_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True ) + # Documento adjunto a una solicitud de servicio (factura, packing list, MSDS, etc.) + service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True + ) # constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio | # contrato | presentacion | certificacion | licencia | convenio | tarifario | otro doc_type: Mapped[str] = mapped_column(String(60), nullable=False) diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index 24e0620..48a533d 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -17,6 +17,7 @@ class OpportunityCreate(BaseModel): source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) notes: str | None = None + operation_type: str | None = Field(None, max_length=20) # importacion | exportacion class OpportunityUpdate(BaseModel): @@ -34,6 +35,7 @@ class OpportunityUpdate(BaseModel): source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) notes: str | None = None + operation_type: str | None = Field(None, max_length=20) class OpportunityMove(BaseModel): @@ -61,6 +63,9 @@ class OpportunityResponse(BaseModel): source: str | None owner_user_id: str | None notes: str | None + operation_type: str | None = None + reference: str | None = None + converted_service_request_id: int | None = None tenant_id: int company_id: int created_at: datetime diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index d9939b1..2686511 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -38,3 +38,10 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin): source: Mapped[str | None] = mapped_column(String(60), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque + operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O... + # Solicitud generada al convertir la oportunidad (back-link idempotente) + converted_service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True + ) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index f82d8e5..39ff209 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -4,6 +4,7 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from ..accounts.models import Account +from ..common.folios import next_folio from ..contacts.models import Contact from ..pipelines.models import Pipeline, PipelineStage from .dto import OpportunityCreate, OpportunityUpdate @@ -149,6 +150,9 @@ def create_opportunity( if opportunity.stage_id is not None: stage = _get_scoped_stage(db, opportunity.stage_id, tenant_id, company_id) _apply_stage_state(opportunity, stage) + # Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo. + if not opportunity.reference: + opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type) db.add(opportunity) db.commit() db.refresh(opportunity) diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py index 5c74b7d..0af9bd4 100644 --- a/backend/api/v1/modules/crm/quotes/dto.py +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -56,6 +56,7 @@ class QuoteBase(BaseModel): service_request_id: int | None = None account_id: int | None = None currency: str = Field("USD", max_length=3) + load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas") issue_date: date | None = None valid_until: date | None = None notes: str | None = None @@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel): service_request_id: int | None = None account_id: int | None = None currency: str | None = Field(None, max_length=3) + load_type: str | None = Field(None, max_length=10) issue_date: date | None = None valid_until: date | None = None notes: str | None = None diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py index cf86854..653a383 100644 --- a/backend/api/v1/modules/crm/quotes/models.py +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -22,6 +22,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin): Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True ) currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'")) + # Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica) + load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # borrador | enviada | aceptada | rechazada status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True) issue_date: Mapped[date | None] = mapped_column(Date, nullable=True) diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py index 4ed0678..01cde30 100644 --- a/backend/api/v1/modules/crm/quotes/pdf_service.py +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -59,6 +59,14 @@ def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> return obj +def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None: + """Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada).""" + head = ", ".join(p for p in (city, country) if p) + if port: + head = f"{head} ({port})" if head else port + return head or None + + def _company_row(db: Session, company_id: int) -> dict: try: row = db.execute( @@ -139,7 +147,8 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) route = [ ("Operación", sr.operation_type), ("Modo", sr.transport_mode), ("Servicio", sr.service_type), ("Incoterm", sr.incoterm), - ("Origen", sr.origin), ("Destino", sr.destination), + ("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)), + ("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)), ("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None), ] diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py index 7693f01..ed335fa 100644 --- a/backend/api/v1/modules/crm/quotes/routes.py +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -110,6 +110,24 @@ def create_quote( return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user)) +@router.post( + "/quotes/from-service-request", + response_model=list[QuoteResponse], + status_code=status.HTTP_201_CREATED, +) +def create_quotes_from_service_request( + service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL).""" + tenant_id = current_user["tenant_id"] + return service.create_quotes_from_service_request( + db, service_request_id, tenant_id, company_id, _user_id(current_user) + ) + + @router.patch("/quotes/{quote_id}", response_model=QuoteResponse) def update_quote( quote_id: int, diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index f4be755..365308d 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from decimal import Decimal from fastapi import HTTPException, status @@ -6,7 +6,8 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account -from ..service_requests.models import ServiceRequest +from ..common.folios import next_folio +from ..service_requests.models import RateRequest, ServiceRequest from ..suppliers.models import Supplier from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate from .models import Quote, QuoteItem @@ -89,18 +90,104 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu return obj +def _sr_direction(db: Session, service_request_id: int | None) -> str | None: + """Dirección impo/expo heredada de la solicitud asociada (para el folio).""" + if not service_request_id: + return None + sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first() + return sr.operation_type if sr else None + + def create_quote( db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Fecha de la cotización: por defecto hoy si no se capturó + if obj.issue_date is None: + obj.issue_date = date.today() + # Folio C... auto-generado (mensual), con la dirección heredada de la solicitud + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id)) db.add(obj) db.commit() db.refresh(obj) return obj +def create_quotes_from_service_request( + db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None +) -> list[Quote]: + """Genera cotización(es) a partir de una solicitud de servicio. + + Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por + variante) para comparar. Cada cotización toma su propio folio C... y hereda la + dirección impo/expo de la solicitud. Los conceptos se siembran desde las + solicitudes de tarifa (RateRequest) capturadas en la solicitud. + """ + sr = ( + db.query(ServiceRequest) + .filter( + ServiceRequest.id == service_request_id, + ServiceRequest.tenant_id == tenant_id, + ServiceRequest.company_id == company_id, + ServiceRequest.deleted_at.is_(None), + ) + .first() + ) + if not sr: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada") + + variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None] + rate_requests = ( + db.query(RateRequest) + .filter( + RateRequest.service_request_id == sr.id, + RateRequest.tenant_id == tenant_id, + RateRequest.company_id == company_id, + RateRequest.deleted_at.is_(None), + ) + .all() + ) + + created: list[Quote] = [] + for variant in variants: + quote = Quote( + account_id=sr.account_id, + service_request_id=sr.id, + currency=sr.currency or "USD", + load_type=variant, + status="borrador", + issue_date=date.today(), + notes=sr.client_notes or sr.notes, + owner_user_id=sr.owner_user_id, + reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type), + tenant_id=tenant_id, + company_id=company_id, + created_by=user_id, + updated_by=user_id, + ) + db.add(quote) + db.flush() + for rr in rate_requests: + amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0) + db.add(QuoteItem( + quote_id=quote.id, concept=rr.concept, description=rr.description, + supplier_id=rr.supplier_id, quantity=Decimal(1), + unit_cost=amount, unit_sale=amount, currency=rr.currency, + tenant_id=tenant_id, company_id=company_id, + )) + db.flush() + _recompute_totals(db, quote) + created.append(quote) + + db.commit() + for quote in created: + db.refresh(quote) + return created + + def update_quote( db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index 6eb44b4..7ccb583 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -7,22 +7,65 @@ from pydantic import BaseModel, ConfigDict, Field class ServiceRequestBase(BaseModel): reference: str | None = Field(None, max_length=40) account_id: int | None = None + contact_id: int | None = None opportunity_id: int | None = None operation_type: str = Field(..., max_length=20) # importacion | exportacion transport_mode: str | None = Field(None, max_length=20) service_type: str | None = Field(None, max_length=20) incoterm: str | None = Field(None, max_length=10) + # Ruta legada (texto libre) — se conserva por compatibilidad origin: str | None = Field(None, max_length=160) destination: str | None = Field(None, max_length=160) + # Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto) + origin_country: str | None = Field(None, max_length=3) + origin_city: str | None = Field(None, max_length=120) + origin_port: str | None = Field(None, max_length=20) + destination_country: str | None = Field(None, max_length=3) + destination_city: str | None = Field(None, max_length=120) + destination_port: str | None = Field(None, max_length=20) + pickup_location: str | None = Field(None, max_length=255) + delivery_location: str | None = Field(None, max_length=255) cargo_type: str | None = Field(None, max_length=120) - weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) - load_type: str | None = Field(None, max_length=10) + load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS container_equipment: str | None = Field(None, max_length=120) + container_count: int | None = Field(None, ge=0) commodity: str | None = None required_date: date | None = None + request_date: date | None = None + estimated_shipment_date: date | None = None + currency: str | None = Field(None, max_length=3) + priority: str | None = Field(None, max_length=20) + # Mercancía + cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + insurance_required: bool = False + hs_code: str | None = Field(None, max_length=20) + goods_origin_country: str | None = Field(None, max_length=3) + hazardous_imo: bool = False + refrigerated: bool = False + stackable: bool = False + # Dimensiones y bultos + pieces_count: int | None = Field(None, ge=0) + boxes_count: int | None = Field(None, ge=0) + pallets_count: int | None = Field(None, ge=0) + net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + measurement_unit: str | None = Field(None, max_length=20) + # LCL + packaging_type: str | None = Field(None, max_length=20) + oversized: bool = False + weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + # Servicios adicionales (códigos del catálogo servicio_adicional) y pago + additional_services: list[str] | None = None + payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None + client_notes: str | None = None + internal_notes: str | None = None status: str = Field("nueva", max_length=20) notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) @@ -38,8 +81,12 @@ class ServiceRequestContactInput(BaseModel): class ServiceRequestFromOpportunityInput(BaseModel): - """Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).""" - operation_type: str = Field(..., max_length=20) # importacion | exportacion + """Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02). + + La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es + solo un respaldo para oportunidades antiguas que no la tengan capturada. + """ + operation_type: str | None = Field(None, max_length=20) # importacion | exportacion transport_mode: str | None = Field(None, max_length=20) service_type: str | None = Field(None, max_length=20) incoterm: str | None = Field(None, max_length=10) @@ -51,6 +98,7 @@ class ServiceRequestFromOpportunityInput(BaseModel): class ServiceRequestUpdate(BaseModel): reference: str | None = Field(None, max_length=40) account_id: int | None = None + contact_id: int | None = None opportunity_id: int | None = None operation_type: str | None = Field(None, max_length=20) transport_mode: str | None = Field(None, max_length=20) @@ -58,15 +106,51 @@ class ServiceRequestUpdate(BaseModel): incoterm: str | None = Field(None, max_length=10) origin: str | None = Field(None, max_length=160) destination: str | None = Field(None, max_length=160) + origin_country: str | None = Field(None, max_length=3) + origin_city: str | None = Field(None, max_length=120) + origin_port: str | None = Field(None, max_length=20) + destination_country: str | None = Field(None, max_length=3) + destination_city: str | None = Field(None, max_length=120) + destination_port: str | None = Field(None, max_length=20) + pickup_location: str | None = Field(None, max_length=255) + delivery_location: str | None = Field(None, max_length=255) cargo_type: str | None = Field(None, max_length=120) weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) load_type: str | None = Field(None, max_length=10) container_equipment: str | None = Field(None, max_length=120) + container_count: int | None = Field(None, ge=0) commodity: str | None = None required_date: date | None = None + request_date: date | None = None + estimated_shipment_date: date | None = None + currency: str | None = Field(None, max_length=3) + priority: str | None = Field(None, max_length=20) + cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + insurance_required: bool | None = None + hs_code: str | None = Field(None, max_length=20) + goods_origin_country: str | None = Field(None, max_length=3) + hazardous_imo: bool | None = None + refrigerated: bool | None = None + stackable: bool | None = None + pieces_count: int | None = Field(None, ge=0) + boxes_count: int | None = Field(None, ge=0) + pallets_count: int | None = Field(None, ge=0) + net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + measurement_unit: str | None = Field(None, max_length=20) + packaging_type: str | None = Field(None, max_length=20) + oversized: bool | None = None + weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + additional_services: list[str] | None = None + payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None + client_notes: str | None = None + internal_notes: str | None = None status: str | None = Field(None, max_length=20) notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index 3182e76..e141967 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -1,6 +1,6 @@ from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text +from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin @@ -57,6 +57,55 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin): created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + # ----- Campos del documento maestro de cotización (T2026-08) ----- + # Datos generales + contact_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True + ) + request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud + currency: Mapped[str | None] = mapped_column(String(3), nullable=True) + priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente + # Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre) + origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) + origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True) + origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True) + destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True) + destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True) + destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True) + pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True) + delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True) + estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True) + # Mercancía + cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) + insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria + goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía + hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + # Dimensiones y bultos + pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto) + length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True) + # FCL + container_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # LCL + packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + # Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago + additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True) + payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Notas + client_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + class RateRequest(Base, TenantScopedMixin, TimestampMixin): """Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6).""" diff --git a/backend/api/v1/modules/crm/service_requests/service.py b/backend/api/v1/modules/crm/service_requests/service.py index 016dbed..42b096a 100644 --- a/backend/api/v1/modules/crm/service_requests/service.py +++ b/backend/api/v1/modules/crm/service_requests/service.py @@ -5,6 +5,8 @@ from sqlalchemy.orm import Session from ..accounts.models import Account from ..catalogs.data import INCOTERM_CODES +from ..common.folios import next_folio +from ..contacts.models import Contact from ..opportunities.models import Opportunity from ..suppliers.models import Supplier from .dto import ( @@ -37,6 +39,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: if not _exists(db, Account, data.get("account_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe") + if not _exists(db, Contact, data.get("contact_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto asociado no existe") if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe") if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id): @@ -103,6 +107,9 @@ def create_service_request( data = payload.model_dump() _validate_request_refs(db, data, tenant_id, company_id) obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Folio S... auto-generado (mensual) si no viene uno explícito + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type) db.add(obj) db.commit() db.refresh(obj) @@ -166,10 +173,24 @@ def create_from_opportunity( ) if not opp: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada") + + # Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud + if opp.converted_service_request_id: + existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id) + return existing + + # La dirección impo/expo se hereda de la oportunidad (respaldo: el payload) + operation_type = opp.operation_type or payload.operation_type + if not operation_type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Define la dirección (importación/exportación) en la oportunidad para convertirla", + ) obj = ServiceRequest( account_id=opp.account_id, + contact_id=opp.contact_id, opportunity_id=opp.id, - operation_type=payload.operation_type, + operation_type=operation_type, transport_mode=payload.transport_mode, service_type=payload.service_type, incoterm=payload.incoterm, @@ -178,12 +199,16 @@ def create_from_opportunity( status="nueva", notes=payload.notes, owner_user_id=opp.owner_user_id, + reference=next_folio(db, tenant_id, company_id, "S", operation_type), tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id, ) db.add(obj) + db.flush() + # Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia) + opp.converted_service_request_id = obj.id db.commit() db.refresh(obj) return obj diff --git a/backend/api/v1/modules/ops/shipments/routes.py b/backend/api/v1/modules/ops/shipments/routes.py index 66985fa..265bd01 100644 --- a/backend/api/v1/modules/ops/shipments/routes.py +++ b/backend/api/v1/modules/ops/shipments/routes.py @@ -65,11 +65,14 @@ def create_shipment( def create_shipment_from_quote( quote_id: int = Query(..., description="Cotización aceptada a liberar"), company_id: int = Query(..., description="Company ID"), + operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): tenant_id = current_user["tenant_id"] - return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user)) + return service.create_shipment_from_quote( + db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type + ) @router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse) diff --git a/backend/api/v1/modules/ops/shipments/service.py b/backend/api/v1/modules/ops/shipments/service.py index 75f7943..2372cd1 100644 --- a/backend/api/v1/modules/ops/shipments/service.py +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -5,10 +5,14 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote from api.v1.modules.crm.service_requests.models import ServiceRequest from api.v1.modules.crm.suppliers.models import Supplier +# Direcciones válidas de la operación (para validar y sembrar hitos). +_OPERATION_TYPES = ("importacion", "exportacion") + from .dto import ( ShipmentCloseInput, ShipmentCreate, @@ -173,9 +177,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i def create_shipment_from_quote( - db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None + db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None, + operation_type: str | None = None, ) -> Shipment: - """Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.""" + """Liberar a Operaciones: crea el embarque a partir de una cotización aceptada. + + La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se + envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio + ``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3). + """ + if operation_type is not None and operation_type not in _OPERATION_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'", + ) quote = ( db.query(Quote) .filter( @@ -198,12 +213,15 @@ def create_shipment_from_quote( if quote.service_request_id: sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + # La dirección enviada al liberar manda; si no viene, se hereda de la solicitud + resolved = operation_type or (sr.operation_type if sr else None) + shipment = Shipment( - reference=quote.reference, + reference=next_folio(db, tenant_id, company_id, "OP", resolved), quote_id=quote.id, service_request_id=quote.service_request_id, account_id=quote.account_id, - operation_type=sr.operation_type if sr else None, + operation_type=resolved, transport_mode=sr.transport_mode if sr else None, service_type=sr.service_type if sr else None, incoterm=sr.incoterm if sr else None, @@ -220,6 +238,13 @@ def create_shipment_from_quote( db.add(shipment) if sr: sr.status = "liberada" + db.flush() + # Siembra automática de hitos si ya se conoce la dirección de la operación + for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])): + db.add(ShipmentEvent( + shipment_id=shipment.id, event_type=event_type, title=title, kind=kind, + status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id, + )) db.commit() db.refresh(shipment) return shipment diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 70d2eaa..dd9bbf5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,8 @@ from core.database import Base # noqa: E402 import api.v1.modules.crm.accounts.models # noqa: E402,F401 import api.v1.modules.crm.activities.models # noqa: E402,F401 import api.v1.modules.crm.addresses.models # noqa: E402,F401 +import api.v1.modules.crm.catalogs.models # noqa: E402,F401 +import api.v1.modules.crm.common.folios # noqa: E402,F401 import api.v1.modules.crm.contacts.models # noqa: E402,F401 import api.v1.modules.crm.documents.models # noqa: E402,F401 import api.v1.modules.crm.leads.models # noqa: E402,F401 diff --git a/backend/tests/test_catalogs_seed.py b/backend/tests/test_catalogs_seed.py new file mode 100644 index 0000000..a0f55c1 --- /dev/null +++ b/backend/tests/test_catalogs_seed.py @@ -0,0 +1,49 @@ +"""Pruebas de la siembra idempotente de catálogos globales del CRM.""" + +from api.v1.modules.crm.catalogs.models import CatalogItem +from api.v1.modules.crm.catalogs.seed import seed_global_catalogs + +# Catálogos nuevos del proceso comercial y una clave base que debe existir en cada uno. +NEW_CATALOGS = { + "tipo_operacion": "importacion", + "medio_transporte": "maritimo", + "tipo_servicio": "puerto_puerto", + "prioridad": "urgente", + "tipo_mercancia": "peligrosa", + "unidad_medida": "kg", + "tipo_embalaje": "pallet", + "servicio_adicional": "seguro", + "tipo_documento": "factura_comercial", +} + + +def _codes(db, catalog: str) -> set[str]: + return { + row.code + for row in db.query(CatalogItem.code).filter( + CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None) + ) + } + + +def test_seed_creates_new_catalogs(db): + seed_global_catalogs(db) + for catalog, base_code in NEW_CATALOGS.items(): + codes = _codes(db, catalog) + assert codes, f"El catálogo {catalog} quedó vacío" + assert base_code in codes, f"Falta la clave base {base_code} en {catalog}" + + +def test_seed_is_idempotent(db): + first = seed_global_catalogs(db) + assert first, "La primera corrida debió sembrar filas" + second = seed_global_catalogs(db) + assert second == {}, "La segunda corrida no debe agregar filas nuevas" + + +def test_pais_catalog_populated(db): + """El catálogo pais alimenta Origen/Destino de la solicitud (decisión 6).""" + seed_global_catalogs(db) + codes = _codes(db, "pais") + assert len(codes) > 100 + assert "MEX" in codes diff --git a/backend/tests/test_folios.py b/backend/tests/test_folios.py new file mode 100644 index 0000000..430659c --- /dev/null +++ b/backend/tests/test_folios.py @@ -0,0 +1,43 @@ +"""Pruebas del generador de folios del ciclo comercial (next_folio).""" + +from datetime import date + +from api.v1.modules.crm.common.folios import next_folio + +T, C = 1, 1 + + +def test_folio_format_and_direction(db): + folio = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 15)) + assert folio == "O2025-08-001-E" + imp = next_folio(db, T, C, "S", "importacion", on_date=date(2025, 8, 15)) + assert imp == "S2025-08-001-I" + sin_dir = next_folio(db, T, C, "C", None, on_date=date(2025, 8, 15)) + assert sin_dir == "C2025-08-001-X" + + +def test_folio_monthly_consecutive_per_entity(db): + a = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 1)) + b = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + assert a == "O2025-08-001-E" + assert b == "O2025-08-002-E" # mismo mes, mismo entity → +1 + + +def test_folio_resets_on_month_change(db): + next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + sep = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 9, 1)) + assert sep == "O2025-09-001-E" # nuevo mes → reinicia consecutivo + + +def test_folio_entities_do_not_share_counter(db): + o = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + s = next_folio(db, T, C, "S", "exportacion", on_date=date(2025, 8, 20)) + op = next_folio(db, T, C, "OP", "importacion", on_date=date(2025, 8, 20)) + assert o == "O2025-08-001-E" + assert s == "S2025-08-001-E" # entity distinto → su propio consecutivo + assert op == "OP2025-08-001-I" + + +def test_folio_unique_across_many(db): + folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)} + assert len(folios) == 25 # sin duplicados diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index 0d10639..aa265ec 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -1,9 +1,13 @@ +from datetime import date from decimal import Decimal +import pytest +from fastapi import HTTPException + from api.v1.modules.crm.quotes import service from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate from api.v1.modules.crm.service_requests import service as sr_service -from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from api.v1.modules.crm.service_requests.dto import RateRequestCreate, ServiceRequestCreate T, C = 1, 1 @@ -44,3 +48,70 @@ def test_accept_quote_updates_service_request(db): # la solicitud asociada queda aceptada sr = sr_service.get_service_request(db, sr.id, T, C) assert sr.status == "aceptada" + + +# ----- Solicitud → Cotización ----- + +def _sr_with_rates(db, load_type="FCL"): + sr = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion", load_type=load_type, currency="USD"), T, C + ) + sr_service.create_rate_request( + db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", + rate_amount=1200, currency="USD"), T, C + ) + sr_service.create_rate_request( + db, RateRequestCreate(service_request_id=sr.id, concept="despacho_aduanal", + rate_amount=300, currency="USD"), T, C + ) + return sr + + +def test_quote_from_service_request_seeds_items(db): + sr = _sr_with_rates(db) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C, user_id="dev") + assert len(quotes) == 1 + q = quotes[0] + assert q.service_request_id == sr.id + assert q.reference.startswith("C") and q.reference.endswith("-I") + items = service.get_quote_items(db, q.id, T, C) + assert len(items) == 2 + assert float(q.total_sale) == 1500.0 # 1200 + 300 + + +def test_quote_from_service_request_without_rates(db): + sr = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="exportacion", load_type="FCL"), T, C + ) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert len(quotes) == 1 + assert service.get_quote_items(db, quotes[0].id, T, C) == [] + + +def test_quote_from_service_request_not_found(db): + with pytest.raises(HTTPException) as exc: + service.create_quotes_from_service_request(db, 999, T, C) + assert exc.value.status_code == 404 + + +def test_quote_from_service_request_ambas_genera_dos(db): + sr = _sr_with_rates(db, load_type="AMBAS") + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert len(quotes) == 2 + variants = {q.load_type for q in quotes} + assert variants == {"FCL", "LCL"} + # cada variante siembra sus propios conceptos y toma su propio folio + assert quotes[0].reference != quotes[1].reference + for q in quotes: + assert len(service.get_quote_items(db, q.id, T, C)) == 2 + + +def test_quote_from_service_request_sets_issue_date_today(db): + sr = _sr_with_rates(db) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert quotes[0].issue_date == date.today() + + +def test_create_quote_sets_issue_date_today(db): + q = service.create_quote(db, QuoteCreate(reference="COT-DATE"), T, C) + assert q.issue_date == date.today() diff --git a/backend/tests/test_service_requests.py b/backend/tests/test_service_requests.py index 2e798d8..1ea641f 100644 --- a/backend/tests/test_service_requests.py +++ b/backend/tests/test_service_requests.py @@ -3,10 +3,15 @@ from fastapi import HTTPException from api.v1.modules.crm.accounts import service as accounts_service from api.v1.modules.crm.accounts.dto import AccountCreate +from api.v1.modules.crm.contacts import service as contacts_service +from api.v1.modules.crm.contacts.dto import ContactCreate +from api.v1.modules.crm.opportunities import service as opp_service +from api.v1.modules.crm.opportunities.dto import OpportunityCreate from api.v1.modules.crm.service_requests import service from api.v1.modules.crm.service_requests.dto import ( RateRequestCreate, ServiceRequestCreate, + ServiceRequestFromOpportunityInput, ServiceRequestUpdate, ) @@ -64,3 +69,88 @@ def test_update_service_request_status(db): sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) upd = service.update_service_request(db, sr.id, ServiceRequestUpdate(status="en_analisis"), T, C) assert upd.status == "en_analisis" + + +# ----- Campos del documento maestro de cotización ----- + +def test_create_service_request_new_fields(db): + sr = service.create_service_request( + db, + ServiceRequestCreate( + operation_type="importacion", load_type="LCL", priority="alta", + origin_country="CHN", origin_city="Shanghai", + destination_country="MEX", destination_city="Manzanillo", + cargo_value=15000, insurance_required=True, hazardous_imo=True, + pieces_count=12, net_weight=800, measurement_unit="kg", + additional_services=["seguro", "despacho_aduanal"], + payment_method="99", client_notes="Manejo con cuidado", + ), + T, C, + ) + assert sr.origin_country == "CHN" + assert sr.insurance_required is True + assert sr.hazardous_imo is True + assert sr.additional_services == ["seguro", "despacho_aduanal"] + assert sr.pieces_count == 12 + + +def test_service_request_rejects_unknown_contact(db): + with pytest.raises(HTTPException) as exc: + service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion", contact_id=999), T, C + ) + assert exc.value.status_code == 422 + + +def test_service_request_generates_folio(db): + sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + assert sr.reference is not None + assert sr.reference.startswith("S") + assert sr.reference.endswith("-E") + + +def test_service_request_accepts_ambas(db): + sr = service.create_service_request( + db, ServiceRequestCreate(operation_type="exportacion", load_type="AMBAS"), T, C + ) + assert sr.load_type == "AMBAS" + + +def test_from_opportunity_inherits_operation_type_and_backlink(db): + acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C) + contact = contacts_service.create_contact( + db, ContactCreate(account_id=acc.id, first_name="Ana"), T, C + ) + opp = opp_service.create_opportunity( + db, + OpportunityCreate(name="Negocio", account_id=acc.id, contact_id=contact.id, + operation_type="importacion"), + T, C, + ) + sr = service.create_from_opportunity( + db, opp.id, ServiceRequestFromOpportunityInput(transport_mode="aereo"), T, C, user_id="dev" + ) + # Hereda dirección y contacto de la oportunidad + assert sr.operation_type == "importacion" + assert sr.contact_id == contact.id + assert sr.opportunity_id == opp.id + assert sr.reference.startswith("S") and sr.reference.endswith("-I") + # Back-link en la oportunidad + refreshed = opp_service.get_opportunity(db, opp.id, T, C) + assert refreshed.converted_service_request_id == sr.id + + +def test_from_opportunity_idempotent(db): + opp = opp_service.create_opportunity( + db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C + ) + first = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + second = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + assert first.id == second.id # no crea una segunda solicitud + + +def test_from_opportunity_without_direction_fails(db): + opp = opp_service.create_opportunity(db, OpportunityCreate(name="Sin dirección"), T, C) + with pytest.raises(HTTPException) as exc: + service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + assert exc.value.status_code == 422 diff --git a/backend/tests/test_shipments.py b/backend/tests/test_shipments.py index b987b38..4364a53 100644 --- a/backend/tests/test_shipments.py +++ b/backend/tests/test_shipments.py @@ -58,3 +58,47 @@ def test_shipment_rejects_unknown_quote(db): with pytest.raises(HTTPException) as exc: service.create_shipment(db, ShipmentCreate(quote_id=999), T, C) assert exc.value.status_code == 422 + + +# ----- Cotización → Operación: dirección IMPO/EXPO + auto-hitos + folio OP ----- + +def _accepted_quote(db, sr=None): + kwargs = {"reference": "COT-Z"} + if sr is not None: + kwargs["service_request_id"] = sr.id + q = quotes_service.create_quote(db, QuoteCreate(**kwargs), T, C) + quotes_service.accept_quote(db, q.id, T, C) + return q + + +def test_from_quote_explicit_operation_type_generates_milestones(db): + q = _accepted_quote(db) + shipment = service.create_shipment_from_quote(db, q.id, T, C, operation_type="importacion") + assert shipment.operation_type == "importacion" + assert shipment.reference.startswith("OP") and shipment.reference.endswith("-I") + events = service.get_shipment_events(db, T, C, shipment.id) + assert len(events) == 11 # hitos de importación (Diagrama 3) + + +def test_from_quote_inherits_sr_operation_type(db): + sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + q = _accepted_quote(db, sr=sr) + shipment = service.create_shipment_from_quote(db, q.id, T, C) # sin operation_type explícito + assert shipment.operation_type == "exportacion" + events = service.get_shipment_events(db, T, C, shipment.id) + assert len(events) == 19 # hitos de exportación (Diagrama 2) + + +def test_from_quote_no_operation_type_no_milestones(db): + q = _accepted_quote(db) # sin solicitud → sin dirección + shipment = service.create_shipment_from_quote(db, q.id, T, C) + assert shipment.operation_type is None + assert service.get_shipment_events(db, T, C, shipment.id) == [] # sin hitos, sin excepción + assert shipment.reference.endswith("-X") + + +def test_from_quote_invalid_operation_type(db): + q = _accepted_quote(db) + with pytest.raises(HTTPException) as exc: + service.create_shipment_from_quote(db, q.id, T, C, operation_type="foo") + assert exc.value.status_code == 422 diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index e190fde..184c5fb 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -11,6 +11,7 @@ export interface ServiceRequest { id: number; reference: string | null; account_id: number | null; + contact_id: number | null; opportunity_id: number | null; operation_type: string; transport_mode: string | null; @@ -18,15 +19,51 @@ export interface ServiceRequest { incoterm: string | null; origin: string | null; destination: string | null; + origin_country: string | null; + origin_city: string | null; + origin_port: string | null; + destination_country: string | null; + destination_city: string | null; + destination_port: string | null; + pickup_location: string | null; + delivery_location: string | null; cargo_type: string | null; weight: number | null; volume: number | null; load_type: string | null; container_equipment: string | null; + container_count: number | null; commodity: string | null; required_date: string | null; + request_date: string | null; + estimated_shipment_date: string | null; + currency: string | null; + priority: string | null; + cargo_value: number | null; + insurance_required: boolean; + hs_code: string | null; + goods_origin_country: string | null; + hazardous_imo: boolean; + refrigerated: boolean; + stackable: boolean; + pieces_count: number | null; + boxes_count: number | null; + pallets_count: number | null; + net_weight: number | null; + length_cm: number | null; + width_cm: number | null; + height_cm: number | null; + measurement_unit: string | null; + packaging_type: string | null; + oversized: boolean; + weight_per_pallet: number | null; + volume_per_pallet: number | null; + additional_services: string[] | null; + payment_method: string | null; destination_agent_id: number | null; requirements: string | null; + client_notes: string | null; + internal_notes: string | null; first_contact_at: string | null; first_contact_notes: string | null; status: ServiceRequestStatus; @@ -70,6 +107,7 @@ export interface Quote { service_request_id: number | null; account_id: number | null; currency: string; + load_type: string | null; status: QuoteStatus; issue_date: string | null; valid_until: string | null; @@ -133,7 +171,7 @@ export const serviceRequestsAPI = { unwrap(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })), requote: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/service-requests/${id}/requote?${qp(companyId)}`, {})), - fromOpportunity: (opportunityId: number, data: { operation_type: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) => + fromOpportunity: (opportunityId: number, data: { operation_type?: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) => unwrap(api.post(`/v1/crm/service-requests/from-opportunity?${qp(companyId, { opportunity_id: opportunityId })}`, data)), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`)) }; @@ -156,6 +194,8 @@ export const quotesAPI = { accept: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})), reject: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})), clone: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})), + fromServiceRequest: (serviceRequestId: number, companyId: number) => + unwrap(api.post(`/v1/crm/quotes/from-service-request?${qp(companyId, { service_request_id: serviceRequestId })}`, {})), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)), items: (quoteId: number, companyId: number) => unwrap(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)), pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise, diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index 58f0870..84d2a77 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -265,6 +265,9 @@ export interface Opportunity { source: string | null; owner_user_id: string | null; notes: string | null; + operation_type: string | null; + reference: string | null; + converted_service_request_id: number | null; tenant_id: number; company_id: number; created_at: string; diff --git a/frontend/src/lib/api/ops/index.ts b/frontend/src/lib/api/ops/index.ts index 7eb309c..c9084aa 100644 --- a/frontend/src/lib/api/ops/index.ts +++ b/frontend/src/lib/api/ops/index.ts @@ -106,8 +106,8 @@ export const shipmentsAPI = { unwrap(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)), get: (id: number, companyId: number) => unwrap(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)), create: (data: ShipmentInput, companyId: number) => unwrap(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)), - createFromQuote: (quoteId: number, companyId: number) => - unwrap(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})), + createFromQuote: (quoteId: number, companyId: number, operationType?: string) => + unwrap(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId, operation_type: operationType })}`, {})), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)), reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) => unwrap(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)), diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte new file mode 100644 index 0000000..6f86a54 --- /dev/null +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -0,0 +1,155 @@ + + +{#if tab === 'datos'} +
+ + + + + + + + + {#if form.status} + + {/if} +
+{:else if tab === 'ruta'} +
+ + + + + +

Origen

+ + + {#if crmCatalogs.options('puerto').length} + + {:else} + + {/if} + + +

Destino

+ + + {#if crmCatalogs.options('puerto').length} + + {:else} + + {/if} + + + + + +
+{:else if tab === 'mercancia'} +
+ + + + + +
+ + + + +
+
+{:else if tab === 'dimensiones'} +
+ + + + + + + + + + +
+ {#if isFcl} +
+

FCL — Contenedor completo

+ + +
+ {/if} + {#if isLcl} +
+

LCL — Carga consolidada

+ + + + +
+ {/if} +{:else if tab === 'servicios'} +

Servicios adicionales

+
+ {#each crmCatalogs.options('servicio_adicional') as s (s.value)} + + {/each} +
+ +{:else if tab === 'notas'} +
+ + + +
+{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 2cca268..8095bc9 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -181,7 +181,25 @@ export const SERVICE_TYPES: Option[] = [ export const LOAD_TYPES: Option[] = [ { value: 'FCL', label: 'FCL (contenedor completo)' }, - { value: 'LCL', label: 'LCL (carga consolidada)' } + { value: 'LCL', label: 'LCL (carga consolidada)' }, + { value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' } +]; + +export const PRIORITIES: Option[] = [ + { value: 'baja', label: 'Baja' }, + { value: 'normal', label: 'Normal' }, + { value: 'alta', label: 'Alta' }, + { value: 'urgente', label: 'Urgente' } +]; + +// Pestañas del formulario de solicitud de servicio (documento maestro de cotización) +export const SR_FORM_TABS: Option[] = [ + { value: 'datos', label: 'Datos' }, + { value: 'ruta', label: 'Servicio y ruta' }, + { value: 'mercancia', label: 'Mercancía' }, + { value: 'dimensiones', label: 'Dimensiones' }, + { value: 'servicios', label: 'Servicios' }, + { value: 'notas', label: 'Notas' } ]; export const SR_STATUS: Option[] = [ diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 6573670..dd8cd01 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -113,13 +113,24 @@ } } - async function release() { + let showRelease = $state(false); + let releaseDir = $state<'importacion' | 'exportacion'>('exportacion'); + + function openRelease() { + if (!quote) return; + // Prefija la dirección desde la solicitud asociada (si la hay) + const sr = requests.find((r) => r.id === quote?.service_request_id); + releaseDir = (sr?.operation_type as 'importacion' | 'exportacion') ?? 'exportacion'; + showRelease = true; + } + + async function confirmRelease() { if (!companyId || !quote) return; - if (!confirm('¿Liberar esta cotización a Operaciones (crear embarque)?')) return; busy = true; try { - const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId); + const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId, releaseDir); toast.success('Embarque creado'); + showRelease = false; await goto(`/dashboard/ops/embarques/${shipment.id}`); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo liberar'); @@ -222,7 +233,7 @@ {/if} {#if quote.status === 'aceptada'} - + {/if} {#if quote.status === 'rechazada'} @@ -284,7 +295,9 @@ + + {#if quote.load_type}{/if} @@ -312,3 +325,26 @@ {/if} + +{#if showRelease && quote} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte index f9a1433..8a9a1de 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte @@ -7,7 +7,7 @@ import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm'; import { toast } from 'svelte-sonner'; - let form = $state({ currency: 'USD' }); + let form = $state({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) }); let accounts = $state([]); let requests = $state([]); let saving = $state(false); @@ -50,6 +50,7 @@ + diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 698378d..06a2211 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -16,7 +16,7 @@ type Stage, type Account } from '$lib/api/crm'; - import { formatMoney } from '$lib/components/crm/format'; + import { formatMoney, OPERATION_TYPES, TRANSPORT_MODES } from '$lib/components/crm/format'; import { toast } from 'svelte-sonner'; let pipelines = $state([]); @@ -32,6 +32,12 @@ let saving = $state(false); let form = $state({ name: '' }); + // Convertir oportunidad → solicitud (la dirección se hereda de la oportunidad) + let convertOpen = $state(false); + let converting = $state(false); + let convertOpp = $state(null); + let convertForm = $state<{ operation_type: string; transport_mode?: string; incoterm?: string; origin?: string; destination?: string; notes?: string }>({ operation_type: 'exportacion' }); + const companyId = $derived(companyStore.activeCompany?.id ?? null); const currentStages = $derived( @@ -124,7 +130,8 @@ form = { name: '', pipeline_id: selectedPipelineId ?? undefined, - stage_id: currentStages[0]?.id + stage_id: currentStages[0]?.id, + operation_type: 'exportacion' }; modalOpen = true; } @@ -152,17 +159,35 @@ } } - async function convertToRequest(opp: Opportunity) { - if (!companyId) return; - const op = window.prompt('Convertir a solicitud — tipo de operación (importacion / exportacion):', 'exportacion'); - if (!op) return; - const operation_type = op.trim().toLowerCase() === 'importacion' ? 'importacion' : 'exportacion'; + function openConvert(opp: Opportunity) { + convertOpp = opp; + convertForm = { operation_type: opp.operation_type ?? 'exportacion' }; + convertOpen = true; + } + + async function confirmConvert() { + if (!companyId || !convertOpp) return; + converting = true; try { - const sr = await serviceRequestsAPI.fromOpportunity(opp.id, { operation_type }, companyId); + const sr = await serviceRequestsAPI.fromOpportunity( + convertOpp.id, + { + operation_type: convertForm.operation_type, + transport_mode: convertForm.transport_mode || undefined, + incoterm: convertForm.incoterm || undefined, + origin: convertForm.origin || undefined, + destination: convertForm.destination || undefined, + notes: convertForm.notes || undefined + }, + companyId + ); toast.success('Solicitud creada desde la oportunidad'); + convertOpen = false; await goto(`/dashboard/crm/solicitudes/${sr.id}`); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo convertir'); + } finally { + converting = false; } } @@ -264,7 +289,7 @@ {opp.probability}% {/if} - @@ -292,6 +317,13 @@ {#each accounts as a (a.id)}{/each} +
{/if} + +{#if convertOpen && convertOpp} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 5660aaf..0e26e7e 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -1,30 +1,36 @@
@@ -46,33 +49,16 @@

Nueva solicitud de servicio

- -
- Generales - - - - - - -
+ +
+ {#each SR_FORM_TABS as t (t.value)} + + {/each} +
-
- Logística y carga - - - - - - - - - - - -
+ -
+