feat(fin,ops): Facturación y Cobranza (Diag. 4), bitácora de embarque (Diag. 3) y subida a MinIO
- schema fin: fin.invoices + fin.invoice_items + fin.payments; totales con IVA, estados borrador→emitida→enviada→pagada, cobranza (pagos) y saldo automático - generar-factura-desde-embarque (toma conceptos de venta de la cotización) - ops.shipment_events: bitácora/hitos del embarque con secuencia por defecto según operación (importación/exportación) — cubre Diagrama 3 - subida de documentos a MinIO: POST /crm/uploads (multipart) + URL firmada - migración c4d5e6f7a8b9, routers/permisos (fin), seed del flujo hasta factura - 58 tests pytest en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,41 @@ class ShipmentResponse(ShipmentBase):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShipmentEventBase(BaseModel):
|
||||
shipment_id: int
|
||||
event_type: str | None = Field(None, max_length=60)
|
||||
title: str = Field(..., min_length=1, max_length=160)
|
||||
status: str = Field("pendiente", max_length=20)
|
||||
position: int = Field(0, ge=0)
|
||||
planned_date: datetime | None = None
|
||||
actual_date: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentEventCreate(ShipmentEventBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShipmentEventUpdate(BaseModel):
|
||||
event_type: str | None = Field(None, max_length=60)
|
||||
title: str | None = Field(None, min_length=1, max_length=160)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
position: int | None = Field(None, ge=0)
|
||||
planned_date: datetime | None = None
|
||||
actual_date: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentEventResponse(ShipmentEventBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShipmentDocumentBase(BaseModel):
|
||||
shipment_id: int
|
||||
doc_kind: str = Field("otro", max_length=10)
|
||||
|
||||
@@ -53,6 +53,26 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Hito / bitácora del embarque (Diagramas 2 y 3). Timeline de la operación."""
|
||||
|
||||
__tablename__ = "shipment_events"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
shipment_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
|
||||
)
|
||||
event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito
|
||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
# pendiente | completado | omitido
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'"))
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
planned_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
actual_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ from .dto import (
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentResponse,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventResponse,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentResponse,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
@@ -130,3 +133,67 @@ def delete_shipment_document(
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Bitácora / hitos -----
|
||||
|
||||
@router.get("/shipments/{shipment_id}/events", response_model=list[ShipmentEventResponse])
|
||||
def list_shipment_events(
|
||||
shipment_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
return service.get_shipment_events(db, current_user["tenant_id"], company_id, shipment_id)
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/events/seed", response_model=list[ShipmentEventResponse])
|
||||
def seed_shipment_events(
|
||||
shipment_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.seed_default_milestones(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/shipment-events", response_model=ShipmentEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment_event(
|
||||
payload: ShipmentEventCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_shipment_event(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/shipment-events/{event_id}", response_model=ShipmentEventResponse)
|
||||
def update_shipment_event(
|
||||
event_id: int,
|
||||
payload: ShipmentEventUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/shipment-events/{event_id}/complete", response_model=ShipmentEventResponse)
|
||||
def complete_shipment_event(
|
||||
event_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.complete_shipment_event(db, event_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/shipment-events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment_event(
|
||||
event_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_shipment_event(db, event_id, current_user["tenant_id"], company_id)
|
||||
|
||||
@@ -8,8 +8,36 @@ 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
|
||||
|
||||
from .dto import ShipmentCreate, ShipmentDocumentCreate, ShipmentDocumentUpdate, ShipmentUpdate
|
||||
from .models import Shipment, ShipmentDocument
|
||||
from .dto import (
|
||||
ShipmentCreate,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
from .models import Shipment, ShipmentDocument, ShipmentEvent
|
||||
|
||||
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3)
|
||||
_DEFAULT_MILESTONES = {
|
||||
"exportacion": [
|
||||
("recoleccion", "Recolección de mercancía"),
|
||||
("despacho_exportacion", "Despacho de exportación"),
|
||||
("embarque", "Embarque"),
|
||||
("zarpe", "Zarpe / Salida del transporte"),
|
||||
("arribo", "Arribo a destino"),
|
||||
("entrega", "Entrega al consignatario"),
|
||||
],
|
||||
"importacion": [
|
||||
("aviso_llegada", "Aviso de llegada"),
|
||||
("recepcion_docs", "Recepción de documentos (MBL/MAWB)"),
|
||||
("despacho_importacion", "Despacho de importación"),
|
||||
("liberacion", "Liberación de mercancía"),
|
||||
("retiro", "Retiro en puerto / aeropuerto"),
|
||||
("traslado", "Traslado a bodega del importador"),
|
||||
("entrega", "Entrega final al cliente"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
|
||||
@@ -228,3 +256,91 @@ def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_i
|
||||
obj = _get_document(db, doc_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ----- Bitácora / hitos del embarque -----
|
||||
|
||||
def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]:
|
||||
query = db.query(ShipmentEvent).filter(
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
)
|
||||
if shipment_id is not None:
|
||||
query = query.filter(ShipmentEvent.shipment_id == shipment_id)
|
||||
return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all()
|
||||
|
||||
|
||||
def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
|
||||
obj = (
|
||||
db.query(ShipmentEvent)
|
||||
.filter(
|
||||
ShipmentEvent.id == event_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent:
|
||||
get_shipment(db, payload.shipment_id, tenant_id, company_id)
|
||||
obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent:
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, field, value)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
obj.status = "completado"
|
||||
obj.actual_date = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]:
|
||||
"""Crea los hitos por defecto del embarque según su tipo de operación (import/export)."""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
existing = get_shipment_events(db, tenant_id, company_id, shipment_id)
|
||||
if existing:
|
||||
return existing
|
||||
milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", [])
|
||||
if not milestones:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
|
||||
)
|
||||
created = []
|
||||
for position, (event_type, title) in enumerate(milestones):
|
||||
ev = ShipmentEvent(
|
||||
shipment_id=shipment_id, event_type=event_type, title=title, status="pendiente",
|
||||
position=position, tenant_id=tenant_id, company_id=company_id,
|
||||
)
|
||||
db.add(ev)
|
||||
created.append(ev)
|
||||
db.commit()
|
||||
for ev in created:
|
||||
db.refresh(ev)
|
||||
return created
|
||||
|
||||
Reference in New Issue
Block a user