Compare commits
49 Commits
b12af1a561
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e6fba75d | ||
|
|
36e98ee976 | ||
|
|
915bdd19fe | ||
|
|
87d23b3d23 | ||
|
|
e269e46d88 | ||
|
|
03055cd377 | ||
|
|
e09cb02b8b | ||
|
|
206ff450f8 | ||
|
|
ef7e69ed57 | ||
|
|
fa542ddf18 | ||
|
|
2ae6901b6a | ||
|
|
8576ec7e37 | ||
|
|
de8557dec2 | ||
|
|
a2a474f8c8 | ||
|
|
8aef99df9c | ||
|
|
5f9c7cf000 | ||
|
|
71be225b33 | ||
|
|
0cffd351df | ||
|
|
a14acf59e5 | ||
|
|
0d8c4ddf62 | ||
|
|
de9e35c501 | ||
|
|
868724d1f2 | ||
|
|
cd3f8c62a4 | ||
|
|
387b3c0e78 | ||
|
|
706da34f4a | ||
|
|
ce8b042e84 | ||
|
|
63ad2e2ecd | ||
|
|
0b6848bbdd | ||
|
|
29170f7c8c | ||
|
|
b76d42be83 | ||
|
|
223395b430 | ||
|
|
579c6e1f19 | ||
|
|
5d1c65f236 | ||
|
|
a613a7a6aa | ||
|
|
45f128a551 | ||
|
|
c6f18013b3 | ||
|
|
f03ac38c4f | ||
|
|
5a250204b5 | ||
|
|
c983c744ac | ||
|
|
e806512a89 | ||
|
|
3ea8d5f3ef | ||
|
|
e79705e6e3 | ||
|
|
116d2e7f5a | ||
|
|
e724aeae50 | ||
|
|
0b12ad5354 | ||
|
|
6f200b4505 | ||
|
|
a196c44fae | ||
|
|
c26e04bcff | ||
|
|
79135d9b5a |
@@ -0,0 +1,60 @@
|
||||
"""crm quote_settings (marca por tenant) + quotes.pdf_file_key
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: f8a9b0c1d2e3
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
|
||||
PDF de cotización con formato maestro + branding por tenant + envío por correo.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a0b1c2d3e4f5"
|
||||
down_revision: Union[str, None] = "f8a9b0c1d2e3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA)
|
||||
|
||||
op.create_table(
|
||||
"quote_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("emitter_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("emitter_rfc", sa.String(length=13), nullable=True),
|
||||
sa.Column("emitter_address", sa.Text(), nullable=True),
|
||||
sa.Column("emitter_phone", sa.String(length=60), nullable=True),
|
||||
sa.Column("emitter_email", sa.String(length=255), nullable=True),
|
||||
sa.Column("emitter_website", sa.String(length=255), nullable=True),
|
||||
sa.Column("logo_file_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")),
|
||||
sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")),
|
||||
sa.Column("default_terms", sa.Text(), nullable=True),
|
||||
sa.Column("footer_note", sa.Text(), nullable=True),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA)
|
||||
# Una configuración por compañía
|
||||
op.create_index(
|
||||
"uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"],
|
||||
unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("quote_settings", schema=SCHEMA)
|
||||
op.drop_column("quotes", "pdf_file_key", schema=SCHEMA)
|
||||
@@ -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)
|
||||
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""crm commercial (service_requests, rate_requests, quotes, quote_items) + ops (shipments, documents)
|
||||
|
||||
Revision ID: b3c4d5e6f7a8
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-07-14 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b3c4d5e6f7a8"
|
||||
down_revision: Union[str, None] = "a7b8c9d0e1f2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _scoped_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def _scoped_indexes(table: str, schema: str) -> None:
|
||||
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
|
||||
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
|
||||
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- crm.service_requests ----------
|
||||
op.create_table(
|
||||
"service_requests",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reference", sa.String(length=40), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("operation_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("transport_mode", sa.String(length=20), nullable=True),
|
||||
sa.Column("service_type", sa.String(length=20), nullable=True),
|
||||
sa.Column("incoterm", sa.String(length=10), nullable=True),
|
||||
sa.Column("origin", sa.String(length=160), nullable=True),
|
||||
sa.Column("destination", sa.String(length=160), nullable=True),
|
||||
sa.Column("cargo_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("weight", sa.Numeric(precision=14, scale=3), nullable=True),
|
||||
sa.Column("volume", sa.Numeric(precision=14, scale=3), nullable=True),
|
||||
sa.Column("load_type", sa.String(length=10), nullable=True),
|
||||
sa.Column("container_equipment", sa.String(length=120), nullable=True),
|
||||
sa.Column("commodity", sa.Text(), nullable=True),
|
||||
sa.Column("required_date", sa.Date(), nullable=True),
|
||||
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("requirements", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'nueva'")),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
|
||||
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
|
||||
schema="crm",
|
||||
)
|
||||
_scoped_indexes("service_requests", "crm")
|
||||
op.create_index("ix_crm_service_requests_reference", "service_requests", ["reference"], schema="crm")
|
||||
op.create_index("ix_crm_service_requests_account_id", "service_requests", ["account_id"], schema="crm")
|
||||
op.create_index("ix_crm_service_requests_status", "service_requests", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_service_requests_owner_user_id", "service_requests", ["owner_user_id"], schema="crm")
|
||||
|
||||
# ---------- crm.rate_requests ----------
|
||||
op.create_table(
|
||||
"rate_requests",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("service_request_id", sa.Integer(), nullable=False),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("concept", sa.String(length=60), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'solicitada'")),
|
||||
sa.Column("rate_amount", sa.Numeric(precision=14, scale=2), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), nullable=True),
|
||||
sa.Column("valid_until", sa.Date(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
|
||||
schema="crm",
|
||||
)
|
||||
_scoped_indexes("rate_requests", "crm")
|
||||
op.create_index("ix_crm_rate_requests_service_request_id", "rate_requests", ["service_request_id"], schema="crm")
|
||||
op.create_index("ix_crm_rate_requests_supplier_id", "rate_requests", ["supplier_id"], schema="crm")
|
||||
|
||||
# ---------- crm.quotes ----------
|
||||
op.create_table(
|
||||
"quotes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reference", sa.String(length=40), nullable=True),
|
||||
sa.Column("service_request_id", sa.Integer(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'USD'")),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
|
||||
sa.Column("issue_date", sa.Date(), nullable=True),
|
||||
sa.Column("valid_until", sa.Date(), nullable=True),
|
||||
sa.Column("total_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("rejected_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("terms", sa.Text(), nullable=True),
|
||||
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
|
||||
schema="crm",
|
||||
)
|
||||
_scoped_indexes("quotes", "crm")
|
||||
op.create_index("ix_crm_quotes_reference", "quotes", ["reference"], schema="crm")
|
||||
op.create_index("ix_crm_quotes_service_request_id", "quotes", ["service_request_id"], schema="crm")
|
||||
op.create_index("ix_crm_quotes_account_id", "quotes", ["account_id"], schema="crm")
|
||||
op.create_index("ix_crm_quotes_status", "quotes", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_quotes_owner_user_id", "quotes", ["owner_user_id"], schema="crm")
|
||||
|
||||
# ---------- crm.quote_items ----------
|
||||
op.create_table(
|
||||
"quote_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("quote_id", sa.Integer(), nullable=False),
|
||||
sa.Column("concept", sa.String(length=60), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("unit_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("unit_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("currency", sa.String(length=3), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
|
||||
schema="crm",
|
||||
)
|
||||
_scoped_indexes("quote_items", "crm")
|
||||
op.create_index("ix_crm_quote_items_quote_id", "quote_items", ["quote_id"], schema="crm")
|
||||
|
||||
# ---------- schema ops ----------
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS ops")
|
||||
|
||||
op.create_table(
|
||||
"shipments",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reference", sa.String(length=40), nullable=True),
|
||||
sa.Column("quote_id", sa.Integer(), nullable=True),
|
||||
sa.Column("service_request_id", sa.Integer(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("operation_type", sa.String(length=20), nullable=True),
|
||||
sa.Column("transport_mode", sa.String(length=20), nullable=True),
|
||||
sa.Column("service_type", sa.String(length=20), nullable=True),
|
||||
sa.Column("incoterm", sa.String(length=10), nullable=True),
|
||||
sa.Column("origin", sa.String(length=160), nullable=True),
|
||||
sa.Column("destination", sa.String(length=160), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierta'")),
|
||||
sa.Column("booking_number", sa.String(length=60), nullable=True),
|
||||
sa.Column("carrier_supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("customs_agent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("cutoff_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("etd", sa.Date(), nullable=True),
|
||||
sa.Column("eta", sa.Date(), nullable=True),
|
||||
sa.Column("vessel_flight", sa.String(length=120), nullable=True),
|
||||
sa.Column("container_number", sa.String(length=60), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
|
||||
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
|
||||
sa.ForeignKeyConstraint(["carrier_supplier_id"], ["crm.suppliers.id"]),
|
||||
sa.ForeignKeyConstraint(["customs_agent_id"], ["crm.suppliers.id"]),
|
||||
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
|
||||
schema="ops",
|
||||
)
|
||||
_scoped_indexes("shipments", "ops")
|
||||
op.create_index("ix_ops_shipments_reference", "shipments", ["reference"], schema="ops")
|
||||
op.create_index("ix_ops_shipments_quote_id", "shipments", ["quote_id"], schema="ops")
|
||||
op.create_index("ix_ops_shipments_account_id", "shipments", ["account_id"], schema="ops")
|
||||
op.create_index("ix_ops_shipments_status", "shipments", ["status"], schema="ops")
|
||||
op.create_index("ix_ops_shipments_owner_user_id", "shipments", ["owner_user_id"], schema="ops")
|
||||
|
||||
op.create_table(
|
||||
"shipment_documents",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("shipment_id", sa.Integer(), nullable=False),
|
||||
sa.Column("doc_kind", sa.String(length=10), nullable=False, server_default=sa.text("'otro'")),
|
||||
sa.Column("doc_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("number", sa.String(length=80), nullable=True),
|
||||
sa.Column("issue_date", sa.Date(), nullable=True),
|
||||
sa.Column("file_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("file_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
|
||||
schema="ops",
|
||||
)
|
||||
_scoped_indexes("shipment_documents", "ops")
|
||||
op.create_index("ix_ops_shipment_documents_shipment_id", "shipment_documents", ["shipment_id"], schema="ops")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("shipment_documents", schema="ops")
|
||||
op.drop_table("shipments", schema="ops")
|
||||
op.execute("DROP SCHEMA IF EXISTS ops")
|
||||
op.drop_table("quote_items", schema="crm")
|
||||
op.drop_table("quotes", schema="crm")
|
||||
op.drop_table("rate_requests", schema="crm")
|
||||
op.drop_table("service_requests", schema="crm")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Costo estimado por servicio adicional en la solicitud de servicio
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
|
||||
Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo})
|
||||
para capturar el costo estimado de cada servicio adicional marcado; ese costo se
|
||||
usa como punto de partida al sembrar los conceptos de la cotización.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c2d3e4f5a6b7"
|
||||
down_revision: Union[str, None] = "b1c2d3e4f5a6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"service_requests",
|
||||
sa.Column("additional_service_costs", sa.JSON(), nullable=True),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("service_requests", "additional_service_costs", schema=SCHEMA)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""fin (invoices, invoice_items, payments) + ops.shipment_events
|
||||
|
||||
Revision ID: c4d5e6f7a8b9
|
||||
Revises: b3c4d5e6f7a8
|
||||
Create Date: 2026-07-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c4d5e6f7a8b9"
|
||||
down_revision: Union[str, None] = "b3c4d5e6f7a8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _scoped_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def _scoped_indexes(table: str, schema: str) -> None:
|
||||
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
|
||||
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
|
||||
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- schema fin ----------
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS fin")
|
||||
|
||||
op.create_table(
|
||||
"invoices",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("reference", sa.String(length=40), nullable=True),
|
||||
sa.Column("shipment_id", sa.Integer(), nullable=True),
|
||||
sa.Column("quote_id", sa.Integer(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
|
||||
sa.Column("issue_date", sa.Date(), nullable=True),
|
||||
sa.Column("due_date", sa.Date(), nullable=True),
|
||||
sa.Column("subtotal", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("tax_rate", sa.Numeric(precision=5, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("tax_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("total", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("paid_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("balance", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("bank_info", sa.Text(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
|
||||
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
|
||||
schema="fin",
|
||||
)
|
||||
_scoped_indexes("invoices", "fin")
|
||||
op.create_index("ix_fin_invoices_reference", "invoices", ["reference"], schema="fin")
|
||||
op.create_index("ix_fin_invoices_shipment_id", "invoices", ["shipment_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoices_account_id", "invoices", ["account_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoices_status", "invoices", ["status"], schema="fin")
|
||||
op.create_index("ix_fin_invoices_owner_user_id", "invoices", ["owner_user_id"], schema="fin")
|
||||
|
||||
op.create_table(
|
||||
"invoice_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||
sa.Column("concept", sa.String(length=60), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("unit_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
|
||||
schema="fin",
|
||||
)
|
||||
_scoped_indexes("invoice_items", "fin")
|
||||
op.create_index("ix_fin_invoice_items_invoice_id", "invoice_items", ["invoice_id"], schema="fin")
|
||||
|
||||
op.create_table(
|
||||
"payments",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False),
|
||||
sa.Column("payment_date", sa.Date(), nullable=True),
|
||||
sa.Column("method", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference", sa.String(length=120), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
|
||||
schema="fin",
|
||||
)
|
||||
_scoped_indexes("payments", "fin")
|
||||
op.create_index("ix_fin_payments_invoice_id", "payments", ["invoice_id"], schema="fin")
|
||||
|
||||
# ---------- ops.shipment_events ----------
|
||||
op.create_table(
|
||||
"shipment_events",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("shipment_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=60), nullable=True),
|
||||
sa.Column("title", sa.String(length=160), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pendiente'")),
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("planned_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("actual_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_scoped_columns(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
|
||||
schema="ops",
|
||||
)
|
||||
_scoped_indexes("shipment_events", "ops")
|
||||
op.create_index("ix_ops_shipment_events_shipment_id", "shipment_events", ["shipment_id"], schema="ops")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("shipment_events", schema="ops")
|
||||
op.drop_table("payments", schema="fin")
|
||||
op.drop_table("invoice_items", schema="fin")
|
||||
op.drop_table("invoices", schema="fin")
|
||||
op.execute("DROP SCHEMA IF EXISTS fin")
|
||||
92
backend/alembic/versions/d5e6f7a8b9c0_pdf_compliance.py
Normal file
92
backend/alembic/versions/d5e6f7a8b9c0_pdf_compliance.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Cumplimiento PDF agente de carga: decisiones/costos en ops, envío/revisión en fin,
|
||||
continuidad comercial en crm (opportunity_id, contacto).
|
||||
|
||||
Revision ID: d5e6f7a8b9c0
|
||||
Revises: c4d5e6f7a8b9
|
||||
Create Date: 2026-07-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d5e6f7a8b9c0"
|
||||
down_revision: Union[str, None] = "c4d5e6f7a8b9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- ops.shipments: transporte terrestre, reprogramación y cierre operativo ----------
|
||||
op.add_column("shipments", sa.Column("ground_carrier_supplier_id", sa.Integer(), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("pickup_at", sa.DateTime(), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("previous_etd", sa.Date(), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("actual_cost_total", sa.Numeric(precision=14, scale=2), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("cost_currency", sa.String(length=3), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("closed_at", sa.DateTime(), nullable=True), schema="ops")
|
||||
op.add_column("shipments", sa.Column("closed_by", sa.String(length=64), nullable=True), schema="ops")
|
||||
op.create_foreign_key(
|
||||
"fk_ops_shipments_ground_carrier_supplier_id", "shipments", "suppliers",
|
||||
["ground_carrier_supplier_id"], ["id"], source_schema="ops", referent_schema="crm",
|
||||
)
|
||||
|
||||
# ---------- ops.shipment_events: puntos de decisión y ciclo de corrección ----------
|
||||
op.add_column("shipment_events", sa.Column("kind", sa.String(length=20), nullable=False, server_default=sa.text("'hito'")), schema="ops")
|
||||
op.add_column("shipment_events", sa.Column("outcome", sa.String(length=20), nullable=True), schema="ops")
|
||||
op.add_column("shipment_events", sa.Column("parent_event_id", sa.Integer(), nullable=True), schema="ops")
|
||||
op.add_column("shipment_events", sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("1")), schema="ops")
|
||||
op.create_foreign_key(
|
||||
"fk_ops_shipment_events_parent_event_id", "shipment_events", "shipment_events",
|
||||
["parent_event_id"], ["id"], source_schema="ops", referent_schema="ops",
|
||||
)
|
||||
|
||||
# ---------- fin.invoices: costos de operación, PDF y revisión del cliente ----------
|
||||
op.add_column("invoices", sa.Column("ops_cost_total", sa.Numeric(precision=14, scale=2), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("client_reviewed_at", sa.DateTime(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("client_approved", sa.Boolean(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("review_notes", sa.Text(), nullable=True), schema="fin")
|
||||
|
||||
# ---------- crm.service_requests: continuidad comercial y contacto ----------
|
||||
op.add_column("service_requests", sa.Column("opportunity_id", sa.Integer(), nullable=True), schema="crm")
|
||||
op.add_column("service_requests", sa.Column("first_contact_at", sa.DateTime(), nullable=True), schema="crm")
|
||||
op.add_column("service_requests", sa.Column("first_contact_notes", sa.Text(), nullable=True), schema="crm")
|
||||
op.create_index("ix_crm_service_requests_opportunity_id", "service_requests", ["opportunity_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_service_requests_opportunity_id", "service_requests", "opportunities",
|
||||
["opportunity_id"], ["id"], source_schema="crm", referent_schema="crm",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# crm.service_requests
|
||||
op.drop_constraint("fk_crm_service_requests_opportunity_id", "service_requests", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_service_requests_opportunity_id", table_name="service_requests", schema="crm")
|
||||
op.drop_column("service_requests", "first_contact_notes", schema="crm")
|
||||
op.drop_column("service_requests", "first_contact_at", schema="crm")
|
||||
op.drop_column("service_requests", "opportunity_id", schema="crm")
|
||||
|
||||
# fin.invoices
|
||||
op.drop_column("invoices", "review_notes", schema="fin")
|
||||
op.drop_column("invoices", "client_approved", schema="fin")
|
||||
op.drop_column("invoices", "client_reviewed_at", schema="fin")
|
||||
op.drop_column("invoices", "pdf_file_key", schema="fin")
|
||||
op.drop_column("invoices", "ops_cost_total", schema="fin")
|
||||
|
||||
# ops.shipment_events
|
||||
op.drop_constraint("fk_ops_shipment_events_parent_event_id", "shipment_events", schema="ops", type_="foreignkey")
|
||||
op.drop_column("shipment_events", "attempt", schema="ops")
|
||||
op.drop_column("shipment_events", "parent_event_id", schema="ops")
|
||||
op.drop_column("shipment_events", "outcome", schema="ops")
|
||||
op.drop_column("shipment_events", "kind", schema="ops")
|
||||
|
||||
# ops.shipments
|
||||
op.drop_constraint("fk_ops_shipments_ground_carrier_supplier_id", "shipments", schema="ops", type_="foreignkey")
|
||||
op.drop_column("shipments", "closed_by", schema="ops")
|
||||
op.drop_column("shipments", "closed_at", schema="ops")
|
||||
op.drop_column("shipments", "cost_currency", schema="ops")
|
||||
op.drop_column("shipments", "actual_cost_total", schema="ops")
|
||||
op.drop_column("shipments", "previous_etd", schema="ops")
|
||||
op.drop_column("shipments", "pickup_at", schema="ops")
|
||||
op.drop_column("shipments", "ground_carrier_supplier_id", schema="ops")
|
||||
90
backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py
Normal file
90
backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
|
||||
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
|
||||
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: Union[str, None] = "d5e6f7a8b9c0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ----- crm.catalog_items -----
|
||||
op.create_table(
|
||||
"catalog_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("catalog", sa.String(length=60), nullable=False),
|
||||
sa.Column("code", sa.String(length=64), nullable=False),
|
||||
sa.Column("label", sa.String(length=255), nullable=False),
|
||||
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
|
||||
sa.Column("parent_code", sa.String(length=64), nullable=True),
|
||||
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("extra", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
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"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
|
||||
op.create_index(
|
||||
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
|
||||
)
|
||||
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_global",
|
||||
"catalog_items",
|
||||
["catalog", "code"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_tenant",
|
||||
"catalog_items",
|
||||
["catalog", "code", "tenant_id"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# ----- columnas nuevas -----
|
||||
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
|
||||
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
|
||||
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
# Proveedores: "otro" de la clasificación múltiple.
|
||||
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
|
||||
|
||||
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_table("catalog_items", schema=SCHEMA)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ampliar crm.addresses.country a 3 (país ISO alfa-3 del catálogo)
|
||||
|
||||
Revision ID: e7f8a9b0c1d2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-07-22 00:30:00.000000
|
||||
|
||||
El catálogo de País usa códigos ISO 3166 alfa-3 (MEX, USA, …). La columna
|
||||
addresses.country era String(2); se amplía a String(3) para almacenarlos.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e7f8a9b0c1d2"
|
||||
down_revision: Union[str, None] = "e6f7a8b9c0d1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"addresses", "country",
|
||||
type_=sa.String(length=3),
|
||||
existing_type=sa.String(length=2),
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'MEX'"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Trunca a 2 chars por si hay códigos alfa-3 guardados (rollback de dev).
|
||||
op.execute("UPDATE crm.addresses SET country = left(country, 2) WHERE length(country) > 2")
|
||||
op.alter_column(
|
||||
"addresses", "country",
|
||||
type_=sa.String(length=2),
|
||||
existing_type=sa.String(length=3),
|
||||
existing_nullable=True,
|
||||
server_default=sa.text("'MX'"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal file
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""crm rates: tarifarios (rate_sheets/lanes/breaks/charges)
|
||||
|
||||
Revision ID: f8a9b0c1d2e3
|
||||
Revises: e7f8a9b0c1d2
|
||||
Create Date: 2026-07-27 00:00:00.000000
|
||||
|
||||
Módulo Tarifario: base de costos para Cotizaciones (import por Excel + motor de costeo).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f8a9b0c1d2e3"
|
||||
down_revision: Union[str, None] = "e7f8a9b0c1d2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def _scoped() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
def _idx(table: str) -> None:
|
||||
op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA)
|
||||
op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA)
|
||||
op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ----- rate_sheets -----
|
||||
op.create_table(
|
||||
"rate_sheets",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), nullable=True, server_default=sa.text("'USD'")),
|
||||
sa.Column("valid_from", sa.Date(), nullable=True),
|
||||
sa.Column("valid_to", sa.Date(), nullable=True),
|
||||
sa.Column("default_origin", sa.String(length=20), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
|
||||
sa.Column("source_file", sa.String(length=512), nullable=True),
|
||||
sa.Column("source_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_scoped(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
_idx("rate_sheets")
|
||||
op.create_index("ix_crm_rate_sheets_mode", "rate_sheets", ["mode"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_rate_sheets_supplier_id", "rate_sheets", ["supplier_id"], schema=SCHEMA)
|
||||
|
||||
# ----- rate_lanes -----
|
||||
op.create_table(
|
||||
"rate_lanes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("rate_sheet_id", sa.Integer(), nullable=False),
|
||||
sa.Column("origin", sa.String(length=20), nullable=True),
|
||||
sa.Column("destination", sa.String(length=20), nullable=True),
|
||||
sa.Column("region", sa.String(length=60), nullable=True),
|
||||
sa.Column("equipment_type", sa.String(length=20), nullable=True),
|
||||
sa.Column("rate_unit", sa.String(length=20), nullable=True),
|
||||
sa.Column("min_charge", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||
sa.Column("flat_rate", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||
sa.Column("transit_days", sa.Integer(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
*_scoped(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
_idx("rate_lanes")
|
||||
op.create_index("ix_crm_rate_lanes_rate_sheet_id", "rate_lanes", ["rate_sheet_id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_rate_lanes_origin", "rate_lanes", ["origin"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_rate_lanes_destination", "rate_lanes", ["destination"], schema=SCHEMA)
|
||||
|
||||
# ----- rate_breaks -----
|
||||
op.create_table(
|
||||
"rate_breaks",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("rate_lane_id", sa.Integer(), nullable=False),
|
||||
sa.Column("from_qty", sa.Numeric(precision=12, scale=3), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("rate", sa.Numeric(precision=14, scale=4), nullable=False, server_default=sa.text("0")),
|
||||
*_scoped(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
_idx("rate_breaks")
|
||||
op.create_index("ix_crm_rate_breaks_rate_lane_id", "rate_breaks", ["rate_lane_id"], schema=SCHEMA)
|
||||
|
||||
# ----- rate_charges -----
|
||||
op.create_table(
|
||||
"rate_charges",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("rate_sheet_id", sa.Integer(), nullable=True),
|
||||
sa.Column("rate_lane_id", sa.Integer(), nullable=True),
|
||||
sa.Column("concept", sa.String(length=60), nullable=False),
|
||||
sa.Column("charge_type", sa.String(length=20), nullable=False, server_default=sa.text("'fijo'")),
|
||||
sa.Column("value", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||
sa.Column("condition", sa.Text(), nullable=True),
|
||||
*_scoped(),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
|
||||
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
_idx("rate_charges")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("rate_charges", schema=SCHEMA)
|
||||
op.drop_table("rate_breaks", schema=SCHEMA)
|
||||
op.drop_table("rate_lanes", schema=SCHEMA)
|
||||
op.drop_table("rate_sheets", schema=SCHEMA)
|
||||
@@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel):
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
# Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED.
|
||||
# Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y
|
||||
# que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token
|
||||
# de arriba sigue siendo el de Keycloak (para llamadas al Hub).
|
||||
session_token: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de refresh token"""
|
||||
|
||||
refresh_token: str = Field(..., description="Refresh token")
|
||||
# Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el
|
||||
# inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el
|
||||
# refresh del token KC contra el Hub falla ("Token is not active" del relay).
|
||||
session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)")
|
||||
# session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser).
|
||||
session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)")
|
||||
|
||||
|
||||
class UserInfoResponseDTO(BaseModel):
|
||||
|
||||
@@ -24,6 +24,12 @@ from .dto import (
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -402,10 +408,16 @@ async def dev_login():
|
||||
@router.get("/my-companies")
|
||||
async def get_my_companies(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Retorna las compañías accesibles para el usuario actual.
|
||||
STUB: implementa con tu modelo de compañías.
|
||||
|
||||
Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con
|
||||
el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se
|
||||
garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se
|
||||
resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario).
|
||||
|
||||
En dev-local retorna una compañía ficticia para que el dashboard funcione.
|
||||
"""
|
||||
from core.config import settings
|
||||
@@ -415,8 +427,239 @@ async def get_my_companies(
|
||||
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||
"name": "Empresa Dev Local",
|
||||
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||
"rfc": None,
|
||||
"logo": None,
|
||||
"is_active": True,
|
||||
}]
|
||||
|
||||
# Implementa aquí la consulta real a tu tabla de compañías.
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from sqlalchemy import text
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
|
||||
# defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de
|
||||
# su tenant. Así cualquier usuario del mismo tenant (misma organización del
|
||||
# Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se
|
||||
# asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin
|
||||
# asigna el rol; el primer usuario recibe super_admin vía /permissions/me).
|
||||
if tenant_id:
|
||||
tenant_id = int(tenant_id)
|
||||
company_ids = [
|
||||
int(r[0])
|
||||
for r in db.execute(
|
||||
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
|
||||
{"tid": tenant_id},
|
||||
).fetchall()
|
||||
]
|
||||
if not company_ids:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).fetchone()
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
company_ids = [int(created[0])]
|
||||
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
|
||||
# Auto-ligado por tenant (solo membresía, sin rol).
|
||||
if user_id:
|
||||
for cid in company_ids:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc)
|
||||
|
||||
# 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona
|
||||
# también para hub_admin sin tenant en el token: verá las compañías que creó
|
||||
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
|
||||
if not user_id:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
|
||||
FROM a76.company c
|
||||
LEFT JOIN core.tenants t ON t.id = c.tenant_id
|
||||
WHERE c.id IN (
|
||||
SELECT company_id FROM core.user_tenants
|
||||
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
|
||||
UNION
|
||||
SELECT company_id FROM core.user_company_roles
|
||||
WHERE user_id = :uid AND is_active
|
||||
)
|
||||
ORDER BY c.id
|
||||
"""
|
||||
),
|
||||
{"uid": str(user_id)},
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(r[0]),
|
||||
"name": r[1] or "Empresa",
|
||||
"tenant_id": int(r[4]),
|
||||
"tenant_name": r[5],
|
||||
"tenant_slug": r[6],
|
||||
"rfc": r[2],
|
||||
"logo": r[3],
|
||||
"is_active": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class _CreateCompanyDTO(BaseModel):
|
||||
name: str
|
||||
tenant_id: int
|
||||
rfc: Optional[str] = None
|
||||
|
||||
|
||||
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
|
||||
"""
|
||||
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
|
||||
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
|
||||
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
|
||||
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
|
||||
responde, no bloquea (se devuelven los tenants ya sincronizados).
|
||||
"""
|
||||
import httpx
|
||||
from sqlalchemy import text as _text
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
sid = request.cookies.get("crm_sid") if request else None
|
||||
kc_token = None
|
||||
if sid:
|
||||
sess = session_store.get_session(sid)
|
||||
kc_token = (sess or {}).get("access_token")
|
||||
if not kc_token:
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/tenants",
|
||||
headers={"Authorization": f"Bearer {kc_token}"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
|
||||
return
|
||||
payload = r.json()
|
||||
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
|
||||
for t in items:
|
||||
tid = t.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
name = t.get("name") or t.get("display_name") or t.get("slug")
|
||||
slug = t.get("slug") or f"tenant-{tid}"
|
||||
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
|
||||
if existing:
|
||||
if name and existing.name != name:
|
||||
existing.name = name
|
||||
else:
|
||||
db.add(Tenant(
|
||||
id=int(tid), name=name or slug, slug=slug,
|
||||
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
|
||||
))
|
||||
db.commit()
|
||||
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/assignable-tenants")
|
||||
async def assignable_tenants(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
|
||||
aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
|
||||
con tenant ve el suyo.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
|
||||
|
||||
if is_hub_admin(current_user):
|
||||
# Sincroniza automáticamente los tenants del Workspace antes de listar.
|
||||
await _sync_tenants_from_hub(request, db)
|
||||
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
|
||||
|
||||
tid = resolve_effective_tenant_id_from_user(current_user)
|
||||
if tid:
|
||||
t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/companies", status_code=201)
|
||||
async def create_company(
|
||||
data: _CreateCompanyDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
|
||||
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
|
||||
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
is_hub_admin,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
|
||||
name = (data.name or "").strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
|
||||
|
||||
tid = int(data.tenant_id)
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
|
||||
|
||||
# Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant;
|
||||
# un usuario ligado a un tenant, solo en el suyo.
|
||||
if not is_hub_admin(current_user):
|
||||
own = resolve_effective_tenant_id_from_user(current_user)
|
||||
if own is None or int(own) != tid:
|
||||
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
|
||||
|
||||
created = db.execute(
|
||||
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
|
||||
{"t": tid, "n": name, "r": (data.rfc or None)},
|
||||
).fetchone()
|
||||
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
cid = int(created[0])
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
|
||||
|
||||
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}
|
||||
|
||||
@@ -213,55 +213,160 @@ class AuthService:
|
||||
logger.error(f"Unexpected login error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication error")
|
||||
|
||||
def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decodifica una sesión local del CRM (HS256) verificando la firma pero
|
||||
SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
|
||||
o None si la firma no valida o no es una sesión local del CRM.
|
||||
"""
|
||||
if not session_token:
|
||||
return None
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
session_token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
if not claims.get("crm_session") or claims.get("source") != "local":
|
||||
return None
|
||||
return claims
|
||||
|
||||
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construye los claims de la sesión local a partir del token KC (decode)."""
|
||||
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
claims: Dict[str, Any] = dict(kc_claims)
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
|
||||
if data.get("tenant_id") is not None:
|
||||
claims["tenant_id"] = data.get("tenant_id")
|
||||
if data.get("tenant_slug") is not None:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
|
||||
is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
|
||||
responde, se cae al decode del token KC. Así la sesión local sabe si el
|
||||
usuario es hub_admin sin volver a consultar al Hub en cada request.
|
||||
"""
|
||||
from core.security import verify_token
|
||||
|
||||
claims: Dict[str, Any] = {}
|
||||
try:
|
||||
info = await verify_token(data.get("access_token", ""))
|
||||
if isinstance(info, dict):
|
||||
claims = dict(info)
|
||||
except Exception as exc:
|
||||
logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)
|
||||
|
||||
if not claims:
|
||||
return self._session_claims_from_kc(data)
|
||||
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia.
|
||||
if data.get("tenant_id") is not None:
|
||||
claims["tenant_id"] = data.get("tenant_id")
|
||||
if data.get("tenant_slug") is not None:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Refresca el access token usando el Hub
|
||||
Refresca la sesión.
|
||||
|
||||
- Intenta el refresh del token KC contra el Hub (comportamiento histórico).
|
||||
- Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
|
||||
(patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
|
||||
modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
|
||||
- Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
|
||||
RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
|
||||
prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
|
||||
prev_sst = prev_claims.get("sst") if prev_claims else None
|
||||
prev_session_id = refresh_data.session_id if session_enabled else None
|
||||
|
||||
# Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
|
||||
# mande el cliente (puede estar desactualizado). Fail-silent.
|
||||
kc_refresh = refresh_data.refresh_token
|
||||
if session_enabled and prev_session_id:
|
||||
from core import session_store
|
||||
|
||||
sess = session_store.get_session(prev_session_id)
|
||||
if sess and sess.get("refresh_token"):
|
||||
kc_refresh = sess["refresh_token"]
|
||||
|
||||
# ── Intento de refresh del token KC contra el Hub ────────────────────────
|
||||
kc_ok = False
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json=refresh_data.model_dump()
|
||||
json={"refresh_token": kc_refresh},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
kc_ok = response.status_code == 200
|
||||
if kc_ok:
|
||||
data = response.json()
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
else:
|
||||
logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub inalcanzable en refresh: %s", exc)
|
||||
kc_ok = False
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(
|
||||
data.get("access_token", "")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "refresh",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
# ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
|
||||
if kc_ok and data is not None:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
|
||||
)
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
workspace_profile = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Token refresh error")
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
|
||||
resp = TokenResponseDTO(**data)
|
||||
|
||||
if session_enabled:
|
||||
from core import local_session, session_store
|
||||
|
||||
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
|
||||
claims = await self._session_claims(data)
|
||||
new_access = data.get("access_token", "")
|
||||
new_refresh = data.get("refresh_token", "")
|
||||
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
|
||||
if prev_session_id and session_store.get_session(prev_session_id):
|
||||
session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
|
||||
resp.session_id = prev_session_id
|
||||
else:
|
||||
resp.session_id = session_store.create_session(new_access, new_refresh, start)
|
||||
resp.session_token = local_session.mint_session_token(claims, session_start=start)
|
||||
|
||||
return resp
|
||||
|
||||
# El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
|
||||
# central de Keycloak (no hay re-emisión local de fallback). El usuario
|
||||
# re-entra por el App Launcher. La sesión local de larga duración evita el
|
||||
# bucle: el refresh solo se intenta al expirar la sesión local por
|
||||
# inactividad (idle), no cada ~60s como con el token KC crudo.
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||
"""
|
||||
|
||||
@@ -37,13 +37,33 @@ async def create_invite(
|
||||
required_permissions=["user.create"],
|
||||
)
|
||||
|
||||
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
|
||||
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
|
||||
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||
if not tenant_slug:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text(
|
||||
"SELECT t.slug FROM a76.company c "
|
||||
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
|
||||
),
|
||||
{"c": data.company_id},
|
||||
).first()
|
||||
if row and row[0]:
|
||||
tenant_slug = row[0]
|
||||
|
||||
created_by: str = current_user.get("sub") or ""
|
||||
|
||||
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
|
||||
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
kc_token = await get_hub_access_token(request)
|
||||
|
||||
service = InviteService(db)
|
||||
return await service.create_invite(
|
||||
data=data,
|
||||
created_by=created_by,
|
||||
tenant_slug=tenant_slug,
|
||||
user_access_token=credentials.credentials,
|
||||
user_access_token=kc_token or credentials.credentials,
|
||||
)
|
||||
|
||||
@@ -3,14 +3,19 @@ Dependencias de FastAPI para verificación de permisos multi-tenant.
|
||||
Proporciona decoradores y funciones para proteger rutas con permisos específicos.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Callable
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from functools import wraps
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user # Asumiendo que existe esta función
|
||||
from .service import PermissionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Dependencia para obtener el servicio de permisos
|
||||
def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService:
|
||||
"""
|
||||
@@ -19,6 +24,34 @@ def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionServ
|
||||
return PermissionService(db)
|
||||
|
||||
|
||||
def _authorize(
|
||||
permission_service: PermissionService,
|
||||
user_id: str,
|
||||
company_id: int,
|
||||
codes: List[str],
|
||||
require_all: bool,
|
||||
) -> bool:
|
||||
"""Verifica permisos y, en desarrollo, aplica auto-bootstrap si el acceso falla.
|
||||
|
||||
Replica el bootstrap perezoso de ``core.security.validate_access_to_resource``:
|
||||
en ``development`` el usuario (incluido el dev local) obtiene el rol super_admin
|
||||
con todos los permisos la primera vez que lo necesita, para no bloquear el
|
||||
entorno de desarrollo al activar el enforcement de permisos por área/carril.
|
||||
"""
|
||||
check = permission_service.has_all_permissions if require_all else permission_service.has_any_permission
|
||||
if check(user_id=user_id, company_id=company_id, permission_codes=codes):
|
||||
return True
|
||||
if settings.ENVIRONMENT == "development":
|
||||
try:
|
||||
permission_service.bootstrap_super_admin(user_id, company_id)
|
||||
if check(user_id=user_id, company_id=company_id, permission_codes=codes):
|
||||
logger.info("Auto-bootstrap de permisos en dev: user_id=%s company_id=%s", user_id, company_id)
|
||||
return True
|
||||
except Exception as exc: # el bootstrap nunca debe escalar como acceso concedido
|
||||
logger.warning("Auto-bootstrap de permisos falló: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# Clase para verificación de permisos (puede usarse como dependencia)
|
||||
class PermissionChecker:
|
||||
"""
|
||||
@@ -61,21 +94,8 @@ class PermissionChecker:
|
||||
detail="User ID not found in token",
|
||||
)
|
||||
|
||||
# Verificar permisos sobre la compañía
|
||||
if self.require_all:
|
||||
has_access = permission_service.has_all_permissions(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=self.required_permissions,
|
||||
)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=self.required_permissions,
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
# Verificar permisos sobre la compañía (con auto-bootstrap en desarrollo)
|
||||
if not _authorize(permission_service, user_id, company_id, self.required_permissions, self.require_all):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {', '.join(self.required_permissions)}",
|
||||
@@ -114,13 +134,7 @@ class RequirePermission:
|
||||
detail="User ID not found in token",
|
||||
)
|
||||
|
||||
has_permission = permission_service.has_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_code=self.permission_code,
|
||||
)
|
||||
|
||||
if not has_permission:
|
||||
if not _authorize(permission_service, user_id, company_id, [self.permission_code], require_all=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permission: {self.permission_code}",
|
||||
|
||||
27
backend/api/v1/modules/core/permissions/seed_v2.py
Normal file
27
backend/api/v1/modules/core/permissions/seed_v2.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Registro de permisos base del núcleo (core).
|
||||
|
||||
``PermissionService.sync_permissions`` y ``bootstrap_super_admin`` importan este
|
||||
módulo por su efecto secundario: dar de alta en el ``PermissionRegistry`` los
|
||||
permisos transversales del sistema antes de sincronizarlos a la base de datos.
|
||||
Los permisos de cada dominio (crm, ops, fin) se registran en el
|
||||
``register_permissions()`` de su propio módulo al importar sus routers.
|
||||
"""
|
||||
|
||||
from .registry import registry
|
||||
|
||||
MODULE = "core"
|
||||
|
||||
|
||||
def register_core_permissions() -> None:
|
||||
"""Da de alta los permisos base del sistema (idempotente)."""
|
||||
registry.register(code="core.access", description="Acceso al sistema", module=MODULE, action="access")
|
||||
registry.register(code="core.admin", description="Administración del sistema", module=MODULE, action="admin")
|
||||
registry.register(
|
||||
code="core.permissions.manage",
|
||||
description="Gestionar roles y permisos",
|
||||
module=MODULE,
|
||||
action="manage",
|
||||
)
|
||||
|
||||
|
||||
register_core_permissions()
|
||||
@@ -53,12 +53,17 @@ async def get_user_statistics(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
@@ -84,12 +89,19 @@ async def list_users(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
|
||||
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
|
||||
@@ -18,10 +18,12 @@ class AccountBase(BaseModel):
|
||||
# Comercial
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None # observaciones generales
|
||||
# Fiscal
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
@@ -59,10 +61,12 @@ class AccountUpdate(BaseModel):
|
||||
status: str | None = Field(None, max_length=20)
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
|
||||
@@ -39,12 +39,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
# ----- Información comercial -----
|
||||
# Clasificación: importador | exportador | ambos
|
||||
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
|
||||
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
|
||||
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Texto libre cuando el medio de contacto es "otro"
|
||||
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
|
||||
|
||||
# ----- Información fiscal -----
|
||||
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
||||
|
||||
@@ -29,7 +29,8 @@ class Address(Base, TenantScopedMixin, TimestampMixin):
|
||||
neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) # colonia
|
||||
postal_code: Mapped[str | None] = mapped_column(String(10), nullable=True) # código postal
|
||||
city: Mapped[str | None] = mapped_column(String(120), nullable=True) # municipio
|
||||
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado
|
||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
|
||||
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado (código catálogo)
|
||||
# País como código ISO 3166 alfa-3 del catálogo (p. ej. MEX). Ampliado de 2→3.
|
||||
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
|
||||
reference_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # referencias
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Catálogos de referencia del dominio (Incoterms y actores/participantes).
|
||||
|
||||
Se centralizan aquí para administrarlos en un solo lugar y validarlos desde el
|
||||
levantamiento de requerimientos (R-T-10) y modelar los participantes del proceso
|
||||
(R-T-01), incluyendo la autoridad aduanera.
|
||||
"""
|
||||
|
||||
# Incoterms 2020 (R-T-10)
|
||||
INCOTERMS: list[dict] = [
|
||||
{"code": "EXW", "name": "Ex Works — En fábrica"},
|
||||
{"code": "FCA", "name": "Free Carrier — Franco transportista"},
|
||||
{"code": "FAS", "name": "Free Alongside Ship — Franco al costado del buque"},
|
||||
{"code": "FOB", "name": "Free On Board — Franco a bordo"},
|
||||
{"code": "CFR", "name": "Cost and Freight — Costo y flete"},
|
||||
{"code": "CIF", "name": "Cost, Insurance and Freight — Costo, seguro y flete"},
|
||||
{"code": "CPT", "name": "Carriage Paid To — Transporte pagado hasta"},
|
||||
{"code": "CIP", "name": "Carriage and Insurance Paid To — Transporte y seguro pagados hasta"},
|
||||
{"code": "DAP", "name": "Delivered At Place — Entregado en lugar"},
|
||||
{"code": "DPU", "name": "Delivered At Place Unloaded — Entregado en lugar descargado"},
|
||||
{"code": "DDP", "name": "Delivered Duty Paid — Entregado con derechos pagados"},
|
||||
]
|
||||
|
||||
INCOTERM_CODES: set[str] = {i["code"] for i in INCOTERMS}
|
||||
|
||||
# Roles/actores del proceso (R-T-01). Los actores externos se administran como
|
||||
# proveedores (crm.suppliers) vía su clasificación; el cliente/prospecto como cuenta.
|
||||
PARTICIPANT_ROLES: list[dict] = [
|
||||
{"code": "exportador", "label": "Exportador", "source": "account"},
|
||||
{"code": "importador", "label": "Importador", "source": "account"},
|
||||
{"code": "agente_carga", "label": "Agente de carga", "source": "supplier"},
|
||||
{"code": "agente_aduanal", "label": "Agente aduanal", "source": "supplier"},
|
||||
{"code": "naviera", "label": "Naviera", "source": "supplier"},
|
||||
{"code": "aerolinea", "label": "Aerolínea", "source": "supplier"},
|
||||
{"code": "transportista_terrestre", "label": "Transportista terrestre", "source": "supplier"},
|
||||
{"code": "agente_corresponsal", "label": "Agente corresponsal", "source": "supplier"},
|
||||
{"code": "autoridad_aduanera", "label": "Autoridad aduanera", "source": "supplier"},
|
||||
]
|
||||
46
backend/api/v1/modules/crm/catalogs/dto.py
Normal file
46
backend/api/v1/modules/crm/catalogs/dto.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CatalogItemBase(BaseModel):
|
||||
code: str = Field(..., max_length=64)
|
||||
label: str = Field(..., max_length=255)
|
||||
parent_catalog: str | None = Field(None, max_length=60)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CatalogItemCreate(CatalogItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class CatalogItemUpdate(BaseModel):
|
||||
"""PATCH: todos los campos opcionales."""
|
||||
|
||||
code: str | None = Field(None, max_length=64)
|
||||
label: str | None = Field(None, max_length=255)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CatalogItemResponse(CatalogItemBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
catalog: str
|
||||
tenant_id: int | None
|
||||
is_system: bool
|
||||
extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo)
|
||||
|
||||
|
||||
class CatalogMeta(BaseModel):
|
||||
"""Metadata de un catálogo para la pantalla de administración."""
|
||||
|
||||
catalog: str
|
||||
label: str
|
||||
scope: str # 'global' | 'tenant'
|
||||
is_system: bool
|
||||
count: int
|
||||
54
backend/api/v1/modules/crm/catalogs/models.py
Normal file
54
backend/api/v1/modules/crm/catalogs/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
|
||||
|
||||
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
|
||||
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
|
||||
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
|
||||
(descripción que se visualiza).
|
||||
|
||||
Alcance:
|
||||
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
|
||||
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
|
||||
|
||||
Los catálogos dependientes (p. ej. Estado depende de País) usan
|
||||
``parent_catalog`` + ``parent_code`` para filtrarse.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class CatalogItem(Base):
|
||||
__tablename__ = "catalog_items"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Dependencia (Estado→País, Municipio→Estado, …)
|
||||
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
|
||||
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
167
backend/api/v1/modules/crm/catalogs/routes.py
Normal file
167
backend/api/v1/modules/crm/catalogs/routes.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
|
||||
|
||||
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
|
||||
(Aduanasoft) y catálogos propios de cada cliente (tenant).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..suppliers.models import Supplier
|
||||
from . import service as catalog_service
|
||||
from .data import INCOTERMS, PARTICIPANT_ROLES
|
||||
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/catalogs/incoterms")
|
||||
def list_incoterms(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Catálogo de Incoterms 2020 (R-T-10)."""
|
||||
return INCOTERMS
|
||||
|
||||
|
||||
@router.get("/catalogs/participant-roles")
|
||||
def list_participant_roles(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Catálogo de roles/actores del proceso, incluida la autoridad aduanera (R-T-01)."""
|
||||
return PARTICIPANT_ROLES
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Catálogos de referencia (CRUD) — T2026-07-081/082
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/catalogs", response_model=list[CatalogMeta])
|
||||
def list_catalog_meta(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
|
||||
return catalog_service.list_meta(db, current_user["tenant_id"])
|
||||
|
||||
|
||||
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
|
||||
def list_catalog_items(
|
||||
catalog: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
|
||||
include_inactive: bool = Query(False),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
|
||||
return catalog_service.list_items(
|
||||
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_catalog_item(
|
||||
catalog: str,
|
||||
data: CatalogItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Inserta una opción en un catálogo."""
|
||||
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
|
||||
|
||||
|
||||
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
|
||||
def update_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
data: CatalogItemUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Edita una opción de catálogo."""
|
||||
return catalog_service.update_item(db, catalog, item_id, data, current_user)
|
||||
|
||||
|
||||
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
|
||||
catalog_service.delete_item(db, catalog, item_id, current_user)
|
||||
|
||||
|
||||
@router.get("/participants")
|
||||
def list_participants(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
role: str | None = Query(None, description="Filtra por rol/clasificación"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Vista unificada de participantes del proceso: clientes/prospectos (cuentas) y
|
||||
actores externos (proveedores por clasificación), en un solo catálogo (R-T-01)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
result: list[dict] = []
|
||||
|
||||
accounts = (
|
||||
db.query(Account)
|
||||
.filter(
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for acc in accounts:
|
||||
acc_role = getattr(acc, "record_type", None) or "cliente"
|
||||
if role and role not in (acc_role, "exportador", "importador"):
|
||||
# Las cuentas representan exportador/importador/cliente; sólo se omiten
|
||||
# cuando el filtro pide explícitamente un rol de proveedor.
|
||||
if role not in ("exportador", "importador", "cliente", "prospecto"):
|
||||
continue
|
||||
result.append({
|
||||
"id": acc.id,
|
||||
"source": "account",
|
||||
"name": acc.name,
|
||||
"role": acc_role,
|
||||
"roles": [acc_role],
|
||||
})
|
||||
|
||||
suppliers = (
|
||||
db.query(Supplier)
|
||||
.filter(
|
||||
Supplier.tenant_id == tenant_id,
|
||||
Supplier.company_id == company_id,
|
||||
Supplier.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for sup in suppliers:
|
||||
classifications = sup.classifications or []
|
||||
if role and role not in classifications:
|
||||
continue
|
||||
result.append({
|
||||
"id": sup.id,
|
||||
"source": "supplier",
|
||||
"name": sup.name,
|
||||
"role": classifications[0] if classifications else "proveedor",
|
||||
"roles": classifications,
|
||||
})
|
||||
|
||||
return result
|
||||
69
backend/api/v1/modules/crm/catalogs/seed.py
Normal file
69
backend/api/v1/modules/crm/catalogs/seed.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Siembra de catálogos globales (Aduanasoft) del CRM.
|
||||
|
||||
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
|
||||
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
|
||||
|
||||
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_global_catalogs(db: Session) -> dict:
|
||||
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
|
||||
summary: dict[str, int] = {}
|
||||
for catalog, meta in GLOBAL_CATALOGS.items():
|
||||
is_system = bool(meta.get("is_system", False))
|
||||
existing = {
|
||||
row.code
|
||||
for row in db.query(CatalogItem.code).filter(
|
||||
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
|
||||
)
|
||||
}
|
||||
added = 0
|
||||
for order, item in enumerate(meta["items"]):
|
||||
if item["code"] in existing:
|
||||
continue
|
||||
db.add(
|
||||
CatalogItem(
|
||||
catalog=catalog,
|
||||
code=item["code"],
|
||||
label=item["label"],
|
||||
parent_catalog=item.get("parent_catalog"),
|
||||
parent_code=item.get("parent_code"),
|
||||
extra=item.get("extra"),
|
||||
tenant_id=None,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
is_system=is_system,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
if added:
|
||||
summary[catalog] = added
|
||||
db.commit()
|
||||
total = sum(summary.values())
|
||||
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def _run() -> None:
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
result = seed_global_catalogs(db)
|
||||
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run()
|
||||
850
backend/api/v1/modules/crm/catalogs/seed_data.py
Normal file
850
backend/api/v1/modules/crm/catalogs/seed_data.py
Normal file
@@ -0,0 +1,850 @@
|
||||
"""Datos semilla de los catálogos de referencia del CRM.
|
||||
|
||||
SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra)
|
||||
+ catálogos del módulo Tarifario. Globales con tenant_id NULL.
|
||||
"""
|
||||
|
||||
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'cliente', 'label': 'Cliente'}, {'code': 'prospecto', 'label': 'Prospecto'}]},
|
||||
'tipo_persona': {'label': 'Tipo de persona',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'fisica', 'label': 'Persona física'},
|
||||
{'code': 'moral', 'label': 'Persona moral'}]},
|
||||
'estatus': {'label': 'Estatus',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'active', 'label': 'Activo'}, {'code': 'inactive', 'label': 'Inactivo'}]},
|
||||
'giro': {'label': 'Giro o industria',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'importadora', 'label': 'Importadora'},
|
||||
{'code': 'exportadora', 'label': 'Exportadora'},
|
||||
{'code': 'manufactura', 'label': 'Manufactura'},
|
||||
{'code': 'comercializadora', 'label': 'Comercializadora'},
|
||||
{'code': 'logistica', 'label': 'Logística y transporte'},
|
||||
{'code': 'agencia_aduanal', 'label': 'Agencia aduanal'},
|
||||
{'code': 'maquiladora', 'label': 'Maquiladora / IMMEX'},
|
||||
{'code': 'servicios', 'label': 'Servicios'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'clasificacion_cliente': {'label': 'Clasificación del cliente',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'importador', 'label': 'Importador'},
|
||||
{'code': 'exportador', 'label': 'Exportador'},
|
||||
{'code': 'importador_exportador', 'label': 'Importador/Exportador'}]},
|
||||
'medio_contacto': {'label': 'Medio de contacto preferido',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'llamada', 'label': 'Llamada telefónica'},
|
||||
{'code': 'correo', 'label': 'Correo electrónico'},
|
||||
{'code': 'videoconferencia', 'label': 'Videoconferencia'},
|
||||
{'code': 'whatsapp', 'label': 'WhatsApp'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'idioma': {'label': 'Idioma',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'es', 'label': 'Español'},
|
||||
{'code': 'en', 'label': 'Inglés'},
|
||||
{'code': 'zh', 'label': 'Chino (mandarín)'},
|
||||
{'code': 'pt', 'label': 'Portugués'},
|
||||
{'code': 'fr', 'label': 'Francés'},
|
||||
{'code': 'de', 'label': 'Alemán'},
|
||||
{'code': 'ja', 'label': 'Japonés'},
|
||||
{'code': 'ko', 'label': 'Coreano'},
|
||||
{'code': 'it', 'label': 'Italiano'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'regimen_fiscal': {'label': 'Régimen fiscal',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'fisica', 'label': 'Persona física'},
|
||||
{'code': 'moral', 'label': 'Persona moral'}]},
|
||||
'uso_cfdi': {'label': 'Uso de CFDI (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'G01', 'label': 'Adquisición de mercancías'},
|
||||
{'code': 'G02', 'label': 'Devoluciones, descuentos o bonificaciones'},
|
||||
{'code': 'G03', 'label': 'Gastos en general'},
|
||||
{'code': 'I01', 'label': 'Construcciones'},
|
||||
{'code': 'I02', 'label': 'Mobiliario y equipo de oficina por inversiones'},
|
||||
{'code': 'I03', 'label': 'Equipo de transporte'},
|
||||
{'code': 'I04', 'label': 'Equipo de cómputo y accesorios'},
|
||||
{'code': 'I05', 'label': 'Dados, troqueles, moldes, matrices y herramental'},
|
||||
{'code': 'I06', 'label': 'Comunicaciones telefónicas'},
|
||||
{'code': 'I07', 'label': 'Comunicaciones satelitales'},
|
||||
{'code': 'I08', 'label': 'Otra maquinaria y equipo'},
|
||||
{'code': 'D01', 'label': 'Honorarios médicos, dentales y gastos hospitalarios'},
|
||||
{'code': 'D02', 'label': 'Gastos médicos por incapacidad o discapacidad'},
|
||||
{'code': 'D03', 'label': 'Gastos funerales'},
|
||||
{'code': 'D04', 'label': 'Donativos'},
|
||||
{'code': 'D05', 'label': 'Intereses por créditos hipotecarios'},
|
||||
{'code': 'D06', 'label': 'Aportaciones voluntarias al SAR'},
|
||||
{'code': 'D07', 'label': 'Primas por seguros de gastos médicos'},
|
||||
{'code': 'D08', 'label': 'Gastos de transportación escolar obligatoria'},
|
||||
{'code': 'D09', 'label': 'Depósitos en cuentas para el ahorro'},
|
||||
{'code': 'D10', 'label': 'Pagos por servicios educativos (colegiaturas)'},
|
||||
{'code': 'S01', 'label': 'Sin efectos fiscales'},
|
||||
{'code': 'CP01', 'label': 'Pagos'},
|
||||
{'code': 'CN01', 'label': 'Nómina'},
|
||||
{'code': 'P01', 'label': 'Por definir'}]},
|
||||
'forma_pago': {'label': 'Forma de pago (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': '1', 'label': 'Efectivo'},
|
||||
{'code': '2', 'label': 'Cheque nominativo'},
|
||||
{'code': '3', 'label': 'Transferencia electrónica de fondos'},
|
||||
{'code': '4', 'label': 'Tarjeta de crédito'},
|
||||
{'code': '5', 'label': 'Monedero electrónico'},
|
||||
{'code': '6', 'label': 'Dinero electrónico'},
|
||||
{'code': '8', 'label': 'Vales de despensa'},
|
||||
{'code': '12', 'label': 'Dación en pago'},
|
||||
{'code': '13', 'label': 'Pago por subrogación'},
|
||||
{'code': '14', 'label': 'Pago por consignación'},
|
||||
{'code': '15', 'label': 'Condonación'},
|
||||
{'code': '17', 'label': 'Compensación'},
|
||||
{'code': '23', 'label': 'Novación'},
|
||||
{'code': '24', 'label': 'Confusión'},
|
||||
{'code': '25', 'label': 'Remisión de deuda'},
|
||||
{'code': '26', 'label': 'Prescripción o caducidad'},
|
||||
{'code': '27', 'label': 'A satisfacción del acreedor'},
|
||||
{'code': '28', 'label': 'Tarjeta de débito'},
|
||||
{'code': '29', 'label': 'Tarjeta de servicios'},
|
||||
{'code': '30', 'label': 'Aplicación de anticipos'},
|
||||
{'code': '31', 'label': 'Intermediario pagos'},
|
||||
{'code': '99', 'label': 'Por definir'}]},
|
||||
'metodo_pago': {'label': 'Método de pago (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'PPD', 'label': 'Pago en parcialidades o diferido'},
|
||||
{'code': 'PUE', 'label': 'Pago en una sola exhibición'}]},
|
||||
'moneda': {'label': 'Moneda (ISO 4217)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'CRC', 'label': 'Colón costarricense'},
|
||||
{'code': 'CUC', 'label': 'Peso Convertible'},
|
||||
{'code': 'CUP', 'label': 'Peso Cubano'},
|
||||
{'code': 'CVE', 'label': 'Cabo Verde Escudo'},
|
||||
{'code': 'CZK', 'label': 'Corona checa'},
|
||||
{'code': 'DJF', 'label': 'Franco de Djibouti'},
|
||||
{'code': 'DKK', 'label': 'Corona danesa'},
|
||||
{'code': 'DOP', 'label': 'Peso Dominicano'},
|
||||
{'code': 'DZD', 'label': 'Dinar argelino'},
|
||||
{'code': 'EGP', 'label': 'Libra egipcia'},
|
||||
{'code': 'ERN', 'label': 'Nakfa'},
|
||||
{'code': 'ETB', 'label': 'Birr etíope'},
|
||||
{'code': 'EUR', 'label': 'Euro'},
|
||||
{'code': 'FJD', 'label': 'Dólar de Fiji'},
|
||||
{'code': 'FKP', 'label': 'Libra malvinense'},
|
||||
{'code': 'GBP', 'label': 'Libra Esterlina'},
|
||||
{'code': 'GEL', 'label': 'Lari'},
|
||||
{'code': 'GHS', 'label': 'Cedi de Ghana'},
|
||||
{'code': 'GIP', 'label': 'Libra de Gibraltar'},
|
||||
{'code': 'GMD', 'label': 'Dalasi'},
|
||||
{'code': 'GNF', 'label': 'Franco guineano'},
|
||||
{'code': 'GTQ', 'label': 'Quetzal'},
|
||||
{'code': 'GYD', 'label': 'Dólar guyanés'},
|
||||
{'code': 'HKD', 'label': 'Dolar De Hong Kong'},
|
||||
{'code': 'HNL', 'label': 'Lempira'},
|
||||
{'code': 'HRK', 'label': 'Kuna'},
|
||||
{'code': 'HTG', 'label': 'Gourde'},
|
||||
{'code': 'HUF', 'label': 'Florín'},
|
||||
{'code': 'IDR', 'label': 'Rupia'},
|
||||
{'code': 'ILS', 'label': 'Nuevo Shekel Israelí'},
|
||||
{'code': 'INR', 'label': 'Rupia india'},
|
||||
{'code': 'IQD', 'label': 'Dinar iraquí'},
|
||||
{'code': 'IRR', 'label': 'Rial iraní'},
|
||||
{'code': 'ISK', 'label': 'Corona islandesa'},
|
||||
{'code': 'JMD', 'label': 'Dólar Jamaiquino'},
|
||||
{'code': 'JOD', 'label': 'Dinar jordano'},
|
||||
{'code': 'JPY', 'label': 'Yen'},
|
||||
{'code': 'KES', 'label': 'Chelín keniano'},
|
||||
{'code': 'KGS', 'label': 'Som'},
|
||||
{'code': 'KHR', 'label': 'Riel'},
|
||||
{'code': 'KMF', 'label': 'Franco Comoro'},
|
||||
{'code': 'KPW', 'label': 'Corea del Norte ganó'},
|
||||
{'code': 'KRW', 'label': 'Won'},
|
||||
{'code': 'KWD', 'label': 'Dinar kuwaití'},
|
||||
{'code': 'KYD', 'label': 'Dólar de las Islas Caimán'},
|
||||
{'code': 'KZT', 'label': 'Tenge'},
|
||||
{'code': 'LAK', 'label': 'Kip'},
|
||||
{'code': 'LBP', 'label': 'Libra libanesa'},
|
||||
{'code': 'LKR', 'label': 'Rupia de Sri Lanka'},
|
||||
{'code': 'LRD', 'label': 'Dólar liberiano'},
|
||||
{'code': 'LSL', 'label': 'Loti'},
|
||||
{'code': 'LYD', 'label': 'Dinar libio'},
|
||||
{'code': 'MAD', 'label': 'Dirham marroquí'},
|
||||
{'code': 'MDL', 'label': 'Leu moldavo'},
|
||||
{'code': 'MGA', 'label': 'Ariary malgache'},
|
||||
{'code': 'MKD', 'label': 'Denar'},
|
||||
{'code': 'MMK', 'label': 'Kyat'},
|
||||
{'code': 'MNT', 'label': 'Tugrik'},
|
||||
{'code': 'MOP', 'label': 'Pataca'},
|
||||
{'code': 'MRO', 'label': 'Ouguiya'},
|
||||
{'code': 'MUR', 'label': 'Rupia de Mauricio'},
|
||||
{'code': 'MVR', 'label': 'Rupia'},
|
||||
{'code': 'MWK', 'label': 'Kwacha'},
|
||||
{'code': 'MXN', 'label': 'Peso Mexicano'},
|
||||
{'code': 'MXV', 'label': 'México Unidad de Inversión (UDI)'},
|
||||
{'code': 'MYR', 'label': 'Ringgit malayo'},
|
||||
{'code': 'MZN', 'label': 'Mozambique Metical'},
|
||||
{'code': 'NAD', 'label': 'Dólar de Namibia'},
|
||||
{'code': 'NGN', 'label': 'Naira'},
|
||||
{'code': 'NIO', 'label': 'Córdoba Oro'},
|
||||
{'code': 'NOK', 'label': 'Corona noruega'},
|
||||
{'code': 'NPR', 'label': 'Rupia nepalí'},
|
||||
{'code': 'NZD', 'label': 'Dólar de Nueva Zelanda'},
|
||||
{'code': 'OMR', 'label': 'Rial omaní'},
|
||||
{'code': 'PAB', 'label': 'Balboa'},
|
||||
{'code': 'PEN', 'label': 'Nuevo Sol'},
|
||||
{'code': 'PGK', 'label': 'Kina'},
|
||||
{'code': 'PHP', 'label': 'Peso filipino'},
|
||||
{'code': 'PKR', 'label': 'Rupia de Pakistán'},
|
||||
{'code': 'PLN', 'label': 'Zloty'},
|
||||
{'code': 'PYG', 'label': 'Guaraní'},
|
||||
{'code': 'QAR', 'label': 'Qatar Rial'},
|
||||
{'code': 'RON', 'label': 'Leu rumano'},
|
||||
{'code': 'RSD', 'label': 'Dinar serbio'},
|
||||
{'code': 'RUB', 'label': 'Rublo ruso'},
|
||||
{'code': 'RWF', 'label': 'Franco ruandés'},
|
||||
{'code': 'SAR', 'label': 'Riyal saudí'},
|
||||
{'code': 'SBD', 'label': 'Dólar de las Islas Salomón'},
|
||||
{'code': 'SCR', 'label': 'Rupia de Seychelles'},
|
||||
{'code': 'SDG', 'label': 'Libra sudanesa'},
|
||||
{'code': 'SEK', 'label': 'Corona sueca'},
|
||||
{'code': 'SGD', 'label': 'Dolar De Singapur'},
|
||||
{'code': 'SHP', 'label': 'Libra de Santa Helena'},
|
||||
{'code': 'SLL', 'label': 'Leona'},
|
||||
{'code': 'SOS', 'label': 'Chelín somalí'},
|
||||
{'code': 'SRD', 'label': 'Dólar de Suriname'},
|
||||
{'code': 'SSP', 'label': 'Libra sudanesa Sur'},
|
||||
{'code': 'STD', 'label': 'Dobra'},
|
||||
{'code': 'SVC', 'label': 'Colon El Salvador'},
|
||||
{'code': 'SYP', 'label': 'Libra Siria'},
|
||||
{'code': 'SZL', 'label': 'Lilangeni'},
|
||||
{'code': 'THB', 'label': 'Baht'},
|
||||
{'code': 'TJS', 'label': 'Somoni'},
|
||||
{'code': 'TMT', 'label': 'Turkmenistán nuevo manat'},
|
||||
{'code': 'TND', 'label': 'Dinar tunecino'},
|
||||
{'code': 'TOP', 'label': "Pa'anga"},
|
||||
{'code': 'TRY', 'label': 'Lira turca'},
|
||||
{'code': 'TTD', 'label': 'Dólar de Trinidad y Tobago'},
|
||||
{'code': 'TWD', 'label': 'Nuevo dólar de Taiwán'},
|
||||
{'code': 'TZS', 'label': 'Shilling tanzano'},
|
||||
{'code': 'UAH', 'label': 'Hryvnia'},
|
||||
{'code': 'UGX', 'label': 'Shilling de Uganda'},
|
||||
{'code': 'USD', 'label': 'Dolar americano'},
|
||||
{'code': 'USN', 'label': 'Dólar estadounidense (día siguiente)'},
|
||||
{'code': 'UYI', 'label': 'Peso Uruguay en Unidades Indexadas (URUIURUI)'},
|
||||
{'code': 'UYU', 'label': 'Peso Uruguayo'},
|
||||
{'code': 'UZS', 'label': 'Uzbekistán Sum'},
|
||||
{'code': 'VEF', 'label': 'Bolívar'},
|
||||
{'code': 'VND', 'label': 'Dong'},
|
||||
{'code': 'VUV', 'label': 'Vatu'},
|
||||
{'code': 'WST', 'label': 'Tala'},
|
||||
{'code': 'XAF', 'label': 'Franco CFA BEAC'},
|
||||
{'code': 'XAG', 'label': 'Plata'},
|
||||
{'code': 'XAU', 'label': 'Oro'},
|
||||
{'code': 'XBA', 'label': 'Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)'},
|
||||
{'code': 'XBB', 'label': 'Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)'},
|
||||
{'code': 'XBC', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)'},
|
||||
{'code': 'XBD', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)'},
|
||||
{'code': 'XCD', 'label': 'Dólar del Caribe Oriental'},
|
||||
{'code': 'XDR', 'label': 'DEG (Derechos Especiales de Giro)'},
|
||||
{'code': 'XOF', 'label': 'Franco CFA BCEAO'},
|
||||
{'code': 'XPD', 'label': 'Paladio'},
|
||||
{'code': 'XPF', 'label': 'Franco CFP'},
|
||||
{'code': 'XPT', 'label': 'Platino'},
|
||||
{'code': 'XSU', 'label': 'Sucre'},
|
||||
{'code': 'XTS', 'label': 'Códigos reservados específicamente para propósitos de prueba'},
|
||||
{'code': 'XUA', 'label': 'Unidad ADB de Cuenta'},
|
||||
{'code': 'XXX',
|
||||
'label': 'Los códigos asignados para las transacciones en que intervenga ninguna moneda'},
|
||||
{'code': 'YER', 'label': 'Rial yemení'},
|
||||
{'code': 'ZAR', 'label': 'Rand'},
|
||||
{'code': 'ZMW', 'label': 'Kwacha zambiano'},
|
||||
{'code': 'ZWL', 'label': 'Zimbabwe Dólar'},
|
||||
{'code': 'NULL', 'label': 'NULL'}]},
|
||||
'pais': {'label': 'País (ISO 3166)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'ABW', 'label': 'Aruba'},
|
||||
{'code': 'AFG', 'label': 'Afganistán'},
|
||||
{'code': 'AGO', 'label': 'Angola'},
|
||||
{'code': 'AIA', 'label': 'Anguila'},
|
||||
{'code': 'ALA', 'label': 'Islas Åland'},
|
||||
{'code': 'ALB', 'label': 'Albania'},
|
||||
{'code': 'AND', 'label': 'Andorra'},
|
||||
{'code': 'ARE', 'label': 'Emiratos Árabes Unidos (Los)'},
|
||||
{'code': 'ARG', 'label': 'Argentina'},
|
||||
{'code': 'ARM', 'label': 'Armenia'},
|
||||
{'code': 'ASM', 'label': 'Samoa Americana'},
|
||||
{'code': 'ATA', 'label': 'Antártida'},
|
||||
{'code': 'ATF', 'label': 'Territorios Australes Franceses (los)'},
|
||||
{'code': 'ATG', 'label': 'Antigua y Barbuda'},
|
||||
{'code': 'AUS', 'label': 'Australia'},
|
||||
{'code': 'AUT', 'label': 'Austria'},
|
||||
{'code': 'AZE', 'label': 'Azerbaiyán'},
|
||||
{'code': 'BDI', 'label': 'Burundi'},
|
||||
{'code': 'BEL', 'label': 'Bélgica'},
|
||||
{'code': 'BEN', 'label': 'Benín'},
|
||||
{'code': 'BES', 'label': 'Bonaire, San Eustaquio y Saba'},
|
||||
{'code': 'BFA', 'label': 'Burkina Faso'},
|
||||
{'code': 'BGD', 'label': 'Bangladés'},
|
||||
{'code': 'BGR', 'label': 'Bulgaria'},
|
||||
{'code': 'BHR', 'label': 'Baréin'},
|
||||
{'code': 'BHS', 'label': 'Bahamas (las)'},
|
||||
{'code': 'BIH', 'label': 'Bosnia y Herzegovina'},
|
||||
{'code': 'BLM', 'label': 'San Bartolomé'},
|
||||
{'code': 'BLR', 'label': 'Bielorrusia'},
|
||||
{'code': 'BLZ', 'label': 'Belice'},
|
||||
{'code': 'BMU', 'label': 'Bermudas'},
|
||||
{'code': 'BOL', 'label': 'Bolivia, Estado Plurinacional de'},
|
||||
{'code': 'BRA', 'label': 'Brasil'},
|
||||
{'code': 'BRB', 'label': 'Barbados'},
|
||||
{'code': 'BRN', 'label': 'Brunéi Darussalam'},
|
||||
{'code': 'BTN', 'label': 'Bután'},
|
||||
{'code': 'BVT', 'label': 'Isla Bouvet'},
|
||||
{'code': 'BWA', 'label': 'Botsuana'},
|
||||
{'code': 'CAF', 'label': 'República Centroafricana (la)'},
|
||||
{'code': 'CAN', 'label': 'Canadá'},
|
||||
{'code': 'CCK', 'label': 'Islas Cocos (Keeling)'},
|
||||
{'code': 'CHE', 'label': 'Suiza'},
|
||||
{'code': 'CHL', 'label': 'Chile'},
|
||||
{'code': 'CHN', 'label': 'China'},
|
||||
{'code': 'CIV', 'label': "Côte d'Ivoire"},
|
||||
{'code': 'CMR', 'label': 'Camerún'},
|
||||
{'code': 'COD', 'label': 'Congo (la República Democrática del)'},
|
||||
{'code': 'COG', 'label': 'Congo'},
|
||||
{'code': 'COK', 'label': 'Islas Cook (las)'},
|
||||
{'code': 'COL', 'label': 'Colombia'},
|
||||
{'code': 'COM', 'label': 'Comoras'},
|
||||
{'code': 'CPV', 'label': 'Cabo Verde'},
|
||||
{'code': 'CRI', 'label': 'Costa Rica'},
|
||||
{'code': 'CUB', 'label': 'Cuba'},
|
||||
{'code': 'CUW', 'label': 'Curaçao'},
|
||||
{'code': 'CXR', 'label': 'Isla de Navidad'},
|
||||
{'code': 'CYM', 'label': 'Islas Caimán (las)'},
|
||||
{'code': 'CYP', 'label': 'Chipre'},
|
||||
{'code': 'CZE', 'label': 'República Checa (la)'},
|
||||
{'code': 'DEU', 'label': 'Alemania'},
|
||||
{'code': 'DJI', 'label': 'Yibuti'},
|
||||
{'code': 'DMA', 'label': 'Dominica'},
|
||||
{'code': 'DNK', 'label': 'Dinamarca'},
|
||||
{'code': 'DOM', 'label': 'República Dominicana (la)'},
|
||||
{'code': 'DZA', 'label': 'Argelia'},
|
||||
{'code': 'ECU', 'label': 'Ecuador'},
|
||||
{'code': 'EGY', 'label': 'Egipto'},
|
||||
{'code': 'ERI', 'label': 'Eritrea'},
|
||||
{'code': 'ESH', 'label': 'Sahara Occidental'},
|
||||
{'code': 'ESP', 'label': 'España'},
|
||||
{'code': 'EST', 'label': 'Estonia'},
|
||||
{'code': 'ETH', 'label': 'Etiopía'},
|
||||
{'code': 'FIN', 'label': 'Finlandia'},
|
||||
{'code': 'FJI', 'label': 'Fiyi'},
|
||||
{'code': 'FLK', 'label': 'Islas Malvinas [Falkland] (las)'},
|
||||
{'code': 'FRA', 'label': 'Francia'},
|
||||
{'code': 'FRO', 'label': 'Islas Feroe (las)'},
|
||||
{'code': 'FSM', 'label': 'Micronesia (los Estados Federados de)'},
|
||||
{'code': 'GAB', 'label': 'Gabón'},
|
||||
{'code': 'GBR', 'label': 'Reino Unido (el)'},
|
||||
{'code': 'GEO', 'label': 'Georgia'},
|
||||
{'code': 'GGY', 'label': 'Guernsey'},
|
||||
{'code': 'GHA', 'label': 'Ghana'},
|
||||
{'code': 'GIB', 'label': 'Gibraltar'},
|
||||
{'code': 'GIN', 'label': 'Guinea'},
|
||||
{'code': 'GLP', 'label': 'Guadalupe'},
|
||||
{'code': 'GMB', 'label': 'Gambia (La)'},
|
||||
{'code': 'GNB', 'label': 'Guinea-Bisáu'},
|
||||
{'code': 'GNQ', 'label': 'Guinea Ecuatorial'},
|
||||
{'code': 'GRC', 'label': 'Grecia'},
|
||||
{'code': 'GRD', 'label': 'Granada'},
|
||||
{'code': 'GRL', 'label': 'Groenlandia'},
|
||||
{'code': 'GTM', 'label': 'Guatemala'},
|
||||
{'code': 'GUF', 'label': 'Guayana Francesa'},
|
||||
{'code': 'GUM', 'label': 'Guam'},
|
||||
{'code': 'GUY', 'label': 'Guyana'},
|
||||
{'code': 'HKG', 'label': 'Hong Kong'},
|
||||
{'code': 'HMD', 'label': 'Isla Heard e Islas McDonald'},
|
||||
{'code': 'HND', 'label': 'Honduras'},
|
||||
{'code': 'HRV', 'label': 'Croacia'},
|
||||
{'code': 'HTI', 'label': 'Haití'},
|
||||
{'code': 'HUN', 'label': 'Hungría'},
|
||||
{'code': 'IDN', 'label': 'Indonesia'},
|
||||
{'code': 'IMN', 'label': 'Isla de Man'},
|
||||
{'code': 'IND', 'label': 'India'},
|
||||
{'code': 'IOT', 'label': 'Territorio Británico del Océano Índico (el)'},
|
||||
{'code': 'IRL', 'label': 'Irlanda'},
|
||||
{'code': 'IRN', 'label': 'Irán (la República Islámica de)'},
|
||||
{'code': 'IRQ', 'label': 'Irak'},
|
||||
{'code': 'ISL', 'label': 'Islandia'},
|
||||
{'code': 'ISR', 'label': 'Israel'},
|
||||
{'code': 'ITA', 'label': 'Italia'},
|
||||
{'code': 'JAM', 'label': 'Jamaica'},
|
||||
{'code': 'JEY', 'label': 'Jersey'},
|
||||
{'code': 'JOR', 'label': 'Jordania'},
|
||||
{'code': 'JPN', 'label': 'Japón'},
|
||||
{'code': 'KAZ', 'label': 'Kazajistán'},
|
||||
{'code': 'KEN', 'label': 'Kenia'},
|
||||
{'code': 'KGZ', 'label': 'Kirguistán'},
|
||||
{'code': 'KHM', 'label': 'Camboya'},
|
||||
{'code': 'KIR', 'label': 'Kiribati'},
|
||||
{'code': 'KNA', 'label': 'San Cristóbal y Nieves'},
|
||||
{'code': 'KOR', 'label': 'Corea (la República de)'},
|
||||
{'code': 'KWT', 'label': 'Kuwait'},
|
||||
{'code': 'LAO', 'label': 'Lao, (la) República Democrática Popular'},
|
||||
{'code': 'LBN', 'label': 'Líbano'},
|
||||
{'code': 'LBR', 'label': 'Liberia'},
|
||||
{'code': 'LBY', 'label': 'Libia'},
|
||||
{'code': 'LCA', 'label': 'Santa Lucía'},
|
||||
{'code': 'LIE', 'label': 'Liechtenstein'},
|
||||
{'code': 'LKA', 'label': 'Sri Lanka'},
|
||||
{'code': 'LSO', 'label': 'Lesoto'},
|
||||
{'code': 'LTU', 'label': 'Lituania'},
|
||||
{'code': 'LUX', 'label': 'Luxemburgo'},
|
||||
{'code': 'LVA', 'label': 'Letonia'},
|
||||
{'code': 'MAC', 'label': 'Macao'},
|
||||
{'code': 'MAF', 'label': 'San Martín (parte francesa)'},
|
||||
{'code': 'MAR', 'label': 'Marruecos'},
|
||||
{'code': 'MCO', 'label': 'Mónaco'},
|
||||
{'code': 'MDA', 'label': 'Moldavia (la República de)'},
|
||||
{'code': 'MDG', 'label': 'Madagascar'},
|
||||
{'code': 'MDV', 'label': 'Maldivas'},
|
||||
{'code': 'MEX', 'label': 'México'},
|
||||
{'code': 'MHL', 'label': 'Islas Marshall (las)'},
|
||||
{'code': 'MKD', 'label': 'Macedonia (la antigua República Yugoslava de)'},
|
||||
{'code': 'MLI', 'label': 'Malí'},
|
||||
{'code': 'MLT', 'label': 'Malta'},
|
||||
{'code': 'MMR', 'label': 'Myanmar'},
|
||||
{'code': 'MNE', 'label': 'Montenegro'},
|
||||
{'code': 'MNG', 'label': 'Mongolia'},
|
||||
{'code': 'MNP', 'label': 'Islas Marianas del Norte (las)'},
|
||||
{'code': 'MOZ', 'label': 'Mozambique'},
|
||||
{'code': 'MRT', 'label': 'Mauritania'},
|
||||
{'code': 'MSR', 'label': 'Montserrat'},
|
||||
{'code': 'MTQ', 'label': 'Martinica'},
|
||||
{'code': 'MUS', 'label': 'Mauricio'},
|
||||
{'code': 'MWI', 'label': 'Malaui'},
|
||||
{'code': 'MYS', 'label': 'Malasia'},
|
||||
{'code': 'MYT', 'label': 'Mayotte'},
|
||||
{'code': 'NAM', 'label': 'Namibia'},
|
||||
{'code': 'NCL', 'label': 'Nueva Caledonia'},
|
||||
{'code': 'NER', 'label': 'Níger (el)'},
|
||||
{'code': 'NFK', 'label': 'Isla Norfolk'},
|
||||
{'code': 'NGA', 'label': 'Nigeria'},
|
||||
{'code': 'NIC', 'label': 'Nicaragua'},
|
||||
{'code': 'NIU', 'label': 'Niue'},
|
||||
{'code': 'NLD', 'label': 'Países Bajos (los)'},
|
||||
{'code': 'NOR', 'label': 'Noruega'},
|
||||
{'code': 'NPL', 'label': 'Nepal'},
|
||||
{'code': 'NRU', 'label': 'Nauru'},
|
||||
{'code': 'NZL', 'label': 'Nueva Zelanda'},
|
||||
{'code': 'OMN', 'label': 'Omán'},
|
||||
{'code': 'PAK', 'label': 'Pakistán'},
|
||||
{'code': 'PAN', 'label': 'Panamá'},
|
||||
{'code': 'PCN', 'label': 'Pitcairn'},
|
||||
{'code': 'PER', 'label': 'Perú'},
|
||||
{'code': 'PHL', 'label': 'Filipinas (las)'},
|
||||
{'code': 'PLW', 'label': 'Palaos'},
|
||||
{'code': 'PNG', 'label': 'Papúa Nueva Guinea'},
|
||||
{'code': 'POL', 'label': 'Polonia'},
|
||||
{'code': 'PRI', 'label': 'Puerto Rico'},
|
||||
{'code': 'PRK', 'label': 'Corea (la República Democrática Popular de)'},
|
||||
{'code': 'PRT', 'label': 'Portugal'},
|
||||
{'code': 'PRY', 'label': 'Paraguay'},
|
||||
{'code': 'PSE', 'label': 'Palestina, Estado de'},
|
||||
{'code': 'PYF', 'label': 'Polinesia Francesa'},
|
||||
{'code': 'QAT', 'label': 'Catar'},
|
||||
{'code': 'REU', 'label': 'Reunión'},
|
||||
{'code': 'ROU', 'label': 'Rumania'},
|
||||
{'code': 'RUS', 'label': 'Rusia, (la) Federación de'},
|
||||
{'code': 'RWA', 'label': 'Ruanda'},
|
||||
{'code': 'SAU', 'label': 'Arabia Saudita'},
|
||||
{'code': 'SDN', 'label': 'Sudán (el)'},
|
||||
{'code': 'SEN', 'label': 'Senegal'},
|
||||
{'code': 'SGP', 'label': 'Singapur'},
|
||||
{'code': 'SGS', 'label': 'Georgia del sur y las islas sandwich del sur'},
|
||||
{'code': 'SHN', 'label': 'Santa Helena, Ascensión y Tristán de Acuña'},
|
||||
{'code': 'SJM', 'label': 'Svalbard y Jan Mayen'},
|
||||
{'code': 'SLB', 'label': 'Islas Salomón (las)'},
|
||||
{'code': 'SLE', 'label': 'Sierra leona'},
|
||||
{'code': 'NULL', 'label': 'NULL'}]},
|
||||
'estado': {'label': 'Estado / Provincia',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'AGU', 'label': 'Aguascalientes', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'BCN', 'label': 'Baja California', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'BCS', 'label': 'Baja California Sur', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CAM', 'label': 'Campeche', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CHP', 'label': 'Chiapas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CHH', 'label': 'Chihuahua', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CMX', 'label': 'Ciudad de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'COA', 'label': 'Coahuila', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'COL', 'label': 'Colima', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'DUR', 'label': 'Durango', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'GUA', 'label': 'Guanajuato', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'GRO', 'label': 'Guerrero', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'HID', 'label': 'Hidalgo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'JAL', 'label': 'Jalisco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MEX', 'label': 'Estado de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MIC', 'label': 'Michoacán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MOR', 'label': 'Morelos', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'NAY', 'label': 'Nayarit', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'NLE', 'label': 'Nuevo León', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'OAX', 'label': 'Oaxaca', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'PUE', 'label': 'Puebla', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'QUE', 'label': 'Querétaro', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'ROO', 'label': 'Quintana Roo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SLP', 'label': 'San Luis Potosí', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SIN', 'label': 'Sinaloa', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SON', 'label': 'Sonora', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TAB', 'label': 'Tabasco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TAM', 'label': 'Tamaulipas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TLA', 'label': 'Tlaxcala', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'VER', 'label': 'Veracruz', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'YUC', 'label': 'Yucatán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'ZAC', 'label': 'Zacatecas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}]},
|
||||
'tipo_domicilio': {'label': 'Tipo de domicilio',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'fiscal', 'label': 'Fiscal'},
|
||||
{'code': 'oficina', 'label': 'Oficina'},
|
||||
{'code': 'sucursal', 'label': 'Sucursal'},
|
||||
{'code': 'bodega', 'label': 'Bodega'},
|
||||
{'code': 'patio', 'label': 'Patio'},
|
||||
{'code': 'terminal', 'label': 'Terminal'},
|
||||
{'code': 'almacen', 'label': 'Almacén'}]},
|
||||
'area': {'label': 'Área / Departamento',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'ventas', 'label': 'Ventas'},
|
||||
{'code': 'operaciones', 'label': 'Operaciones'},
|
||||
{'code': 'facturacion', 'label': 'Facturación'},
|
||||
{'code': 'cobranza', 'label': 'Cobranza'},
|
||||
{'code': 'servicio_cliente', 'label': 'Servicio al cliente'}]},
|
||||
'cobertura': {'label': 'Cobertura',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'nacional', 'label': 'Nacional'},
|
||||
{'code': 'internacional', 'label': 'Internacional'}]},
|
||||
'clasificacion_proveedor': {'label': 'Clasificación del proveedor',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'naviera', 'label': 'Naviera'},
|
||||
{'code': 'aerolinea', 'label': 'Aerolínea'},
|
||||
{'code': 'transportista_terrestre', 'label': 'Transportista Terrestre'},
|
||||
{'code': 'ferrocarril', 'label': 'Ferrocarril'},
|
||||
{'code': 'agente_aduanal', 'label': 'Agente Aduanal'},
|
||||
{'code': 'agente_carga', 'label': 'Agente de Carga'},
|
||||
{'code': 'agente_corresponsal', 'label': 'Agente Corresponsal'},
|
||||
{'code': 'almacen', 'label': 'Almacén'},
|
||||
{'code': 'aseguradora', 'label': 'Aseguradora'},
|
||||
{'code': 'paqueteria', 'label': 'Paquetería'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'tipo_equipo': {'label': 'Tipo de equipo / contenedor',
|
||||
'is_system': True,
|
||||
'items': [{'code': '40DC',
|
||||
'label': "40' Standard",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 12.035,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.392,
|
||||
'capacidad_m3': 67.7,
|
||||
'tara_kg': 3700,
|
||||
'carga_max_kg': 26790}},
|
||||
{'code': '20DC',
|
||||
'label': "20' Standard",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.9,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.392,
|
||||
'capacidad_m3': 33.2,
|
||||
'tara_kg': 2230,
|
||||
'carga_max_kg': 21770}},
|
||||
{'code': '20OT',
|
||||
'label': "20' Open Top",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.894,
|
||||
'ancho_m': 2.311,
|
||||
'alto_m': 2.354,
|
||||
'capacidad_m3': 32.23,
|
||||
'tara_kg': 2400,
|
||||
'carga_max_kg': 30490}},
|
||||
{'code': '20FR',
|
||||
'label': "20' Flat Rack",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.62,
|
||||
'ancho_m': 2.23,
|
||||
'alto_m': 2.233,
|
||||
'tara_kg': 2530,
|
||||
'carga_max_kg': 21470}},
|
||||
{'code': '40HC',
|
||||
'label': "40' High Cube",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 12.036,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.697,
|
||||
'capacidad_m3': 76.3,
|
||||
'tara_kg': 3970,
|
||||
'carga_max_kg': 26510}},
|
||||
{'code': '20PL',
|
||||
'label': "20' Platform",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 6.058,
|
||||
'ancho_m': 2.438,
|
||||
'alto_m': 0.37,
|
||||
'tara_kg': 2520,
|
||||
'carga_max_kg': 27960}},
|
||||
{'code': '20FRC',
|
||||
'label': "20' Flat Rack Collapsible",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.618,
|
||||
'ancho_m': 2.206,
|
||||
'alto_m': 2.233,
|
||||
'tara_kg': 2750,
|
||||
'carga_max_kg': 27730}},
|
||||
{'code': '20BK',
|
||||
'label': "20' Bulk",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.93,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.34,
|
||||
'capacidad_m3': 32.0,
|
||||
'tara_kg': 2450,
|
||||
'carga_max_kg': 21350}},
|
||||
{'code': '20TK',
|
||||
'label': "20' Tank",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 6.058,
|
||||
'ancho_m': 2.438,
|
||||
'alto_m': 2.438,
|
||||
'tara_kg': 4100,
|
||||
'carga_max_kg': 26200}},
|
||||
{'code': 'LD2',
|
||||
'label': 'LD2',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 3.5,
|
||||
'tara_kg': 30,
|
||||
'carga_max_kg': 1225,
|
||||
'nota': 'Aviones 767'}},
|
||||
{'code': 'LD3',
|
||||
'label': 'LD3',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 4.2,
|
||||
'tara_kg': 80,
|
||||
'carga_max_kg': 1587,
|
||||
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
|
||||
{'code': 'LBD',
|
||||
'label': 'LBD (Flex Door)',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 7.0,
|
||||
'tara_kg': 123,
|
||||
'carga_max_kg': 2449,
|
||||
'nota': 'Aviones 767'}},
|
||||
{'code': 'LD6',
|
||||
'label': 'LD6',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 8.9,
|
||||
'tara_kg': 175,
|
||||
'carga_max_kg': 3175,
|
||||
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
|
||||
{'code': 'PAG',
|
||||
'label': 'PAP / PIP / PAG',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 10.0,
|
||||
'tara_kg': 120,
|
||||
'carga_max_kg': 6033,
|
||||
'nota': 'Boeing 747/767/777/DC10'}},
|
||||
{'code': 'LD9',
|
||||
'label': 'LD9 AAP',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 10.0,
|
||||
'tara_kg': 85,
|
||||
'carga_max_kg': 1588,
|
||||
'nota': 'Boeing 747/777/DC10'}},
|
||||
{'code': 'XAW',
|
||||
'label': 'XAW',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 14.0,
|
||||
'tara_kg': 170,
|
||||
'carga_max_kg': 5000,
|
||||
'nota': 'Boeing 747/777/DC10'}},
|
||||
{'code': 'PMC',
|
||||
'label': 'PMC',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 12.7,
|
||||
'tara_kg': 130,
|
||||
'carga_max_kg': 6804,
|
||||
'nota': 'Boeing 747/767/777'}},
|
||||
{'code': 'LD8',
|
||||
'label': 'LD8',
|
||||
'extra': {'modo': 'aereo', 'capacidad_m3': 7.2, 'tara_kg': 120, 'carga_max_kg': 2450}},
|
||||
{'code': 'DV48',
|
||||
'label': "Dry Van 48'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 98.0,
|
||||
'carga_max_kg': 20412,
|
||||
'pallets': 22}},
|
||||
{'code': 'SD',
|
||||
'label': 'Legal Step Deck (Single Drop)',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 11.58,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 3.05,
|
||||
'carga_max_kg': 20865}},
|
||||
{'code': 'TANK',
|
||||
'label': 'Tanker',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_l': 22712}},
|
||||
{'code': 'DV53',
|
||||
'label': "Dry Van 53'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 99.11,
|
||||
'carga_max_kg': 20412,
|
||||
'pallets': 26}},
|
||||
{'code': 'DD',
|
||||
'label': 'Double Drop (Low Boy)',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 8.53,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 3.51,
|
||||
'carga_max_kg': 18144}},
|
||||
{'code': 'RF48',
|
||||
'label': "48' Reefer Trailer",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.4,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 90.0,
|
||||
'carga_max_kg': 19958,
|
||||
'pallets': 20}},
|
||||
{'code': 'FB48',
|
||||
'label': "48' Legal Flatbed",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.59,
|
||||
'carga_max_kg': 21772}},
|
||||
{'code': 'PUP28',
|
||||
'label': "Pup Trailer 28'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 8.53,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 57.45,
|
||||
'carga_max_kg': 9979,
|
||||
'pallets': 14}},
|
||||
{'code': 'IM53',
|
||||
'label': "Intermodal 53' Container",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 99.11,
|
||||
'carga_max_kg': 19958,
|
||||
'pallets': 24}}]},
|
||||
'modo_tarifario': {'label': 'Modo de tarifario',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'aereo', 'label': 'Aéreo'},
|
||||
{'code': 'maritimo_fcl', 'label': 'Marítimo FCL'},
|
||||
{'code': 'maritimo_lcl', 'label': 'Marítimo LCL'},
|
||||
{'code': 'terrestre', 'label': 'Terrestre'}]},
|
||||
'unidad_tarifa': {'label': 'Unidad de tarifa',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'per_kg', 'label': 'Por kg'},
|
||||
{'code': 'per_wm', 'label': 'Por peso/medida (W/M)'},
|
||||
{'code': 'per_container', 'label': 'Por contenedor'},
|
||||
{'code': 'flat', 'label': 'Tarifa plana'}]},
|
||||
'concepto_cargo': {'label': 'Concepto de cargo',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'},
|
||||
{'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'},
|
||||
{'code': 'moc', 'label': 'MOC (mínimo origen)'},
|
||||
{'code': 'afs', 'label': 'AFS'},
|
||||
{'code': 'thc', 'label': 'THC (manejo en terminal)'},
|
||||
{'code': 'maniobras', 'label': 'Maniobras'},
|
||||
{'code': 'almacenaje', 'label': 'Almacenaje'},
|
||||
{'code': 'seguro', 'label': 'Seguro'},
|
||||
{'code': 'despacho', 'label': 'Despacho aduanal'},
|
||||
{'code': 'documentacion', 'label': 'Documentación'},
|
||||
{'code': 'custodia', 'label': 'Custodia'},
|
||||
{'code': 'otro', 'label': 'Otro'}]}}
|
||||
|
||||
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'}]},
|
||||
})
|
||||
168
backend/api/v1/modules/crm/catalogs/service.py
Normal file
168
backend/api/v1/modules/crm/catalogs/service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Lógica de negocio de los catálogos de referencia del CRM."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.security import is_hub_admin
|
||||
|
||||
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
|
||||
|
||||
# Metadata de catálogos (labels y si el cliente puede llenarlos).
|
||||
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
|
||||
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
|
||||
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
|
||||
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
|
||||
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
|
||||
|
||||
|
||||
def _require_known(catalog: str) -> None:
|
||||
if catalog not in KNOWN_CATALOGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
|
||||
|
||||
|
||||
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
|
||||
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
|
||||
out: list[CatalogMeta] = []
|
||||
for key, label in CATALOG_LABELS.items():
|
||||
is_tenant = key in TENANT_CATALOG_KEYS
|
||||
count = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == key,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
out.append(
|
||||
CatalogMeta(
|
||||
catalog=key,
|
||||
label=label,
|
||||
scope="tenant" if is_tenant else "global",
|
||||
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
|
||||
count=count,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def list_items(
|
||||
db: Session,
|
||||
catalog: str,
|
||||
tenant_id: int,
|
||||
parent_code: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list[CatalogItem]:
|
||||
_require_known(catalog)
|
||||
q = db.query(CatalogItem).filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
if not include_inactive:
|
||||
q = q.filter(CatalogItem.is_active.is_(True))
|
||||
if parent_code:
|
||||
q = q.filter(CatalogItem.parent_code == parent_code)
|
||||
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
|
||||
|
||||
|
||||
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
|
||||
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
|
||||
|
||||
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
|
||||
- scope 'tenant' (default) → se guarda en el tenant del usuario.
|
||||
"""
|
||||
wants_global = scope == "global"
|
||||
if wants_global:
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
|
||||
)
|
||||
return None
|
||||
return int(current_user["tenant_id"])
|
||||
|
||||
|
||||
def create_item(
|
||||
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
|
||||
) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
target_tenant = _resolve_write_scope(catalog, scope, current_user)
|
||||
|
||||
# No duplicar por (catalog, code, tenant_id)
|
||||
exists = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
CatalogItem.code == data.code,
|
||||
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
|
||||
)
|
||||
|
||||
item = CatalogItem(
|
||||
catalog=catalog,
|
||||
code=data.code,
|
||||
label=data.label,
|
||||
parent_catalog=data.parent_catalog,
|
||||
parent_code=data.parent_code,
|
||||
tenant_id=target_tenant,
|
||||
sort_order=data.sort_order,
|
||||
is_active=data.is_active,
|
||||
is_system=False,
|
||||
created_by=current_user.get("sub"),
|
||||
updated_by=current_user.get("sub"),
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
if item.tenant_id is None:
|
||||
# Global (Aduanasoft): solo hub_admin.
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
|
||||
)
|
||||
elif item.tenant_id != int(current_user["tenant_id"]):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def update_item(
|
||||
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
|
||||
) -> CatalogItem:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
|
||||
for field, value in payload.items():
|
||||
setattr(item, field, value)
|
||||
item.updated_by = current_user.get("sub")
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
if item.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
|
||||
)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
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)}"
|
||||
37
backend/api/v1/modules/crm/common/pricing.py
Normal file
37
backend/api/v1/modules/crm/common/pricing.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Cálculos de precio compartidos del proceso comercial.
|
||||
|
||||
Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización):
|
||||
P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000
|
||||
El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo).
|
||||
6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
# Factor internacional de peso volumétrico aéreo (cm³ por kg).
|
||||
AIR_VOLUMETRIC_DIVISOR = Decimal("6000")
|
||||
|
||||
|
||||
def _d(value) -> Decimal:
|
||||
if value is None:
|
||||
return Decimal(0)
|
||||
return value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
|
||||
|
||||
def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal:
|
||||
"""Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos.
|
||||
|
||||
Devuelve 0 si falta alguna dimensión (no se puede calcular).
|
||||
"""
|
||||
length, width, height = _d(length_cm), _d(width_cm), _d(height_cm)
|
||||
if length <= 0 or width <= 0 or height <= 0:
|
||||
return Decimal(0)
|
||||
quantity = _d(qty) if _d(qty) > 0 else Decimal(1)
|
||||
return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR
|
||||
|
||||
|
||||
def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal:
|
||||
"""Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones)."""
|
||||
return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty))
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,9 @@ _ENTITIES = [
|
||||
("contact", "contactos"),
|
||||
("address", "direcciones"),
|
||||
("document", "documentos"),
|
||||
("service_request", "solicitudes de servicio"),
|
||||
("rate_request", "solicitudes de tarifa"),
|
||||
("quote", "cotizaciones"),
|
||||
("lead", "prospectos"),
|
||||
("opportunity", "oportunidades"),
|
||||
("pipeline", "embudos"),
|
||||
|
||||
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
132
backend/api/v1/modules/crm/quotes/dto.py
Normal file
132
backend/api/v1/modules/crm/quotes/dto.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
|
||||
# ----- Quote items -----
|
||||
|
||||
class QuoteItemBase(BaseModel):
|
||||
concept: str = Field(..., max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
supplier_id: int | None = None
|
||||
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
||||
unit_cost: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
|
||||
unit_sale: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
|
||||
|
||||
class QuoteItemCreate(QuoteItemBase):
|
||||
quote_id: int
|
||||
|
||||
|
||||
class QuoteItemUpdate(BaseModel):
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
supplier_id: int | None = None
|
||||
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
|
||||
unit_cost: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
unit_sale: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
|
||||
|
||||
class QuoteItemResponse(QuoteItemBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
quote_id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def line_cost(self) -> Decimal:
|
||||
return (self.quantity or Decimal(0)) * (self.unit_cost or Decimal(0))
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def line_sale(self) -> Decimal:
|
||||
return (self.quantity or Decimal(0)) * (self.unit_sale or Decimal(0))
|
||||
|
||||
|
||||
# ----- Quotes -----
|
||||
|
||||
class QuoteBase(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
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
|
||||
terms: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class QuoteCreate(QuoteBase):
|
||||
pass
|
||||
|
||||
|
||||
class QuoteUpdate(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
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
|
||||
terms: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class QuoteResponse(QuoteBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: str
|
||||
total_cost: Decimal
|
||||
total_sale: Decimal
|
||||
pdf_file_key: str | None = None
|
||||
sent_at: datetime | None = None
|
||||
accepted_at: datetime | None = None
|
||||
rejected_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def margin(self) -> Decimal:
|
||||
return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0))
|
||||
|
||||
|
||||
# ----- Configuración de marca del formato de cotización -----
|
||||
|
||||
class QuoteSettingsInput(BaseModel):
|
||||
emitter_name: str | None = Field(None, max_length=255)
|
||||
emitter_rfc: str | None = Field(None, max_length=13)
|
||||
emitter_address: str | None = None
|
||||
emitter_phone: str | None = Field(None, max_length=60)
|
||||
emitter_email: str | None = Field(None, max_length=255)
|
||||
emitter_website: str | None = Field(None, max_length=255)
|
||||
accent_color: str | None = Field(None, max_length=9)
|
||||
quote_prefix: str | None = Field(None, max_length=12)
|
||||
default_terms: str | None = None
|
||||
footer_note: str | None = None
|
||||
|
||||
|
||||
class QuoteSettingsResponse(QuoteSettingsInput):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int | None = None
|
||||
logo_file_key: str | None = None
|
||||
|
||||
|
||||
class SendQuoteEmailRequest(BaseModel):
|
||||
to: str | None = None
|
||||
subject: str | None = None
|
||||
message: str | None = None
|
||||
87
backend/api/v1/modules/crm/quotes/models.py
Normal file
87
backend/api/v1/modules/crm/quotes/models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Quote(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cotización (Diagrama 1, pasos 7-9). Integra los conceptos de costo/venta."""
|
||||
|
||||
__tablename__ = "quotes"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
service_request_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
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)
|
||||
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
total_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
total_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# Clave del PDF generado en MinIO (para regenerar/enviar)
|
||||
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class QuoteSettings(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Configuración de marca del formato de cotización, por compañía (tenant).
|
||||
|
||||
Encabezado del emisor, logo y textos por defecto que se imprimen en el PDF.
|
||||
"""
|
||||
|
||||
__tablename__ = "quote_settings"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
emitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
emitter_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True)
|
||||
emitter_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
emitter_phone: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
emitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
emitter_website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
logo_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
accent_color: Mapped[str | None] = mapped_column(String(9), nullable=True, server_default=text("'#2f6bf0'"))
|
||||
quote_prefix: Mapped[str | None] = mapped_column(String(12), nullable=True, server_default=text("'COT'"))
|
||||
default_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class QuoteItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros)."""
|
||||
|
||||
__tablename__ = "quote_items"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
quote_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.quotes.id"), nullable=False, index=True
|
||||
)
|
||||
# flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros
|
||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
supplier_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
)
|
||||
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
||||
unit_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
unit_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
376
backend/api/v1/modules/crm/quotes/pdf.py
Normal file
376
backend/api/v1/modules/crm/quotes/pdf.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema.
|
||||
|
||||
Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección,
|
||||
tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado
|
||||
(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from decimal import Decimal
|
||||
|
||||
_W = 612
|
||||
_H = 792
|
||||
_ML = 50 # margen izquierdo
|
||||
_MR = 562 # margen derecho (x)
|
||||
|
||||
CONCEPT_LABELS = {
|
||||
"flete_internacional": "Flete internacional",
|
||||
"transporte_terrestre": "Transporte terrestre",
|
||||
"despacho_aduanal": "Despacho aduanal",
|
||||
"gastos_destino": "Gastos en destino",
|
||||
"otros": "Otros cargos",
|
||||
}
|
||||
|
||||
_TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "})
|
||||
|
||||
|
||||
def _esc(text) -> str:
|
||||
s = ("" if text is None else str(text)).translate(_TRANSLATE)
|
||||
s = s.encode("latin-1", "replace").decode("latin-1")
|
||||
return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
|
||||
|
||||
|
||||
def _money(value) -> str:
|
||||
return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}"
|
||||
|
||||
|
||||
def _num(value) -> str:
|
||||
return f"{Decimal(str(value or 0)):,.2f}"
|
||||
|
||||
|
||||
# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar)
|
||||
def _text_w(s: str, size: float, bold: bool = False) -> float:
|
||||
return len(s) * size * (0.56 if bold else 0.52)
|
||||
|
||||
|
||||
def _fit(s: str, size: float, max_w: float) -> str:
|
||||
s = s or ""
|
||||
if _text_w(s, size) <= max_w:
|
||||
return s
|
||||
while s and _text_w(s + "…", size) > max_w:
|
||||
s = s[:-1]
|
||||
return s + "…"
|
||||
|
||||
|
||||
def _wrap(text: str, width_chars: int) -> list[str]:
|
||||
words = (text or "").split()
|
||||
if not words:
|
||||
return []
|
||||
out, cur = [], ""
|
||||
for w in words:
|
||||
cand = f"{cur} {w}".strip()
|
||||
if len(cand) > width_chars and cur:
|
||||
out.append(cur)
|
||||
cur = w
|
||||
else:
|
||||
cur = cand
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
|
||||
try:
|
||||
h = (hexs or "#12294c").lstrip("#")
|
||||
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
|
||||
except Exception:
|
||||
return (0.07, 0.16, 0.30)
|
||||
|
||||
|
||||
def _prep_logo(logo_bytes: bytes | None):
|
||||
if not logo_bytes:
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
|
||||
im.thumbnail((600, 300))
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, format="JPEG", quality=88)
|
||||
return buf.getvalue(), im.width, im.height
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class _Canvas:
|
||||
"""Acumula operadores de contenido con paginación simple."""
|
||||
|
||||
def __init__(self):
|
||||
self.pages: list[list[str]] = [[]]
|
||||
self.y = _H
|
||||
|
||||
@property
|
||||
def ops(self) -> list[str]:
|
||||
return self.pages[-1]
|
||||
|
||||
def new_page(self):
|
||||
self.pages.append([])
|
||||
self.y = _H - 50
|
||||
|
||||
def ensure(self, needed: float):
|
||||
if self.y - needed < 50:
|
||||
self.new_page()
|
||||
|
||||
def rect(self, x, y, w, h, rgb):
|
||||
r, g, b = rgb
|
||||
self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f")
|
||||
|
||||
def line(self, x1, y1, x2, y2, rgb, width=0.6):
|
||||
r, g, b = rgb
|
||||
self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S")
|
||||
|
||||
def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False):
|
||||
font = "F2" if bold else "F1"
|
||||
r, g, b = rgb
|
||||
tx = x - _text_w(str(s), size, bold) if right else x
|
||||
self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET")
|
||||
|
||||
|
||||
def build_quote_pdf(
|
||||
*,
|
||||
emitter: dict,
|
||||
head: dict,
|
||||
client: dict,
|
||||
cargo: list[tuple[str, str]],
|
||||
route: list[tuple[str, str]],
|
||||
items: list[dict],
|
||||
currency: str,
|
||||
subtotal,
|
||||
terms: str | None,
|
||||
footer: str | None,
|
||||
logo_bytes: bytes | None = None,
|
||||
accent: str | None = "#12294c",
|
||||
) -> bytes:
|
||||
ACC = _hex_rgb(accent)
|
||||
INK = (0.10, 0.15, 0.24)
|
||||
GRAY = (0.42, 0.47, 0.55)
|
||||
LINE = (0.80, 0.84, 0.90)
|
||||
ZEBRA = (0.955, 0.965, 0.980)
|
||||
logo = _prep_logo(logo_bytes)
|
||||
|
||||
c = _Canvas()
|
||||
|
||||
# ---------------- Encabezado ----------------
|
||||
c.rect(0, _H - 12, _W, 12, ACC) # banda superior
|
||||
logo_bottom = _H - 95
|
||||
if logo:
|
||||
_, lw, lh = logo
|
||||
dw, dh = 150.0, 150.0 * lh / lw
|
||||
if dh > 55:
|
||||
dh, dw = 55.0, 55.0 * lw / lh
|
||||
c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q")
|
||||
else:
|
||||
c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True)
|
||||
|
||||
# Emisor (derecha)
|
||||
ex, ey = 320, _H - 42
|
||||
c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True)
|
||||
ey -= 14
|
||||
em_lines = []
|
||||
if emitter.get("rfc"):
|
||||
em_lines.append(f"RFC: {emitter['rfc']}")
|
||||
for a in (emitter.get("address") or "").splitlines():
|
||||
if a.strip():
|
||||
em_lines.append(a.strip())
|
||||
contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x])
|
||||
if contact:
|
||||
em_lines.append(contact)
|
||||
for ln in em_lines[:5]:
|
||||
c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY)
|
||||
ey -= 11
|
||||
|
||||
# Título + regla
|
||||
c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True)
|
||||
c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2)
|
||||
|
||||
# Panel de datos (derecha)
|
||||
px, pw = 320, _MR - 320
|
||||
py_top = _H - 128
|
||||
ph = 74
|
||||
c.rect(px, py_top - ph, pw, ph, ZEBRA)
|
||||
c.line(px, py_top, px, py_top - ph, LINE)
|
||||
hy = py_top - 15
|
||||
info = [
|
||||
("No.", head.get("reference") or "-"),
|
||||
("Fecha", head.get("issue_date") or "-"),
|
||||
("Vigencia", head.get("valid_until") or "-"),
|
||||
("Ejecutivo", head.get("owner") or "-"),
|
||||
("Estatus", str(head.get("status") or "-").capitalize()),
|
||||
]
|
||||
for k, v in info:
|
||||
c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True)
|
||||
c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK)
|
||||
hy -= 12.5
|
||||
|
||||
c.y = _H - 215
|
||||
|
||||
# ---------------- Helpers de sección ----------------
|
||||
def section(title: str):
|
||||
c.ensure(30)
|
||||
c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC)
|
||||
c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True)
|
||||
c.y -= 26
|
||||
|
||||
def kv_block(pairs: list[tuple[str, str]]):
|
||||
rows = [(k, v) for k, v in pairs if v not in (None, "", "None")]
|
||||
if not rows:
|
||||
return False
|
||||
col_w = (_MR - _ML) / 2
|
||||
i = 0
|
||||
while i < len(rows):
|
||||
c.ensure(16)
|
||||
for col in range(2):
|
||||
if i + col < len(rows):
|
||||
k, v = rows[i + col]
|
||||
x = _ML + 6 + col * col_w
|
||||
c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True)
|
||||
c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK)
|
||||
c.y -= 16
|
||||
i += 2
|
||||
c.y -= 4
|
||||
return True
|
||||
|
||||
# ---------------- Cliente ----------------
|
||||
section("Cliente")
|
||||
if not kv_block([
|
||||
("Cliente", client.get("name")), ("RFC", client.get("rfc")),
|
||||
("Correo", client.get("email")), ("Teléfono", client.get("phone")),
|
||||
]):
|
||||
c.text(_ML + 6, c.y - 11, "—", 9, GRAY)
|
||||
c.y -= 16
|
||||
|
||||
# ---------------- Carga / Ruta (solo si hay datos) ----------------
|
||||
if [v for _, v in cargo if v not in (None, "", "None")]:
|
||||
section("Información de la carga")
|
||||
kv_block(cargo)
|
||||
if [v for _, v in route if v not in (None, "", "None")]:
|
||||
section("Ruta logística")
|
||||
kv_block(route)
|
||||
|
||||
# ---------------- Costos ----------------
|
||||
section("Costos cotizados")
|
||||
x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6
|
||||
row_h = 18
|
||||
# encabezado de tabla
|
||||
c.ensure(row_h)
|
||||
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC)
|
||||
c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True)
|
||||
c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True)
|
||||
c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True)
|
||||
c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True)
|
||||
c.y -= row_h
|
||||
z = False
|
||||
for it in items:
|
||||
code = str(it.get("concept") or "")
|
||||
label = CONCEPT_LABELS.get(code, code)
|
||||
desc = str(it.get("description") or "")
|
||||
if desc:
|
||||
label = f"{label} - {desc}"
|
||||
qty = Decimal(str(it.get("quantity") or 0))
|
||||
unit = Decimal(str(it.get("unit_sale") or 0))
|
||||
amount = (qty * unit).quantize(Decimal("0.01"))
|
||||
c.ensure(row_h)
|
||||
if z:
|
||||
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA)
|
||||
c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK)
|
||||
c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True)
|
||||
c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True)
|
||||
c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True)
|
||||
c.y -= row_h
|
||||
z = not z
|
||||
if not items:
|
||||
c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY)
|
||||
c.y -= row_h
|
||||
# borde de la tabla
|
||||
c.line(_ML, c.y, _MR, c.y, LINE)
|
||||
c.y -= 12
|
||||
|
||||
# ---------------- Totales (caja derecha) ----------------
|
||||
tb_x, tb_w = 360, _MR - 360
|
||||
c.ensure(58)
|
||||
c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA)
|
||||
c.line(tb_x, c.y, tb_x, c.y - 58, LINE)
|
||||
ty = c.y - 16
|
||||
c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True)
|
||||
c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True)
|
||||
ty -= 15
|
||||
c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True)
|
||||
c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True)
|
||||
ty -= 6
|
||||
c.rect(tb_x, ty - 20, tb_w, 20, ACC)
|
||||
c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True)
|
||||
c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True)
|
||||
c.y -= 70
|
||||
|
||||
# ---------------- Condiciones ----------------
|
||||
if terms:
|
||||
section("Condiciones comerciales")
|
||||
for para in terms.splitlines():
|
||||
for ln in (_wrap(para, 108) or [""]):
|
||||
c.ensure(13)
|
||||
c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY)
|
||||
c.y -= 12
|
||||
c.y -= 4
|
||||
|
||||
# pie en todas las páginas
|
||||
for ops in c.pages:
|
||||
if footer:
|
||||
r, g, b = GRAY
|
||||
ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET")
|
||||
ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f")
|
||||
|
||||
# ---------------- Ensamblado ----------------
|
||||
streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages]
|
||||
objects: list[bytes] = []
|
||||
|
||||
def add(obj: bytes):
|
||||
objects.append(obj)
|
||||
|
||||
n_pages = len(c.pages)
|
||||
has_img = 1 if logo else 0
|
||||
# numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams
|
||||
img_num = 5 if has_img else None
|
||||
base = 6 if has_img else 5
|
||||
page_nums = list(range(base, base + n_pages))
|
||||
content_nums = list(range(base + n_pages, base + 2 * n_pages))
|
||||
|
||||
kids = " ".join(f"{n} 0 R" for n in page_nums)
|
||||
add(b"<< /Type /Catalog /Pages 2 0 R >>")
|
||||
add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1"))
|
||||
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
if logo:
|
||||
jpeg, lw, lh = logo
|
||||
add(
|
||||
(
|
||||
f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} "
|
||||
f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n"
|
||||
).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream"
|
||||
)
|
||||
for i in range(n_pages):
|
||||
res = "/Font << /F1 3 0 R /F2 4 0 R >>"
|
||||
if has_img and i == 0:
|
||||
res += f" /XObject << /Im0 {img_num} 0 R >>"
|
||||
add(
|
||||
(
|
||||
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] "
|
||||
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>"
|
||||
).encode("latin-1")
|
||||
)
|
||||
for stream in streams:
|
||||
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
|
||||
|
||||
out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = []
|
||||
for i, obj in enumerate(objects, start=1):
|
||||
offsets.append(len(out))
|
||||
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
total = len(objects) + 1
|
||||
out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n"
|
||||
for off in offsets:
|
||||
out += f"{off:010d} 00000 n \n".encode("latin-1")
|
||||
out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
|
||||
return bytes(out)
|
||||
250
backend/api/v1/modules/crm/quotes/pdf_service.py
Normal file
250
backend/api/v1/modules/crm/quotes/pdf_service.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""PDF de cotización, configuración de marca por tenant y envío por correo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..service_requests.models import ServiceRequest
|
||||
from .models import Quote, QuoteItem, QuoteSettings
|
||||
from .pdf import build_quote_pdf
|
||||
from .service import get_quote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TERMS = (
|
||||
"Tarifas sujetas a disponibilidad de espacio.\n"
|
||||
"Cualquier variación en peso o volumen generará ajuste tarifario.\n"
|
||||
"No incluye cargos extraordinarios, maniobras especiales o servicios no especificados.\n"
|
||||
"Tarifas sujetas a revisión por parte de la línea transportista y autoridades correspondientes."
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Configuración de marca ----------------
|
||||
def get_settings(db: Session, tenant_id: int, company_id: int) -> QuoteSettings | None:
|
||||
return (
|
||||
db.query(QuoteSettings)
|
||||
.filter(QuoteSettings.tenant_id == tenant_id, QuoteSettings.company_id == company_id,
|
||||
QuoteSettings.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def upsert_settings(db: Session, tenant_id: int, company_id: int, data: dict) -> QuoteSettings:
|
||||
obj = get_settings(db, tenant_id, company_id)
|
||||
if obj is None:
|
||||
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(obj)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(obj, field, value)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> QuoteSettings:
|
||||
obj = get_settings(db, tenant_id, company_id)
|
||||
if obj is None:
|
||||
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(obj)
|
||||
obj.logo_file_key = file_key
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
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(
|
||||
text("SELECT name, rfc, logo FROM a76.company WHERE id = :c"), {"c": company_id}
|
||||
).first()
|
||||
if row:
|
||||
return {"name": row[0], "rfc": row[1], "logo": row[2]}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------- Construcción del PDF ----------------
|
||||
def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) -> bytes:
|
||||
items = (
|
||||
db.query(QuoteItem)
|
||||
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
|
||||
.order_by(QuoteItem.id.asc())
|
||||
.all()
|
||||
)
|
||||
account = (
|
||||
db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
|
||||
)
|
||||
sr = (
|
||||
db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
|
||||
if quote.service_request_id else None
|
||||
)
|
||||
settings = get_settings(db, tenant_id, company_id)
|
||||
company = _company_row(db, company_id)
|
||||
|
||||
# Emisor: config del tenant con respaldo en a76.company
|
||||
emitter = {
|
||||
"name": (settings.emitter_name if settings else None) or company.get("name") or "Emisor",
|
||||
"rfc": (settings.emitter_rfc if settings else None) or company.get("rfc"),
|
||||
"address": settings.emitter_address if settings else None,
|
||||
"phone": settings.emitter_phone if settings else None,
|
||||
"email": settings.emitter_email if settings else None,
|
||||
"website": settings.emitter_website if settings else None,
|
||||
}
|
||||
accent = (settings.accent_color if settings else None) or "#12294c"
|
||||
prefix = (settings.quote_prefix if settings else None) or "COT"
|
||||
terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS
|
||||
footer = settings.footer_note if settings else None
|
||||
|
||||
# Logo (MinIO)
|
||||
logo_bytes = None
|
||||
logo_key = settings.logo_file_key if settings else None
|
||||
if logo_key:
|
||||
try:
|
||||
from core.storage_s3 import get_object_bytes
|
||||
logo_bytes = get_object_bytes(logo_key)
|
||||
except Exception as exc:
|
||||
logger.warning("No se pudo leer el logo del tarifario: %s", exc)
|
||||
|
||||
reference = quote.reference or f"{prefix}-{datetime.now().strftime('%Y%m%d')}-{quote.id:03d}"
|
||||
head = {
|
||||
"reference": reference,
|
||||
"issue_date": quote.issue_date.isoformat() if quote.issue_date else None,
|
||||
"valid_until": quote.valid_until.isoformat() if quote.valid_until else None,
|
||||
"owner": quote.owner_user_id or "-",
|
||||
"status": quote.status,
|
||||
}
|
||||
client = {
|
||||
"name": account.name if account else None,
|
||||
"rfc": account.rfc if account else None,
|
||||
"email": account.email if account else None,
|
||||
"phone": account.phone if account else None,
|
||||
}
|
||||
cargo = []
|
||||
route = []
|
||||
if sr:
|
||||
cargo = [
|
||||
("Tipo de mercancía", sr.cargo_type), ("Descripción", sr.commodity),
|
||||
("Peso", str(sr.weight) if sr.weight is not None else None),
|
||||
("Volumen", str(sr.volume) if sr.volume is not None else None),
|
||||
("Tipo de carga", sr.load_type), ("Equipo", sr.container_equipment),
|
||||
]
|
||||
route = [
|
||||
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
|
||||
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
|
||||
("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),
|
||||
]
|
||||
|
||||
return build_quote_pdf(
|
||||
emitter=emitter, head=head, client=client, cargo=cargo, route=route,
|
||||
items=[{"concept": i.concept, "description": i.description, "quantity": i.quantity, "unit_sale": i.unit_sale} for i in items],
|
||||
currency=quote.currency, subtotal=quote.total_sale, terms=terms, footer=footer,
|
||||
logo_bytes=logo_bytes, accent=accent,
|
||||
)
|
||||
|
||||
|
||||
def _store_pdf(db: Session, quote: Quote, tenant_id: int, company_id: int, pdf_bytes: bytes) -> str:
|
||||
from core.storage_s3 import put_object_bytes
|
||||
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quotes/{quote.id}/cotizacion-{ref}.pdf"
|
||||
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
|
||||
quote.pdf_file_key = key
|
||||
db.commit()
|
||||
return key
|
||||
|
||||
|
||||
def get_pdf_url(db: Session, quote_id: int, tenant_id: int, company_id: int) -> str:
|
||||
from core.storage_s3 import presigned_get_url
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
|
||||
key = _store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
|
||||
return presigned_get_url(key)
|
||||
|
||||
|
||||
# ---------------- Envío por correo ----------------
|
||||
async def send_quote_email(
|
||||
db: Session, quote_id: int, tenant_id: int, company_id: int,
|
||||
to: str | None, subject: str | None, message: str | None,
|
||||
) -> dict:
|
||||
import ssl
|
||||
from email import encoders
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from core.config import settings as cfg
|
||||
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
account = db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
|
||||
recipient = to or (account.email if account else None)
|
||||
if not recipient:
|
||||
raise HTTPException(status_code=400, detail="No hay correo destino (captura uno o pon el correo del cliente).")
|
||||
|
||||
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
|
||||
_store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
|
||||
ref = quote.reference or f"COT-{quote.id}"
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = f"{cfg.SMTP_FROM_NAME} <{cfg.SMTP_USER}>"
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject or f"Cotización {ref}"
|
||||
html = (
|
||||
"<div style='font-family:Arial,sans-serif;color:#333;max-width:600px'>"
|
||||
f"<p>{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '<br>')}</p>"
|
||||
f"<p style='color:#6b7280;font-size:12px'>Cotización {ref}</p></div>"
|
||||
)
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
part = MIMEBase("application", "pdf")
|
||||
part.set_payload(pdf_bytes)
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"')
|
||||
msg.attach(part)
|
||||
|
||||
if not (cfg.SMTP_USER and cfg.SMTP_PASSWORD):
|
||||
raise HTTPException(status_code=503, detail="El correo saliente (SMTP) no está configurado en el servidor.")
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
# Puerto 465 = SSL implícito; los demás (587/2525/…) = STARTTLS.
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=cfg.SMTP_HOST,
|
||||
port=cfg.SMTP_PORT,
|
||||
username=cfg.SMTP_USER,
|
||||
password=cfg.SMTP_PASSWORD,
|
||||
use_tls=(cfg.SMTP_PORT == 465),
|
||||
start_tls=(cfg.SMTP_PORT != 465),
|
||||
tls_context=ctx,
|
||||
validate_certs=False,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Error enviando cotización %s: %s", quote_id, exc)
|
||||
raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}")
|
||||
|
||||
# Marca como enviada
|
||||
if quote.status == "borrador":
|
||||
quote.status = "enviada"
|
||||
quote.sent_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"sent_to": recipient, "reference": ref}
|
||||
267
backend/api/v1/modules/crm/quotes/routes.py
Normal file
267
backend/api/v1/modules/crm/quotes/routes.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import pdf_service, service
|
||||
from .dto import (
|
||||
QuoteCreate,
|
||||
QuoteItemCreate,
|
||||
QuoteItemResponse,
|
||||
QuoteItemUpdate,
|
||||
QuoteResponse,
|
||||
QuoteSettingsInput,
|
||||
QuoteSettingsResponse,
|
||||
QuoteUpdate,
|
||||
SendQuoteEmailRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ----- Configuración de marca del formato de cotización -----
|
||||
|
||||
@router.get("/quote-settings", response_model=QuoteSettingsResponse)
|
||||
def get_quote_settings(
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
|
||||
return obj or QuoteSettingsResponse()
|
||||
|
||||
|
||||
@router.put("/quote-settings", response_model=QuoteSettingsResponse)
|
||||
def save_quote_settings(
|
||||
payload: QuoteSettingsInput,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return pdf_service.upsert_settings(db, current_user["tenant_id"], company_id, payload.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.post("/quote-settings/logo", response_model=QuoteSettingsResponse)
|
||||
async def upload_quote_logo(
|
||||
company_id: int = Query(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
from core.storage_s3 import put_object_bytes
|
||||
tenant_id = current_user["tenant_id"]
|
||||
content = await file.read()
|
||||
safe = (file.filename or "logo").replace("/", "-")
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quote-logo/{safe}"
|
||||
put_object_bytes(key, content, content_type=file.content_type or "image/png")
|
||||
return pdf_service.set_logo_key(db, tenant_id, company_id, key)
|
||||
|
||||
|
||||
@router.get("/quote-settings/logo-url")
|
||||
def get_logo_url(
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
from core.storage_s3 import presigned_get_url
|
||||
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
|
||||
if not obj or not obj.logo_file_key:
|
||||
return {"url": None}
|
||||
return {"url": presigned_get_url(obj.logo_file_key)}
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
@router.get("/quotes", response_model=list[QuoteResponse])
|
||||
def list_quotes(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None),
|
||||
quote_status: str | None = Query(None, alias="status"),
|
||||
account_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_quotes(db, tenant_id, company_id, search, quote_status, account_id)
|
||||
|
||||
|
||||
@router.get("/quotes/{quote_id}", response_model=QuoteResponse)
|
||||
def get_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_quote(db, quote_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/quotes", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_quote(
|
||||
payload: QuoteCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
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,
|
||||
payload: QuoteUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_quote(db, quote_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.patch("/quotes/{quote_id}/send", response_model=QuoteResponse)
|
||||
def send_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.send_quote(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/quotes/{quote_id}/accept", response_model=QuoteResponse)
|
||||
def accept_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.accept_quote(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/quotes/{quote_id}/reject", response_model=QuoteResponse)
|
||||
def reject_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.get("/quotes/{quote_id}/pdf")
|
||||
def quote_pdf(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
quote = service.get_quote(db, quote_id, tenant_id, company_id)
|
||||
pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id)
|
||||
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quotes/{quote_id}/send-email")
|
||||
async def quote_send_email(
|
||||
quote_id: int,
|
||||
payload: SendQuoteEmailRequest,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Genera el PDF y lo envía por correo (al cliente o al destinatario indicado)."""
|
||||
return await pdf_service.send_quote_email(
|
||||
db, quote_id, current_user["tenant_id"], company_id, payload.to, payload.subject, payload.message
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
|
||||
def clone_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Clona la cotización como borrador para re-cotizar (R-C-12)."""
|
||||
return service.clone_quote(db, quote_id, current_user["tenant_id"], company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_quote(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Conceptos de la cotización -----
|
||||
|
||||
@router.get("/quotes/{quote_id}/items", response_model=list[QuoteItemResponse])
|
||||
def list_quote_items(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_quote_items(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/quote-items", response_model=QuoteItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_quote_item(
|
||||
payload: QuoteItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_quote_item(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/quote-items/{item_id}", response_model=QuoteItemResponse)
|
||||
def update_quote_item(
|
||||
item_id: int,
|
||||
payload: QuoteItemUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_quote_item(db, item_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/quote-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_quote_item(
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
service.delete_quote_item(db, item_id, current_user["tenant_id"], company_id)
|
||||
400
backend/api/v1/modules/crm/quotes/service.py
Normal file
400
backend/api/v1/modules/crm/quotes/service.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..catalogs.models import CatalogItem
|
||||
from ..common.folios import next_folio
|
||||
from ..common.pricing import air_chargeable_kg
|
||||
from ..service_requests.models import RateRequest, ServiceRequest
|
||||
from ..suppliers.models import Supplier
|
||||
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
|
||||
from .models import Quote, QuoteItem
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
|
||||
if _id is None:
|
||||
return True
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(
|
||||
model.id == _id,
|
||||
model.tenant_id == tenant_id,
|
||||
model.company_id == company_id,
|
||||
model.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _validate_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, ServiceRequest, data.get("service_request_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe")
|
||||
|
||||
|
||||
def _recompute_totals(db: Session, quote: Quote) -> None:
|
||||
"""Recalcula total_cost/total_sale a partir de los conceptos vigentes."""
|
||||
cost, sale = (
|
||||
db.query(
|
||||
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0),
|
||||
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0),
|
||||
)
|
||||
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
|
||||
.one()
|
||||
)
|
||||
quote.total_cost = Decimal(cost or 0)
|
||||
quote.total_sale = Decimal(sale or 0)
|
||||
|
||||
|
||||
# ----- Quotes -----
|
||||
|
||||
def get_quotes(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
quote_status: str | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> list[Quote]:
|
||||
query = db.query(Quote).filter(
|
||||
Quote.tenant_id == tenant_id,
|
||||
Quote.company_id == company_id,
|
||||
Quote.deleted_at.is_(None),
|
||||
)
|
||||
if quote_status:
|
||||
query = query.filter(Quote.status == quote_status)
|
||||
if account_id is not None:
|
||||
query = query.filter(Quote.account_id == account_id)
|
||||
if search:
|
||||
query = query.filter(Quote.reference.ilike(f"%{search}%"))
|
||||
return query.order_by(Quote.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
obj = (
|
||||
db.query(Quote)
|
||||
.filter(
|
||||
Quote.id == quote_id,
|
||||
Quote.tenant_id == tenant_id,
|
||||
Quote.company_id == company_id,
|
||||
Quote.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
|
||||
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()
|
||||
)
|
||||
# Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos
|
||||
service_labels = {
|
||||
code: label
|
||||
for code, label in db.query(CatalogItem.code, CatalogItem.label).filter(
|
||||
CatalogItem.catalog == "servicio_adicional"
|
||||
)
|
||||
}
|
||||
service_costs = sr.additional_service_costs or {}
|
||||
|
||||
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,
|
||||
))
|
||||
# Servicios adicionales marcados en la solicitud → conceptos con su costo estimado
|
||||
for code in (sr.additional_services or []):
|
||||
amount = Decimal(str(service_costs.get(code) or 0))
|
||||
db.add(QuoteItem(
|
||||
quote_id=quote.id, concept=code[:60],
|
||||
description=service_labels.get(code, "Servicio adicional"),
|
||||
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
|
||||
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
|
||||
))
|
||||
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
|
||||
# para que el ejecutivo capture la tarifa por kg.
|
||||
if (variant or "").upper() == "AEREO":
|
||||
chargeable = air_chargeable_kg(
|
||||
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
|
||||
sr.pallets_count or sr.pieces_count or 1,
|
||||
)
|
||||
db.add(QuoteItem(
|
||||
quote_id=quote.id, concept="flete_internacional",
|
||||
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
|
||||
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
|
||||
currency=sr.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:
|
||||
obj = get_quote(db, quote_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = get_quote(db, quote_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None:
|
||||
if quote.service_request_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
|
||||
if sr:
|
||||
sr.status = new_status
|
||||
|
||||
|
||||
def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "enviada"
|
||||
quote.sent_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "cotizada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "aceptada"
|
||||
quote.accepted_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "aceptada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
quote.status = "rechazada"
|
||||
quote.rejected_at = datetime.now(timezone.utc)
|
||||
_set_service_request_status(db, quote, "rechazada")
|
||||
db.commit()
|
||||
db.refresh(quote)
|
||||
return quote
|
||||
|
||||
|
||||
def clone_quote(
|
||||
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Quote:
|
||||
"""Clona una cotización (y sus conceptos) como borrador para re-cotizar (R-C-12).
|
||||
|
||||
Si la cotización origen fue rechazada, reabre su solicitud a 'en_analisis' para
|
||||
cerrar el ciclo de reintento del Diagrama 1.
|
||||
"""
|
||||
src = get_quote(db, quote_id, tenant_id, company_id)
|
||||
new_quote = Quote(
|
||||
reference=(f"{src.reference}-R" if src.reference else None),
|
||||
service_request_id=src.service_request_id,
|
||||
account_id=src.account_id,
|
||||
currency=src.currency,
|
||||
status="borrador",
|
||||
valid_until=src.valid_until,
|
||||
notes=src.notes,
|
||||
terms=src.terms,
|
||||
owner_user_id=src.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(new_quote)
|
||||
db.flush()
|
||||
src_items = (
|
||||
db.query(QuoteItem)
|
||||
.filter(QuoteItem.quote_id == src.id, QuoteItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
for it in src_items:
|
||||
db.add(QuoteItem(
|
||||
quote_id=new_quote.id, concept=it.concept, description=it.description,
|
||||
supplier_id=it.supplier_id, quantity=it.quantity, unit_cost=it.unit_cost,
|
||||
unit_sale=it.unit_sale, currency=it.currency,
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
))
|
||||
db.flush()
|
||||
_recompute_totals(db, new_quote)
|
||||
# Reabre la solicitud origen para el ciclo de re-cotización
|
||||
if src.service_request_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == src.service_request_id).first()
|
||||
if sr and sr.status in ("rechazada", "cotizada"):
|
||||
sr.status = "en_analisis"
|
||||
db.commit()
|
||||
db.refresh(new_quote)
|
||||
return new_quote
|
||||
|
||||
|
||||
# ----- Quote items -----
|
||||
|
||||
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:
|
||||
get_quote(db, quote_id, tenant_id, company_id) # valida scope
|
||||
return (
|
||||
db.query(QuoteItem)
|
||||
.filter(
|
||||
QuoteItem.quote_id == quote_id,
|
||||
QuoteItem.tenant_id == tenant_id,
|
||||
QuoteItem.company_id == company_id,
|
||||
QuoteItem.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(QuoteItem.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem:
|
||||
item = (
|
||||
db.query(QuoteItem)
|
||||
.filter(
|
||||
QuoteItem.id == item_id,
|
||||
QuoteItem.tenant_id == tenant_id,
|
||||
QuoteItem.company_id == company_id,
|
||||
QuoteItem.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem:
|
||||
quote = get_quote(db, payload.quote_id, tenant_id, company_id)
|
||||
if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def update_quote_item(
|
||||
db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int
|
||||
) -> QuoteItem:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
for field, value in data.items():
|
||||
setattr(item, field, value)
|
||||
db.flush()
|
||||
quote = get_quote(db, item.quote_id, tenant_id, company_id)
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
quote_id = item.quote_id
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
quote = get_quote(db, quote_id, tenant_id, company_id)
|
||||
_recompute_totals(db, quote)
|
||||
db.commit()
|
||||
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
178
backend/api/v1/modules/crm/rates/dto.py
Normal file
178
backend/api/v1/modules/crm/rates/dto.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Schemas del módulo Tarifario."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ---------- Quiebres y cargos ----------
|
||||
class RateBreakDTO(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
from_qty: Decimal = Field(0)
|
||||
rate: Decimal = Field(0)
|
||||
|
||||
|
||||
class RateChargeDTO(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
concept: str = Field(..., max_length=60)
|
||||
charge_type: str = Field("fijo", max_length=20)
|
||||
value: Decimal | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RateChargeCreate(BaseModel):
|
||||
concept: str = Field(..., max_length=60)
|
||||
charge_type: str = Field("fijo", max_length=20)
|
||||
value: Decimal | None = None
|
||||
condition: str | None = None
|
||||
rate_lane_id: int | None = None
|
||||
|
||||
|
||||
class RateChargeUpdate(BaseModel):
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
charge_type: str | None = Field(None, max_length=20)
|
||||
value: Decimal | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RateChargeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
rate_sheet_id: int | None
|
||||
rate_lane_id: int | None
|
||||
concept: str
|
||||
charge_type: str
|
||||
value: Decimal | None
|
||||
condition: str | None
|
||||
|
||||
|
||||
# ---------- Rutas ----------
|
||||
class RateLaneBase(BaseModel):
|
||||
origin: str | None = Field(None, max_length=20)
|
||||
destination: str | None = Field(None, max_length=20)
|
||||
region: str | None = Field(None, max_length=60)
|
||||
equipment_type: str | None = Field(None, max_length=20)
|
||||
rate_unit: str | None = Field(None, max_length=20)
|
||||
min_charge: Decimal | None = None
|
||||
flat_rate: Decimal | None = None
|
||||
transit_days: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RateLaneCreate(RateLaneBase):
|
||||
breaks: list[RateBreakDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RateLaneUpdate(RateLaneBase):
|
||||
breaks: list[RateBreakDTO] | None = None
|
||||
|
||||
|
||||
class RateLaneResponse(RateLaneBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
rate_sheet_id: int
|
||||
breaks: list[RateBreakDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- Tarifario (cabecera) ----------
|
||||
class RateSheetBase(BaseModel):
|
||||
supplier_id: int | None = None
|
||||
mode: str = Field(..., max_length=20)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
currency: str | None = Field("USD", max_length=3)
|
||||
valid_from: date | None = None
|
||||
valid_to: date | None = None
|
||||
default_origin: str | None = Field(None, max_length=20)
|
||||
status: str = Field("borrador", max_length=20)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RateSheetCreate(RateSheetBase):
|
||||
pass
|
||||
|
||||
|
||||
class RateSheetUpdate(BaseModel):
|
||||
supplier_id: int | None = None
|
||||
mode: str | None = Field(None, max_length=20)
|
||||
name: str | None = Field(None, max_length=255)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
valid_from: date | None = None
|
||||
valid_to: date | None = None
|
||||
default_origin: str | None = Field(None, max_length=20)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RateSheetResponse(RateSheetBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
source_file: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
lane_count: int | None = None
|
||||
|
||||
|
||||
# ---------- Importación ----------
|
||||
class ImportPreviewRow(BaseModel):
|
||||
row: int
|
||||
data: dict
|
||||
ok: bool
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImportPreview(BaseModel):
|
||||
mode: str
|
||||
total: int
|
||||
valid: int
|
||||
rows: list[ImportPreviewRow]
|
||||
columns: list[str]
|
||||
|
||||
|
||||
class ImportConfirm(RateSheetCreate):
|
||||
lanes: list[RateLaneCreate]
|
||||
|
||||
|
||||
# ---------- Costeo ----------
|
||||
class CostRequest(BaseModel):
|
||||
mode: str
|
||||
origin: str | None = None
|
||||
destination: str | None = None
|
||||
on_date: date | None = None
|
||||
gross_weight_kg: Decimal | None = None
|
||||
volume_m3: Decimal | None = None
|
||||
# Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000)
|
||||
length_cm: Decimal | None = None
|
||||
width_cm: Decimal | None = None
|
||||
height_cm: Decimal | None = None
|
||||
equipment_type: str | None = None
|
||||
quantity: int = 1
|
||||
dangerous: bool = False
|
||||
|
||||
|
||||
class CostChargeLine(BaseModel):
|
||||
concept: str
|
||||
amount: Decimal
|
||||
|
||||
|
||||
class CostOption(BaseModel):
|
||||
rate_sheet_id: int
|
||||
rate_sheet_name: str
|
||||
supplier_id: int | None
|
||||
currency: str | None
|
||||
chargeable: Decimal | None = None # peso/wm facturable usado
|
||||
base_cost: Decimal
|
||||
charges: list[CostChargeLine] = Field(default_factory=list)
|
||||
total_cost: Decimal
|
||||
transit_days: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class CostResult(BaseModel):
|
||||
request: CostRequest
|
||||
options: list[CostOption]
|
||||
93
backend/api/v1/modules/crm/rates/models.py
Normal file
93
backend/api/v1/modules/crm/rates/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Modelos del módulo Tarifario (base de costos para Cotizaciones).
|
||||
|
||||
Un ``RateSheet`` (tarifario) pertenece a un proveedor y agrupa muchas
|
||||
``RateLane`` (rutas origen→destino). Cada ruta tiene, según el modo:
|
||||
- Aéreo / LCL: varios ``RateBreak`` (quiebres de peso/volumen con su tarifa).
|
||||
- FCL / terrestre: una tarifa plana por contenedor/unidad (``flat_rate``).
|
||||
Los ``RateCharge`` son cargos adicionales a nivel tarifario o ruta.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class RateSheet(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "rate_sheets"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
supplier_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
|
||||
)
|
||||
# aereo | maritimo_fcl | maritimo_lcl | terrestre
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
|
||||
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
default_origin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# borrador | activo | vencido | reemplazado
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"))
|
||||
source_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class RateLane(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "rate_lanes"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
rate_sheet_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.rate_sheets.id"), nullable=False, index=True
|
||||
)
|
||||
origin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
destination: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
region: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
# Solo FCL/terrestre (código del catálogo tipo_equipo). Nulo en aéreo/LCL.
|
||||
equipment_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# per_kg | per_wm | per_container | flat
|
||||
rate_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
min_charge: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||
flat_rate: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||
transit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class RateBreak(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "rate_breaks"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
rate_lane_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.rate_lanes.id"), nullable=False, index=True
|
||||
)
|
||||
# Umbral del quiebre (kg en aéreo; W/M en LCL)
|
||||
from_qty: Mapped[Decimal] = mapped_column(Numeric(12, 3), nullable=False, server_default=text("0"))
|
||||
rate: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class RateCharge(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "rate_charges"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
rate_sheet_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.rate_sheets.id"), nullable=True, index=True
|
||||
)
|
||||
rate_lane_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.rate_lanes.id"), nullable=True, index=True
|
||||
)
|
||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
# fijo | por_kg | por_guia | por_contenedor | porcentaje
|
||||
charge_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fijo'"))
|
||||
value: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||
condition: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
257
backend/api/v1/modules/crm/rates/routes.py
Normal file
257
backend/api/v1/modules/crm/rates/routes.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Endpoints del módulo Tarifario."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
CostRequest,
|
||||
CostResult,
|
||||
ImportPreview,
|
||||
RateBreakDTO,
|
||||
RateChargeCreate,
|
||||
RateChargeResponse,
|
||||
RateChargeUpdate,
|
||||
RateLaneCreate,
|
||||
RateLaneResponse,
|
||||
RateSheetCreate,
|
||||
RateSheetResponse,
|
||||
RateSheetUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"])
|
||||
|
||||
|
||||
def _ctx(current_user: dict):
|
||||
return current_user["tenant_id"], current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse:
|
||||
out = RateSheetResponse.model_validate(sheet)
|
||||
out.lane_count = service.lane_count(db, tenant_id, sheet.id)
|
||||
return out
|
||||
|
||||
|
||||
def _lane_out(db: Session, lane) -> RateLaneResponse:
|
||||
out = RateLaneResponse.model_validate(lane)
|
||||
out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- Tarifarios ----------------
|
||||
@router.get("", response_model=list[RateSheetResponse])
|
||||
def list_sheets(
|
||||
company_id: int = Query(...),
|
||||
mode: str | None = Query(None),
|
||||
supplier_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id)
|
||||
return [_sheet_out(db, tenant_id, s) for s in sheets]
|
||||
|
||||
|
||||
@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_sheet(
|
||||
data: RateSheetCreate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, user_id = _ctx(current_user)
|
||||
sheet = service.create_sheet(db, tenant_id, company_id, data, user_id)
|
||||
return _sheet_out(db, tenant_id, sheet)
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template(
|
||||
mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"),
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
content = service.build_template(mode)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/preview", response_model=ImportPreview)
|
||||
async def import_preview(
|
||||
company_id: int = Query(...),
|
||||
mode: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
content = await file.read()
|
||||
return service.parse_excel(mode, content)
|
||||
|
||||
|
||||
@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def import_sheet(
|
||||
company_id: int = Query(...),
|
||||
mode: str = Form(...),
|
||||
name: str = Form(...),
|
||||
supplier_id: int | None = Form(None),
|
||||
currency: str = Form("USD"),
|
||||
valid_from: date | None = Form(None),
|
||||
valid_to: date | None = Form(None),
|
||||
default_origin: str | None = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, user_id = _ctx(current_user)
|
||||
content = await file.read()
|
||||
header = RateSheetCreate(
|
||||
mode=mode, name=name, supplier_id=supplier_id, currency=currency,
|
||||
valid_from=valid_from, valid_to=valid_to, default_origin=default_origin,
|
||||
)
|
||||
sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id)
|
||||
return _sheet_out(db, tenant_id, sheet)
|
||||
|
||||
|
||||
@router.get("/{sheet_id}", response_model=RateSheetResponse)
|
||||
def get_sheet(
|
||||
sheet_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id))
|
||||
|
||||
|
||||
@router.patch("/{sheet_id}", response_model=RateSheetResponse)
|
||||
def update_sheet(
|
||||
sheet_id: int,
|
||||
data: RateSheetUpdate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, user_id = _ctx(current_user)
|
||||
return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id))
|
||||
|
||||
|
||||
@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_sheet(
|
||||
sheet_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.delete_sheet(db, tenant_id, company_id, sheet_id)
|
||||
|
||||
|
||||
# ---------------- Rutas (lanes) ----------------
|
||||
@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse])
|
||||
def list_lanes(
|
||||
sheet_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
return [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)]
|
||||
|
||||
|
||||
@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_lane(
|
||||
sheet_id: int,
|
||||
data: RateLaneCreate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
lane = service.create_lane(db, tenant_id, company_id, sheet_id, data)
|
||||
return _lane_out(db, lane)
|
||||
|
||||
|
||||
@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_lane(
|
||||
sheet_id: int,
|
||||
lane_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.delete_lane(db, tenant_id, sheet_id, lane_id)
|
||||
|
||||
|
||||
# ---------------- Cargos adicionales ----------------
|
||||
@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse])
|
||||
def list_charges(
|
||||
sheet_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
return service.list_charges(db, tenant_id, sheet_id)
|
||||
|
||||
|
||||
@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_charge(
|
||||
sheet_id: int,
|
||||
data: RateChargeCreate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return service.create_charge(db, tenant_id, company_id, sheet_id, data)
|
||||
|
||||
|
||||
@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse)
|
||||
def update_charge(
|
||||
sheet_id: int,
|
||||
charge_id: int,
|
||||
data: RateChargeUpdate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return service.update_charge(db, tenant_id, sheet_id, charge_id, data)
|
||||
|
||||
|
||||
@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_charge(
|
||||
sheet_id: int,
|
||||
charge_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.delete_charge(db, tenant_id, sheet_id, charge_id)
|
||||
|
||||
|
||||
# ---------------- Motor de costeo ----------------
|
||||
cost_router = APIRouter(tags=["Tarifario"])
|
||||
|
||||
|
||||
@cost_router.post("/rate-quote", response_model=CostResult)
|
||||
def rate_quote(
|
||||
req: CostRequest,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Calcula opciones de costo (por proveedor) para una ruta/carga."""
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
options = service.quote_cost(db, tenant_id, company_id, req)
|
||||
return CostResult(request=req, options=options)
|
||||
541
backend/api/v1/modules/crm/rates/service.py
Normal file
541
backend/api/v1/modules/crm/rates/service.py
Normal file
@@ -0,0 +1,541 @@
|
||||
"""Lógica del módulo Tarifario: CRUD, importación por Excel y motor de costeo."""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
CostChargeLine,
|
||||
CostOption,
|
||||
CostRequest,
|
||||
ImportConfirm,
|
||||
ImportPreview,
|
||||
ImportPreviewRow,
|
||||
RateLaneCreate,
|
||||
RateSheetCreate,
|
||||
RateSheetUpdate,
|
||||
)
|
||||
from ..common.pricing import air_volumetric_kg
|
||||
from .models import RateBreak, RateCharge, RateLane, RateSheet
|
||||
|
||||
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
|
||||
# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm).
|
||||
AIR_VOLUMETRIC_FACTOR = Decimal("167")
|
||||
|
||||
|
||||
# ============================================================ CRUD tarifarios
|
||||
def _sheet_query(db: Session, tenant_id: int, company_id: int):
|
||||
return db.query(RateSheet).filter(
|
||||
RateSheet.tenant_id == tenant_id,
|
||||
RateSheet.company_id == company_id,
|
||||
RateSheet.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
|
||||
def list_sheets(db: Session, tenant_id: int, company_id: int, mode: str | None = None,
|
||||
supplier_id: int | None = None) -> list[RateSheet]:
|
||||
q = _sheet_query(db, tenant_id, company_id)
|
||||
if mode:
|
||||
q = q.filter(RateSheet.mode == mode)
|
||||
if supplier_id:
|
||||
q = q.filter(RateSheet.supplier_id == supplier_id)
|
||||
return q.order_by(RateSheet.created_at.desc()).all()
|
||||
|
||||
|
||||
def lane_count(db: Session, tenant_id: int, sheet_id: int) -> int:
|
||||
return (
|
||||
db.query(RateLane)
|
||||
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
|
||||
RateLane.deleted_at.is_(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def get_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> RateSheet:
|
||||
sheet = _sheet_query(db, tenant_id, company_id).filter(RateSheet.id == sheet_id).first()
|
||||
if not sheet:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tarifario no encontrado")
|
||||
return sheet
|
||||
|
||||
|
||||
def create_sheet(db: Session, tenant_id: int, company_id: int, data: RateSheetCreate,
|
||||
user_id: str | None) -> RateSheet:
|
||||
sheet = RateSheet(
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
**data.model_dump(),
|
||||
created_by=user_id, updated_by=user_id,
|
||||
)
|
||||
db.add(sheet)
|
||||
db.commit()
|
||||
db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
|
||||
def update_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||
data: RateSheetUpdate, user_id: str | None) -> RateSheet:
|
||||
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(sheet, field, value)
|
||||
sheet.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
|
||||
def delete_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> None:
|
||||
from sqlalchemy import func
|
||||
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
sheet.deleted_at = func.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================ Rutas (lanes)
|
||||
def list_lanes(db: Session, tenant_id: int, sheet_id: int) -> list[RateLane]:
|
||||
return (
|
||||
db.query(RateLane)
|
||||
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
|
||||
RateLane.deleted_at.is_(None))
|
||||
.order_by(RateLane.region, RateLane.destination)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def breaks_of(db: Session, lane_id: int) -> list[RateBreak]:
|
||||
return (
|
||||
db.query(RateBreak)
|
||||
.filter(RateBreak.rate_lane_id == lane_id, RateBreak.deleted_at.is_(None))
|
||||
.order_by(RateBreak.from_qty)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _add_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||
lane_data: RateLaneCreate) -> RateLane:
|
||||
payload = lane_data.model_dump(exclude={"breaks"})
|
||||
lane = RateLane(tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, **payload)
|
||||
db.add(lane)
|
||||
db.flush() # id
|
||||
for br in lane_data.breaks:
|
||||
db.add(RateBreak(
|
||||
tenant_id=tenant_id, company_id=company_id, rate_lane_id=lane.id,
|
||||
from_qty=br.from_qty, rate=br.rate,
|
||||
))
|
||||
return lane
|
||||
|
||||
|
||||
def create_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||
lane_data: RateLaneCreate) -> RateLane:
|
||||
get_sheet(db, tenant_id, company_id, sheet_id) # valida pertenencia
|
||||
lane = _add_lane(db, tenant_id, company_id, sheet_id, lane_data)
|
||||
db.commit()
|
||||
db.refresh(lane)
|
||||
return lane
|
||||
|
||||
|
||||
def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> None:
|
||||
from sqlalchemy import func
|
||||
lane = (
|
||||
db.query(RateLane)
|
||||
.filter(RateLane.id == lane_id, RateLane.rate_sheet_id == sheet_id,
|
||||
RateLane.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not lane:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ruta no encontrada")
|
||||
lane.deleted_at = func.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================ Cargos adicionales
|
||||
def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]:
|
||||
return (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id,
|
||||
RateCharge.deleted_at.is_(None))
|
||||
.order_by(RateCharge.concept)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge:
|
||||
get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
ch = RateCharge(
|
||||
tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id,
|
||||
rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type,
|
||||
value=data.value, condition=data.condition,
|
||||
)
|
||||
db.add(ch)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
return ch
|
||||
|
||||
|
||||
def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge:
|
||||
ch = (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||||
RateCharge.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(ch, field, value)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
return ch
|
||||
|
||||
|
||||
def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None:
|
||||
from sqlalchemy import func
|
||||
ch = (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||||
RateCharge.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||||
ch.deleted_at = func.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================ Importación Excel
|
||||
# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre).
|
||||
TEMPLATES: dict[str, list[str]] = {
|
||||
"aereo": ["Region", "Origen", "Destino", "IATA", "Min", "100", "300", "500", "1000"],
|
||||
"maritimo_fcl": ["Origen", "Destino", "Tipo contenedor", "Tarifa", "Transito", "Notas"],
|
||||
"maritimo_lcl": ["Origen", "Destino", "Tarifa W/M", "Minimo", "Notas"],
|
||||
"terrestre": ["Origen", "Destino", "Tarifa", "Transito", "Notas"],
|
||||
}
|
||||
|
||||
|
||||
def build_template(mode: str) -> bytes:
|
||||
"""Genera un .xlsx con los encabezados del modo + una fila de ejemplo."""
|
||||
import openpyxl
|
||||
|
||||
if mode not in TEMPLATES:
|
||||
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = mode
|
||||
headers = TEMPLATES[mode]
|
||||
ws.append(headers)
|
||||
examples = {
|
||||
"aereo": ["EUROPA", "NLU", "Frankfurt", "FRA", 190, 1.00, 1.00, 0.95, 0.90],
|
||||
"maritimo_fcl": ["MXZLO", "CNSHA", "40HC", 2500, 28, "THC no incluido"],
|
||||
"maritimo_lcl": ["MXZLO", "USLAX", 45, 80, "1 W/M = 1 ton o 1 m3"],
|
||||
"terrestre": ["Monterrey", "Laredo", 850, 1, ""],
|
||||
}
|
||||
ws.append(examples[mode])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _num(v: Any) -> Decimal | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(v).replace("$", "").replace(",", "").strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_excel(mode: str, content: bytes) -> ImportPreview:
|
||||
"""Lee el Excel y devuelve una vista previa con validaciones (no persiste)."""
|
||||
import openpyxl
|
||||
|
||||
if mode not in TEMPLATES:
|
||||
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
|
||||
try:
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True, read_only=True)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="No se pudo leer el archivo Excel")
|
||||
ws = wb.active
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
header = next(rows_iter, None)
|
||||
if not header:
|
||||
raise HTTPException(status_code=400, detail="El archivo está vacío")
|
||||
cols = [str(c).strip() if c is not None else "" for c in header]
|
||||
idx = {name.lower(): i for i, name in enumerate(cols)}
|
||||
|
||||
def cell(row, name):
|
||||
i = idx.get(name.lower())
|
||||
return row[i] if i is not None and i < len(row) else None
|
||||
|
||||
preview_rows: list[ImportPreviewRow] = []
|
||||
valid = 0
|
||||
for n, row in enumerate(rows_iter, start=2):
|
||||
if row is None or all(c is None or str(c).strip() == "" for c in row):
|
||||
continue
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
data: dict = {}
|
||||
if mode == "aereo":
|
||||
data = {
|
||||
"region": cell(row, "Region"),
|
||||
"origin": cell(row, "Origen"),
|
||||
"destination": cell(row, "Destino") or cell(row, "IATA"),
|
||||
"iata": cell(row, "IATA"),
|
||||
"min_charge": _num(cell(row, "Min")),
|
||||
"breaks": {b: _num(cell(row, b)) for b in ("100", "300", "500", "1000")},
|
||||
}
|
||||
if not data["destination"]:
|
||||
errors.append("Falta destino/IATA")
|
||||
if not any(v is not None for v in data["breaks"].values()):
|
||||
errors.append("Sin tarifas por quiebre")
|
||||
elif mode == "maritimo_fcl":
|
||||
data = {
|
||||
"origin": cell(row, "Origen"),
|
||||
"destination": cell(row, "Destino"),
|
||||
"equipment_type": cell(row, "Tipo contenedor"),
|
||||
"flat_rate": _num(cell(row, "Tarifa")),
|
||||
"transit_days": _num(cell(row, "Transito")),
|
||||
"notes": cell(row, "Notas"),
|
||||
}
|
||||
if data["flat_rate"] is None:
|
||||
errors.append("Falta la tarifa")
|
||||
if not data["equipment_type"]:
|
||||
warnings.append("Sin tipo de contenedor")
|
||||
elif mode == "maritimo_lcl":
|
||||
data = {
|
||||
"origin": cell(row, "Origen"),
|
||||
"destination": cell(row, "Destino"),
|
||||
"wm_rate": _num(cell(row, "Tarifa W/M")),
|
||||
"min_charge": _num(cell(row, "Minimo")),
|
||||
"notes": cell(row, "Notas"),
|
||||
}
|
||||
if data["wm_rate"] is None:
|
||||
errors.append("Falta la tarifa W/M")
|
||||
else: # terrestre
|
||||
data = {
|
||||
"origin": cell(row, "Origen"),
|
||||
"destination": cell(row, "Destino"),
|
||||
"flat_rate": _num(cell(row, "Tarifa")),
|
||||
"transit_days": _num(cell(row, "Transito")),
|
||||
"notes": cell(row, "Notas"),
|
||||
}
|
||||
if data["flat_rate"] is None:
|
||||
errors.append("Falta la tarifa")
|
||||
if not data.get("destination"):
|
||||
errors.append("Falta destino")
|
||||
ok = not errors
|
||||
if ok:
|
||||
valid += 1
|
||||
preview_rows.append(ImportPreviewRow(row=n, data=_jsonable(data), ok=ok,
|
||||
warnings=warnings, errors=errors))
|
||||
return ImportPreview(mode=mode, total=len(preview_rows), valid=valid,
|
||||
rows=preview_rows, columns=cols)
|
||||
|
||||
|
||||
def _jsonable(d: dict) -> dict:
|
||||
out = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, Decimal):
|
||||
out[k] = float(v)
|
||||
elif isinstance(v, dict):
|
||||
out[k] = {kk: (float(vv) if isinstance(vv, Decimal) else vv) for kk, vv in v.items()}
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _rows_to_lanes(mode: str, rows: list[ImportPreviewRow], default_origin: str | None) -> list[RateLaneCreate]:
|
||||
lanes: list[RateLaneCreate] = []
|
||||
for r in rows:
|
||||
if not r.ok:
|
||||
continue
|
||||
d = r.data
|
||||
origin = d.get("origin") or default_origin
|
||||
if mode == "aereo":
|
||||
breaks = [
|
||||
{"from_qty": Decimal(b), "rate": Decimal(str(v))}
|
||||
for b, v in (d.get("breaks") or {}).items() if v is not None
|
||||
]
|
||||
lanes.append(RateLaneCreate(
|
||||
origin=str(origin) if origin else None,
|
||||
destination=str(d.get("destination")),
|
||||
region=d.get("region"), rate_unit="per_kg",
|
||||
min_charge=_num(d.get("min_charge")),
|
||||
breaks=breaks, # type: ignore[arg-type]
|
||||
))
|
||||
elif mode == "maritimo_fcl":
|
||||
lanes.append(RateLaneCreate(
|
||||
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||
equipment_type=d.get("equipment_type"), rate_unit="per_container",
|
||||
flat_rate=_num(d.get("flat_rate")),
|
||||
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
|
||||
notes=d.get("notes"),
|
||||
))
|
||||
elif mode == "maritimo_lcl":
|
||||
lanes.append(RateLaneCreate(
|
||||
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||
rate_unit="per_wm", min_charge=_num(d.get("min_charge")),
|
||||
breaks=[{"from_qty": Decimal(0), "rate": Decimal(str(d["wm_rate"]))}], # type: ignore[arg-type]
|
||||
notes=d.get("notes"),
|
||||
))
|
||||
else:
|
||||
lanes.append(RateLaneCreate(
|
||||
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||
rate_unit="flat", flat_rate=_num(d.get("flat_rate")),
|
||||
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
|
||||
notes=d.get("notes"),
|
||||
))
|
||||
return lanes
|
||||
|
||||
|
||||
def confirm_import(db: Session, tenant_id: int, company_id: int, data: ImportConfirm,
|
||||
user_id: str | None) -> RateSheet:
|
||||
"""Crea el tarifario + rutas a partir de la vista previa confirmada."""
|
||||
sheet = RateSheet(
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
supplier_id=data.supplier_id, mode=data.mode, name=data.name,
|
||||
currency=data.currency, valid_from=data.valid_from, valid_to=data.valid_to,
|
||||
default_origin=data.default_origin, status=data.status or "borrador",
|
||||
notes=data.notes, created_by=user_id, updated_by=user_id,
|
||||
)
|
||||
db.add(sheet)
|
||||
db.flush()
|
||||
for lane in data.lanes:
|
||||
_add_lane(db, tenant_id, company_id, sheet.id, lane)
|
||||
db.commit()
|
||||
db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
|
||||
def import_from_excel(db: Session, tenant_id: int, company_id: int, mode: str,
|
||||
content: bytes, header: RateSheetCreate, user_id: str | None) -> RateSheet:
|
||||
"""Atajo: parsea el Excel y crea el tarifario en un solo paso."""
|
||||
preview = parse_excel(mode, content)
|
||||
lanes = _rows_to_lanes(mode, preview.rows, header.default_origin)
|
||||
return confirm_import(
|
||||
db, tenant_id, company_id,
|
||||
ImportConfirm(**header.model_dump(), lanes=lanes), user_id,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================ Motor de costeo
|
||||
def _volumetric_kg(volume_m3: Decimal | None) -> Decimal:
|
||||
return (volume_m3 or Decimal(0)) * AIR_VOLUMETRIC_FACTOR
|
||||
|
||||
|
||||
def _rate_for(breaks: list[RateBreak], qty: Decimal) -> Decimal | None:
|
||||
"""Tarifa aplicable al peso/wm 'qty' (mayor quiebre cuyo umbral <= qty)."""
|
||||
if not breaks:
|
||||
return None
|
||||
applicable = None
|
||||
for b in breaks:
|
||||
if b.from_qty <= qty:
|
||||
applicable = b.rate
|
||||
if applicable is None:
|
||||
applicable = breaks[0].rate # por debajo del primer quiebre → tarifa base (gobierna el mínimo)
|
||||
return applicable
|
||||
|
||||
|
||||
def _best_break_cost(breaks: list[RateBreak], qty: Decimal) -> Decimal:
|
||||
"""Costo base con optimización de quiebre (declarar peso mayor si conviene)."""
|
||||
base_rate = _rate_for(breaks, qty)
|
||||
base = (qty * base_rate) if base_rate is not None else Decimal(0)
|
||||
for b in breaks:
|
||||
if b.from_qty > qty:
|
||||
candidate = b.from_qty * b.rate
|
||||
if candidate < base:
|
||||
base = candidate
|
||||
return base
|
||||
|
||||
|
||||
def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
|
||||
chargeable: Decimal, quantity: int, dangerous: bool) -> list[CostChargeLine]:
|
||||
charges = (
|
||||
db.query(RateCharge)
|
||||
.filter(
|
||||
RateCharge.deleted_at.is_(None),
|
||||
or_(RateCharge.rate_sheet_id == sheet.id, RateCharge.rate_lane_id == lane.id),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
lines: list[CostChargeLine] = []
|
||||
for c in charges:
|
||||
if c.concept == "dgr" and not dangerous:
|
||||
continue
|
||||
v = c.value or Decimal(0)
|
||||
if c.charge_type == "fijo" or c.charge_type == "por_guia":
|
||||
amt = v
|
||||
elif c.charge_type == "por_kg":
|
||||
amt = v * chargeable
|
||||
elif c.charge_type == "por_contenedor":
|
||||
amt = v * quantity
|
||||
elif c.charge_type == "porcentaje":
|
||||
amt = base * v / Decimal(100)
|
||||
else:
|
||||
amt = v
|
||||
lines.append(CostChargeLine(concept=c.concept, amount=amt))
|
||||
return lines
|
||||
|
||||
|
||||
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
|
||||
on_date = req.on_date or date.today()
|
||||
sheets = _sheet_query(db, tenant_id, company_id).filter(
|
||||
RateSheet.mode == req.mode,
|
||||
RateSheet.status == "activo",
|
||||
or_(RateSheet.valid_from.is_(None), RateSheet.valid_from <= on_date),
|
||||
or_(RateSheet.valid_to.is_(None), RateSheet.valid_to >= on_date),
|
||||
).all()
|
||||
|
||||
gross = req.gross_weight_kg or Decimal(0)
|
||||
options: list[CostOption] = []
|
||||
for sheet in sheets:
|
||||
lanes_q = db.query(RateLane).filter(
|
||||
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
|
||||
)
|
||||
if req.destination:
|
||||
lanes_q = lanes_q.filter(RateLane.destination == req.destination)
|
||||
for lane in lanes_q.all():
|
||||
# Origen: match exacto o el default del tarifario.
|
||||
lane_origin = lane.origin or sheet.default_origin
|
||||
if req.origin and lane_origin and lane_origin != req.origin:
|
||||
continue
|
||||
if req.mode == "maritimo_fcl":
|
||||
if req.equipment_type and lane.equipment_type and lane.equipment_type != req.equipment_type:
|
||||
continue
|
||||
chargeable = Decimal(req.quantity)
|
||||
base = (lane.flat_rate or Decimal(0)) * req.quantity
|
||||
detail = f"{req.quantity} x {lane.equipment_type or 'contenedor'}"
|
||||
elif req.mode == "terrestre":
|
||||
chargeable = Decimal(req.quantity)
|
||||
base = (lane.flat_rate or Decimal(0)) * req.quantity
|
||||
detail = "tarifa por ruta"
|
||||
elif req.mode == "maritimo_lcl":
|
||||
tons = gross / Decimal(1000)
|
||||
wm = max(tons, req.volume_m3 or Decimal(0))
|
||||
brks = breaks_of(db, lane.id)
|
||||
base = _best_break_cost(brks, wm) if brks else Decimal(0)
|
||||
chargeable = wm
|
||||
base = max(base, lane.min_charge or Decimal(0))
|
||||
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
|
||||
else: # aereo
|
||||
# P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones,
|
||||
# respaldo con el volumen en m³ × 167.
|
||||
vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity)
|
||||
volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3)
|
||||
chargeable = max(gross, volumetric)
|
||||
brks = breaks_of(db, lane.id)
|
||||
base = _best_break_cost(brks, chargeable)
|
||||
base = max(base, lane.min_charge or Decimal(0))
|
||||
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)"
|
||||
|
||||
charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous)
|
||||
total = base + sum((c.amount for c in charge_lines), Decimal(0))
|
||||
options.append(CostOption(
|
||||
rate_sheet_id=sheet.id, rate_sheet_name=sheet.name, supplier_id=sheet.supplier_id,
|
||||
currency=sheet.currency, chargeable=chargeable, base_cost=base,
|
||||
charges=charge_lines, total_cost=total, transit_days=lane.transit_days, detail=detail,
|
||||
))
|
||||
options.sort(key=lambda o: o.total_cost)
|
||||
return options
|
||||
@@ -5,29 +5,46 @@ Importar este módulo también registra los permisos del CRM (side-effect de
|
||||
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
|
||||
from .accounts.routes import router as accounts_router
|
||||
from .activities.routes import router as activities_router
|
||||
from .addresses.routes import router as addresses_router
|
||||
from .catalogs.routes import router as catalogs_router
|
||||
from .contacts.routes import router as contacts_router
|
||||
from .documents.routes import router as documents_router
|
||||
from .leads.routes import router as leads_router
|
||||
from .metrics.routes import router as metrics_router
|
||||
from .opportunities.routes import router as opportunities_router
|
||||
from .pipelines.routes import router as pipelines_router
|
||||
from .quotes.routes import router as quotes_router
|
||||
from .rates.routes import cost_router as rates_cost_router
|
||||
from .rates.routes import router as rates_router
|
||||
from .service_requests.routes import router as service_requests_router
|
||||
from .suppliers.routes import router as suppliers_router
|
||||
from .uploads.routes import router as uploads_router
|
||||
|
||||
router = APIRouter()
|
||||
# Enforcement por área/carril (R-T-07): se exige el permiso crm.access para tocar
|
||||
# cualquier endpoint del módulo. En desarrollo el usuario se auto-bootstrapea a
|
||||
# super_admin (ver PermissionChecker) para no bloquear el entorno.
|
||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["crm.access"]))])
|
||||
|
||||
router.include_router(accounts_router)
|
||||
router.include_router(suppliers_router)
|
||||
router.include_router(contacts_router)
|
||||
router.include_router(addresses_router)
|
||||
router.include_router(documents_router)
|
||||
router.include_router(service_requests_router)
|
||||
router.include_router(quotes_router)
|
||||
router.include_router(leads_router)
|
||||
router.include_router(pipelines_router)
|
||||
router.include_router(opportunities_router)
|
||||
router.include_router(activities_router)
|
||||
router.include_router(metrics_router)
|
||||
router.include_router(catalogs_router)
|
||||
router.include_router(uploads_router)
|
||||
router.include_router(rates_router)
|
||||
router.include_router(rates_cost_router)
|
||||
|
||||
209
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
209
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
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) # peso bruto
|
||||
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
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
|
||||
additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado}
|
||||
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)
|
||||
|
||||
|
||||
class ServiceRequestCreate(ServiceRequestBase):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceRequestContactInput(BaseModel):
|
||||
"""Registro del contacto al cliente como etapa del flujo comercial (R-C-04)."""
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ServiceRequestFromOpportunityInput(BaseModel):
|
||||
"""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)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
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)
|
||||
service_type: str | None = Field(None, max_length=20)
|
||||
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
|
||||
additional_service_costs: dict[str, float] | 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)
|
||||
|
||||
|
||||
class ServiceRequestResponse(ServiceRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
first_contact_at: datetime | None = None
|
||||
first_contact_notes: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RateRequestBase(BaseModel):
|
||||
service_request_id: int
|
||||
supplier_id: int | None = None
|
||||
concept: str = Field(..., max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
status: str = Field("solicitada", max_length=20)
|
||||
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
valid_until: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RateRequestCreate(RateRequestBase):
|
||||
pass
|
||||
|
||||
|
||||
class RateRequestUpdate(BaseModel):
|
||||
supplier_id: int | None = None
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
valid_until: date | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RateRequestResponse(RateRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
133
backend/api/v1/modules/crm/service_requests/models.py
Normal file
133
backend/api/v1/modules/crm/service_requests/models.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
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
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Solicitud de cotización / levantamiento de requerimientos (Diagrama 1, pasos 3-5).
|
||||
|
||||
Captura los requerimientos logísticos de la operación que el cliente solicita
|
||||
cotizar (tipo de operación, medio de transporte, ruta, carga, Incoterm, etc.).
|
||||
"""
|
||||
|
||||
__tablename__ = "service_requests"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
# Oportunidad de origen: enlaza el embudo (primer contacto) con la cadena RFQ→cotización (R-C-02)
|
||||
opportunity_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
|
||||
)
|
||||
# importacion | exportacion
|
||||
operation_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
# maritimo | aereo | terrestre | ferroviario | multimodal
|
||||
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# puerto_puerto | puerto_puerta | puerta_puerto | puerta_puerta
|
||||
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
cargo_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
|
||||
volume: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
|
||||
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # FCL | LCL
|
||||
container_equipment: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
commodity: Mapped[str | None] = mapped_column(Text, nullable=True) # mercancía
|
||||
required_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# Agente en destino / contraparte (proveedor)
|
||||
destination_agent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
)
|
||||
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos
|
||||
# Contacto al cliente como etapa del flujo comercial (Diagrama 1, paso 2 — R-C-04)
|
||||
first_contact_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
first_contact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# nueva | contacto | en_analisis | cotizada | aceptada | rechazada | liberada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
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)
|
||||
# Costo estimado por servicio adicional marcado: {codigo: costo}
|
||||
additional_service_costs: Mapped[dict | 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)."""
|
||||
|
||||
__tablename__ = "rate_requests"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
service_request_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=False, index=True
|
||||
)
|
||||
supplier_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
|
||||
)
|
||||
# flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros
|
||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# solicitada | recibida | declinada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'solicitada'"))
|
||||
rate_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
169
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
169
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
RateRequestCreate,
|
||||
RateRequestResponse,
|
||||
RateRequestUpdate,
|
||||
ServiceRequestContactInput,
|
||||
ServiceRequestCreate,
|
||||
ServiceRequestFromOpportunityInput,
|
||||
ServiceRequestResponse,
|
||||
ServiceRequestUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
# ----- Solicitudes de servicio (RFQ) -----
|
||||
|
||||
@router.get("/service-requests", response_model=list[ServiceRequestResponse])
|
||||
def list_service_requests(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None),
|
||||
req_status: str | None = Query(None, alias="status"),
|
||||
operation_type: str | None = Query(None),
|
||||
account_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_service_requests(db, tenant_id, company_id, search, req_status, operation_type, account_id)
|
||||
|
||||
|
||||
@router.get("/service-requests/{request_id}", response_model=ServiceRequestResponse)
|
||||
def get_service_request(
|
||||
request_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_service_request(db, request_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/service-requests", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_service_request(
|
||||
payload: ServiceRequestCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_service_request(db, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.patch("/service-requests/{request_id}", response_model=ServiceRequestResponse)
|
||||
def update_service_request(
|
||||
request_id: int,
|
||||
payload: ServiceRequestUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/service-requests/from-opportunity", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_from_opportunity(
|
||||
payload: ServiceRequestFromOpportunityInput,
|
||||
opportunity_id: int = Query(..., description="Oportunidad a convertir en solicitud"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Convierte una oportunidad del embudo en solicitud/RFQ enlazada (R-C-02)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_from_opportunity(db, opportunity_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/service-requests/{request_id}/contact", response_model=ServiceRequestResponse)
|
||||
def register_contact(
|
||||
request_id: int,
|
||||
payload: ServiceRequestContactInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.register_contact(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/service-requests/{request_id}/requote", response_model=ServiceRequestResponse)
|
||||
def reopen_for_requote(
|
||||
request_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.reopen_for_requote(db, request_id, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_service_request(
|
||||
request_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_service_request(db, request_id, tenant_id, company_id)
|
||||
|
||||
|
||||
# ----- Solicitudes de tarifa -----
|
||||
|
||||
@router.get("/rate-requests", response_model=list[RateRequestResponse])
|
||||
def list_rate_requests(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
service_request_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_rate_requests(db, tenant_id, company_id, service_request_id)
|
||||
|
||||
|
||||
@router.post("/rate-requests", response_model=RateRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_rate_request(
|
||||
payload: RateRequestCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_rate_request(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/rate-requests/{rate_id}", response_model=RateRequestResponse)
|
||||
def update_rate_request(
|
||||
rate_id: int,
|
||||
payload: RateRequestUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_rate_request(db, rate_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/rate-requests/{rate_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_rate_request(
|
||||
rate_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_rate_request(db, rate_id, tenant_id, company_id)
|
||||
295
backend/api/v1/modules/crm/service_requests/service.py
Normal file
295
backend/api/v1/modules/crm/service_requests/service.py
Normal file
@@ -0,0 +1,295 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
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 (
|
||||
RateRequestCreate,
|
||||
RateRequestUpdate,
|
||||
ServiceRequestContactInput,
|
||||
ServiceRequestCreate,
|
||||
ServiceRequestFromOpportunityInput,
|
||||
ServiceRequestUpdate,
|
||||
)
|
||||
from .models import RateRequest, ServiceRequest
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
|
||||
if _id is None:
|
||||
return True
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(
|
||||
model.id == _id,
|
||||
model.tenant_id == tenant_id,
|
||||
model.company_id == company_id,
|
||||
model.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La oportunidad asociada no existe")
|
||||
incoterm = data.get("incoterm")
|
||||
if incoterm and incoterm not in INCOTERM_CODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Incoterm inválido: usa uno del catálogo ({', '.join(sorted(INCOTERM_CODES))})",
|
||||
)
|
||||
|
||||
|
||||
# ----- Service requests (RFQ) -----
|
||||
|
||||
def get_service_requests(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
req_status: str | None = None,
|
||||
operation_type: str | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> list[ServiceRequest]:
|
||||
query = db.query(ServiceRequest).filter(
|
||||
ServiceRequest.tenant_id == tenant_id,
|
||||
ServiceRequest.company_id == company_id,
|
||||
ServiceRequest.deleted_at.is_(None),
|
||||
)
|
||||
if req_status:
|
||||
query = query.filter(ServiceRequest.status == req_status)
|
||||
if operation_type:
|
||||
query = query.filter(ServiceRequest.operation_type == operation_type)
|
||||
if account_id is not None:
|
||||
query = query.filter(ServiceRequest.account_id == account_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
ServiceRequest.reference.ilike(pattern)
|
||||
| ServiceRequest.origin.ilike(pattern)
|
||||
| ServiceRequest.destination.ilike(pattern)
|
||||
)
|
||||
return query.order_by(ServiceRequest.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
|
||||
obj = (
|
||||
db.query(ServiceRequest)
|
||||
.filter(
|
||||
ServiceRequest.id == request_id,
|
||||
ServiceRequest.tenant_id == tenant_id,
|
||||
ServiceRequest.company_id == company_id,
|
||||
ServiceRequest.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def create_service_request(
|
||||
db: Session, payload: ServiceRequestCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> ServiceRequest:
|
||||
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)
|
||||
return obj
|
||||
|
||||
|
||||
def update_service_request(
|
||||
db: Session, request_id: int, payload: ServiceRequestUpdate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> ServiceRequest:
|
||||
obj = get_service_request(db, request_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_request_refs(db, data, tenant_id, company_id)
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = get_service_request(db, request_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def register_contact(
|
||||
db: Session, request_id: int, payload: ServiceRequestContactInput, tenant_id: int, company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> ServiceRequest:
|
||||
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
|
||||
obj = get_service_request(db, request_id, tenant_id, company_id)
|
||||
obj.first_contact_at = datetime.now(timezone.utc)
|
||||
obj.first_contact_notes = payload.notes
|
||||
if obj.status == "nueva":
|
||||
obj.status = "contacto"
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def create_from_opportunity(
|
||||
db: Session, opportunity_id: int, payload: ServiceRequestFromOpportunityInput,
|
||||
tenant_id: int, company_id: int, user_id: str | None = None,
|
||||
) -> ServiceRequest:
|
||||
"""Convierte una oportunidad del embudo en una solicitud/RFQ enlazada (R-C-02).
|
||||
|
||||
Da continuidad al hilo comercial: el embudo (primer contacto) queda ligado a la
|
||||
cadena RFQ → cotización → embarque vía ``opportunity_id``.
|
||||
"""
|
||||
opp = (
|
||||
db.query(Opportunity)
|
||||
.filter(
|
||||
Opportunity.id == opportunity_id,
|
||||
Opportunity.tenant_id == tenant_id,
|
||||
Opportunity.company_id == company_id,
|
||||
Opportunity.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
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=operation_type,
|
||||
transport_mode=payload.transport_mode,
|
||||
service_type=payload.service_type,
|
||||
incoterm=payload.incoterm,
|
||||
origin=payload.origin,
|
||||
destination=payload.destination,
|
||||
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
|
||||
|
||||
|
||||
def reopen_for_requote(
|
||||
db: Session, request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> ServiceRequest:
|
||||
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
|
||||
obj = get_service_request(db, request_id, tenant_id, company_id)
|
||||
if obj.status not in ("rechazada", "cotizada"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Solo una solicitud rechazada o cotizada puede reabrirse para re-cotizar",
|
||||
)
|
||||
obj.status = "en_analisis"
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ----- Rate requests -----
|
||||
|
||||
def get_rate_requests(
|
||||
db: Session, tenant_id: int, company_id: int, service_request_id: int | None = None
|
||||
) -> list[RateRequest]:
|
||||
query = db.query(RateRequest).filter(
|
||||
RateRequest.tenant_id == tenant_id,
|
||||
RateRequest.company_id == company_id,
|
||||
RateRequest.deleted_at.is_(None),
|
||||
)
|
||||
if service_request_id is not None:
|
||||
query = query.filter(RateRequest.service_request_id == service_request_id)
|
||||
return query.order_by(RateRequest.id.asc()).all()
|
||||
|
||||
|
||||
def get_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> RateRequest:
|
||||
obj = (
|
||||
db.query(RateRequest)
|
||||
.filter(
|
||||
RateRequest.id == rate_id,
|
||||
RateRequest.tenant_id == tenant_id,
|
||||
RateRequest.company_id == company_id,
|
||||
RateRequest.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud de tarifa no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def create_rate_request(db: Session, payload: RateRequestCreate, tenant_id: int, company_id: int) -> RateRequest:
|
||||
data = payload.model_dump()
|
||||
# La solicitud de servicio debe existir en el tenant/company
|
||||
get_service_request(db, data["service_request_id"], tenant_id, company_id)
|
||||
if not _exists(db, Supplier, data.get("supplier_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
obj = RateRequest(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_rate_request(
|
||||
db: Session, rate_id: int, payload: RateRequestUpdate, tenant_id: int, company_id: int
|
||||
) -> RateRequest:
|
||||
obj = get_rate_request(db, rate_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = get_rate_request(db, rate_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -13,6 +13,7 @@ class SupplierBase(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str = Field("active", max_length=20)
|
||||
classifications: list[str] = Field(default_factory=list)
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
# Comercial
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
@@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
classifications: list[str] | None = None
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
countries: list[str] | None = None
|
||||
|
||||
@@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
|
||||
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
|
||||
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
|
||||
# Texto libre cuando la clasificación incluye "otro"
|
||||
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
|
||||
# ----- Información comercial -----
|
||||
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones.
|
||||
|
||||
Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo
|
||||
guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se
|
||||
pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
|
||||
"""
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
|
||||
from core.security import get_current_user
|
||||
from core.storage_s3 import presigned_get_url, put_object_bytes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
base = (name or "archivo").strip().replace(" ", "_")
|
||||
base = _SAFE_NAME.sub("", base) or "archivo"
|
||||
return base[:120]
|
||||
|
||||
|
||||
@router.post("/uploads")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El archivo excede el tamaño máximo permitido (25 MB)",
|
||||
)
|
||||
filename = _safe_filename(file.filename)
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
|
||||
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
|
||||
return {
|
||||
"file_key": key,
|
||||
"file_url": presigned_get_url(key),
|
||||
"name": file.filename,
|
||||
"content_type": file.content_type,
|
||||
"size_bytes": len(content),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/uploads/url")
|
||||
def get_upload_url(
|
||||
key: str = Query(..., description="Object key del archivo en el almacén"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
|
||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
return {"url": presigned_get_url(key)}
|
||||
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
121
backend/api/v1/modules/fin/invoices/dto.py
Normal file
121
backend/api/v1/modules/fin/invoices/dto.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
|
||||
class InvoiceClientReviewInput(BaseModel):
|
||||
"""Resultado de la revisión de la factura por el cliente (R-F-06)."""
|
||||
approved: bool
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InvoiceItemBase(BaseModel):
|
||||
concept: str = Field(..., max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
||||
unit_amount: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
|
||||
|
||||
|
||||
class InvoiceItemCreate(InvoiceItemBase):
|
||||
invoice_id: int
|
||||
|
||||
|
||||
class InvoiceItemUpdate(BaseModel):
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
|
||||
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
|
||||
|
||||
class InvoiceItemResponse(InvoiceItemBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def line_total(self) -> Decimal:
|
||||
return (self.quantity or Decimal(0)) * (self.unit_amount or Decimal(0))
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
amount: Decimal = Field(..., gt=0, max_digits=14, decimal_places=2)
|
||||
payment_date: date | None = None
|
||||
method: str | None = Field(None, max_length=40)
|
||||
reference: str | None = Field(None, max_length=120)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class PaymentCreate(PaymentBase):
|
||||
invoice_id: int
|
||||
|
||||
|
||||
class PaymentResponse(PaymentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InvoiceBase(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
shipment_id: int | None = None
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str = Field("MXN", max_length=3)
|
||||
issue_date: date | None = None
|
||||
due_date: date | None = None
|
||||
tax_rate: Decimal = Field(Decimal(0), ge=0, le=100, max_digits=5, decimal_places=2)
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class InvoiceCreate(InvoiceBase):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
shipment_id: int | None = None
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
issue_date: date | None = None
|
||||
due_date: date | None = None
|
||||
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class InvoiceResponse(InvoiceBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: str
|
||||
subtotal: Decimal
|
||||
tax_amount: Decimal
|
||||
total: Decimal
|
||||
paid_amount: Decimal
|
||||
balance: Decimal
|
||||
ops_cost_total: Decimal | None = None
|
||||
sent_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
pdf_file_key: str | None = None
|
||||
client_reviewed_at: datetime | None = None
|
||||
client_approved: bool | None = None
|
||||
review_notes: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
86
backend/api/v1/modules/fin/invoices/models.py
Normal file
86
backend/api/v1/modules/fin/invoices/models.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import 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
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
||||
|
||||
__tablename__ = "invoices"
|
||||
__table_args__ = {"schema": "fin"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
|
||||
shipment_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
|
||||
)
|
||||
quote_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.quotes.id"), nullable=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
|
||||
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)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0")) # % IVA
|
||||
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
balance: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
# Costos reales de la operación traídos de Operaciones al cierre (R-F-02)
|
||||
ops_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
bank_info: Mapped[str | None] = mapped_column(Text, nullable=True) # datos bancarios
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# ----- Envío al cliente (R-F-05): PDF almacenado en MinIO -----
|
||||
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# ----- Revisión del cliente (R-F-06) -----
|
||||
client_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
client_approved: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Concepto de una factura (transporte, flete, despacho, gastos en destino, otros)."""
|
||||
|
||||
__tablename__ = "invoice_items"
|
||||
__table_args__ = {"schema": "fin"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||
)
|
||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
||||
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Pago (cobranza) aplicado a una factura."""
|
||||
|
||||
__tablename__ = "payments"
|
||||
__table_args__ = {"schema": "fin"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# transferencia | efectivo | cheque | tarjeta | otro
|
||||
method: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal file
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Generador de PDF de factura sin dependencias externas.
|
||||
|
||||
Se evita ``pdfkit`` (requiere el binario ``wkhtmltopdf``, ausente en el contenedor)
|
||||
y librerías extra. Produce un PDF válido de una o varias páginas con la fuente
|
||||
estándar Helvetica (no requiere incrustar fuentes). El texto se codifica en
|
||||
WinAnsi/Latin-1; los caracteres fuera de ese rango se sustituyen para no romper
|
||||
el flujo de contenido.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Sequence
|
||||
|
||||
_PAGE_W = 612 # carta (8.5in) en puntos
|
||||
_PAGE_H = 792 # carta (11in)
|
||||
_MARGIN = 56
|
||||
_LINE_H = 16
|
||||
_LINES_PER_PAGE = 42
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
"""Escapa y codifica una cadena para un literal de texto PDF (WinAnsi)."""
|
||||
out = (text or "").encode("latin-1", "replace").decode("latin-1")
|
||||
return out.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
|
||||
|
||||
|
||||
def _money(value, currency: str) -> str:
|
||||
d = Decimal(str(value or 0)).quantize(Decimal("0.01"))
|
||||
return f"{currency} {d:,.2f}"
|
||||
|
||||
|
||||
def _wrap(text: str, width: int) -> list[str]:
|
||||
text = text or ""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return [""]
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in words:
|
||||
candidate = f"{current} {word}".strip()
|
||||
if len(candidate) > width and current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
else:
|
||||
current = candidate
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def _build_lines(
|
||||
*,
|
||||
folio: str,
|
||||
issue_date: str,
|
||||
due_date: str,
|
||||
account_name: str,
|
||||
currency: str,
|
||||
items: Sequence[dict],
|
||||
subtotal,
|
||||
tax_rate,
|
||||
tax_amount,
|
||||
total,
|
||||
paid,
|
||||
balance,
|
||||
bank_info: str | None,
|
||||
notes: str | None,
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Devuelve una lista de (texto, tamaño_fuente) que compone el cuerpo."""
|
||||
L: list[tuple[str, int]] = []
|
||||
L.append(("FACTURA", 20))
|
||||
L.append((f"Folio: {folio or 's/f'}", 11))
|
||||
L.append((f"Fecha de emision: {issue_date or '-'} Vencimiento: {due_date or '-'}", 11))
|
||||
L.append(("", 11))
|
||||
L.append((f"Cliente: {account_name or '-'}", 12))
|
||||
L.append(("", 11))
|
||||
L.append(("Conceptos", 13))
|
||||
L.append(("-" * 78, 10))
|
||||
L.append(("Cant. Concepto P. unitario Importe", 10))
|
||||
L.append(("-" * 78, 10))
|
||||
for it in items:
|
||||
concept = str(it.get("concept") or "")
|
||||
desc = str(it.get("description") or "")
|
||||
qty = Decimal(str(it.get("quantity") or 0))
|
||||
unit = Decimal(str(it.get("unit_amount") or 0))
|
||||
amount = (qty * unit).quantize(Decimal("0.01"))
|
||||
label = concept if not desc else f"{concept} — {desc}"
|
||||
label = label[:42].ljust(42)
|
||||
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
|
||||
L.append((row, 10))
|
||||
L.append(("-" * 78, 10))
|
||||
L.append(("", 11))
|
||||
L.append((f"Subtotal: {_money(subtotal, currency)}", 11))
|
||||
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
|
||||
L.append((f"Total: {_money(total, currency)}", 13))
|
||||
L.append((f"Pagado: {_money(paid, currency)}", 11))
|
||||
L.append((f"Saldo: {_money(balance, currency)}", 12))
|
||||
if bank_info:
|
||||
L.append(("", 11))
|
||||
L.append(("Datos bancarios / de pago", 12))
|
||||
for line in _wrap(bank_info, 90):
|
||||
L.append((line, 10))
|
||||
if notes:
|
||||
L.append(("", 11))
|
||||
L.append(("Notas", 12))
|
||||
for line in _wrap(notes, 90):
|
||||
L.append((line, 10))
|
||||
return L
|
||||
|
||||
|
||||
def build_invoice_pdf(**kwargs) -> bytes:
|
||||
"""Construye el PDF de la factura y devuelve los bytes."""
|
||||
lines = _build_lines(**kwargs)
|
||||
|
||||
# Paginar el cuerpo
|
||||
pages: list[list[tuple[str, int]]] = []
|
||||
for i in range(0, len(lines), _LINES_PER_PAGE):
|
||||
pages.append(lines[i : i + _LINES_PER_PAGE])
|
||||
if not pages:
|
||||
pages = [[("FACTURA", 20)]]
|
||||
|
||||
# Un content stream por página
|
||||
content_streams: list[bytes] = []
|
||||
for page_lines in pages:
|
||||
parts = ["BT", f"/F1 11 Tf", f"1 0 0 1 {_MARGIN} {_PAGE_H - _MARGIN} Tm", f"{_LINE_H} TL"]
|
||||
first = True
|
||||
for text, size in page_lines:
|
||||
parts.append(f"/F1 {size} Tf")
|
||||
if first:
|
||||
parts.append(f"({_esc(text)}) Tj")
|
||||
first = False
|
||||
else:
|
||||
parts.append(f"T* ({_esc(text)}) Tj")
|
||||
parts.append("ET")
|
||||
content_streams.append("\n".join(parts).encode("latin-1", "replace"))
|
||||
|
||||
# Ensamblado de objetos PDF
|
||||
objects: list[bytes] = []
|
||||
|
||||
def add(obj: bytes) -> int:
|
||||
objects.append(obj)
|
||||
return len(objects) # número de objeto (1-indexado)
|
||||
|
||||
# Reservamos números: catalog(1), pages(2), font(3), luego páginas y streams
|
||||
font_obj_num = 3
|
||||
page_obj_nums: list[int] = []
|
||||
content_obj_nums: list[int] = []
|
||||
# Precalcular números de páginas y streams
|
||||
next_num = 4
|
||||
for _ in pages:
|
||||
page_obj_nums.append(next_num)
|
||||
next_num += 1
|
||||
for _ in pages:
|
||||
content_obj_nums.append(next_num)
|
||||
next_num += 1
|
||||
|
||||
kids = " ".join(f"{n} 0 R" for n in page_obj_nums)
|
||||
add(f"<< /Type /Catalog /Pages 2 0 R >>".encode("latin-1"))
|
||||
add(f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode("latin-1"))
|
||||
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||
for i, _ in enumerate(pages):
|
||||
page_dict = (
|
||||
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] "
|
||||
f"/Resources << /Font << /F1 {font_obj_num} 0 R >> >> "
|
||||
f"/Contents {content_obj_nums[i]} 0 R >>"
|
||||
)
|
||||
add(page_dict.encode("latin-1"))
|
||||
for stream in content_streams:
|
||||
obj = b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream"
|
||||
add(obj)
|
||||
|
||||
# Serialización con tabla xref
|
||||
out = bytearray()
|
||||
out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
|
||||
offsets: list[int] = []
|
||||
for i, obj in enumerate(objects, start=1):
|
||||
offsets.append(len(out))
|
||||
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
n = len(objects) + 1
|
||||
out += f"xref\n0 {n}\n".encode("latin-1")
|
||||
out += b"0000000000 65535 f \n"
|
||||
for off in offsets:
|
||||
out += f"{off:010d} 00000 n \n".encode("latin-1")
|
||||
out += f"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
|
||||
return bytes(out)
|
||||
134
backend/api/v1/modules/fin/invoices/routes.py
Normal file
134
backend/api/v1/modules/fin/invoices/routes.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemResponse,
|
||||
InvoiceItemUpdate,
|
||||
InvoiceResponse,
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
PaymentResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _uid(cu: dict) -> str | None:
|
||||
return cu.get("sub") or cu.get("id")
|
||||
|
||||
|
||||
@router.get("/invoices", response_model=list[InvoiceResponse])
|
||||
def list_invoices(
|
||||
company_id: int = Query(...),
|
||||
search: str | None = Query(None),
|
||||
inv_status: str | None = Query(None, alias="status"),
|
||||
account_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_invoices(db, current_user["tenant_id"], company_id, search, inv_status, account_id)
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}", response_model=InvoiceResponse)
|
||||
def get_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.get_invoice(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/invoices", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_invoice(payload: InvoiceCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.create_invoice(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.post("/invoices/from-shipment", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def generate_from_shipment(shipment_id: int = Query(...), company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.generate_from_shipment(db, shipment_id, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}", response_model=InvoiceResponse)
|
||||
def update_invoice(invoice_id: int, payload: InvoiceUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.update_invoice(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}/emit", response_model=InvoiceResponse)
|
||||
def emit_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.emit_invoice(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}/send", response_model=InvoiceResponse)
|
||||
def send_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""Genera el PDF, lo guarda en MinIO y marca la factura como enviada (R-F-05)."""
|
||||
return service.send_invoice(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/pdf-url")
|
||||
def get_invoice_pdf_url(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""URL firmada fresca del PDF de la factura (R-F-05)."""
|
||||
return {"url": service.get_invoice_pdf_url(db, invoice_id, current_user["tenant_id"], company_id)}
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}/client-review", response_model=InvoiceResponse)
|
||||
def mark_client_review(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""Marca la factura en revisión del cliente (R-F-06)."""
|
||||
return service.mark_client_review(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}/client-decision", response_model=InvoiceResponse)
|
||||
def client_review_decision(invoice_id: int, payload: InvoiceClientReviewInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""Registra la decisión del cliente sobre la factura: aprobada o con observaciones (R-F-06)."""
|
||||
return service.client_review_decision(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch("/invoices/{invoice_id}/cancel", response_model=InvoiceResponse)
|
||||
def cancel_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.cancel_invoice(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
service.delete_invoice(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Conceptos -----
|
||||
|
||||
@router.get("/invoices/{invoice_id}/items", response_model=list[InvoiceItemResponse])
|
||||
def list_items(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.get_items(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/invoice-items", response_model=InvoiceItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_item(payload: InvoiceItemCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.create_item(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/invoice-items/{item_id}", response_model=InvoiceItemResponse)
|
||||
def update_item(item_id: int, payload: InvoiceItemUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.update_item(db, item_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/invoice-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_item(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
service.delete_item(db, item_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Pagos (cobranza) -----
|
||||
|
||||
@router.get("/invoices/{invoice_id}/payments", response_model=list[PaymentResponse])
|
||||
def list_payments(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.get_payments(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/payments", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_payment(payload: PaymentCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return service.create_payment(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)
|
||||
397
backend/api/v1/modules/fin/invoices/service.py
Normal file
397
backend/api/v1/modules/fin/invoices/service.py
Normal file
@@ -0,0 +1,397 @@
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.crm.accounts.models import Account
|
||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||
from api.v1.modules.ops.shipments.models import Shipment
|
||||
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemUpdate,
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
)
|
||||
from .models import Invoice, InvoiceItem, Payment
|
||||
from .pdf import build_invoice_pdf
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
|
||||
if _id is None:
|
||||
return True
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None))
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
for field, model, msg in [
|
||||
("account_id", Account, "El cliente asociado no existe"),
|
||||
("shipment_id", Shipment, "El embarque asociado no existe"),
|
||||
("quote_id", Quote, "La cotización asociada no existe"),
|
||||
]:
|
||||
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
|
||||
def _recompute(db: Session, invoice: Invoice) -> None:
|
||||
subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
|
||||
InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
|
||||
).scalar()
|
||||
paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
|
||||
Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
|
||||
).scalar()
|
||||
subtotal = Decimal(subtotal or 0)
|
||||
rate = Decimal(invoice.tax_rate or 0)
|
||||
tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
|
||||
total = subtotal + tax
|
||||
paid = Decimal(paid or 0)
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax
|
||||
invoice.total = total
|
||||
invoice.paid_amount = paid
|
||||
invoice.balance = total - paid
|
||||
# Estado de cobranza (no toca borrador ni cancelada)
|
||||
if invoice.status in ("emitida", "enviada", "en_revision_cliente", "pagada"):
|
||||
if total > 0 and invoice.balance <= 0:
|
||||
invoice.status = "pagada"
|
||||
invoice.paid_at = datetime.now(timezone.utc)
|
||||
elif invoice.status == "pagada" and invoice.balance > 0:
|
||||
invoice.status = "enviada"
|
||||
invoice.paid_at = None
|
||||
|
||||
|
||||
# ----- Invoices -----
|
||||
|
||||
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
|
||||
q = db.query(Invoice).filter(Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None))
|
||||
if inv_status:
|
||||
q = q.filter(Invoice.status == inv_status)
|
||||
if account_id is not None:
|
||||
q = q.filter(Invoice.account_id == account_id)
|
||||
if search:
|
||||
q = q.filter(Invoice.reference.ilike(f"%{search}%"))
|
||||
return q.order_by(Invoice.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
||||
obj = db.query(Invoice).filter(
|
||||
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None)
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
_recompute(db, obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(obj, f, v)
|
||||
obj.updated_by = user_id
|
||||
db.flush()
|
||||
_recompute(db, obj) # tax_rate pudo cambiar
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _set_status(db, invoice_id, tenant_id, company_id, new_status, set_issue=False) -> Invoice:
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
obj.status = new_status
|
||||
if set_issue and not obj.issue_date:
|
||||
obj.issue_date = date.today()
|
||||
if new_status == "enviada":
|
||||
obj.sent_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
||||
return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)
|
||||
|
||||
|
||||
def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
|
||||
"""Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
|
||||
items = get_items(db, invoice.id, tenant_id, company_id)
|
||||
account_name = None
|
||||
if invoice.account_id:
|
||||
acc = db.query(Account).filter(Account.id == invoice.account_id).first()
|
||||
account_name = acc.name if acc else None
|
||||
return build_invoice_pdf(
|
||||
folio=invoice.reference or f"FAC-{invoice.id}",
|
||||
issue_date=str(invoice.issue_date or ""),
|
||||
due_date=str(invoice.due_date or ""),
|
||||
account_name=account_name or "Cliente",
|
||||
currency=invoice.currency or "MXN",
|
||||
items=[
|
||||
{"concept": it.concept, "description": it.description, "quantity": it.quantity, "unit_amount": it.unit_amount}
|
||||
for it in items
|
||||
],
|
||||
subtotal=invoice.subtotal,
|
||||
tax_rate=invoice.tax_rate,
|
||||
tax_amount=invoice.tax_amount,
|
||||
total=invoice.total,
|
||||
paid=invoice.paid_amount,
|
||||
balance=invoice.balance,
|
||||
bank_info=invoice.bank_info,
|
||||
notes=invoice.notes,
|
||||
)
|
||||
|
||||
|
||||
def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
"""Envía la factura al cliente: genera el PDF, lo guarda en MinIO y marca 'enviada' (R-F-05)."""
|
||||
from core.storage_s3 import put_object_bytes # import diferido: evita conectar en tests
|
||||
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
if obj.status in ("borrador", "cancelada"):
|
||||
# La factura debe estar emitida antes de enviarse al cliente
|
||||
if obj.status == "cancelada":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada")
|
||||
obj.status = "emitida"
|
||||
if not obj.issue_date:
|
||||
obj.issue_date = date.today()
|
||||
db.flush()
|
||||
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
|
||||
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
|
||||
obj.pdf_file_key = key
|
||||
obj.status = "enviada"
|
||||
obj.sent_at = datetime.now(timezone.utc)
|
||||
if not obj.issue_date:
|
||||
obj.issue_date = date.today()
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def get_invoice_pdf_url(db, invoice_id, tenant_id, company_id) -> str:
|
||||
"""Devuelve una URL firmada fresca del PDF de la factura (las presignadas expiran)."""
|
||||
from core.storage_s3 import presigned_get_url
|
||||
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
if not obj.pdf_file_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="La factura aún no tiene PDF; envíala al cliente para generarlo",
|
||||
)
|
||||
return presigned_get_url(obj.pdf_file_key)
|
||||
|
||||
|
||||
def mark_client_review(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
"""Pone la factura en revisión del cliente (R-F-06)."""
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
if obj.status not in ("enviada", "en_revision_cliente"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Solo una factura enviada puede pasar a revisión del cliente",
|
||||
)
|
||||
obj.status = "en_revision_cliente"
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def client_review_decision(
|
||||
db, invoice_id, payload: InvoiceClientReviewInput, tenant_id, company_id, user_id=None
|
||||
) -> Invoice:
|
||||
"""Registra la decisión de revisión del cliente: aprobada o con observaciones (R-F-06)."""
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
if obj.status not in ("enviada", "en_revision_cliente"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="La factura debe estar enviada o en revisión para registrar la decisión del cliente",
|
||||
)
|
||||
obj.client_reviewed_at = datetime.now(timezone.utc)
|
||||
obj.client_approved = payload.approved
|
||||
obj.review_notes = payload.notes
|
||||
# Aprobada → lista para cobranza (enviada). Con observaciones → regresa a emitida para corregir.
|
||||
obj.status = "enviada" if payload.approved else "emitida"
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
||||
return _set_status(db, invoice_id, tenant_id, company_id, "cancelada")
|
||||
|
||||
|
||||
def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
"""Genera la factura de un embarque, tomando los conceptos (venta) de su cotización.
|
||||
|
||||
El disparador válido de la facturación es el cierre operativo del embarque
|
||||
(R-F-01): solo se factura un embarque en estado 'cerrada'. Los costos reales de
|
||||
la operación se arrastran a la factura (R-F-02).
|
||||
"""
|
||||
shipment = db.query(Shipment).filter(
|
||||
Shipment.id == shipment_id, Shipment.tenant_id == tenant_id, Shipment.company_id == company_id, Shipment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not shipment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
|
||||
if shipment.status != "cerrada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="El embarque debe estar cerrado (cierre operativo) para facturarse",
|
||||
)
|
||||
existing = db.query(Invoice).filter(
|
||||
Invoice.shipment_id == shipment_id, Invoice.tenant_id == tenant_id,
|
||||
Invoice.company_id == company_id, Invoice.deleted_at.is_(None),
|
||||
Invoice.status != "cancelada",
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="El embarque ya tiene una factura vigente",
|
||||
)
|
||||
|
||||
quote = None
|
||||
if shipment.quote_id:
|
||||
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
|
||||
|
||||
invoice = Invoice(
|
||||
reference=shipment.reference,
|
||||
shipment_id=shipment.id,
|
||||
quote_id=shipment.quote_id,
|
||||
account_id=shipment.account_id,
|
||||
currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
|
||||
ops_cost_total=shipment.actual_cost_total,
|
||||
status="borrador",
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(invoice)
|
||||
db.flush()
|
||||
|
||||
if quote:
|
||||
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
|
||||
for qi in q_items:
|
||||
db.add(InvoiceItem(
|
||||
invoice_id=invoice.id, concept=qi.concept, description=qi.description,
|
||||
quantity=qi.quantity, unit_amount=qi.unit_sale,
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
# ----- Items -----
|
||||
|
||||
def get_items(db, invoice_id, tenant_id, company_id) -> list[InvoiceItem]:
|
||||
get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
return db.query(InvoiceItem).filter(
|
||||
InvoiceItem.invoice_id == invoice_id, InvoiceItem.tenant_id == tenant_id,
|
||||
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
|
||||
).order_by(InvoiceItem.id.asc()).all()
|
||||
|
||||
|
||||
def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
||||
obj = db.query(InvoiceItem).filter(
|
||||
InvoiceItem.id == item_id, InvoiceItem.tenant_id == tenant_id,
|
||||
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
||||
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
||||
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
for f, v in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, f, v)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(db, item_id, tenant_id, company_id) -> None:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
invoice_id = item.invoice_id
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ----- Payments -----
|
||||
|
||||
def get_payments(db, invoice_id, tenant_id, company_id) -> list[Payment]:
|
||||
get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
return db.query(Payment).filter(
|
||||
Payment.invoice_id == invoice_id, Payment.tenant_id == tenant_id,
|
||||
Payment.company_id == company_id, Payment.deleted_at.is_(None)
|
||||
).order_by(Payment.id.asc()).all()
|
||||
|
||||
|
||||
def create_payment(db, payload: PaymentCreate, tenant_id, company_id) -> Payment:
|
||||
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
||||
pay = Payment(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(pay)
|
||||
db.flush()
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(pay)
|
||||
return pay
|
||||
|
||||
|
||||
def delete_payment(db, payment_id, tenant_id, company_id) -> None:
|
||||
pay = db.query(Payment).filter(
|
||||
Payment.id == payment_id, Payment.tenant_id == tenant_id,
|
||||
Payment.company_id == company_id, Payment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not pay:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pago no encontrado")
|
||||
invoice_id = pay.invoice_id
|
||||
pay.deleted_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
|
||||
db.commit()
|
||||
17
backend/api/v1/modules/fin/permissions.py
Normal file
17
backend/api/v1/modules/fin/permissions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Registro de permisos del módulo Facturación (fin)."""
|
||||
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "fin"
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
def register_permissions() -> None:
|
||||
registry.register(code=f"{MODULE}.access", description="Acceso a Facturación", module=MODULE, action="access")
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
|
||||
|
||||
|
||||
register_permissions()
|
||||
12
backend/api/v1/modules/fin/router.py
Normal file
12
backend/api/v1/modules/fin/router.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Router agregador del módulo Facturación (Diagrama 4). Prefijo ``/fin``."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos)
|
||||
from .invoices.routes import router as invoices_router
|
||||
|
||||
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
|
||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
|
||||
router.include_router(invoices_router)
|
||||
0
backend/api/v1/modules/ops/__init__.py
Normal file
0
backend/api/v1/modules/ops/__init__.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Registro de permisos del módulo Operaciones (ops)."""
|
||||
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "ops"
|
||||
|
||||
_ENTITIES = [
|
||||
("shipment", "embarques"),
|
||||
("document", "documentos de embarque"),
|
||||
]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
def register_permissions() -> None:
|
||||
registry.register(code=f"{MODULE}.access", description="Acceso a Operaciones", module=MODULE, action="access")
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
registry.register(
|
||||
code=f"{MODULE}.{entity}.{action}",
|
||||
description=f"{verb} {label}",
|
||||
module=MODULE,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
register_permissions()
|
||||
16
backend/api/v1/modules/ops/router.py
Normal file
16
backend/api/v1/modules/ops/router.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Router agregador del módulo Operaciones (Diagramas 2-4).
|
||||
|
||||
Se monta bajo el prefijo ``/ops`` en ``api/v1/router.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos de ops)
|
||||
from .shipments.routes import router as shipments_router
|
||||
|
||||
# Enforcement por área/carril (R-T-07): se exige ops.access para el módulo.
|
||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["ops.access"]))])
|
||||
|
||||
router.include_router(shipments_router)
|
||||
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
173
backend/api/v1/modules/ops/shipments/dto.py
Normal file
173
backend/api/v1/modules/ops/shipments/dto.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ShipmentBase(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
quote_id: int | None = None
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
operation_type: str | None = Field(None, max_length=20)
|
||||
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)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
status: str = Field("abierta", max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
ground_carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
pickup_at: datetime | None = None
|
||||
etd: date | None = None
|
||||
previous_etd: date | None = None
|
||||
eta: date | None = None
|
||||
vessel_flight: str | None = Field(None, max_length=120)
|
||||
container_number: str | None = Field(None, max_length=60)
|
||||
notes: str | None = None
|
||||
actual_cost_total: Decimal | None = None
|
||||
cost_currency: str | None = Field(None, max_length=3)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ShipmentCreate(ShipmentBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShipmentUpdate(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
account_id: int | None = None
|
||||
operation_type: str | None = Field(None, max_length=20)
|
||||
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)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
booking_number: str | None = Field(None, max_length=60)
|
||||
carrier_supplier_id: int | None = None
|
||||
ground_carrier_supplier_id: int | None = None
|
||||
customs_agent_id: int | None = None
|
||||
destination_agent_id: int | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
pickup_at: datetime | None = None
|
||||
etd: date | None = None
|
||||
eta: date | None = None
|
||||
vessel_flight: str | None = Field(None, max_length=120)
|
||||
container_number: str | None = Field(None, max_length=60)
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ShipmentRescheduleInput(BaseModel):
|
||||
"""Reprogramación de salida cuando no se alcanza el Cut Off (R-E-06)."""
|
||||
etd: date | None = None
|
||||
cutoff_date: datetime | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ShipmentCloseInput(BaseModel):
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22)."""
|
||||
actual_cost_total: Decimal = Field(..., ge=0)
|
||||
cost_currency: str = Field("MXN", max_length=3)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentResponse(ShipmentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
closed_at: datetime | None = None
|
||||
closed_by: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
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)
|
||||
kind: str = Field("hito", max_length=20) # hito | decision
|
||||
status: str = Field("pendiente", max_length=20)
|
||||
outcome: str | None = Field(None, max_length=20) # autorizado | rechazado
|
||||
parent_event_id: int | None = None
|
||||
attempt: int = Field(1, ge=1)
|
||||
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)
|
||||
kind: str | None = Field(None, max_length=20)
|
||||
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 ShipmentEventDecisionInput(BaseModel):
|
||||
"""Resultado de un punto de decisión del flujo (R-E-13, R-E-05, R-I-06)."""
|
||||
outcome: Literal["autorizado", "rechazado"]
|
||||
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)
|
||||
doc_type: str = Field(..., max_length=30)
|
||||
number: str | None = Field(None, max_length=80)
|
||||
issue_date: date | None = None
|
||||
file_url: str | None = Field(None, max_length=1024)
|
||||
file_key: str | None = Field(None, max_length=512)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentDocumentCreate(ShipmentDocumentBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShipmentDocumentUpdate(BaseModel):
|
||||
doc_kind: str | None = Field(None, max_length=10)
|
||||
doc_type: str | None = Field(None, max_length=30)
|
||||
number: str | None = Field(None, max_length=80)
|
||||
issue_date: date | None = None
|
||||
file_url: str | None = Field(None, max_length=1024)
|
||||
file_key: str | None = Field(None, max_length=512)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ShipmentDocumentResponse(ShipmentDocumentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
112
backend/api/v1/modules/ops/shipments/models.py
Normal file
112
backend/api/v1/modules/ops/shipments/models.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Shipment(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada."""
|
||||
|
||||
__tablename__ = "shipments"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
|
||||
quote_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
|
||||
)
|
||||
service_request_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
# abierta | booking | en_transito | arribado | entregada | cerrada | cancelada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True)
|
||||
booking_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
carrier_supplier_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
) # naviera / aerolínea / transportista principal
|
||||
ground_carrier_supplier_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
) # transporte terrestre / recolección (R-E-07)
|
||||
customs_agent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
) # agente aduanal
|
||||
destination_agent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.suppliers.id"), nullable=True
|
||||
) # agente corresponsal en destino
|
||||
cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off
|
||||
pickup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cita/ventana de recolección (R-E-07)
|
||||
etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada
|
||||
previous_etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida previa tras reprogramación (R-E-06)
|
||||
eta: Mapped[date | None] = mapped_column(Date, nullable=True) # llegada estimada
|
||||
vessel_flight: Mapped[str | None] = mapped_column(String(120), nullable=True) # buque / vuelo
|
||||
container_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# ----- Cierre operativo (R-E-22 / disparador de facturación R-F-01) -----
|
||||
actual_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) # costos finales reales
|
||||
cost_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cierre operativo
|
||||
closed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
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)
|
||||
# hito | decision — un 'decision' es un punto de decisión del diagrama (rombo)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'hito'"))
|
||||
# pendiente | completado | omitido | rechazado | en_correccion
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'"))
|
||||
# Resultado de un punto de decisión: autorizado | rechazado (NULL mientras está pendiente)
|
||||
outcome: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Ciclo de corrección: el hito de re-trámite apunta a la decisión rechazada que lo originó
|
||||
parent_event_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ops.shipment_events.id"), nullable=True
|
||||
)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) # número de intento
|
||||
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.)."""
|
||||
|
||||
__tablename__ = "shipment_documents"
|
||||
__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
|
||||
)
|
||||
doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro
|
||||
# MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro
|
||||
doc_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
243
backend/api/v1/modules/ops/shipments/routes.py
Normal file
243
backend/api/v1/modules/ops/shipments/routes.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
ShipmentCloseInput,
|
||||
ShipmentCreate,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentResponse,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventDecisionInput,
|
||||
ShipmentEventResponse,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentRescheduleInput,
|
||||
ShipmentResponse,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
@router.get("/shipments", response_model=list[ShipmentResponse])
|
||||
def list_shipments(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None),
|
||||
shipment_status: str | None = Query(None, alias="status"),
|
||||
account_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_shipments(db, tenant_id, company_id, search, shipment_status, account_id)
|
||||
|
||||
|
||||
@router.get("/shipments/{shipment_id}", response_model=ShipmentResponse)
|
||||
def get_shipment(
|
||||
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.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/shipments", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment(
|
||||
payload: ShipmentCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_shipment(db, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/shipments/from-quote", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
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), operation_type=operation_type
|
||||
)
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)
|
||||
def reschedule_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentRescheduleInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.reschedule_departure(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/close", response_model=ShipmentResponse)
|
||||
def close_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentCloseInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22, dispara facturación R-F-01)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.close_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse)
|
||||
def update_shipment(
|
||||
shipment_id: int,
|
||||
payload: ShipmentUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.delete("/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment(
|
||||
shipment_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(db, shipment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Documentos del embarque -----
|
||||
|
||||
@router.get("/shipments/{shipment_id}/documents", response_model=list[ShipmentDocumentResponse])
|
||||
def list_shipment_documents(
|
||||
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_documents(db, current_user["tenant_id"], company_id, shipment_id)
|
||||
|
||||
|
||||
@router.post("/shipment-documents", response_model=ShipmentDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_shipment_document(
|
||||
payload: ShipmentDocumentCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_shipment_document(db, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.patch("/shipment-documents/{doc_id}", response_model=ShipmentDocumentResponse)
|
||||
def update_shipment_document(
|
||||
doc_id: int,
|
||||
payload: ShipmentDocumentUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_shipment_document(db, doc_id, payload, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.delete("/shipment-documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shipment_document(
|
||||
doc_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_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.patch("/shipment-events/{event_id}/decision", response_model=ShipmentEventResponse)
|
||||
def decide_shipment_event(
|
||||
event_id: int,
|
||||
payload: ShipmentEventDecisionInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Resuelve un punto de decisión: autorizado o rechazado (abre corrección). R-E-13/R-I-06."""
|
||||
return service.decide_shipment_event(db, event_id, payload, 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)
|
||||
549
backend/api/v1/modules/ops/shipments/service.py
Normal file
549
backend/api/v1/modules/ops/shipments/service.py
Normal file
@@ -0,0 +1,549 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
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,
|
||||
ShipmentDocumentCreate,
|
||||
ShipmentDocumentUpdate,
|
||||
ShipmentEventCreate,
|
||||
ShipmentEventDecisionInput,
|
||||
ShipmentEventUpdate,
|
||||
ShipmentRescheduleInput,
|
||||
ShipmentUpdate,
|
||||
)
|
||||
from .models import Shipment, ShipmentDocument, ShipmentEvent
|
||||
|
||||
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3).
|
||||
# Tupla: (event_type, título, kind). kind="decision" son puntos de decisión (rombos)
|
||||
# que se resuelven con autorizado/rechazado y disparan el ciclo de corrección.
|
||||
_DEFAULT_MILESTONES = {
|
||||
# Diagrama 2 — Proceso operativo de exportación
|
||||
"exportacion": [
|
||||
("coordinacion_fecha_cliente", "Coordinar fecha de operación con el cliente", "hito"),
|
||||
("revision_salidas", "Revisar disponibilidad de salidas del transporte", "hito"),
|
||||
("validacion_cutoff", "Validar Cut Off del transportista", "hito"),
|
||||
("decision_cutoff", "¿Se alcanza el Cut Off?", "decision"),
|
||||
("programacion_transporte_terrestre", "Programar transporte terrestre y recolección", "hito"),
|
||||
("recoleccion", "Recolección de mercancía", "hito"),
|
||||
("traslado_puerto", "Trasladar la mercancía al puerto / aeropuerto", "hito"),
|
||||
("entrega_terminal", "Entregar la mercancía en la terminal", "hito"),
|
||||
("entrega_docs_agente", "Entregar documentación al agente aduanal", "hito"),
|
||||
("despacho_exportacion", "Despacho de exportación", "hito"),
|
||||
("decision_despacho_exportacion", "¿Despacho de exportación autorizado?", "decision"),
|
||||
("emision_docs_internacionales", "Emitir documentación internacional (MBL/HBL, MAWB/HAWB, CMR)", "hito"),
|
||||
("embarque", "Embarque", "hito"),
|
||||
("zarpe", "Zarpe / Salida del transporte", "hito"),
|
||||
("coordinacion_corresponsal", "Coordinar con el agente corresponsal en destino", "hito"),
|
||||
("arribo", "Arribo a destino", "hito"),
|
||||
("despacho_destino", "Despacho de importación en destino (corresponsal)", "hito"),
|
||||
("entrega", "Entrega al consignatario", "hito"),
|
||||
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
|
||||
],
|
||||
# Diagrama 3 — Proceso de importación
|
||||
"importacion": [
|
||||
("aviso_llegada", "Aviso de llegada", "hito"),
|
||||
("recepcion_docs", "Recepción de documentos (MBL/MAWB)", "hito"),
|
||||
("coordinacion_agente_aduanal", "Coordinar con el agente aduanal el despacho", "hito"),
|
||||
("entrega_docs_agente", "Entregar documentos y requisitos al agente aduanal", "hito"),
|
||||
("despacho_importacion", "Despacho de importación", "hito"),
|
||||
("decision_despacho_importacion", "¿Despacho de importación autorizado?", "decision"),
|
||||
("liberacion", "Liberación de mercancía", "hito"),
|
||||
("retiro", "Retiro en puerto / aeropuerto", "hito"),
|
||||
("traslado", "Traslado a bodega del importador", "hito"),
|
||||
("entrega", "Entrega final al cliente", "hito"),
|
||||
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
|
||||
if _id is None:
|
||||
return True
|
||||
return (
|
||||
db.query(model.id)
|
||||
.filter(
|
||||
model.id == _id,
|
||||
model.tenant_id == tenant_id,
|
||||
model.company_id == company_id,
|
||||
model.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
checks = [
|
||||
("account_id", Account, "El cliente asociado no existe"),
|
||||
("quote_id", Quote, "La cotización asociada no existe"),
|
||||
("service_request_id", ServiceRequest, "La solicitud asociada no existe"),
|
||||
("carrier_supplier_id", Supplier, "El transportista/naviera no existe"),
|
||||
("ground_carrier_supplier_id", Supplier, "El transportista terrestre no existe"),
|
||||
("customs_agent_id", Supplier, "El agente aduanal no existe"),
|
||||
("destination_agent_id", Supplier, "El agente en destino no existe"),
|
||||
]
|
||||
for field, model, msg in checks:
|
||||
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
|
||||
def get_shipments(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
shipment_status: str | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> list[Shipment]:
|
||||
query = db.query(Shipment).filter(
|
||||
Shipment.tenant_id == tenant_id,
|
||||
Shipment.company_id == company_id,
|
||||
Shipment.deleted_at.is_(None),
|
||||
)
|
||||
if shipment_status:
|
||||
query = query.filter(Shipment.status == shipment_status)
|
||||
if account_id is not None:
|
||||
query = query.filter(Shipment.account_id == account_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
Shipment.reference.ilike(pattern)
|
||||
| Shipment.booking_number.ilike(pattern)
|
||||
| Shipment.origin.ilike(pattern)
|
||||
| Shipment.destination.ilike(pattern)
|
||||
)
|
||||
return query.order_by(Shipment.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> Shipment:
|
||||
obj = (
|
||||
db.query(Shipment)
|
||||
.filter(
|
||||
Shipment.id == shipment_id,
|
||||
Shipment.tenant_id == tenant_id,
|
||||
Shipment.company_id == company_id,
|
||||
Shipment.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def create_shipment(
|
||||
db: Session, payload: ShipmentCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Shipment:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
obj = Shipment(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_shipment(
|
||||
db: Session, shipment_id: int, payload: ShipmentUpdate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Shipment:
|
||||
obj = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_shipment_from_quote(
|
||||
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.
|
||||
|
||||
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(
|
||||
Quote.id == quote_id,
|
||||
Quote.tenant_id == tenant_id,
|
||||
Quote.company_id == company_id,
|
||||
Quote.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not quote:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
|
||||
if quote.status != "aceptada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La cotización debe estar aceptada para liberarse a Operaciones",
|
||||
)
|
||||
|
||||
sr = None
|
||||
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=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=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,
|
||||
origin=sr.origin if sr else None,
|
||||
destination=sr.destination if sr else None,
|
||||
destination_agent_id=sr.destination_agent_id if sr else None,
|
||||
status="abierta",
|
||||
owner_user_id=quote.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ----- Documentos del embarque -----
|
||||
|
||||
def get_shipment_documents(
|
||||
db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None
|
||||
) -> list[ShipmentDocument]:
|
||||
query = db.query(ShipmentDocument).filter(
|
||||
ShipmentDocument.tenant_id == tenant_id,
|
||||
ShipmentDocument.company_id == company_id,
|
||||
ShipmentDocument.deleted_at.is_(None),
|
||||
)
|
||||
if shipment_id is not None:
|
||||
query = query.filter(ShipmentDocument.shipment_id == shipment_id)
|
||||
return query.order_by(ShipmentDocument.id.asc()).all()
|
||||
|
||||
|
||||
def _get_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> ShipmentDocument:
|
||||
obj = (
|
||||
db.query(ShipmentDocument)
|
||||
.filter(
|
||||
ShipmentDocument.id == doc_id,
|
||||
ShipmentDocument.tenant_id == tenant_id,
|
||||
ShipmentDocument.company_id == company_id,
|
||||
ShipmentDocument.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def create_shipment_document(
|
||||
db: Session, payload: ShipmentDocumentCreate, tenant_id: int, company_id: int
|
||||
) -> ShipmentDocument:
|
||||
get_shipment(db, payload.shipment_id, tenant_id, company_id) # valida scope
|
||||
obj = ShipmentDocument(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_shipment_document(
|
||||
db: Session, doc_id: int, payload: ShipmentDocumentUpdate, tenant_id: int, company_id: int
|
||||
) -> ShipmentDocument:
|
||||
obj = _get_document(db, doc_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 delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> None:
|
||||
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)
|
||||
# Un punto de decisión no se "completa" a mano: se resuelve con decide_shipment_event
|
||||
if obj.kind == "decision":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Este hito es un punto de decisión: resuélvelo como autorizado o rechazado",
|
||||
)
|
||||
obj.status = "completado"
|
||||
obj.actual_date = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def decide_shipment_event(
|
||||
db: Session, event_id: int, payload: ShipmentEventDecisionInput, tenant_id: int, company_id: int
|
||||
) -> ShipmentEvent:
|
||||
"""Resuelve un punto de decisión del flujo (Cut Off / despacho autorizado).
|
||||
|
||||
- autorizado → la decisión queda completada y el flujo continúa.
|
||||
- rechazado → la decisión queda 'rechazada' y se genera automáticamente un hito
|
||||
de corrección (rehacer trámite) que apunta a esta decisión, implementando el
|
||||
ciclo de corrección de los diagramas 2 (R-E-14) y 3 (R-I-07).
|
||||
"""
|
||||
obj = _get_event(db, event_id, tenant_id, company_id)
|
||||
if obj.kind != "decision":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Solo los puntos de decisión aceptan un resultado (autorizado/rechazado)",
|
||||
)
|
||||
obj.outcome = payload.outcome
|
||||
obj.actual_date = datetime.now(timezone.utc)
|
||||
if payload.notes:
|
||||
obj.notes = payload.notes
|
||||
|
||||
if payload.outcome == "autorizado":
|
||||
obj.status = "completado"
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
# Rechazado: se abre el ciclo de corrección
|
||||
obj.status = "rechazado"
|
||||
correction = ShipmentEvent(
|
||||
shipment_id=obj.shipment_id,
|
||||
event_type=f"{obj.event_type or 'tramite'}_correccion",
|
||||
title=f"Corrección: rehacer trámite — {obj.title}",
|
||||
kind="hito",
|
||||
status="en_correccion",
|
||||
parent_event_id=obj.id,
|
||||
attempt=(obj.attempt or 1) + 1,
|
||||
# Se inserta justo después de la decisión rechazada para conservar el orden del flujo
|
||||
position=obj.position,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
# Empuja una posición los hitos posteriores para dejar hueco a la corrección
|
||||
db.query(ShipmentEvent).filter(
|
||||
ShipmentEvent.shipment_id == obj.shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
ShipmentEvent.position > obj.position,
|
||||
).update({ShipmentEvent.position: ShipmentEvent.position + 1})
|
||||
correction.position = obj.position + 1
|
||||
db.add(correction)
|
||||
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 reschedule_departure(
|
||||
db: Session, shipment_id: int, payload: ShipmentRescheduleInput, tenant_id: int, company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Shipment:
|
||||
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06).
|
||||
|
||||
Conserva la salida anterior en ``previous_etd`` y deja constancia en la bitácora.
|
||||
"""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
if payload.etd is not None:
|
||||
shipment.previous_etd = shipment.etd
|
||||
shipment.etd = payload.etd
|
||||
if payload.cutoff_date is not None:
|
||||
shipment.cutoff_date = payload.cutoff_date
|
||||
shipment.updated_by = user_id
|
||||
|
||||
last_pos = (
|
||||
db.query(func.max(ShipmentEvent.position))
|
||||
.filter(
|
||||
ShipmentEvent.shipment_id == shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
detail = payload.reason or "Reprogramación de salida por Cut Off no alcanzado"
|
||||
db.add(
|
||||
ShipmentEvent(
|
||||
shipment_id=shipment_id,
|
||||
event_type="reprogramacion",
|
||||
title="Reprogramación de salida (nuevo Cut Off / ETD)",
|
||||
kind="hito",
|
||||
status="completado",
|
||||
actual_date=datetime.now(timezone.utc),
|
||||
position=(last_pos or 0) + 1,
|
||||
notes=detail,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
def close_shipment(
|
||||
db: Session, shipment_id: int, payload: ShipmentCloseInput, tenant_id: int, company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Shipment:
|
||||
"""Cierre operativo del embarque con costos finales (R-E-22).
|
||||
|
||||
Marca el embarque como 'cerrada' y registra los costos reales; el cierre es el
|
||||
disparador válido de la facturación (R-F-01). No permite cerrar si quedan puntos
|
||||
de decisión sin resolver.
|
||||
"""
|
||||
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
|
||||
if shipment.status == "cancelada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="El embarque está cancelado"
|
||||
)
|
||||
pending_decision = (
|
||||
db.query(ShipmentEvent.id)
|
||||
.filter(
|
||||
ShipmentEvent.shipment_id == shipment_id,
|
||||
ShipmentEvent.tenant_id == tenant_id,
|
||||
ShipmentEvent.company_id == company_id,
|
||||
ShipmentEvent.deleted_at.is_(None),
|
||||
ShipmentEvent.kind == "decision",
|
||||
ShipmentEvent.status.in_(["pendiente", "rechazado", "en_correccion"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pending_decision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No se puede cerrar: hay puntos de decisión pendientes o en corrección",
|
||||
)
|
||||
shipment.actual_cost_total = payload.actual_cost_total
|
||||
shipment.cost_currency = payload.cost_currency
|
||||
shipment.status = "cerrada"
|
||||
shipment.closed_at = datetime.now(timezone.utc)
|
||||
shipment.closed_by = user_id
|
||||
shipment.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
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, kind) in enumerate(milestones):
|
||||
ev = 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.add(ev)
|
||||
created.append(ev)
|
||||
db.commit()
|
||||
for ev in created:
|
||||
db.refresh(ev)
|
||||
return created
|
||||
@@ -6,6 +6,8 @@ from fastapi import APIRouter
|
||||
|
||||
from .modules.core.router import router as core_router
|
||||
from .modules.crm.router import router as crm_router
|
||||
from .modules.ops.router import router as ops_router
|
||||
from .modules.fin.router import router as fin_router
|
||||
from .modules.example.routes import router as example_router
|
||||
|
||||
|
||||
@@ -13,6 +15,8 @@ router = APIRouter()
|
||||
|
||||
router.include_router(core_router)
|
||||
router.include_router(crm_router, prefix="/crm", tags=["crm"])
|
||||
router.include_router(ops_router, prefix="/ops", tags=["ops"])
|
||||
router.include_router(fin_router, prefix="/fin", tags=["fin"])
|
||||
router.include_router(example_router, prefix="/example", tags=["example"])
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,19 @@ class Settings(BaseSettings):
|
||||
PERMISSION_CACHE_ENABLED: bool = True
|
||||
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
|
||||
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
|
||||
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
|
||||
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
|
||||
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
|
||||
#
|
||||
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
|
||||
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
|
||||
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
|
||||
SESSION_STORE_ENABLED: bool = False
|
||||
SESSION_IDLE_MINUTES: int = 30
|
||||
SESSION_MAX_HOURS: int = 10
|
||||
|
||||
# Synchronization
|
||||
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||
|
||||
67
backend/core/hub_token.py
Normal file
67
backend/core/hub_token.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
|
||||
|
||||
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
|
||||
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
|
||||
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from jose import jwt
|
||||
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
|
||||
"""True si el token KC no está expirado (con margen)."""
|
||||
try:
|
||||
claims = jwt.get_unverified_claims(token)
|
||||
exp = claims.get("exp")
|
||||
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_hub_access_token(request) -> Optional[str]:
|
||||
"""
|
||||
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
|
||||
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
|
||||
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
|
||||
"""
|
||||
sid = request.cookies.get("crm_sid") if request is not None else None
|
||||
if not sid:
|
||||
return None
|
||||
sess = session_store.get_session(sid)
|
||||
if not sess:
|
||||
return None
|
||||
|
||||
access = sess.get("access_token")
|
||||
refresh = sess.get("refresh_token")
|
||||
|
||||
if access and _kc_exp_ok(access):
|
||||
return access
|
||||
|
||||
if refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
new_access = data.get("access_token") or access
|
||||
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
|
||||
return new_access
|
||||
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("get_hub_access_token: refresh falló: %s", exc)
|
||||
|
||||
return access
|
||||
94
backend/core/local_session.py
Normal file
94
backend/core/local_session.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Sesión local del CRM (patrón SIWEB).
|
||||
|
||||
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
|
||||
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
|
||||
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
|
||||
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
|
||||
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
|
||||
|
||||
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
|
||||
(no hay re-emisión de fallback).
|
||||
|
||||
Marcadores del token:
|
||||
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
|
||||
token dev-local (dev_local: True).
|
||||
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
|
||||
- exp → sliding por inactividad (idle); se
|
||||
re-emite en cada refresh mientras no se supere el cap.
|
||||
|
||||
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
|
||||
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
|
||||
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from core.config import settings
|
||||
|
||||
# Claims de identidad que se propagan del token KC a la sesión local.
|
||||
_IDENTITY_CLAIMS = (
|
||||
"sub", "email", "preferred_username", "username", "name",
|
||||
"given_name", "family_name", "first_name", "last_name",
|
||||
"tenant_id", "tenant_slug", "roles", "permissions",
|
||||
"is_hub_admin", "avatar_url",
|
||||
)
|
||||
|
||||
|
||||
def _now_epoch() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
|
||||
"""
|
||||
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
|
||||
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
|
||||
no se provee, se usa el momento actual (sesión nueva).
|
||||
"""
|
||||
now = _now_epoch()
|
||||
sst = int(session_start) if session_start else now
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
|
||||
}
|
||||
payload.update({
|
||||
"source": "local",
|
||||
"crm_session": True,
|
||||
"sst": sst,
|
||||
"iat": now,
|
||||
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
|
||||
})
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
|
||||
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
|
||||
Nunca lanza (para poder encadenar con la validación contra el Hub).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
|
||||
if not payload.get("crm_session") or payload.get("source") != "local":
|
||||
return None
|
||||
|
||||
# Cap absoluto de vida de sesión (independiente del sliding por idle).
|
||||
sst = payload.get("sst")
|
||||
if isinstance(sst, (int, float)):
|
||||
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
|
||||
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
|
||||
sst = payload.get("sst")
|
||||
return int(sst) if isinstance(sst, (int, float)) else None
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
|
||||
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
|
||||
# los cambios de licencia se propaguen en minutos.
|
||||
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str:
|
||||
if not value:
|
||||
@@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
|
||||
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
|
||||
# guardado en valkey y se cachea por tenant.
|
||||
if getattr(settings, "SESSION_STORE_ENABLED", False):
|
||||
try:
|
||||
from core.local_session import verify_session_token
|
||||
local_claims = verify_session_token(token)
|
||||
except Exception:
|
||||
local_claims = None
|
||||
if local_claims is not None:
|
||||
return await self._handle_local_session_license(request, call_next, local_claims)
|
||||
|
||||
tenant_override = request.headers.get("X-Tenant-Override")
|
||||
if not tenant_override:
|
||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
||||
@@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
|
||||
"""
|
||||
Valida licencia para una sesión local del CRM (patrón SIWEB).
|
||||
|
||||
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
|
||||
valkey (refrescándolo si está vencido) para consultar verify-license, con
|
||||
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
|
||||
permite el paso: la sesión local se emitió tras un login válido (el App
|
||||
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
|
||||
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
|
||||
"""
|
||||
from core import session_store
|
||||
|
||||
tenant_key = str(local_claims.get("tenant_id") or "")
|
||||
|
||||
cached = _license_cache.get(tenant_key) if tenant_key else None
|
||||
if cached == "valid":
|
||||
return await call_next(request)
|
||||
if isinstance(cached, str) and cached.startswith("invalid:"):
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
|
||||
)
|
||||
|
||||
tenant_override = (
|
||||
tenant_key
|
||||
or request.cookies.get("sso_tenant_id")
|
||||
or request.cookies.get("sso_tenant_pub")
|
||||
or ""
|
||||
)
|
||||
|
||||
sid = request.cookies.get("crm_sid")
|
||||
sess = session_store.get_session(sid) if sid else None
|
||||
kc_token = (sess or {}).get("access_token") or ""
|
||||
kc_refresh = (sess or {}).get("refresh_token") or ""
|
||||
|
||||
async def _verify(tok: str):
|
||||
if not tok:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {tok}"}
|
||||
if tenant_override:
|
||||
headers["X-Tenant-Override"] = str(tenant_override)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
return await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
|
||||
return None
|
||||
|
||||
resp = await _verify(kc_token)
|
||||
|
||||
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
|
||||
needs_refresh = resp is None or resp.status_code == 401
|
||||
if not needs_refresh and resp.status_code == 200:
|
||||
try:
|
||||
_d = resp.json()
|
||||
except Exception:
|
||||
_d = {}
|
||||
if not _d.get("valid", False) and _is_token_issue_message(
|
||||
_d.get("message"), _d.get("detail"), _d.get("reason")
|
||||
):
|
||||
needs_refresh = True
|
||||
|
||||
if needs_refresh and kc_refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
rr = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": kc_refresh},
|
||||
)
|
||||
if rr.status_code == 200:
|
||||
nt = rr.json()
|
||||
kc_token = nt.get("access_token") or kc_token
|
||||
if sid:
|
||||
session_store.update_session_tokens(
|
||||
sid, kc_token, nt.get("refresh_token") or kc_refresh
|
||||
)
|
||||
resp = await _verify(kc_token)
|
||||
else:
|
||||
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
|
||||
|
||||
if resp is not None and resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("valid", False):
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{msg}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = "valid"
|
||||
request.state.license_info = data
|
||||
return await call_next(request)
|
||||
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{message}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
|
||||
)
|
||||
|
||||
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
|
||||
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
|
||||
# bucle de 401 por el bug de refresh del Hub.
|
||||
logger.warning(
|
||||
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
|
||||
tenant_key,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada
|
||||
# (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh
|
||||
# del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que
|
||||
# elimina el bucle de login. verify_session_token retorna None para tokens KC
|
||||
# (RS256), así que no interfiere con el flujo normal.
|
||||
if settings.SESSION_STORE_ENABLED:
|
||||
from core.local_session import verify_session_token
|
||||
|
||||
local_claims = verify_session_token(token)
|
||||
if local_claims is not None:
|
||||
token_cache[cache_key] = local_claims
|
||||
return local_claims
|
||||
|
||||
# Shortcut para tokens de desarrollo local
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
try:
|
||||
@@ -653,6 +666,20 @@ def validate_access_to_resource(
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
|
||||
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
|
||||
# por compañía seleccionada cuando el token no está ligado a un tenant.
|
||||
if tenant_id is None and company_id:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
tenant_id = int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
|
||||
111
backend/core/session_store.py
Normal file
111
backend/core/session_store.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Store de sesión en Valkey/Redis (patrón SIWEB).
|
||||
|
||||
Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por
|
||||
un session_id opaco. La app usa la sesión local firmada (ver core.local_session)
|
||||
para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub
|
||||
(provisioning, my-apps, my-tenants), refrescándolos best-effort.
|
||||
|
||||
Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op
|
||||
y la sesión local firmada sigue sosteniendo la app.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from core.config import settings
|
||||
|
||||
try:
|
||||
import redis # type: ignore
|
||||
except Exception: # pragma: no cover - redis es opcional en algunos entornos
|
||||
redis = None # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEY_PREFIX = "crm:session:"
|
||||
_client = None
|
||||
|
||||
|
||||
def _get_client():
|
||||
"""Cliente Redis/Valkey compartido (perezoso). None si no está disponible."""
|
||||
global _client
|
||||
if redis is None:
|
||||
return None
|
||||
if _client is None:
|
||||
try:
|
||||
_client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True)
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_init_failed: %s", exc)
|
||||
return None
|
||||
return _client
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
# La sesión en valkey vive como máximo lo que la vida absoluta de la sesión.
|
||||
return settings.SESSION_MAX_HOURS * 3600
|
||||
|
||||
|
||||
def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]:
|
||||
"""Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está)."""
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return None
|
||||
session_id = str(uuid.uuid4())
|
||||
data = json.dumps({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token or "",
|
||||
"sst": int(session_start),
|
||||
})
|
||||
try:
|
||||
client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data)
|
||||
return session_id
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_create_failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_session(session_id: str) -> Optional[dict]:
|
||||
"""Devuelve {access_token, refresh_token, sst} de la sesión, o None."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return None
|
||||
try:
|
||||
raw = client.get(f"{_KEY_PREFIX}{session_id}")
|
||||
return json.loads(raw) if raw else None
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_get_failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None:
|
||||
"""Actualiza los tokens KC de una sesión existente conservando su TTL y su sst."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return
|
||||
try:
|
||||
key = f"{_KEY_PREFIX}{session_id}"
|
||||
ttl = client.ttl(key)
|
||||
if ttl and ttl > 0:
|
||||
existing = client.get(key)
|
||||
sst = json.loads(existing).get("sst") if existing else None
|
||||
data = json.dumps({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token or "",
|
||||
"sst": sst,
|
||||
})
|
||||
client.setex(key, ttl, data)
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_update_failed: %s", exc)
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
"""Elimina la sesión (logout). Fail-silent."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return
|
||||
try:
|
||||
client.delete(f"{_KEY_PREFIX}{session_id}")
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_delete_failed: %s", exc)
|
||||
@@ -22,7 +22,21 @@ from api.v1.modules.crm.documents.models import Document
|
||||
from api.v1.modules.crm.leads.models import Lead
|
||||
from api.v1.modules.crm.opportunities.models import Opportunity
|
||||
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
|
||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||
from api.v1.modules.crm.service_requests.models import ServiceRequest
|
||||
from api.v1.modules.crm.suppliers.models import Supplier
|
||||
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument, ShipmentEvent
|
||||
from api.v1.modules.ops.shipments import service as shipments_service
|
||||
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentEventDecisionInput
|
||||
from api.v1.modules.fin.invoices.models import Invoice
|
||||
from api.v1.modules.fin.invoices import service as invoices_service
|
||||
from api.v1.modules.fin.invoices.dto import PaymentCreate
|
||||
from api.v1.modules.core.permissions.models import CompanyRole, Permission, RolePermission
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
# Efecto secundario: poblar el PermissionRegistry con los permisos de cada dominio
|
||||
import api.v1.modules.crm.permissions # noqa: F401
|
||||
import api.v1.modules.ops.permissions # noqa: F401
|
||||
import api.v1.modules.fin.permissions # noqa: F401
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
|
||||
@@ -254,6 +268,188 @@ def seed_suppliers_and_related(db) -> None:
|
||||
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
|
||||
|
||||
|
||||
def seed_commercial_and_ops(db) -> None:
|
||||
"""Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado."""
|
||||
if db.query(ServiceRequest).filter(
|
||||
ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID,
|
||||
ServiceRequest.deleted_at.is_(None),
|
||||
).first():
|
||||
print("• Ya existe flujo comercial; se omite")
|
||||
return
|
||||
|
||||
account = (
|
||||
db.query(Account)
|
||||
.filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None))
|
||||
.order_by(Account.id.asc())
|
||||
.first()
|
||||
)
|
||||
account_id = account.id if account else None
|
||||
|
||||
# 1. Solicitud de servicio (RFQ)
|
||||
sr = ServiceRequest(
|
||||
reference="SOL-0001", account_id=account_id, operation_type="exportacion",
|
||||
transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB",
|
||||
origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general",
|
||||
load_type="FCL", container_equipment="1x40'HC", status="cotizada",
|
||||
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(sr)
|
||||
db.flush()
|
||||
|
||||
# 2. Cotización aceptada con conceptos
|
||||
quote = Quote(
|
||||
reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD",
|
||||
status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(quote)
|
||||
db.flush()
|
||||
items = [
|
||||
("flete_internacional", 1, 1800, 2200),
|
||||
("transporte_terrestre", 1, 350, 500),
|
||||
("despacho_aduanal", 1, 200, 320),
|
||||
]
|
||||
total_cost = total_sale = 0
|
||||
for concept, qty, cost, sale in items:
|
||||
db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost,
|
||||
unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID))
|
||||
total_cost += qty * cost
|
||||
total_sale += qty * sale
|
||||
quote.total_cost = total_cost
|
||||
quote.total_sale = total_sale
|
||||
sr.status = "liberada"
|
||||
|
||||
# 3. Embarque liberado a Operaciones
|
||||
shipment = Shipment(
|
||||
reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id,
|
||||
operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type,
|
||||
incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination,
|
||||
status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(shipment)
|
||||
db.flush()
|
||||
db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL",
|
||||
number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID))
|
||||
|
||||
db.commit()
|
||||
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
|
||||
|
||||
|
||||
def seed_invoicing_and_events(db) -> None:
|
||||
"""Factura desde el embarque (con pago parcial) + hitos de la bitácora."""
|
||||
from decimal import Decimal
|
||||
|
||||
if db.query(Invoice).filter(
|
||||
Invoice.tenant_id == TENANT_ID, Invoice.company_id == COMPANY_ID, Invoice.deleted_at.is_(None)
|
||||
).first():
|
||||
print("• Ya existe facturación; se omite")
|
||||
return
|
||||
|
||||
shipment = (
|
||||
db.query(Shipment)
|
||||
.filter(Shipment.tenant_id == TENANT_ID, Shipment.company_id == COMPANY_ID, Shipment.deleted_at.is_(None))
|
||||
.order_by(Shipment.id.asc())
|
||||
.first()
|
||||
)
|
||||
if not shipment:
|
||||
print("• Sin embarque; se omite facturación")
|
||||
return
|
||||
|
||||
# Bitácora de hitos (según tipo de operación, incluye puntos de decisión)
|
||||
events = shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
# Resolver los puntos de decisión como autorizados y completar los hitos simples,
|
||||
# para poder cerrar operativamente el embarque.
|
||||
for ev in events:
|
||||
if ev.kind == "decision":
|
||||
shipments_service.decide_shipment_event(
|
||||
db, ev.id, ShipmentEventDecisionInput(outcome="autorizado"), TENANT_ID, COMPANY_ID
|
||||
)
|
||||
else:
|
||||
shipments_service.complete_shipment_event(db, ev.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
# Cierre operativo con costos finales (dispara la facturación — R-F-01/R-E-22)
|
||||
shipments_service.close_shipment(
|
||||
db, shipment.id,
|
||||
ShipmentCloseInput(actual_cost_total=Decimal("2350"), cost_currency="USD", notes="Cierre demo"),
|
||||
TENANT_ID, COMPANY_ID,
|
||||
)
|
||||
|
||||
# Factura generada desde el embarque cerrado (toma conceptos de la cotización)
|
||||
invoice = invoices_service.generate_from_shipment(db, shipment.id, TENANT_ID, COMPANY_ID)
|
||||
invoices_service.emit_invoice(db, invoice.id, TENANT_ID, COMPANY_ID)
|
||||
invoices_service.create_payment(
|
||||
db, PaymentCreate(invoice_id=invoice.id, amount=Decimal("1000"), method="transferencia", reference="SPEI-001"),
|
||||
TENANT_ID, COMPANY_ID,
|
||||
)
|
||||
print("✓ Embarque cerrado, factura emitida con pago parcial + bitácora con decisiones resueltas")
|
||||
|
||||
|
||||
# Carriles del proceso (R-T-07): a qué módulos/acciones puede acceder cada rol.
|
||||
# clave = (code, nombre); valor = función que decide si un permiso pertenece al rol.
|
||||
def _carril_roles() -> dict:
|
||||
# cada función recibe el CÓDIGO del permiso (str) y decide si pertenece al carril
|
||||
return {
|
||||
("ventas", "Ventas"): lambda c: c.startswith("crm.") or c in {"ops.access", "ops.shipment.view"},
|
||||
("operaciones", "Operaciones"): lambda c: c.startswith("ops.")
|
||||
or c in {"crm.access", "crm.account.view", "crm.quote.view", "crm.service_request.view", "fin.access", "fin.invoice.view"},
|
||||
("facturacion", "Facturación"): lambda c: c.startswith("fin.")
|
||||
or c in {"ops.access", "ops.shipment.view", "crm.access", "crm.account.view"},
|
||||
("consulta", "Consulta"): lambda c: c.endswith(".access") or c.endswith(".view"),
|
||||
}
|
||||
|
||||
|
||||
def ensure_company(db) -> None:
|
||||
"""Garantiza la empresa dev (a76.company id=1), requerida por la FK company_id de
|
||||
los roles/permisos (core.company_roles → a76.company). La plantilla no la crea."""
|
||||
from sqlalchemy import text
|
||||
|
||||
exists = db.execute(text("SELECT 1 FROM a76.company WHERE id = :id"), {"id": COMPANY_ID}).first()
|
||||
if exists:
|
||||
print(f"• Empresa id={COMPANY_ID} ya existe (a76.company)")
|
||||
return
|
||||
db.execute(
|
||||
text("INSERT INTO a76.company (id, tenant_id) VALUES (:id, :tid)"),
|
||||
{"id": COMPANY_ID, "tid": TENANT_ID},
|
||||
)
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
print(f"✓ Empresa demo id={COMPANY_ID} creada (a76.company)")
|
||||
|
||||
|
||||
def seed_carril_roles(db) -> None:
|
||||
"""Crea los roles por carril del proceso con sus permisos (R-T-07). Idempotente."""
|
||||
# Poblar el catálogo de permisos desde el registry de código
|
||||
PermissionService(db).sync_permissions()
|
||||
all_perms = db.query(Permission).filter(Permission.is_active == True).all() # noqa: E712
|
||||
|
||||
created = 0
|
||||
for (code, name), belongs in _carril_roles().items():
|
||||
role = (
|
||||
db.query(CompanyRole)
|
||||
.filter(CompanyRole.company_id == COMPANY_ID, CompanyRole.tenant_id == TENANT_ID, CompanyRole.code == code)
|
||||
.first()
|
||||
)
|
||||
if not role:
|
||||
role = CompanyRole(
|
||||
company_id=COMPANY_ID, tenant_id=TENANT_ID, name=name, code=code,
|
||||
description=f"Rol de carril: {name}", is_active=True,
|
||||
)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
created += 1
|
||||
existing_perm_ids = {
|
||||
pid for (pid,) in db.query(RolePermission.permission_id).filter(RolePermission.company_role_id == role.id)
|
||||
}
|
||||
for perm in all_perms:
|
||||
if belongs(perm.code) and perm.id not in existing_perm_ids:
|
||||
db.add(RolePermission(
|
||||
company_role_id=role.id, permission_id=perm.id,
|
||||
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
))
|
||||
db.commit()
|
||||
print(f"✓ Roles por carril sembrados (nuevos: {created}) — Ventas, Operaciones, Facturación, Consulta")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
@@ -261,6 +457,10 @@ def main() -> None:
|
||||
pipeline, stages = ensure_pipeline(db)
|
||||
seed_sample_data(db, pipeline, stages)
|
||||
seed_suppliers_and_related(db)
|
||||
seed_commercial_and_ops(db)
|
||||
seed_invoicing_and_events(db)
|
||||
ensure_company(db)
|
||||
seed_carril_roles(db)
|
||||
print("\nSeed CRM completado.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -28,14 +28,20 @@ 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
|
||||
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.quotes.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
|
||||
import api.v1.modules.ops.shipments.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
||||
|
||||
_SCHEMA_MAP = {"crm": None, "core": None}
|
||||
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
|
||||
|
||||
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
||||
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
||||
|
||||
49
backend/tests/test_catalogs_seed.py
Normal file
49
backend/tests/test_catalogs_seed.py
Normal file
@@ -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
|
||||
169
backend/tests/test_compliance.py
Normal file
169
backend/tests/test_compliance.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Pruebas de las reglas de negocio agregadas para cumplir el PDF de agente de carga.
|
||||
|
||||
Cubren: puntos de decisión y ciclo de corrección de la bitácora (R-E-13/14, R-I-06/07),
|
||||
cierre operativo y gate de facturación (R-E-22 / R-F-01), envío y revisión de factura
|
||||
(R-F-05/06) y continuidad comercial (R-C-02/04/12) + catálogo de Incoterms (R-T-10).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
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.opportunities import service as opps_service
|
||||
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
|
||||
from api.v1.modules.crm.quotes import service as quotes_service
|
||||
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import (
|
||||
ServiceRequestContactInput,
|
||||
ServiceRequestCreate,
|
||||
ServiceRequestFromOpportunityInput,
|
||||
)
|
||||
from api.v1.modules.fin.invoices import service as inv_service
|
||||
from api.v1.modules.fin.invoices.dto import InvoiceClientReviewInput, InvoiceCreate, InvoiceItemCreate
|
||||
from api.v1.modules.ops.shipments import service as ops_service
|
||||
from api.v1.modules.ops.shipments.dto import (
|
||||
ShipmentCloseInput,
|
||||
ShipmentCreate,
|
||||
ShipmentEventDecisionInput,
|
||||
ShipmentRescheduleInput,
|
||||
)
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def _shipment(db, op_type="exportacion"):
|
||||
return ops_service.create_shipment(db, ShipmentCreate(reference="EMB-T", operation_type=op_type), T, C)
|
||||
|
||||
|
||||
# ---------- Bitácora: decisiones y ciclo de corrección ----------
|
||||
|
||||
def test_seed_milestones_include_decision_points(db):
|
||||
sh = _shipment(db, "exportacion")
|
||||
events = ops_service.seed_default_milestones(db, sh.id, T, C)
|
||||
decisions = [e for e in events if e.kind == "decision"]
|
||||
assert any(e.event_type == "decision_cutoff" for e in decisions)
|
||||
assert any(e.event_type == "decision_despacho_exportacion" for e in decisions)
|
||||
|
||||
|
||||
def test_decision_autorizado_completa(db):
|
||||
sh = _shipment(db, "importacion")
|
||||
events = ops_service.seed_default_milestones(db, sh.id, T, C)
|
||||
decision = next(e for e in events if e.kind == "decision")
|
||||
updated = ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="autorizado"), T, C)
|
||||
assert updated.status == "completado" and updated.outcome == "autorizado"
|
||||
|
||||
|
||||
def test_decision_rechazado_abre_correccion(db):
|
||||
sh = _shipment(db, "importacion")
|
||||
events = ops_service.seed_default_milestones(db, sh.id, T, C)
|
||||
before = len(ops_service.get_shipment_events(db, T, C, sh.id))
|
||||
decision = next(e for e in events if e.kind == "decision")
|
||||
ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="rechazado", notes="Docs incompletos"), T, C)
|
||||
after = ops_service.get_shipment_events(db, T, C, sh.id)
|
||||
assert len(after) == before + 1 # se creó el hito de corrección
|
||||
correction = next(e for e in after if e.parent_event_id == decision.id)
|
||||
assert correction.status == "en_correccion" and correction.attempt == 2
|
||||
|
||||
|
||||
def test_complete_on_decision_falla(db):
|
||||
sh = _shipment(db, "importacion")
|
||||
events = ops_service.seed_default_milestones(db, sh.id, T, C)
|
||||
decision = next(e for e in events if e.kind == "decision")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ops_service.complete_shipment_event(db, decision.id, T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------- Cierre operativo y gate de facturación ----------
|
||||
|
||||
def test_close_blocked_with_pending_decision(db):
|
||||
sh = _shipment(db, "importacion")
|
||||
ops_service.seed_default_milestones(db, sh.id, T, C) # deja decisiones pendientes
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("100")), T, C)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_generate_invoice_requires_closed_shipment(db):
|
||||
sh = _shipment(db, "exportacion") # abierta, sin cerrar
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
inv_service.generate_from_shipment(db, sh.id, T, C)
|
||||
assert exc.value.status_code == 409
|
||||
# Tras cerrar (sin hitos → sin decisiones pendientes) sí factura
|
||||
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("500"), cost_currency="MXN"), T, C)
|
||||
inv = inv_service.generate_from_shipment(db, sh.id, T, C)
|
||||
assert inv.shipment_id == sh.id
|
||||
|
||||
|
||||
def test_reschedule_keeps_previous_etd(db):
|
||||
from datetime import date
|
||||
sh = ops_service.create_shipment(db, ShipmentCreate(reference="EMB-R", operation_type="exportacion", etd=date(2026, 1, 10)), T, C)
|
||||
ops_service.reschedule_departure(db, sh.id, ShipmentRescheduleInput(etd=date(2026, 1, 20), reason="Cut Off perdido"), T, C)
|
||||
sh = ops_service.get_shipment(db, sh.id, T, C)
|
||||
assert str(sh.previous_etd) == "2026-01-10" and str(sh.etd) == "2026-01-20"
|
||||
|
||||
|
||||
# ---------- Envío y revisión de factura ----------
|
||||
|
||||
def test_send_invoice_generates_pdf_and_reviews(db, monkeypatch):
|
||||
stored = {}
|
||||
monkeypatch.setattr("core.storage_s3.put_object_bytes", lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}))
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente PDF"), T, C)
|
||||
inv = inv_service.create_invoice(db, InvoiceCreate(reference="F-PDF", account_id=acc.id, tax_rate=Decimal("16")), T, C)
|
||||
inv_service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
|
||||
inv_service.emit_invoice(db, inv.id, T, C)
|
||||
inv = inv_service.send_invoice(db, inv.id, T, C)
|
||||
assert inv.status == "enviada" and inv.pdf_file_key and stored["len"] > 0
|
||||
# Revisión del cliente: aprobada
|
||||
inv = inv_service.mark_client_review(db, inv.id, T, C)
|
||||
assert inv.status == "en_revision_cliente"
|
||||
inv = inv_service.client_review_decision(db, inv.id, InvoiceClientReviewInput(approved=True, notes="OK"), T, C)
|
||||
assert inv.status == "enviada" and inv.client_approved is True and inv.client_reviewed_at is not None
|
||||
|
||||
|
||||
# ---------- Continuidad comercial ----------
|
||||
|
||||
def test_register_contact_sets_stage(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Prospecto"), T, C)
|
||||
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
|
||||
sr = sr_service.register_contact(db, sr.id, ServiceRequestContactInput(notes="Primer contacto"), T, C)
|
||||
assert sr.status == "contacto" and sr.first_contact_at is not None
|
||||
|
||||
|
||||
def test_service_request_from_opportunity_links_back(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Op"), T, C)
|
||||
opp = opps_service.create_opportunity(db, OpportunityCreate(name="Oportunidad X", account_id=acc.id), T, C)
|
||||
sr = sr_service.create_from_opportunity(
|
||||
db, opp.id, ServiceRequestFromOpportunityInput(operation_type="importacion"), T, C
|
||||
)
|
||||
assert sr.opportunity_id == opp.id and sr.account_id == acc.id
|
||||
|
||||
|
||||
def test_clone_quote_reopens_request(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Q"), T, C)
|
||||
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
|
||||
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-1", account_id=acc.id, service_request_id=sr.id, currency="USD"), T, C)
|
||||
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=1, unit_cost=100, unit_sale=200), T, C)
|
||||
quotes_service.reject_quote(db, q.id, T, C)
|
||||
clone = quotes_service.clone_quote(db, q.id, T, C)
|
||||
assert clone.id != q.id and clone.status == "borrador"
|
||||
items = quotes_service.get_quote_items(db, clone.id, T, C)
|
||||
assert len(items) == 1
|
||||
sr = sr_service.get_service_request(db, sr.id, T, C)
|
||||
assert sr.status == "en_analisis" # reabierta para re-cotizar
|
||||
|
||||
|
||||
def test_incoterm_catalog_validation(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Inc"), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="XXX"), T, C
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
ok = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="FOB"), T, C
|
||||
)
|
||||
assert ok.incoterm == "FOB"
|
||||
43
backend/tests/test_folios.py
Normal file
43
backend/tests/test_folios.py
Normal file
@@ -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
|
||||
57
backend/tests/test_invoices.py
Normal file
57
backend/tests/test_invoices.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from decimal import Decimal
|
||||
|
||||
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.quotes import service as quotes_service
|
||||
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
|
||||
from api.v1.modules.fin.invoices import service
|
||||
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, PaymentCreate
|
||||
from api.v1.modules.ops.shipments import service as shipments_service
|
||||
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def test_invoice_totals_with_tax(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
|
||||
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
|
||||
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500), T, C)
|
||||
inv = service.get_invoice(db, inv.id, T, C)
|
||||
assert float(inv.subtotal) == 1500.0
|
||||
assert float(inv.tax_amount) == 240.0 # 16% de 1500
|
||||
assert float(inv.total) == 1740.0
|
||||
assert float(inv.balance) == 1740.0
|
||||
|
||||
|
||||
def test_payment_marks_paid(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
|
||||
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
|
||||
service.emit_invoice(db, inv.id, T, C)
|
||||
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("400"), method="transferencia"), T, C)
|
||||
inv = service.get_invoice(db, inv.id, T, C)
|
||||
assert float(inv.paid_amount) == 400.0 and float(inv.balance) == 600.0
|
||||
assert inv.status == "emitida"
|
||||
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("600")), T, C)
|
||||
inv = service.get_invoice(db, inv.id, T, C)
|
||||
assert float(inv.balance) == 0.0 and inv.status == "pagada" and inv.paid_at is not None
|
||||
|
||||
|
||||
def test_generate_from_shipment_copies_quote_items(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
|
||||
quote = quotes_service.create_quote(db, QuoteCreate(reference="COT-9", account_id=acc.id, currency="USD"), T, C)
|
||||
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500), T, C)
|
||||
quotes_service.accept_quote(db, quote.id, T, C)
|
||||
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
|
||||
# El embarque debe cerrarse operativamente antes de facturar (R-F-01)
|
||||
shipments_service.close_shipment(
|
||||
db, shipment.id, ShipmentCloseInput(actual_cost_total=Decimal("1000"), cost_currency="USD"), T, C
|
||||
)
|
||||
|
||||
inv = service.generate_from_shipment(db, shipment.id, T, C)
|
||||
assert inv.shipment_id == shipment.id
|
||||
assert inv.account_id == acc.id
|
||||
assert inv.currency == "USD"
|
||||
assert float(inv.ops_cost_total) == 1000.0 # costos de operación arrastrados (R-F-02)
|
||||
items = service.get_items(db, inv.id, T, C)
|
||||
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
|
||||
assert float(inv.subtotal) == 1500.0
|
||||
74
backend/tests/test_local_session.py
Normal file
74
backend/tests/test_local_session.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session.
|
||||
|
||||
Lógica pura (firma HS256 + claims); no requiere BD ni valkey.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from core.config import settings
|
||||
from core.local_session import mint_session_token, verify_session_token, session_start_of
|
||||
|
||||
|
||||
def _now() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def test_round_trip_conserva_identidad():
|
||||
claims = {
|
||||
"sub": "kc-user-123",
|
||||
"email": "user@example.com",
|
||||
"tenant_id": 11,
|
||||
"tenant_slug": "aduanasoft",
|
||||
"is_hub_admin": True,
|
||||
}
|
||||
token = mint_session_token(claims)
|
||||
out = verify_session_token(token)
|
||||
|
||||
assert out is not None
|
||||
assert out["sub"] == "kc-user-123"
|
||||
assert out["tenant_id"] == 11
|
||||
assert out["tenant_slug"] == "aduanasoft"
|
||||
assert out["is_hub_admin"] is True
|
||||
assert out["source"] == "local"
|
||||
assert out["crm_session"] is True
|
||||
assert isinstance(out["sst"], int)
|
||||
|
||||
|
||||
def test_cap_absoluto_rechaza_sesion_vieja():
|
||||
# session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle.
|
||||
old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120)
|
||||
token = mint_session_token({"sub": "x"}, session_start=old_start)
|
||||
assert verify_session_token(token) is None
|
||||
|
||||
|
||||
def test_preserva_session_start():
|
||||
start = _now() - 60
|
||||
token = mint_session_token({"sub": "x"}, session_start=start)
|
||||
out = verify_session_token(token)
|
||||
assert out is not None
|
||||
assert session_start_of(out) == start
|
||||
|
||||
|
||||
def test_firma_alterada_se_rechaza():
|
||||
token = mint_session_token({"sub": "x"})
|
||||
# Alterar el último carácter de la firma invalida el token.
|
||||
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||
assert verify_session_token(tampered) is None
|
||||
|
||||
|
||||
def test_token_no_crm_se_rechaza():
|
||||
# Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse.
|
||||
other = jwt.encode(
|
||||
{"sub": "x", "exp": _now() + 600},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
assert verify_session_token(other) is None
|
||||
|
||||
|
||||
def test_token_basura_se_rechaza():
|
||||
assert verify_session_token("no-es-un-jwt") is None
|
||||
assert verify_session_token("") is None
|
||||
29
backend/tests/test_pricing.py
Normal file
29
backend/tests/test_pricing.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Pruebas del cálculo de peso/volumen (P/Vol) aéreo."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from api.v1.modules.crm.common.pricing import air_chargeable_kg, air_volumetric_kg
|
||||
|
||||
|
||||
def test_air_volumetric_doc_example():
|
||||
# 3 pallets 120×120×100 cm → (120*120*100*3)/6000 = 720 (ejemplo del documento)
|
||||
assert air_volumetric_kg(120, 120, 100, 3) == Decimal(720)
|
||||
|
||||
|
||||
def test_air_volumetric_zero_without_dimensions():
|
||||
assert air_volumetric_kg(None, 120, 100, 3) == Decimal(0)
|
||||
assert air_volumetric_kg(0, 120, 100, 3) == Decimal(0)
|
||||
|
||||
|
||||
def test_air_qty_defaults_to_one():
|
||||
assert air_volumetric_kg(100, 100, 100, 0) == air_volumetric_kg(100, 100, 100, 1)
|
||||
|
||||
|
||||
def test_air_chargeable_takes_gross_when_larger():
|
||||
# bruto 800 > volumétrico 720 → se cobra 800
|
||||
assert air_chargeable_kg(800, 120, 120, 100, 3) == Decimal(800)
|
||||
|
||||
|
||||
def test_air_chargeable_takes_volumetric_when_larger():
|
||||
# bruto 200 < volumétrico 720 → se cobra 720
|
||||
assert air_chargeable_kg(200, 120, 120, 100, 3) == Decimal(720)
|
||||
154
backend/tests/test_quotes.py
Normal file
154
backend/tests/test_quotes.py
Normal file
@@ -0,0 +1,154 @@
|
||||
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 RateRequestCreate, ServiceRequestCreate
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def test_quote_totals_recompute_on_items(db):
|
||||
q = service.create_quote(db, QuoteCreate(reference="COT-001", currency="USD"), T, C)
|
||||
service.create_quote_item(
|
||||
db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=2, unit_cost=100, unit_sale=150), T, C
|
||||
)
|
||||
service.create_quote_item(
|
||||
db, QuoteItemCreate(quote_id=q.id, concept="despacho_aduanal", quantity=1, unit_cost=50, unit_sale=90), T, C
|
||||
)
|
||||
q = service.get_quote(db, q.id, T, C)
|
||||
assert float(q.total_cost) == 250.0 # 2*100 + 1*50
|
||||
assert float(q.total_sale) == 390.0 # 2*150 + 1*90
|
||||
|
||||
|
||||
def test_quote_totals_update_and_delete_item(db):
|
||||
q = service.create_quote(db, QuoteCreate(reference="COT-002"), T, C)
|
||||
item = service.create_quote_item(
|
||||
db, QuoteItemCreate(quote_id=q.id, concept="otros", quantity=1, unit_cost=100, unit_sale=200), T, C
|
||||
)
|
||||
service.update_quote_item(db, item.id, QuoteItemUpdate(unit_sale=Decimal("300")), T, C)
|
||||
q = service.get_quote(db, q.id, T, C)
|
||||
assert float(q.total_sale) == 300.0
|
||||
service.delete_quote_item(db, item.id, T, C)
|
||||
q = service.get_quote(db, q.id, T, C)
|
||||
assert float(q.total_sale) == 0.0
|
||||
|
||||
|
||||
def test_accept_quote_updates_service_request(db):
|
||||
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
|
||||
q = service.create_quote(db, QuoteCreate(reference="COT-003", service_request_id=sr.id), T, C)
|
||||
service.send_quote(db, q.id, T, C)
|
||||
accepted = service.accept_quote(db, q.id, T, C)
|
||||
assert accepted.status == "aceptada"
|
||||
assert accepted.accepted_at is not None
|
||||
# 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_seeds_additional_services(db):
|
||||
sr = sr_service.create_service_request(
|
||||
db,
|
||||
ServiceRequestCreate(
|
||||
operation_type="importacion", load_type="FCL", currency="USD",
|
||||
additional_services=["seguro", "despacho_aduanal"],
|
||||
additional_service_costs={"seguro": 500, "despacho_aduanal": 300},
|
||||
),
|
||||
T, C,
|
||||
)
|
||||
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
|
||||
items = service.get_quote_items(db, quotes[0].id, T, C)
|
||||
costs = {i.concept: float(i.unit_cost) for i in items}
|
||||
assert costs.get("seguro") == 500.0
|
||||
assert costs.get("despacho_aduanal") == 300.0
|
||||
# el costo estimado de la solicitud es el punto de partida (costo=venta)
|
||||
assert float(quotes[0].total_sale) == 800.0
|
||||
|
||||
|
||||
def test_quote_from_service_request_aereo_seeds_pvol_concept(db):
|
||||
sr = sr_service.create_service_request(
|
||||
db,
|
||||
ServiceRequestCreate(
|
||||
operation_type="exportacion", load_type="AEREO", currency="USD",
|
||||
weight=200, length_cm=120, width_cm=120, height_cm=100, pallets_count=3,
|
||||
),
|
||||
T, C,
|
||||
)
|
||||
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
|
||||
assert len(quotes) == 1
|
||||
assert quotes[0].load_type == "AEREO"
|
||||
flete = [i for i in service.get_quote_items(db, quotes[0].id, T, C) if i.concept == "flete_internacional"]
|
||||
assert len(flete) == 1
|
||||
# cantidad del flete = peso a cobrar (P/Vol 720 > bruto 200)
|
||||
assert float(flete[0].quantity) == 720.0
|
||||
|
||||
|
||||
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()
|
||||
156
backend/tests/test_service_requests.py
Normal file
156
backend/tests/test_service_requests.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import pytest
|
||||
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,
|
||||
)
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def test_create_service_request(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
|
||||
sr = service.create_service_request(
|
||||
db,
|
||||
ServiceRequestCreate(
|
||||
account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
|
||||
service_type="puerta_puerta", origin="Manzanillo", destination="Long Beach",
|
||||
incoterm="FOB", load_type="FCL",
|
||||
),
|
||||
T, C, user_id="dev",
|
||||
)
|
||||
assert sr.id is not None
|
||||
assert sr.status == "nueva"
|
||||
assert sr.created_by == "dev"
|
||||
|
||||
|
||||
def test_service_request_rejects_unknown_account(db):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.create_service_request(db, ServiceRequestCreate(account_id=999, operation_type="importacion"), T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_filter_by_operation_and_status(db):
|
||||
service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
|
||||
service.create_service_request(db, ServiceRequestCreate(operation_type="importacion"), T, C)
|
||||
exp = service.get_service_requests(db, T, C, operation_type="exportacion")
|
||||
assert len(exp) == 1 and exp[0].operation_type == "exportacion"
|
||||
|
||||
|
||||
def test_rate_request_requires_service_request(db):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.create_rate_request(
|
||||
db, RateRequestCreate(service_request_id=999, concept="flete_internacional"), T, C
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_rate_request_ok_and_listed(db):
|
||||
sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
|
||||
service.create_rate_request(
|
||||
db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", rate_amount=1200, currency="USD"),
|
||||
T, C,
|
||||
)
|
||||
rates = service.get_rate_requests(db, T, C, service_request_id=sr.id)
|
||||
assert len(rates) == 1 and rates[0].concept == "flete_internacional"
|
||||
|
||||
|
||||
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
|
||||
34
backend/tests/test_shipment_events.py
Normal file
34
backend/tests/test_shipment_events.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.ops.shipments import service
|
||||
from api.v1.modules.ops.shipments.dto import ShipmentCreate
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def test_seed_import_milestones(db):
|
||||
s = service.create_shipment(db, ShipmentCreate(reference="IMP-1", operation_type="importacion"), T, C)
|
||||
events = service.seed_default_milestones(db, s.id, T, C)
|
||||
titles = [e.event_type for e in events]
|
||||
assert "aviso_llegada" in titles
|
||||
assert "despacho_importacion" in titles
|
||||
assert "entrega" in titles
|
||||
# idempotente: no duplica
|
||||
again = service.seed_default_milestones(db, s.id, T, C)
|
||||
assert len(again) == len(events)
|
||||
|
||||
|
||||
def test_seed_export_milestones_and_complete(db):
|
||||
s = service.create_shipment(db, ShipmentCreate(reference="EXP-1", operation_type="exportacion"), T, C)
|
||||
events = service.seed_default_milestones(db, s.id, T, C)
|
||||
assert any(e.event_type == "embarque" for e in events)
|
||||
done = service.complete_shipment_event(db, events[0].id, T, C)
|
||||
assert done.status == "completado" and done.actual_date is not None
|
||||
|
||||
|
||||
def test_seed_requires_operation_type(db):
|
||||
s = service.create_shipment(db, ShipmentCreate(reference="X-1"), T, C) # sin operation_type
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.seed_default_milestones(db, s.id, T, C)
|
||||
assert exc.value.status_code == 422
|
||||
104
backend/tests/test_shipments.py
Normal file
104
backend/tests/test_shipments.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
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.quotes import service as quotes_service
|
||||
from api.v1.modules.crm.quotes.dto import QuoteCreate
|
||||
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.ops.shipments import service
|
||||
from api.v1.modules.ops.shipments.dto import ShipmentCreate, ShipmentDocumentCreate
|
||||
|
||||
T, C = 1, 1
|
||||
|
||||
|
||||
def test_release_requires_accepted_quote(db):
|
||||
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-A"), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.create_shipment_from_quote(db, q.id, T, C)
|
||||
assert exc.value.status_code == 422 # aún no aceptada
|
||||
|
||||
|
||||
def test_release_from_accepted_quote_copies_data(db):
|
||||
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
|
||||
sr = sr_service.create_service_request(
|
||||
db,
|
||||
ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
|
||||
origin="Veracruz", destination="Rotterdam"),
|
||||
T, C,
|
||||
)
|
||||
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-B", service_request_id=sr.id, account_id=acc.id), T, C)
|
||||
quotes_service.accept_quote(db, q.id, T, C)
|
||||
|
||||
shipment = service.create_shipment_from_quote(db, q.id, T, C, user_id="dev")
|
||||
assert shipment.quote_id == q.id
|
||||
assert shipment.account_id == acc.id
|
||||
assert shipment.operation_type == "exportacion"
|
||||
assert shipment.transport_mode == "maritimo"
|
||||
assert shipment.origin == "Veracruz"
|
||||
assert shipment.status == "abierta"
|
||||
# la solicitud queda liberada
|
||||
sr = sr_service.get_service_request(db, sr.id, T, C)
|
||||
assert sr.status == "liberada"
|
||||
|
||||
|
||||
def test_shipment_crud_and_documents(db):
|
||||
shipment = service.create_shipment(db, ShipmentCreate(reference="EMB-001", status="abierta", origin="MX"), T, C)
|
||||
assert shipment.id is not None
|
||||
doc = service.create_shipment_document(
|
||||
db, ShipmentDocumentCreate(shipment_id=shipment.id, doc_kind="master", doc_type="MBL", number="MBL123"), T, C
|
||||
)
|
||||
assert doc.doc_type == "MBL"
|
||||
docs = service.get_shipment_documents(db, T, C, shipment_id=shipment.id)
|
||||
assert len(docs) == 1
|
||||
|
||||
|
||||
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
|
||||
84
deploy/README.md
Normal file
84
deploy/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Despliegue — testing.crm.aduanasoft.com (entorno de pruebas)
|
||||
|
||||
Guía para publicar el CRM Agente de Carga en el servidor de pruebas. El estándar
|
||||
Aduanasoft es desplegar vía **Jenkins (CI/CD)**; este runbook manual es para el
|
||||
levantamiento inicial o cuando el pipeline aún no está conectado.
|
||||
|
||||
> **Servidor:** `deploy@216.250.125.140:3232` · **DNS:** `testing.crm.aduanasoft.com` → `216.250.125.140`
|
||||
|
||||
## 0. Reglas de seguridad (no negociables)
|
||||
- `ENVIRONMENT=production` y `DEV_LOCAL_AUTH=false`. En `development` el RBAC
|
||||
auto-bootstrapea **super_admin** a cualquiera y se activa el login local — inaceptable
|
||||
en un dominio público.
|
||||
- Los secretos (DB, S3, `SECRET_KEY`, Keycloak) los captura **el operador en el servidor**,
|
||||
nunca se versionan ni se comparten por chat.
|
||||
- Las **migraciones** las ejecuta el operador/CI, no de forma automática. No correr
|
||||
migraciones contra producción.
|
||||
|
||||
## 1. Acceso por llave (una vez)
|
||||
Autoriza la llave pública del operador en el servidor (desde una máquina que ya entre):
|
||||
```bash
|
||||
ssh -p 3232 deploy@216.250.125.140 \
|
||||
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '<TU_LLAVE_PUBLICA>' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
## 2. Código y entorno
|
||||
```bash
|
||||
ssh -p 3232 deploy@216.250.125.140
|
||||
git clone https://git.aduanasoft.com/ADUANASOFT/CRM_AGENTES_CARGA.git
|
||||
cd CRM_AGENTES_CARGA
|
||||
git checkout feature/crm-cumplimiento-pdf # o la rama/tag liberado
|
||||
cp deploy/env.testing.example .env
|
||||
$EDITOR .env # rellenar TODOS los CHANGE_ME
|
||||
```
|
||||
|
||||
## 3. Build de producción (importante)
|
||||
El `docker-compose.yml` del repo está orientado a desarrollo (vite dev + `--reload`).
|
||||
Para un sitio público hay que servir la **build** de producción:
|
||||
- **Frontend:** `npm ci && npm run build` (adapter-node) y arrancar con `node build`
|
||||
escuchando en `5173`, con `ORIGIN=https://testing.crm.aduanasoft.com`.
|
||||
- **Backend:** `uvicorn main:app --host 0.0.0.0 --port 8000` **sin** `--reload`.
|
||||
- Publica los puertos SOLO en loopback (override):
|
||||
```yaml
|
||||
# docker-compose.testing.yml (ejemplo de override)
|
||||
services:
|
||||
backend:
|
||||
ports: ["127.0.0.1:8000:8000"]
|
||||
command: ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
|
||||
frontend:
|
||||
ports: ["127.0.0.1:5173:5173"]
|
||||
command: ["node","build"]
|
||||
```
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.testing.yml up -d --build
|
||||
```
|
||||
|
||||
## 4. Migraciones y datos base
|
||||
```bash
|
||||
docker compose exec backend alembic upgrade head
|
||||
# Catálogo de permisos + roles por carril (Ventas/Operaciones/Facturación/Consulta):
|
||||
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
|
||||
```
|
||||
> En producción NO se usa el auto-bootstrap de dev: asigna los roles a los usuarios
|
||||
> reales desde el módulo de Roles y permisos.
|
||||
|
||||
## 5. Nginx + TLS
|
||||
```bash
|
||||
sudo cp deploy/nginx/testing.crm.aduanasoft.com.conf /etc/nginx/sites-available/
|
||||
sudo ln -s /etc/nginx/sites-available/testing.crm.aduanasoft.com.conf /etc/nginx/sites-enabled/
|
||||
sudo mkdir -p /var/www/certbot
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d testing.crm.aduanasoft.com
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 6. Smoke test
|
||||
```bash
|
||||
curl -fsS https://testing.crm.aduanasoft.com/api/health && echo OK
|
||||
# Abre la app y valida login (Keycloak), listar clientes, y un flujo end-to-end.
|
||||
```
|
||||
|
||||
## Notas
|
||||
- App y API van en el **mismo origen** (`/` y `/api/`) para no requerir CORS entre hosts.
|
||||
- MinIO: si el navegador debe abrir URLs prefirmadas, `S3_ENDPOINT_URL` debe resolver a un
|
||||
host accesible públicamente (o publicar MinIO detrás de nginx en otro subdominio). Revisar
|
||||
según la política de red del entorno de pruebas.
|
||||
162
deploy/RUNBOOK-produccion.md
Normal file
162
deploy/RUNBOOK-produccion.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga
|
||||
|
||||
> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de
|
||||
> datos de prod. **Este runbook no lo ejecuta ningún agente automático.**
|
||||
> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario`
|
||||
> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar.
|
||||
>
|
||||
> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod
|
||||
> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes.
|
||||
|
||||
Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local
|
||||
(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo
|
||||
completo antes de tocar prod.
|
||||
|
||||
---
|
||||
|
||||
## 0. Alcance del cambio
|
||||
|
||||
- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub
|
||||
(App Launcher → `/auth/sso?relay=<uuid>` → `POST {HUB_URL}/api/v1/auth/sso-exchange`).
|
||||
- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey**
|
||||
por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`.
|
||||
- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril
|
||||
(Ventas, Operaciones, Facturación, Consulta); permisos por módulo.
|
||||
|
||||
**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`,
|
||||
`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes
|
||||
de desplegar (paso 5). No corras migraciones a ciegas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerrequisitos de infraestructura
|
||||
|
||||
- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod.
|
||||
- [ ] Docker + Docker Compose en el server.
|
||||
- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`,
|
||||
`celery_worker`, `celery_beat`.
|
||||
- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`).
|
||||
- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**.
|
||||
- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos.
|
||||
|
||||
---
|
||||
|
||||
## 2. Variables de entorno (`.env` en el server de prod)
|
||||
|
||||
Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de
|
||||
producción**. Nunca subas el `.env` con secretos al repo.
|
||||
|
||||
```env
|
||||
# --- Seguridad ---
|
||||
ENVIRONMENT=production
|
||||
DEV_LOCAL_AUTH=false
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
|
||||
# --- Sesión local (patrón SIWEB) ---
|
||||
SESSION_STORE_ENABLED=true
|
||||
SESSION_IDLE_MINUTES=30
|
||||
SESSION_MAX_HOURS=10
|
||||
VALKEY_URL=redis://valkey:6379/0
|
||||
|
||||
# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) ---
|
||||
WORKSPACE_URL=https://<hub-produccion>
|
||||
HUB_URL=https://<hub-produccion>
|
||||
INTERNAL_HUB_URL=https://<hub-produccion>
|
||||
VITE_HUB_URL=https://<hub-produccion>
|
||||
|
||||
# --- Keycloak (single-realm / single-client) ---
|
||||
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=aduanasoft
|
||||
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado en prod>
|
||||
|
||||
# --- Dominio del CRM (mismo origen app + API vía nginx) ---
|
||||
ORIGIN=https://<dominio-crm-produccion>
|
||||
APP_PUBLIC_URL=https://<dominio-crm-produccion>
|
||||
VITE_API_URL=https://<dominio-crm-produccion>/api/
|
||||
INTERNAL_API_URL=http://backend:8000/api/
|
||||
CORS_ORIGINS=https://<dominio-crm-produccion>
|
||||
|
||||
# --- PostgreSQL ---
|
||||
CORE_DB_HOST=postgres
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=crm_core
|
||||
CORE_DB_USER=<usuario>
|
||||
POSTGRES_APP_PASSWORD=<password fuerte>
|
||||
|
||||
# --- MinIO / S3 ---
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
S3_ACCESS_KEY=<access>
|
||||
S3_SECRET_KEY=<secret>
|
||||
S3_BUCKET=crm
|
||||
S3_REGION=us-east-1
|
||||
S3_USE_SSL=false
|
||||
```
|
||||
|
||||
> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé
|
||||
> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build del frontend — **en CI, no en el server**
|
||||
|
||||
La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen
|
||||
en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
|
||||
# push al registry interno si aplica
|
||||
```
|
||||
|
||||
El backend usa la imagen/código directamente (uvicorn sin --reload).
|
||||
|
||||
---
|
||||
|
||||
## 4. Despliegue
|
||||
|
||||
```bash
|
||||
# En el server de prod, en el directorio del proyecto:
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
docker compose ps # verifica que todos queden healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho**
|
||||
|
||||
1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro):
|
||||
revisar y aplicar **solo** las que correspondan, con backup previo.
|
||||
2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py`
|
||||
(`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta).
|
||||
3. **Sync de permisos** (registra el catálogo por módulo):
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verificación / smoke test
|
||||
|
||||
```bash
|
||||
curl -fsS https://<dominio-crm-produccion>/api/health && echo OK
|
||||
```
|
||||
|
||||
- [ ] Login vía **App Launcher del Workspace** entra sin bucle.
|
||||
- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto).
|
||||
- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace.
|
||||
- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda
|
||||
(toast) y persiste al refrescar.
|
||||
- [ ] Licencia válida (sin bucle 401 / "sesión expirada").
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend.
|
||||
2. Restaurar `.env` anterior si se cambió.
|
||||
3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles.
|
||||
4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin
|
||||
redeploy de código (feature-flag), como mitigación rápida.
|
||||
17
deploy/docker-compose.preview.yml
Normal file
17
deploy/docker-compose.preview.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Preview PRIVADO en el servidor (login dev), acceso solo por túnel SSH.
|
||||
# Publica los puertos ÚNICAMENTE en 127.0.0.1 para que NADA quede expuesto a internet.
|
||||
# Postgres y MinIO no se publican al host (solo red interna de Docker).
|
||||
#
|
||||
# Uso:
|
||||
# docker compose -f docker-compose.yml -f deploy/docker-compose.preview.yml up -d
|
||||
services:
|
||||
backend:
|
||||
ports: !override
|
||||
- "127.0.0.1:8000:8000"
|
||||
frontend:
|
||||
ports: !override
|
||||
- "127.0.0.1:5173:5173"
|
||||
postgres:
|
||||
ports: !override []
|
||||
minio:
|
||||
ports: !override []
|
||||
58
deploy/docker-compose.testing.yml
Normal file
58
deploy/docker-compose.testing.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
# Despliegue en testing.crm.aduanasoft.com — AUTH REAL vía Hub (SSO relay).
|
||||
# Frontend: build de PRODUCCIÓN (Dockerfile.prod → node build). Backend: uvicorn sin --reload.
|
||||
# Puertos SOLO en loopback (nginx del host hace TLS + proxy). Postgres/MinIO no se publican.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build
|
||||
services:
|
||||
backend:
|
||||
# DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores;
|
||||
# el backend debe resolver workspace.aduanasoft.com (Hub) en runtime.
|
||||
dns:
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
ports: !override
|
||||
- "127.0.0.1:8000:8000"
|
||||
# Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista
|
||||
# (no lee el .env dentro del contenedor), así que los flags van aquí. Valores
|
||||
# desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo.
|
||||
environment:
|
||||
- SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true}
|
||||
- SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30}
|
||||
- SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10}
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
|
||||
frontend:
|
||||
dns:
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL}
|
||||
VITE_HUB_URL: ${VITE_HUB_URL}
|
||||
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL}
|
||||
VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-master}
|
||||
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# El callback OIDC (/auth/callback) intercambia el código por tokens con el
|
||||
# secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend.
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
|
||||
volumes: !override []
|
||||
command: !override ["node", "build/index.js"]
|
||||
ports: !override
|
||||
- "127.0.0.1:5173:5173"
|
||||
|
||||
postgres:
|
||||
ports: !override []
|
||||
|
||||
minio:
|
||||
ports: !override []
|
||||
|
||||
# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local.
|
||||
# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red.
|
||||
networks:
|
||||
app-hub:
|
||||
external: false
|
||||
driver: bridge
|
||||
48
deploy/env.testing.example
Normal file
48
deploy/env.testing.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# ==========================================================================
|
||||
# testing.crm.aduanasoft.com — AUTH REAL vía Hub (SSO relay). Copia como `.env`
|
||||
# EN EL SERVIDOR y rellena los <...>. NO subas el .env con secretos al repo.
|
||||
#
|
||||
# Flujo: el Hub (App Launcher) redirige a /auth/sso?relay=<uuid>; el CRM
|
||||
# intercambia el relay en POST {HUB_URL}/api/v1/auth/sso-exchange y entra.
|
||||
# ==========================================================================
|
||||
|
||||
# ---- SEGURIDAD ----
|
||||
ENVIRONMENT=production
|
||||
DEV_LOCAL_AUTH=false
|
||||
SECRET_KEY=<genera: openssl rand -hex 32>
|
||||
|
||||
# ---- Workspace / Hub (DEBE ser el MISMO Hub que generó el relay) ----
|
||||
# Confirmar el host real de producción (workspace.aduanasoft.com o hub.aduanasoft.com):
|
||||
WORKSPACE_URL=https://<hub-produccion>
|
||||
HUB_URL=https://<hub-produccion>
|
||||
INTERNAL_HUB_URL=https://<hub-produccion>
|
||||
VITE_HUB_URL=https://<hub-produccion>
|
||||
|
||||
# ---- Keycloak (arquitectura single-realm / single-client) ----
|
||||
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=aduanasoft
|
||||
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado — lo pones tú>
|
||||
|
||||
# ---- Dominio del CRM (mismo origen app + API vía nginx) ----
|
||||
ORIGIN=https://testing.crm.aduanasoft.com
|
||||
APP_PUBLIC_URL=https://testing.crm.aduanasoft.com
|
||||
VITE_API_URL=https://testing.crm.aduanasoft.com/api/
|
||||
INTERNAL_API_URL=http://backend:8000/api/
|
||||
CORS_ORIGINS=https://testing.crm.aduanasoft.com
|
||||
|
||||
# ---- Base de datos (PostgreSQL) ----
|
||||
CORE_DB_HOST=postgres
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=crm_core
|
||||
CORE_DB_USER=<usuario>
|
||||
POSTGRES_APP_PASSWORD=<password fuerte>
|
||||
|
||||
# ---- MinIO / S3 ----
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
S3_ACCESS_KEY=<access>
|
||||
S3_SECRET_KEY=<secret>
|
||||
S3_BUCKET=crm
|
||||
S3_REGION=us-east-1
|
||||
S3_USE_SSL=false
|
||||
87
deploy/nginx/testing.crm.aduanasoft.com.conf
Normal file
87
deploy/nginx/testing.crm.aduanasoft.com.conf
Normal file
@@ -0,0 +1,87 @@
|
||||
# nginx — testing.crm.aduanasoft.com
|
||||
# CRM Agente de Carga (entorno de PRUEBAS). App (SvelteKit adapter-node :5173) + API (FastAPI :8000)
|
||||
# servidas en el MISMO origen para evitar CORS. TLS con Let's Encrypt (certbot).
|
||||
#
|
||||
# Instalar en el servidor:
|
||||
# sudo cp testing.crm.aduanasoft.com.conf /etc/nginx/sites-available/
|
||||
# sudo ln -s /etc/nginx/sites-available/testing.crm.aduanasoft.com.conf /etc/nginx/sites-enabled/
|
||||
# sudo certbot certonly --webroot -w /var/www/certbot -d testing.crm.aduanasoft.com
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# IMPORTANTE (seguridad): publica los puertos de la app SOLO en loopback del servidor
|
||||
# (127.0.0.1:5173 y 127.0.0.1:8000) para que nginx sea el único acceso público.
|
||||
|
||||
# ---- HTTP: reto ACME + redirección a HTTPS ----
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name testing.crm.aduanasoft.com;
|
||||
|
||||
# Renovación de certificados (webroot)
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ---- HTTPS ----
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name testing.crm.aduanasoft.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/testing.crm.aduanasoft.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/testing.crm.aduanasoft.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# Encabezados de seguridad
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Subida de documentos (máx. 25 MB en la app) + margen
|
||||
client_max_body_size 30m;
|
||||
|
||||
# Buffers grandes para headers de respuesta: /auth/sso setea el JWT (fragmentado
|
||||
# si supera ~4KB) + refresh/id_token/tenant como cookies → el header excede el
|
||||
# buffer default de nginx (evita "upstream sent too big header" → 502).
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 16 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# ---- API (FastAPI) — mismo origen: /api/... -> backend :8000 (conserva el path) ----
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ---- App (SvelteKit adapter-node) — todo lo demás -> frontend :5173 ----
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5173;
|
||||
proxy_http_version 1.1;
|
||||
# WebSocket / upgrade (SSR streaming y HMR si corriera en modo dev)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ ENV INTERNAL_API_URL=${INTERNAL_API_URL}
|
||||
# Copiar el resto del código
|
||||
COPY . .
|
||||
|
||||
# Subir el límite de heap de Node para el build (VM chico → evita OOM en vite build)
|
||||
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||
|
||||
# Construir el proyecto
|
||||
RUN pnpm run build
|
||||
|
||||
@@ -49,10 +52,8 @@ FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
RUN npm config set strict-ssl false && \
|
||||
npm install -g pnpm
|
||||
# Runtime SIN dependencias de red (redes restringidas): busybox ya trae wget y
|
||||
# se arranca con node directo (sin pnpm), así el runtime no toca apk/npm.
|
||||
|
||||
# Crear usuario no-root para seguridad antes de copiar con --chown
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
@@ -85,5 +86,5 @@ ENV INTERNAL_API_URL=http://backend:8000/api/
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Ejecutar aplicación con Node.js
|
||||
CMD ["pnpm", "start"]
|
||||
# Ejecutar aplicación con Node.js (adapter-node, sin pnpm)
|
||||
CMD ["node", "build/index.js"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user