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:
Aduanasoft
2026-07-15 07:18:11 -06:00
parent 6f200b4505
commit 0b12ad5354
20 changed files with 1167 additions and 3 deletions

View File

@@ -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)

View File

@@ -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.)."""

View File

@@ -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)

View File

@@ -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