feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).
Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.
Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).
Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).
Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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'}]},
|
||||
})
|
||||
|
||||
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
92
backend/api/v1/modules/crm/common/folios.py
Normal file
92
backend/api/v1/modules/crm/common/folios.py
Normal file
@@ -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)}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user