Compare commits
45 Commits
feature/cr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5708e12d7b | |||
| abdf1ce790 | |||
| f9258bad05 | |||
| db4f3b1d53 | |||
| 5a62c31d93 | |||
| 1f8fdf2866 | |||
| dbda0b5755 | |||
| 16aca537be | |||
| a1793de2f2 | |||
| 81c237d852 | |||
| 57745af9b5 | |||
| 4f3bb81607 | |||
| c99cf2c7e9 | |||
| c195c05c0d | |||
| 061b48d30f | |||
| 7c0a04fb3b | |||
| e32c9f110e | |||
| 926a75c5f8 | |||
| 21ea958f33 | |||
| 6e208876f7 | |||
| 61af5c80fe | |||
| 192a2d9d89 | |||
| be45f950b8 | |||
| bc50a7d099 | |||
| d1fcd236e4 | |||
| 5c4df590d4 | |||
| dfb3c8a08d | |||
| f4ef6a037d | |||
| 15717314fd | |||
| ce09e0d30a | |||
| ae0664e987 | |||
| cb2acb11fc | |||
| 5a112a0171 | |||
| 8d9db3505d | |||
| b8b8311ece | |||
| 9cf142add6 | |||
| e24435c74b | |||
|
|
afe659e56a | ||
|
|
b47dc542f2 | ||
|
|
8431132b10 | ||
|
|
8c7aeef1a6 | ||
|
|
9c46f5bf3c | ||
|
|
f1e6fba75d | ||
|
|
36e98ee976 | ||
|
|
915bdd19fe |
24
.env.example
24
.env.example
@@ -112,3 +112,27 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||
SPOKE_URLS=""
|
||||
|
||||
# ==================================
|
||||
# PAC COMERCIO DIGITAL (timbrado CFDI)
|
||||
# ==================================
|
||||
# El modo de timbrado se decide POR FACTURA, en fin.invoices.stamping_mode.
|
||||
# Esta variable solo fija con qué valor nacen las facturas que no lo especifican.
|
||||
# pruebas -> pruebas.comercio-digital.mx | produccion -> ws.comercio-digital.mx
|
||||
# Una factura en "produccion" emite un CFDI con validez fiscal real ante el SAT.
|
||||
PAC_DEFAULT_MODE=pruebas
|
||||
# Credenciales del web service (headers usrws / pwdws). NO commitear valores reales:
|
||||
# van en .env, que está en .gitignore.
|
||||
PAC_USER=
|
||||
PAC_PASSWORD=
|
||||
# Opcional: correo al que el PAC notifica el comprobante (header email).
|
||||
PAC_NOTIFICATION_EMAIL=
|
||||
# Clave maestra que cifra las contraseñas de los CSD en la base de datos. OBLIGATORIA para
|
||||
# poder cargar certificados desde Configuración de Facturación. Generarla con:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Si se cambia, las contraseñas ya guardadas dejan de poder descifrarse y hay que recargar
|
||||
# los certificados.
|
||||
CSD_ENCRYPTION_KEY=
|
||||
# LEGADO: contraseña global del CSD. Sólo se usa como respaldo si una empresa no tiene la
|
||||
# suya guardada. Lo correcto es cargar el CSD por empresa desde la interfaz.
|
||||
CSD_PASSWORD=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -84,3 +84,8 @@ celerybeat-schedule.*
|
||||
celerybeat.pid
|
||||
backend/api/v1/modules/reports/generated/
|
||||
docker-compose.override.yml
|
||||
|
||||
SUNRISE/
|
||||
# Corredor de la corrida autonoma continua: vive local, no se versiona.
|
||||
automatizacion/
|
||||
docker-compose.dev.yml
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
180
backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py
Normal file
180
backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Carril CRM -> EFC: columnas efc_* en crm.cases + outbox transaccional (dos tablas).
|
||||
|
||||
El expediente NO se crea aquí: ya existe como ``crm.cases`` (ver d4e5f6a7b8c9), con su folio
|
||||
en ``reference`` y su consecutivo en ``crm.folio_counters``. Esta migración solo le cuelga lo
|
||||
que el carril hacia EFC necesita y crea el outbox. Es aditiva a propósito: la estructura del
|
||||
expediente es de quien la definió, nosotros aportamos la conexión.
|
||||
|
||||
Las seis columnas ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle con
|
||||
el que el CRM habla de un expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es
|
||||
un caché de la resolución y el ``pedimento_app`` del lado de EFC es mutable —se reescribe al
|
||||
completar el provisional—, así que apoyarse en él rompería en cuanto llegue la data real.
|
||||
|
||||
``efc_storage_token`` es INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda
|
||||
los objetos de este expediente. Que no cambie nunca es lo que permite completar el pedimento
|
||||
sin mover un solo archivo.
|
||||
|
||||
``efc_link_state`` nace en 'PENDING' para las filas que ya existen. Eso es deliberado: al
|
||||
encender el carril, el barrido de respaldo replica el histórico. Mientras ``EFC_API_URL`` esté
|
||||
vacía el carril está apagado y no se encola nada.
|
||||
|
||||
Revision ID: c5d6e7f8a9b0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c5d6e7f8a9b0"
|
||||
down_revision: Union[str, None] = "d4e5f6a7b8c9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- crm.cases: el espejo de EFC ----------
|
||||
op.add_column("cases", sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_storage_token", sa.String(length=25), nullable=True), schema="crm")
|
||||
op.add_column(
|
||||
"cases",
|
||||
# PENDING | LINKED | FAILED
|
||||
sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")),
|
||||
schema="crm",
|
||||
)
|
||||
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
|
||||
# obligar a nadie a ir a los logs del worker.
|
||||
op.add_column("cases", sa.Column("efc_error_code", sa.String(length=60), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_error_detail", sa.Text(), nullable=True), schema="crm")
|
||||
op.create_index("ix_crm_cases_efc_link_state", "cases", ["efc_link_state"], schema="crm")
|
||||
|
||||
# Relleno del token para los expedientes que ya existían. Sin esto, el barrido de
|
||||
# reconciliación los encola, EFC los rechaza con
|
||||
# {'storage_token': ['This field may not be null.']} y agotan sus 8 intentos hasta
|
||||
# quedar en `failed`: ruido permanente por un dato que se podía derivar.
|
||||
#
|
||||
# La condición de longitud replica la guarda de storage_token(): en los 25 caracteres de
|
||||
# `pedimento_app` caben hasta 6 dígitos de company. Lo que no cabe se queda NULL a
|
||||
# propósito y el encolado lo salta avisando, porque un token recortado apuntaría a la
|
||||
# carpeta de otro expediente y mezclaría documentos en silencio.
|
||||
#
|
||||
# `reference IS NOT NULL` porque el folio es nullable en crm.cases: un expediente sin
|
||||
# folio no tiene con qué identificarse ante EFC.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE crm.cases
|
||||
SET efc_storage_token = 'CRM-' || company_id::text || '-' || reference
|
||||
WHERE efc_storage_token IS NULL
|
||||
AND reference IS NOT NULL
|
||||
AND length('CRM-' || company_id::text || '-' || reference) <= 25
|
||||
"""
|
||||
)
|
||||
|
||||
# ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ----------
|
||||
op.create_table(
|
||||
"efc_sync_outbox",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("expediente_ref", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("efc_pedimento_id", sa.String(length=36), 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"),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_efc_sync_outbox_status", "efc_sync_outbox", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_kind_status", "efc_sync_outbox", ["kind", "status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_expediente_ref", "efc_sync_outbox", ["expediente_ref"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_company_id", "efc_sync_outbox", ["company_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
|
||||
# ---------- crm.efc_file_outbox: archivos ----------
|
||||
op.create_table(
|
||||
"efc_file_outbox",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("s3_key", sa.String(length=1024), nullable=False),
|
||||
sa.Column("file_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("content_type", sa.String(length=100), nullable=True),
|
||||
sa.Column("efc_tipo", sa.String(length=40), nullable=False),
|
||||
# La pareja (tabla, id) desambigua entre las DOS secuencias de documentos del CRM:
|
||||
# crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten.
|
||||
sa.Column("source_table", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_id", sa.Integer(), nullable=True),
|
||||
sa.Column("crm_document_ref", sa.String(length=64), nullable=True),
|
||||
sa.Column("expediente_ref", sa.Integer(), nullable=False),
|
||||
sa.Column("delete_local", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("efc_document_id", sa.String(length=36), 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"),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_efc_file_outbox_status", "efc_file_outbox", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_kind_status", "efc_file_outbox", ["kind", "status"], schema="crm")
|
||||
# Índice de la guarda _ya_entregado, que es lo que se consulta en cada encolado.
|
||||
op.create_index("ix_crm_efc_file_outbox_source", "efc_file_outbox", ["source_table", "source_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", ["expediente_ref"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_tenant_id", "efc_file_outbox", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_company_id", "efc_file_outbox", ["company_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
# expediente_ref -> crm.cases.id. El nombre de la columna conserva el término del dominio:
|
||||
# crm.cases ES el expediente (así lo nombra su propio docstring), y el carril, EFC y el
|
||||
# ticket hablan de expedientes. Renombrarlo a case_ref solo movería la ambigüedad de sitio.
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "cases",
|
||||
["expediente_ref"], ["id"], source_schema="crm", referent_schema="crm",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_efc_file_outbox_company_id", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_tenant_id", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_expediente_ref", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_source", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_kind_status", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_status", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_table("efc_file_outbox", schema="crm")
|
||||
|
||||
op.drop_constraint("fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_company_id", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_tenant_id", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_expediente_ref", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_kind_status", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_status", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_table("efc_sync_outbox", schema="crm")
|
||||
|
||||
op.drop_index("ix_crm_cases_efc_link_state", table_name="cases", schema="crm")
|
||||
op.drop_column("cases", "efc_error_detail", schema="crm")
|
||||
op.drop_column("cases", "efc_error_code", schema="crm")
|
||||
op.drop_column("cases", "efc_link_state", schema="crm")
|
||||
op.drop_column("cases", "efc_storage_token", schema="crm")
|
||||
op.drop_column("cases", "efc_pedimento_id", schema="crm")
|
||||
op.drop_column("cases", "efc_organizacion_id", schema="crm")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos
|
||||
|
||||
Revision ID: d3e4f5a6b7c8
|
||||
Revises: c2d3e4f5a6b7
|
||||
Create Date: 2026-08-04 01:00:00.000000
|
||||
|
||||
- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais).
|
||||
- crm.accounts.industry_other (especificar cuando el giro es "otro").
|
||||
- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en
|
||||
los valores guardados en accounts/suppliers; y país 'MX'→'MEX'.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d3e4f5a6b7c8"
|
||||
down_revision: Union[str, None] = "c2d3e4f5a6b7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# País a ISO alfa-3 en accounts (addresses ya es String(3)).
|
||||
# Primero se amplía la columna; luego se normaliza el dato (evita truncamiento).
|
||||
op.alter_column(
|
||||
"accounts", "country", schema=SCHEMA,
|
||||
existing_type=sa.String(length=2), type_=sa.String(length=3),
|
||||
existing_nullable=True, server_default=sa.text("'MEX'"),
|
||||
)
|
||||
op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'")
|
||||
op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'")
|
||||
# Giro "otro" — campo para especificar
|
||||
op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
|
||||
# Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados)
|
||||
op.execute(
|
||||
"UPDATE crm.catalog_items SET code = lpad(code, 2, '0') "
|
||||
"WHERE catalog = 'forma_pago' AND char_length(code) = 1"
|
||||
)
|
||||
op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
|
||||
op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("accounts", "industry_other", schema=SCHEMA)
|
||||
# Regresar país a String(2) sin truncar filas existentes
|
||||
op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'")
|
||||
op.alter_column(
|
||||
"accounts", "country", schema=SCHEMA,
|
||||
existing_type=sa.String(length=3), type_=sa.String(length=2),
|
||||
existing_nullable=True, server_default=sa.text("'MX'"),
|
||||
)
|
||||
# La normalización de formas de pago no se revierte (evita romper códigos multi-dígito).
|
||||
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal file
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Expediente (crm.cases) + case_id en el ciclo comercial
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: f0a1b2c3d4e5
|
||||
Create Date: 2026-08-07 02:00:00.000000
|
||||
|
||||
Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a
|
||||
crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: Union[str, None] = "f0a1b2c3d4e5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# (schema, tabla) donde se agrega case_id
|
||||
_CASE_FK_TABLES = [
|
||||
("crm", "opportunities"),
|
||||
("crm", "service_requests"),
|
||||
("crm", "quotes"),
|
||||
("ops", "shipments"),
|
||||
("fin", "invoices"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cases",
|
||||
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("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), 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"]),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm")
|
||||
op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm")
|
||||
op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm")
|
||||
op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm")
|
||||
op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm")
|
||||
|
||||
for schema, table in _CASE_FK_TABLES:
|
||||
op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema)
|
||||
op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema)
|
||||
op.create_foreign_key(
|
||||
f"fk_{schema}_{table}_case_id", table, "cases",
|
||||
["case_id"], ["id"], source_schema=schema, referent_schema="crm",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for schema, table in _CASE_FK_TABLES:
|
||||
op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey")
|
||||
op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema)
|
||||
op.drop_column(table, "case_id", schema=schema)
|
||||
|
||||
for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"):
|
||||
op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm")
|
||||
op.drop_table("cases", schema="crm")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Fechas separadas de ganada/perdida en la oportunidad
|
||||
|
||||
Revision ID: e4f5a6b7c8d9
|
||||
Revises: d3e4f5a6b7c8
|
||||
Create Date: 2026-08-04 02:00:00.000000
|
||||
|
||||
Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas,
|
||||
editables) además de closed_at y lost_reason ya existentes.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e4f5a6b7c8d9"
|
||||
down_revision: Union[str, None] = "d3e4f5a6b7c8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA)
|
||||
op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("opportunities", "lost_date", schema=SCHEMA)
|
||||
op.drop_column("opportunities", "won_date", schema=SCHEMA)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Medio de contacto preferido en el prospecto (lead)
|
||||
|
||||
Revision ID: f0a1b2c3d4e5
|
||||
Revises: e4f5a6b7c8d9
|
||||
Create Date: 2026-08-07 01:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f0a1b2c3d4e5"
|
||||
down_revision: Union[str, None] = "e4f5a6b7c8d9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts.
|
||||
|
||||
Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega
|
||||
``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los
|
||||
catálogos, conservando las columnas de texto libre que ya existían.
|
||||
|
||||
Re-encadenada al integrar main: esta rama y la del CRM habían salido las dos de
|
||||
d5e6f7a8b9c0, y con dos cabezas ``alembic upgrade head`` falla. La historia queda lineal,
|
||||
con las migraciones de facturación detrás de las del CRM.
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: g1h2i3j4k5l6
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: Union[str, None] = "g1h2i3j4k5l6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- sat.cfdi_uses ----------
|
||||
op.create_table(
|
||||
"cfdi_uses",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=4), nullable=False),
|
||||
sa.Column("description", sa.String(length=500), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("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="sat",
|
||||
)
|
||||
op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat")
|
||||
op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat")
|
||||
|
||||
# sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos
|
||||
# que ya sembró la migración anterior.
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
# ---------- crm.accounts: claves fiscales del receptor ----------
|
||||
# Nullables: las cuentas existentes solo tienen el texto libre.
|
||||
op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm")
|
||||
op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes",
|
||||
["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses",
|
||||
["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||
)
|
||||
|
||||
# Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre
|
||||
# contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción
|
||||
# exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se
|
||||
# queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor
|
||||
# a partir de texto libre provoca CFDI rechazados.
|
||||
for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE crm.accounts AS a
|
||||
SET {column}_id = c.id
|
||||
FROM sat.{catalog} AS c
|
||||
WHERE a.{column}_id IS NULL
|
||||
AND a.{column} IS NOT NULL
|
||||
AND (
|
||||
upper(btrim(a.{column})) = upper(c.code)
|
||||
OR upper(btrim(a.{column})) = upper(c.description)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey")
|
||||
op.drop_column("accounts", "cfdi_use_id", schema="crm")
|
||||
op.drop_column("accounts", "tax_regime_id", schema="crm")
|
||||
op.drop_table("cfdi_uses", schema="sat")
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor
|
||||
y amarre de facturas y partidas a los catálogos.
|
||||
|
||||
Renumerada al integrar main: nació como e6f7a8b9c0d1, el mismo identificador que la
|
||||
migración de catálogos del CRM, porque ambas ramas salieron de d5e6f7a8b9c0 sin verse. Se
|
||||
renumera ésta y no la del CRM porque aquélla ya está en main y hay otra migración que la
|
||||
referencia por id.
|
||||
|
||||
Revision ID: g1h2i3j4k5l6
|
||||
Revises: c5d6e7f8a9b0
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "g1h2i3j4k5l6"
|
||||
down_revision: Union[str, None] = "c5d6e7f8a9b0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Índices únicos parciales: la baja lógica (deleted_at) libera la clave.
|
||||
_ALIVE = "deleted_at IS NULL"
|
||||
|
||||
# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo).
|
||||
_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [
|
||||
("tax_regimes", 3, [
|
||||
sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
]),
|
||||
("taxes", 3, [
|
||||
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
]),
|
||||
("payment_forms", 2, []),
|
||||
("units_of_measure", 20, [
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=20), nullable=True),
|
||||
]),
|
||||
("products_services", 8, []),
|
||||
("voucher_types", 1, []),
|
||||
("payment_methods", 3, []),
|
||||
("tax_objects", 2, []),
|
||||
]
|
||||
|
||||
# units_of_measure guarda el nombre corto aparte, así que su description es opcional.
|
||||
_NULLABLE_DESCRIPTION = {"units_of_measure"}
|
||||
|
||||
|
||||
def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]:
|
||||
columns = [
|
||||
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()")),
|
||||
]
|
||||
if with_soft_delete:
|
||||
columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||
return columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- Schema y catálogos globales del SAT ----------
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS sat")
|
||||
|
||||
for table, code_length, extra_columns in _SAT_CATALOGS:
|
||||
op.create_table(
|
||||
table,
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=code_length), nullable=False),
|
||||
sa.Column(
|
||||
"description",
|
||||
sa.String(length=500),
|
||||
nullable=table in _NULLABLE_DESCRIPTION,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
*extra_columns,
|
||||
*_timestamp_columns(with_soft_delete=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="sat",
|
||||
)
|
||||
op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat")
|
||||
# La clave oficial del SAT es única dentro de su catálogo.
|
||||
op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat")
|
||||
|
||||
# Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar).
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
# ---------- fin.concepts ----------
|
||||
op.create_table(
|
||||
"concepts",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=40), nullable=False),
|
||||
sa.Column("description", sa.String(length=500), nullable=False),
|
||||
sa.Column("product_service_id", sa.Integer(), nullable=False),
|
||||
sa.Column("unit_of_measure_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tax_object_id", sa.Integer(), nullable=True),
|
||||
sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("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),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id"
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin")
|
||||
# La clave interna del concepto es única por empresa.
|
||||
op.create_index(
|
||||
"uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
# Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre
|
||||
# los conceptos vigentes de la misma empresa.
|
||||
op.create_index(
|
||||
"uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.issuer_settings ----------
|
||||
op.create_table(
|
||||
"issuer_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("legal_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("rfc", sa.String(length=13), nullable=False),
|
||||
sa.Column("tax_regime_id", sa.Integer(), nullable=False),
|
||||
sa.Column("zip_code", sa.String(length=5), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id"
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin")
|
||||
# Una sola configuración fiscal vigente por empresa.
|
||||
op.create_index(
|
||||
"uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_item_taxes ----------
|
||||
op.create_table(
|
||||
"invoice_item_taxes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("invoice_item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tax_id", sa.Integer(), nullable=False),
|
||||
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True),
|
||||
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin")
|
||||
op.create_index(
|
||||
"ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin"
|
||||
)
|
||||
# Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida.
|
||||
op.create_index(
|
||||
"uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.invoices: claves fiscales del comprobante ----------
|
||||
# Todas nullable: las facturas ya emitidas no tienen estos datos.
|
||||
op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin")
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_voucher_type_id", "invoices", "voucher_types",
|
||||
["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_payment_form_id", "invoices", "payment_forms",
|
||||
["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_payment_method_id", "invoices", "payment_methods",
|
||||
["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_items: claves fiscales de la partida ----------
|
||||
# La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el
|
||||
# PDF actual de la factura.
|
||||
op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin")
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_concept_id", "invoice_items", "concepts",
|
||||
["concept_id"], ["id"], source_schema="fin", referent_schema="fin",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_product_service_id", "invoice_items", "products_services",
|
||||
["product_service_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure",
|
||||
["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects",
|
||||
["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# fin.invoice_items
|
||||
for constraint in (
|
||||
"fk_fin_invoice_items_tax_object_id",
|
||||
"fk_fin_invoice_items_unit_of_measure_id",
|
||||
"fk_fin_invoice_items_product_service_id",
|
||||
"fk_fin_invoice_items_concept_id",
|
||||
):
|
||||
op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey")
|
||||
op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin")
|
||||
for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"):
|
||||
op.drop_column("invoice_items", column, schema="fin")
|
||||
|
||||
# fin.invoices
|
||||
for constraint in (
|
||||
"fk_fin_invoices_payment_method_id",
|
||||
"fk_fin_invoices_payment_form_id",
|
||||
"fk_fin_invoices_voucher_type_id",
|
||||
):
|
||||
op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey")
|
||||
for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"):
|
||||
op.drop_column("invoices", column, schema="fin")
|
||||
|
||||
# Tablas nuevas (los índices caen con la tabla).
|
||||
op.drop_table("invoice_item_taxes", schema="fin")
|
||||
op.drop_table("issuer_settings", schema="fin")
|
||||
op.drop_table("concepts", schema="fin")
|
||||
|
||||
# Catálogos del SAT: se va el schema completo.
|
||||
op.execute("DROP SCHEMA IF EXISTS sat CASCADE")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Timbrado de CFDI: ``fin.invoice_stamps`` y ``fin.invoices.stamping_mode``.
|
||||
|
||||
Escrita a mano y no con ``--autogenerate``: el autogenerate de este proyecto arrastra
|
||||
drift preexistente entre los modelos y la base (llaves foráneas de ``core``, cambios de
|
||||
tipo en ``invite_tokens``), y generaba 1,516 operaciones ajenas a este ticket. Aquí van
|
||||
sólo los dos cambios del timbrado.
|
||||
|
||||
Revision ID: h3i4j5k6l7m8
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "h3i4j5k6l7m8"
|
||||
down_revision: Union[str, None] = "f7a8b9c0d1e2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- fin.invoices: modo de timbrado por factura ----------
|
||||
# NOT NULL con server_default: las facturas existentes quedan en 'pruebas', que es el
|
||||
# valor seguro. Marcar como 'produccion' es siempre una decisión explícita.
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column(
|
||||
"stamping_mode",
|
||||
sa.String(length=12),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pruebas'"),
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_stamps ----------
|
||||
op.create_table(
|
||||
"invoice_stamps",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("invoice_id", sa.Integer(), nullable=False),
|
||||
sa.Column("mode", sa.String(length=12), nullable=False),
|
||||
sa.Column("status", sa.String(length=12), nullable=False, server_default=sa.text("'pendiente'")),
|
||||
# Timbre Fiscal Digital
|
||||
sa.Column("uuid", sa.String(length=36), nullable=True),
|
||||
sa.Column("stamped_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("pac_rfc", sa.String(length=13), nullable=True),
|
||||
sa.Column("sat_cert_number", sa.String(length=20), nullable=True),
|
||||
sa.Column("sat_seal", sa.Text(), nullable=True),
|
||||
sa.Column("cfd_seal", sa.Text(), nullable=True),
|
||||
# Respuesta del PAC
|
||||
sa.Column("pac_code", sa.Integer(), nullable=True),
|
||||
sa.Column("pac_balance", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("xml_file_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_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.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_invoice_stamps_id", "invoice_stamps", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_invoice_id", "invoice_stamps", ["invoice_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_status", "invoice_stamps", ["status"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_uuid", "invoice_stamps", ["uuid"], schema="fin")
|
||||
# Un UUID lo emite el SAT una sola vez. Parcial sobre uuid IS NOT NULL porque los intentos
|
||||
# fallidos no traen UUID y colisionarían entre sí bajo un único convencional.
|
||||
op.create_index(
|
||||
"uq_fin_invoice_stamps_uuid",
|
||||
"invoice_stamps",
|
||||
["uuid"],
|
||||
unique=True,
|
||||
schema="fin",
|
||||
postgresql_where=sa.text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_status", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_invoice_id", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_id", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_table("invoice_stamps", schema="fin")
|
||||
op.drop_column("invoices", "stamping_mode", schema="fin")
|
||||
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal file
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""CSD por empresa en ``fin.issuer_settings``.
|
||||
|
||||
Cierra el hueco de que el certificado de sello digital tuviera que dejarse a mano en el
|
||||
almacenamiento y de que su contraseña fuera una variable de entorno global: con varias
|
||||
empresas emisoras eso no funciona, porque cada una tiene su propio certificado.
|
||||
|
||||
La contraseña se guarda cifrada (``core.crypto``); la clave maestra vive en el entorno.
|
||||
|
||||
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
|
||||
|
||||
Revision ID: i4j5k6l7m8n9
|
||||
Revises: h3i4j5k6l7m8
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "i4j5k6l7m8n9"
|
||||
down_revision: Union[str, None] = "h3i4j5k6l7m8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLUMNAS = (
|
||||
("csd_cer_file_key", sa.String(length=512)),
|
||||
("csd_key_file_key", sa.String(length=512)),
|
||||
("csd_password_enc", sa.Text()),
|
||||
("csd_cert_number", sa.String(length=20)),
|
||||
("csd_uploaded_at", sa.DateTime()),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for nombre, tipo in _COLUMNAS:
|
||||
op.add_column("issuer_settings", sa.Column(nombre, tipo, nullable=True), schema="fin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Al revés, para que el orden de la tabla quede como estaba.
|
||||
for nombre, _ in reversed(_COLUMNAS):
|
||||
op.drop_column("issuer_settings", nombre, schema="fin")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""XML enviado y recibido de cada intento de timbrado, en ``fin.invoice_stamps``.
|
||||
|
||||
Hasta ahora sólo se guardaba el XML del timbrado exitoso, que es justo el caso en el que
|
||||
menos falta hace. Cuando el PAC rechaza el comprobante no queda rastro de qué se le mandó
|
||||
ni de qué contestó: el XML sellado vive en memoria durante la petición y desaparece con
|
||||
ella, y el cuerpo de la respuesta también. Estas dos columnas apuntan al par enviado/recibido
|
||||
que se guarda en el almacenamiento por cada intento.
|
||||
|
||||
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
|
||||
|
||||
Revision ID: j5k6l7m8n9o0
|
||||
Revises: i4j5k6l7m8n9
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "j5k6l7m8n9o0"
|
||||
down_revision: Union[str, None] = "i4j5k6l7m8n9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLUMNAS = (
|
||||
("request_xml_file_key", sa.String(length=512)),
|
||||
("response_xml_file_key", sa.String(length=512)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for nombre, tipo in _COLUMNAS:
|
||||
op.add_column("invoice_stamps", sa.Column(nombre, tipo, nullable=True), schema="fin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Al revés, para que el orden de la tabla quede como estaba.
|
||||
for nombre, _ in reversed(_COLUMNAS):
|
||||
op.drop_column("invoice_stamps", nombre, schema="fin")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Claves de c_RegimenFiscal con vigencia 2024: 628, 629 y 630.
|
||||
|
||||
El catálogo se había sembrado con las 19 claves vigentes hasta 2022 y quedaron fuera las
|
||||
tres que el SAT publicó con vigencia a partir del 01-01-2024 (628 Hidrocarburos, 629
|
||||
Regímenes Fiscales Preferentes y Empresas Multinacionales, 630 Enajenación de acciones en
|
||||
bolsa de valores). Sin ellas, el select de régimen del receptor no puede representar a un
|
||||
contribuyente en esos regímenes y el timbrado quedaría con una clave incorrecta.
|
||||
|
||||
``sync_catalogs`` es idempotente y hace upsert: inserta las tres claves nuevas y refresca
|
||||
descripción y banderas de las que ya existen, sin tocar el resto de los catálogos.
|
||||
|
||||
No se agrega la clave 609 (Consolidación): su vigencia terminó el 31-12-2019 y el catálogo
|
||||
solo lleva claves vigentes.
|
||||
|
||||
Revision ID: k6l7m8n9o0p1
|
||||
Revises: j5k6l7m8n9o0
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "k6l7m8n9o0p1"
|
||||
down_revision: Union[str, None] = "j5k6l7m8n9o0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NUEVAS = ("628", "629", "630")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No se borran las filas: ``crm.accounts.tax_regime_id`` y
|
||||
# ``fin.issuer_settings.tax_regime_id`` las referencian por FK, y un CFDI ya timbrado
|
||||
# con una de estas claves debe seguir siendo legible. Se desactivan, que es el mismo
|
||||
# criterio que usa el catálogo para una clave retirada por el SAT.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE sat.tax_regimes SET is_active = false, updated_at = now() "
|
||||
"WHERE code IN :codes"
|
||||
).bindparams(sa.bindparam("codes", value=_NUEVAS, expanding=True))
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tipo de cambio de la factura (``fin.invoices.exchange_rate``).
|
||||
|
||||
La factura hereda la moneda de la ficha del cliente, y una factura en moneda distinta de MXN
|
||||
**no se puede timbrar** sin tipo de cambio: ``CfdiData.validate`` lo exige y ``_build_data``
|
||||
pasaba ``exchange_rate=None`` siempre, así que el campo no existía en ninguna parte. Un cliente
|
||||
con ``currency='USD'`` producía facturas que fallaban al timbrar sin pista del porqué.
|
||||
|
||||
Nullable a propósito: en MXN no aplica y el CFDI no lleva ``TipoCambio``. La validación de
|
||||
"falta el tipo de cambio" la sigue haciendo el builder, que acumula todos los faltantes y los
|
||||
reporta juntos.
|
||||
|
||||
Escala 6: el SAT admite hasta seis decimales en ``TipoCambio``. El builder redondea a cuatro al
|
||||
escribir el XML, que es una decisión previa suya y no se toca aquí.
|
||||
|
||||
Revision ID: l7m8n9o0p1q2
|
||||
Revises: k6l7m8n9o0p1
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "l7m8n9o0p1q2"
|
||||
down_revision: Union[str, None] = "k6l7m8n9o0p1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column("exchange_rate", sa.Numeric(precision=14, scale=6), nullable=True),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invoices", "exchange_rate", schema="fin")
|
||||
163
backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py
Normal file
163
backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""IVA por partida: ``taxes_per_item``, retenciones en el total, ``factor`` y defaults del concepto.
|
||||
|
||||
Hasta aquí el impuesto vivía en dos planos que podían divergir: el dinero salía de
|
||||
``invoices.tax_rate`` aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada
|
||||
partida. Con una partida no objeto de impuesto la factura le cobraba IVA igual, y con una
|
||||
retención capturada el total de la factura y el del comprobante no coincidían.
|
||||
|
||||
**No se reescribe ni una fila de las facturas existentes.** El cálculo se versiona con
|
||||
``taxes_per_item``: las facturas nuevas nacen en ``true`` y usan la suma por partida; todas las
|
||||
que ya existen quedan en ``false`` y conservan la fórmula con la que se emitieron.
|
||||
|
||||
La alternativa —backfillear ``invoice_item_taxes`` desde ``tax_rate``— se descartó por dos
|
||||
razones. Obligaría a poner ``tax_object_id = '02'`` en partidas que nadie clasificó, que es
|
||||
inventar una afirmación fiscal. Y ``_recompute`` no corre en la migración sino la próxima vez que
|
||||
alguien toque la factura: registrar un pago meses después le bajaría el total, dejaría saldo
|
||||
negativo, la marcaría 'pagada' y pisaría su ``paid_at``, sin que nada explicara por qué. El
|
||||
rollback aquí es ``UPDATE fin.invoices SET taxes_per_item = false``.
|
||||
|
||||
Revision ID: m8n9o0p1q2r3
|
||||
Revises: l7m8n9o0p1q2
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
import logging
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "m8n9o0p1q2r3"
|
||||
down_revision: Union[str, None] = "l7m8n9o0p1q2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
logger = logging.getLogger("alembic.runtime.migration")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── fin.invoices ─────────────────────────────────────────────────────────────────────────
|
||||
# server_default false: TODA factura existente queda con la fórmula vieja. Después se cambia
|
||||
# el default a true para que las nuevas nazcan con el cálculo por partida.
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column("taxes_per_item", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
schema="fin",
|
||||
)
|
||||
op.alter_column("invoices", "taxes_per_item", server_default=sa.text("true"), schema="fin")
|
||||
# Las retenciones restan del total y hasta ahora no se guardaban en ningún lado: el total no
|
||||
# cuadraba con subtotal + tax_amount y nada en la fila explicaba el faltante.
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column("withheld_amount", sa.Numeric(14, 2), nullable=False, server_default=sa.text("0")),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
# ── fin.invoice_item_taxes ───────────────────────────────────────────────────────────────
|
||||
# factor: c_TipoFactor. Sin esta columna un Exento es inexpresable — el builder ya sabe
|
||||
# omitir TasaOCuota e Importe y excluirlo de los totales, pero nada podía pedírselo.
|
||||
op.add_column(
|
||||
"invoice_item_taxes",
|
||||
sa.Column("factor", sa.String(7), nullable=False, server_default=sa.text("'Tasa'")),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_fin_invoice_item_taxes_factor",
|
||||
"invoice_item_taxes",
|
||||
"factor IN ('Tasa', 'Cuota', 'Exento')",
|
||||
schema="fin",
|
||||
)
|
||||
# is_manual reemplaza al heurístico que adivinaba la captura manual por la forma de la fila
|
||||
# (una retención, o un impuesto distinto del IVA). Con IVA al 0% y Exento en el catálogo de
|
||||
# conceptos ese heurístico deja de discriminar: un traslado de IVA capturado a mano es
|
||||
# idéntico en forma a uno derivado.
|
||||
op.add_column(
|
||||
"invoice_item_taxes",
|
||||
sa.Column("is_manual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
# ── fin.concepts: configuración fiscal por defecto ───────────────────────────────────────
|
||||
# La tasa va como FRACCIÓN con 6 decimales (0.160000), igual que invoice_item_taxes.rate y
|
||||
# que el TasaOCuota del XML — NO como el porcentaje de invoices.tax_rate (16.00). El tipo es
|
||||
# idéntico al destino a propósito: convertir en el camino es la vía corta a un IVA del 1600%.
|
||||
op.add_column("concepts", sa.Column("default_tax_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("concepts", sa.Column("default_tax_rate", sa.Numeric(8, 6), nullable=True), schema="fin")
|
||||
op.add_column("concepts", sa.Column("default_tax_factor", sa.String(7), nullable=True), schema="fin")
|
||||
op.create_foreign_key(
|
||||
"fk_fin_concepts_default_tax_id", "concepts", "taxes",
|
||||
["default_tax_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_fin_concepts_default_tax_factor",
|
||||
"concepts",
|
||||
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
|
||||
schema="fin",
|
||||
)
|
||||
# Impide el estado medio capturado (impuesto sin factor, tasa sin impuesto) que después
|
||||
# habría que adivinar en el service. Un Exento no lleva tasa; lo demás sí.
|
||||
op.create_check_constraint(
|
||||
"ck_fin_concepts_default_tax_coherente",
|
||||
"concepts",
|
||||
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
|
||||
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
|
||||
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
_reporta_facturas_afectadas()
|
||||
|
||||
|
||||
def _reporta_facturas_afectadas() -> None:
|
||||
"""Deja en la bitácora cuántas facturas se quedan con la fórmula vieja y por qué.
|
||||
|
||||
Solo lee y cuenta: no cambia nada. Es la constancia de que la migración no movió dinero, y
|
||||
la lista de trabajo para quien decida pasar borradores al cálculo por partida.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if not bind.dialect.has_table(bind, "invoices", schema="fin"):
|
||||
return
|
||||
|
||||
total = bind.execute(
|
||||
sa.text("SELECT count(*) FROM fin.invoices WHERE deleted_at IS NULL")
|
||||
).scalar()
|
||||
|
||||
# Facturas a las que la fórmula vieja les cobró IVA sobre partidas que no lo causan: es el
|
||||
# bug que motiva el cambio. Se quedan como están (su total no se toca) y se listan para que
|
||||
# Cobranza decida qué hacer con las que ya salieron al cliente.
|
||||
con_iva_indebido = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT count(DISTINCT i.id)
|
||||
FROM fin.invoices i
|
||||
JOIN fin.invoice_items ii ON ii.invoice_id = i.id AND ii.deleted_at IS NULL
|
||||
LEFT JOIN sat.tax_objects tobj ON tobj.id = ii.tax_object_id
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.tax_rate > 0
|
||||
AND (tobj.code IS NULL OR tobj.code <> '02')
|
||||
"""
|
||||
)
|
||||
).scalar()
|
||||
|
||||
logger.info(
|
||||
"IVA por partida: %s facturas existentes quedan en taxes_per_item=false y conservan su "
|
||||
"total. De ellas, %s tienen partidas que no causan IVA y a las que la fórmula anterior "
|
||||
"se lo cobró; su total NO se modifica.",
|
||||
total, con_iva_indebido,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_fin_concepts_default_tax_coherente", "concepts", schema="fin", type_="check")
|
||||
op.drop_constraint("ck_fin_concepts_default_tax_factor", "concepts", schema="fin", type_="check")
|
||||
op.drop_constraint("fk_fin_concepts_default_tax_id", "concepts", schema="fin", type_="foreignkey")
|
||||
op.drop_column("concepts", "default_tax_factor", schema="fin")
|
||||
op.drop_column("concepts", "default_tax_rate", schema="fin")
|
||||
op.drop_column("concepts", "default_tax_id", schema="fin")
|
||||
|
||||
op.drop_column("invoice_item_taxes", "is_manual", schema="fin")
|
||||
op.drop_constraint("ck_fin_invoice_item_taxes_factor", "invoice_item_taxes", schema="fin", type_="check")
|
||||
op.drop_column("invoice_item_taxes", "factor", schema="fin")
|
||||
|
||||
op.drop_column("invoices", "withheld_amount", schema="fin")
|
||||
op.drop_column("invoices", "taxes_per_item", schema="fin")
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Set, Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy import and_, or_, text
|
||||
|
||||
from core.database import RLS_TENANT_KEY
|
||||
from .cache import PermissionCache
|
||||
@@ -51,8 +51,18 @@ class PermissionService:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
|
||||
pass
|
||||
# La tabla de compañías del CRM es ``a76.company`` (esquema legado). No tiene modelo
|
||||
# ORM en este proyecto, así que se consulta con SQL crudo, igual que ``seed_crm.py``.
|
||||
#
|
||||
# Sin este respaldo la función devolvía None siempre que la sesión no traía contexto
|
||||
# RLS, y con ella se scopean las consultas de permisos de los cinco llamadores de
|
||||
# abajo: el efecto neto era que nadie resolvía permisos.
|
||||
row = self.db.execute(
|
||||
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
|
||||
{"company_id": company_id},
|
||||
).first()
|
||||
if row is not None and row[0] is not None:
|
||||
return int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"resolve_tenant_id_for_company_failed",
|
||||
@@ -470,8 +480,35 @@ class PermissionService:
|
||||
sync_res = self.sync_permissions()
|
||||
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
|
||||
|
||||
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
|
||||
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
|
||||
# 1. tenant_id del rol: se toma de la COMPAÑÍA, que es su fuente autoritativa.
|
||||
# ``company_roles`` referencia a la vez a ``a76.company`` y a ``core.tenants``, así
|
||||
# que el tenant del rol tiene que ser el de su compañía o la fila queda cruzada
|
||||
# entre dos tenants.
|
||||
#
|
||||
# Antes esto era ``self.db.info.get(RLS_TENANT_KEY) or 1``. Cuando la sesión no
|
||||
# traía contexto RLS —justo el caso de ``/permissions/me`` en el primer acceso— el
|
||||
# rol se creaba con ``tenant_id=1``; en una instalación real ese tenant no existe y
|
||||
# el INSERT moría con ForeignKeyViolation. El bootstrap quedaba a medias, sin rol
|
||||
# ni permisos, toda la API respondía 403 y ``/permissions/me`` seguía devolviendo
|
||||
# 200 con la lista vacía: el fallo se leía en pantalla como "no tienes permisos"
|
||||
# en lugar de como el error de configuración que era.
|
||||
# Se consulta la compañía DIRECTAMENTE y no vía ``_resolve_tenant_id_for_company``:
|
||||
# ese helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser
|
||||
# el de la compañía. Para leer permisos esa preferencia está bien y ahorra una
|
||||
# consulta en el camino caliente; para escribir una fila atada por FK a las dos
|
||||
# tablas, no: si difirieran, el rol nacería cruzado.
|
||||
_row = self.db.execute(
|
||||
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
|
||||
{"company_id": company_id},
|
||||
).first()
|
||||
if _row is None or _row[0] is None:
|
||||
logger.error(
|
||||
"Bootstrap: la compañía %s no existe en a76.company, no hay tenant al que "
|
||||
"colgar el rol. Se aborta sin crear nada.",
|
||||
company_id,
|
||||
)
|
||||
return False
|
||||
tenant_id = int(_row[0])
|
||||
|
||||
# 2. Buscar si ya existe el rol "super_admin"
|
||||
admin_role = self.db.query(CompanyRole).filter(
|
||||
|
||||
@@ -13,6 +13,7 @@ class AccountBase(BaseModel):
|
||||
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
|
||||
person_type: str | None = Field(None, max_length=10) # fisica | moral
|
||||
industry: str | None = Field(None, max_length=120)
|
||||
industry_other: str | None = Field(None, max_length=120)
|
||||
account_type: str | None = Field(None, max_length=40)
|
||||
status: str = Field("active", max_length=20) # active | inactive
|
||||
# Comercial
|
||||
@@ -27,6 +28,9 @@ class AccountBase(BaseModel):
|
||||
# Fiscal
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
# Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar.
|
||||
tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor")
|
||||
cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor")
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
payment_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
@@ -38,7 +42,7 @@ class AccountBase(BaseModel):
|
||||
address: str | None = None
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field("MX", max_length=2)
|
||||
country: str | None = Field("MEX", max_length=3)
|
||||
# Observaciones
|
||||
notes: str | None = None
|
||||
internal_notes: str | None = None
|
||||
@@ -57,6 +61,7 @@ class AccountUpdate(BaseModel):
|
||||
record_type: str | None = Field(None, max_length=20)
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
industry: str | None = Field(None, max_length=120)
|
||||
industry_other: str | None = Field(None, max_length=120)
|
||||
account_type: str | None = Field(None, max_length=40)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
@@ -69,6 +74,8 @@ class AccountUpdate(BaseModel):
|
||||
commercial_observations: str | None = None
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
tax_regime_id: int | None = None
|
||||
cfdi_use_id: int | None = None
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
payment_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
@@ -79,7 +86,7 @@ class AccountUpdate(BaseModel):
|
||||
address: str | None = None
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field(None, max_length=2)
|
||||
country: str | None = Field(None, max_length=3)
|
||||
notes: str | None = None
|
||||
internal_notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Integer, Numeric, String, Text, text
|
||||
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK)
|
||||
from core.database import Base
|
||||
|
||||
|
||||
@@ -31,6 +32,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Tipo de persona: fisica | moral
|
||||
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
|
||||
industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro"
|
||||
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
|
||||
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
# Estatus: active | inactive
|
||||
@@ -50,8 +52,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
|
||||
|
||||
# ----- Información fiscal -----
|
||||
# Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario
|
||||
# para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo.
|
||||
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
||||
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
|
||||
# Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI).
|
||||
tax_regime_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_regimes.id"), nullable=True
|
||||
)
|
||||
cfdi_use_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True
|
||||
)
|
||||
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
|
||||
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
|
||||
@@ -65,7 +76,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
|
||||
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
|
||||
|
||||
# ----- Observaciones y auditoría -----
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales
|
||||
|
||||
@@ -3,10 +3,24 @@ from datetime import datetime, timezone
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime
|
||||
|
||||
from .dto import AccountCreate, AccountUpdate
|
||||
from .models import Account
|
||||
|
||||
|
||||
def _validate_sat_refs(db: Session, data: dict) -> None:
|
||||
"""Verifica las claves del SAT del receptor antes de guardar la cuenta."""
|
||||
for field, model, msg in [
|
||||
("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"),
|
||||
("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"),
|
||||
]:
|
||||
value = data.get(field)
|
||||
if field in data and value is not None:
|
||||
if db.query(model.id).filter(model.id == value).first() is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
|
||||
def get_accounts(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
@@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
|
||||
def create_account(
|
||||
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Account:
|
||||
data = payload.model_dump()
|
||||
_validate_sat_refs(db, data)
|
||||
account = Account(
|
||||
**payload.model_dump(),
|
||||
**data,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
@@ -75,7 +91,9 @@ def update_account(
|
||||
user_id: str | None = None,
|
||||
) -> Account:
|
||||
account = get_account(db, account_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_sat_refs(db, data)
|
||||
for field, value in data.items():
|
||||
setattr(account, field, value)
|
||||
account.updated_by = user_id
|
||||
db.commit()
|
||||
|
||||
@@ -14,7 +14,7 @@ class AddressBase(BaseModel):
|
||||
postal_code: str | None = Field(None, max_length=10)
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field("MX", max_length=2)
|
||||
country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais)
|
||||
reference_notes: str | None = None
|
||||
is_primary: bool = False
|
||||
|
||||
@@ -32,7 +32,7 @@ class AddressUpdate(BaseModel):
|
||||
postal_code: str | None = Field(None, max_length=10)
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field(None, max_length=2)
|
||||
country: str | None = Field(None, max_length=3)
|
||||
reference_notes: str | None = None
|
||||
is_primary: bool | None = None
|
||||
|
||||
|
||||
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CaseResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
reference: str | None
|
||||
account_id: int | None
|
||||
title: str | None
|
||||
stage: str
|
||||
status: str
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CaseTimelineEvent(BaseModel):
|
||||
kind: str # oportunidad | solicitud | cotizacion | operacion | factura
|
||||
id: int
|
||||
reference: str | None = None
|
||||
status: str | None = None
|
||||
created_at: datetime
|
||||
url: str
|
||||
|
||||
|
||||
class CaseWithTimeline(CaseResponse):
|
||||
timeline: list[CaseTimelineEvent] = []
|
||||
52
backend/api/v1/modules/crm/cases/models.py
Normal file
52
backend/api/v1/modules/crm/cases/models.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from sqlalchemy import ForeignKey, Integer, 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 Case(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
|
||||
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
|
||||
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
|
||||
"""
|
||||
|
||||
__tablename__ = "cases"
|
||||
__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 EXP...
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
|
||||
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
|
||||
# abierto | cerrado
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ── Espejo del carril hacia EFC (T2026-08-046) ──────────────────────────────────────────
|
||||
# EFC es la fuente única de los documentos del expediente: cada expediente se refleja allá
|
||||
# como un *pedimento provisional* y los archivos viven en su MinIO, no en el del CRM.
|
||||
#
|
||||
# Estas columnas son un ESPEJO, nunca el handle. El handle con el que el CRM habla de este
|
||||
# expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es un caché de la
|
||||
# resolución, y del lado de EFC el ``pedimento_app`` es mutable —se reescribe al completar
|
||||
# el provisional con la data aduanera real—, así que apoyarse en él rompería justo cuando
|
||||
# llegue esa data. La liga vive en EFC, en la tabla desechable ``pedimento_expediente``.
|
||||
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
# INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los objetos de este
|
||||
# expediente. Que no cambie nunca es lo que permite completar el pedimento sin mover ni un
|
||||
# archivo.
|
||||
efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True)
|
||||
# PENDING | LINKED | FAILED
|
||||
efc_link_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default=text("'PENDING'"), index=True
|
||||
)
|
||||
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
|
||||
# obligar a nadie a ir a los logs del worker.
|
||||
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
51
backend/api/v1/modules/crm/cases/routes.py
Normal file
51
backend/api/v1/modules/crm/cases/routes.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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 CaseResponse, CaseWithTimeline
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _with_timeline(db, case) -> CaseWithTimeline:
|
||||
data = CaseWithTimeline.model_validate(case)
|
||||
data.timeline = service.build_timeline(db, case) # type: ignore[assignment]
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/cases", response_model=list[CaseResponse])
|
||||
def list_cases(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None),
|
||||
account_id: int | None = Query(None),
|
||||
stage: str | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage)
|
||||
|
||||
|
||||
@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline)
|
||||
def get_case_by_ref(
|
||||
reference: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Expediente + historia completa por su referencia (para UI y otros sistemas)."""
|
||||
case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id)
|
||||
return _with_timeline(db, case)
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}", response_model=CaseWithTimeline)
|
||||
def get_case(
|
||||
case_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
case = service.get_case(db, case_id, current_user["tenant_id"], company_id)
|
||||
return _with_timeline(db, case)
|
||||
130
backend/api/v1/modules/crm/cases/service.py
Normal file
130
backend/api/v1/modules/crm/cases/service.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline."""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..common.folios import next_folio
|
||||
from .models import Case
|
||||
|
||||
# Orden de etapas (solo se avanza, nunca retrocede)
|
||||
STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"]
|
||||
|
||||
|
||||
def create_case(
|
||||
db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None,
|
||||
title: str | None = None, stage: str = "oportunidad", user_id: str | None = None,
|
||||
) -> Case:
|
||||
"""Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca)."""
|
||||
case = Case(
|
||||
reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False),
|
||||
account_id=account_id, title=title, stage=stage, status="abierto",
|
||||
tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id,
|
||||
)
|
||||
db.add(case)
|
||||
db.flush()
|
||||
|
||||
# ── Carril hacia EFC (T2026-08-046) ──────────────────────────────────────────────────
|
||||
# EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento
|
||||
# provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es
|
||||
# precisamente la llave con la que las dos mitades se reconocen.
|
||||
#
|
||||
# El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO
|
||||
# del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la
|
||||
# data aduanera real sin mover un solo archivo.
|
||||
#
|
||||
# Todo es best-effort y va en la MISMA transacción que el expediente:
|
||||
# - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM;
|
||||
# - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat
|
||||
# recoge lo pendiente. Un sistema de terceros caído no puede romper un alta.
|
||||
# El import es diferido para no acoplar el arranque del módulo del expediente al carril.
|
||||
from ..expediente_gateway import service as gateway
|
||||
from ..expediente_gateway.storage import storage_token
|
||||
|
||||
case.efc_storage_token = storage_token(company_id, case.reference)
|
||||
db.flush()
|
||||
gateway.replicate_expediente_best_effort(db, case)
|
||||
return case
|
||||
|
||||
|
||||
def advance_stage(db: Session, case_id: int | None, stage: str) -> None:
|
||||
"""Avanza la etapa del expediente si la nueva es posterior a la actual."""
|
||||
if not case_id or stage not in STAGE_ORDER:
|
||||
return
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
return
|
||||
current = case.stage if case.stage in STAGE_ORDER else "oportunidad"
|
||||
if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current):
|
||||
case.stage = stage
|
||||
|
||||
|
||||
def get_cases(
|
||||
db: Session, tenant_id: int, company_id: int, search: str | None = None,
|
||||
account_id: int | None = None, stage: str | None = None,
|
||||
) -> list[Case]:
|
||||
q = db.query(Case).filter(
|
||||
Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None),
|
||||
)
|
||||
if account_id is not None:
|
||||
q = q.filter(Case.account_id == account_id)
|
||||
if stage:
|
||||
q = q.filter(Case.stage == stage)
|
||||
if search:
|
||||
q = q.filter(Case.reference.ilike(f"%{search}%"))
|
||||
return q.order_by(Case.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case:
|
||||
obj = (
|
||||
db.query(Case)
|
||||
.filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case:
|
||||
obj = (
|
||||
db.query(Case)
|
||||
.filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id,
|
||||
Case.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def build_timeline(db: Session, case: Case) -> list[dict]:
|
||||
"""Devuelve la historia del expediente: todas las entidades ligadas por case_id,
|
||||
en orden cronológico. Un único lookup para la UI y para otros sistemas."""
|
||||
# Import local para evitar ciclos de importación entre módulos.
|
||||
from ..opportunities.models import Opportunity
|
||||
from ..quotes.models import Quote
|
||||
from ..service_requests.models import ServiceRequest
|
||||
from api.v1.modules.fin.invoices.models import Invoice
|
||||
from api.v1.modules.ops.shipments.models import Shipment
|
||||
|
||||
events: list[dict] = []
|
||||
specs = [
|
||||
("oportunidad", Opportunity, "/dashboard/crm/oportunidades"),
|
||||
("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"),
|
||||
("cotizacion", Quote, "/dashboard/crm/cotizaciones"),
|
||||
("operacion", Shipment, "/dashboard/ops/embarques"),
|
||||
("factura", Invoice, "/dashboard/fin/facturas"),
|
||||
]
|
||||
for kind, model, base_url in specs:
|
||||
rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all()
|
||||
for r in rows:
|
||||
events.append({
|
||||
"kind": kind,
|
||||
"id": r.id,
|
||||
"reference": getattr(r, "reference", None),
|
||||
"status": getattr(r, "status", None),
|
||||
"created_at": r.created_at,
|
||||
"url": f"{base_url}/{r.id}",
|
||||
})
|
||||
events.sort(key=lambda e: e["created_at"])
|
||||
return events
|
||||
@@ -766,3 +766,109 @@ 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'}]},
|
||||
'incoterm': {'label': 'Incoterm (2020)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'},
|
||||
{'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'},
|
||||
{'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'},
|
||||
{'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'},
|
||||
{'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'},
|
||||
{'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'},
|
||||
{'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'},
|
||||
{'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'},
|
||||
{'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'},
|
||||
{'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'},
|
||||
{'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]},
|
||||
})
|
||||
|
||||
# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08).
|
||||
# El SAT exige dos posiciones; se corrige el catálogo base.
|
||||
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
|
||||
if len(_fp['code']) == 1:
|
||||
_fp['code'] = _fp['code'].zfill(2)
|
||||
|
||||
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
|
||||
from .seed_locations import LOCATION_CATALOGS # noqa: E402
|
||||
|
||||
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)
|
||||
|
||||
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal file
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
|
||||
|
||||
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
|
||||
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
|
||||
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
|
||||
para que el Cotizador encuentre ruta.
|
||||
"""
|
||||
|
||||
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
|
||||
_LOC = [
|
||||
("MEX",
|
||||
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
|
||||
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
|
||||
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
|
||||
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
|
||||
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
|
||||
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
|
||||
("USA",
|
||||
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
|
||||
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
|
||||
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
|
||||
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
|
||||
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
|
||||
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
|
||||
("CHN",
|
||||
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
|
||||
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
|
||||
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
|
||||
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
|
||||
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
|
||||
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
|
||||
("DEU",
|
||||
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
|
||||
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
|
||||
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
|
||||
("ESP",
|
||||
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
|
||||
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
|
||||
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
|
||||
("NLD",
|
||||
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
|
||||
[("NLRTM", "Róterdam")],
|
||||
[("AMS", "Ámsterdam Schiphol")]),
|
||||
("BRA",
|
||||
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
|
||||
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
|
||||
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
|
||||
("CAN",
|
||||
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
|
||||
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
|
||||
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
|
||||
("JPN",
|
||||
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
|
||||
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
|
||||
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
|
||||
("KOR",
|
||||
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
|
||||
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
|
||||
[("ICN", "Seúl Incheon")]),
|
||||
]
|
||||
|
||||
|
||||
def _build() -> dict:
|
||||
ciudad, puerto, aeropuerto = [], [], []
|
||||
for iso3, cities, ports, airports in _LOC:
|
||||
for code, label in cities:
|
||||
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
|
||||
for code, label in ports:
|
||||
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
|
||||
for code, label in airports:
|
||||
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
|
||||
return {
|
||||
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
|
||||
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
|
||||
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
|
||||
}
|
||||
|
||||
|
||||
LOCATION_CATALOGS = _build()
|
||||
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
96
backend/api/v1/modules/crm/common/folios.py
Normal file
96
backend/api/v1/modules/crm/common/folios.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""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 (F = factura, EXP = expediente; sin dirección).
|
||||
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
|
||||
# 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,
|
||||
with_direction: bool = True,
|
||||
) -> 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. ``with_direction=False``
|
||||
omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``).
|
||||
"""
|
||||
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}"
|
||||
if not with_direction:
|
||||
return f"{entity}{period}-{sequence}"
|
||||
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)
|
||||
|
||||
64
backend/api/v1/modules/crm/expediente_gateway/doc_types.py
Normal file
64
backend/api/v1/modules/crm/expediente_gateway/doc_types.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM.
|
||||
|
||||
Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en
|
||||
``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con
|
||||
despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un
|
||||
tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción.
|
||||
|
||||
Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el
|
||||
tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` /
|
||||
``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript
|
||||
(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo
|
||||
**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo.
|
||||
|
||||
Las tres fuentes del CRM y su origen:
|
||||
|
||||
- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts``
|
||||
- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo
|
||||
- ``fin.invoices`` → el PDF de factura (``factura_venta``)
|
||||
|
||||
``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada.
|
||||
"""
|
||||
|
||||
# --- crm.documents ---------------------------------------------------------------------------
|
||||
_TIPOS_DOCUMENTOS_CLIENTE = (
|
||||
"constancia_fiscal",
|
||||
"acta_constitutiva",
|
||||
"identificacion",
|
||||
"comprobante_domicilio",
|
||||
"contrato",
|
||||
"presentacion",
|
||||
"certificacion",
|
||||
"licencia",
|
||||
"convenio",
|
||||
"tarifario",
|
||||
)
|
||||
|
||||
# --- ops.shipment_documents ------------------------------------------------------------------
|
||||
_TIPOS_DOCUMENTOS_EMBARQUE = (
|
||||
"MBL",
|
||||
"HBL",
|
||||
"MAWB",
|
||||
"HAWB",
|
||||
"CMR",
|
||||
"factura_comercial",
|
||||
"packing_list",
|
||||
"carta_encomienda",
|
||||
"carta_garantia",
|
||||
"certificado_permiso",
|
||||
)
|
||||
|
||||
# --- fin.invoices ----------------------------------------------------------------------------
|
||||
_TIPOS_FACTURACION = ("factura_venta",)
|
||||
|
||||
# --- común a varias fuentes -------------------------------------------------------------------
|
||||
_TIPOS_COMUNES = ("otro",)
|
||||
|
||||
EFC_DOC_TYPES: frozenset[str] = frozenset(
|
||||
_TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES
|
||||
)
|
||||
|
||||
|
||||
def is_valid_doc_type(doc_type: str | None) -> bool:
|
||||
"""``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red."""
|
||||
return bool(doc_type) and doc_type in EFC_DOC_TYPES
|
||||
140
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
140
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Outbox transaccional del carril CRM Agentes de Carga -> EFC.
|
||||
|
||||
DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los
|
||||
expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian
|
||||
por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos.
|
||||
|
||||
**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción
|
||||
que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra
|
||||
base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de
|
||||
huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier
|
||||
crash— pero deja de ser el parche de una ventana estructural.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES.
|
||||
KIND_EXPEDIENTE = "expediente"
|
||||
KIND_COMPLETAR = "completar"
|
||||
|
||||
# Tipos del outbox de ARCHIVOS (efc_file_outbox).
|
||||
FILE_KIND_DOCUMENTO = "documento"
|
||||
# Los dos XML del timbrado. Son kinds SEPARADOS y no un solo 'cfdi', porque la guarda de
|
||||
# idempotencia es (source_table, source_id, kind): los dos XML de un mismo timbre comparten
|
||||
# source_id —el id del intento—, así que con un kind común la entrega del segundo se saltaría
|
||||
# para siempre en cuanto el primero quedara 'sent'.
|
||||
FILE_KIND_CFDI_REQUEST = "cfdi_request"
|
||||
FILE_KIND_CFDI_RESPONSE = "cfdi_response"
|
||||
|
||||
# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias
|
||||
# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y
|
||||
# ops.shipment_documents.id = 5 coexisten.
|
||||
SOURCE_CRM_DOCUMENTS = "crm.documents"
|
||||
SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents"
|
||||
SOURCE_FIN_INVOICES = "fin.invoices"
|
||||
# El origen de los XML del timbrado es el INTENTO (fin.invoice_stamps), no la factura: una
|
||||
# factura puede acumular varios intentos y el par enviado/recibido pertenece a uno concreto.
|
||||
SOURCE_FIN_INVOICE_STAMPS = "fin.invoice_stamps"
|
||||
|
||||
# Estados (columna status).
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_SENT = "sent"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual).
|
||||
# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de
|
||||
# rendirse y dejar la fila visible para que una persona la reintente a mano.
|
||||
MAX_ATTEMPTS = 8
|
||||
|
||||
|
||||
class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cola de metadatos hacia EFC: crear el expediente provisional y completarlo."""
|
||||
|
||||
__tablename__ = "efc_sync_outbox"
|
||||
__table_args__ = (
|
||||
Index("ix_crm_efc_sync_outbox_status", "status"),
|
||||
Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"),
|
||||
{"schema": "crm"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data
|
||||
# aduanera si el kind es 'completar').
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
# id local del expediente (crm.cases.id) que originó la fila.
|
||||
expediente_ref: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
# Ciclo de vida.
|
||||
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Acuse de EFC al confirmar (trazabilidad).
|
||||
efc_pedimento_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class EfcFileOutbox(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cola de ARCHIVOS hacia EFC.
|
||||
|
||||
El binario vive en el MinIO del CRM (durable); esta fila referencia su ``s3_key`` y el expediente
|
||||
destino. El worker lo sube a EFC y, con ``delete_local`` (corte directo), BORRA la copia local al
|
||||
confirmar la entrega.
|
||||
|
||||
``delete_local`` **es el mecanismo de «EFC es la fuente única»**: "solo EFC" es el estado FINAL
|
||||
(eventual), no el inmediato. Entre que el usuario sube el archivo y que EFC lo confirma, la copia
|
||||
local es lo único que hay, y borrarla antes perdería el archivo si la entrega fallara.
|
||||
|
||||
``source_table`` es un añadido necesario sobre el original de Anexo22, que solo llevaba
|
||||
``source_id``. El CRM tiene dos tablas de documentos con secuencias independientes, así que un
|
||||
entero solo es ambiguo entre ellas. Es el mismo problema que Anexo22 resolvió con su mapa por
|
||||
``kind``, y su comentario dice qué pasa si se ignora: un UPDATE con el id de otra tabla **vacía la
|
||||
columna de un documento ajeno** que tuviera ese mismo entero — daño en el dato de otro, sin un
|
||||
solo error visible. Un ``(kind, source_table)`` que no esté en el mapa **no toca nada**, en vez
|
||||
de caer por omisión.
|
||||
"""
|
||||
|
||||
__tablename__ = "efc_file_outbox"
|
||||
__table_args__ = (
|
||||
Index("ix_crm_efc_file_outbox_status", "status"),
|
||||
Index("ix_crm_efc_file_outbox_kind_status", "kind", "status"),
|
||||
Index("ix_crm_efc_file_outbox_source", "source_table", "source_id"),
|
||||
{"schema": "crm"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
|
||||
# Objeto en MinIO a subir + metadata para el upload a EFC.
|
||||
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
efc_tipo: Mapped[str] = mapped_column(String(40), nullable=False) # tipo de documento en EFC
|
||||
|
||||
# Origen: la pareja (tabla, id) desambigua entre las dos secuencias de documentos del CRM.
|
||||
source_table: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
# El handle autoritativo que viaja a EFC y garantiza la idempotencia del lado de allá.
|
||||
crm_document_ref: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
|
||||
expediente_ref: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.cases.id"), nullable=False, index=True
|
||||
)
|
||||
delete_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
|
||||
# Ciclo de vida.
|
||||
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
efc_document_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Endpoints de operación y observabilidad del carril CRM -> EFC.
|
||||
|
||||
Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con
|
||||
el auth normal del CRM y acotado por tenant/company, como el resto del módulo.
|
||||
Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...``
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
|
||||
router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"])
|
||||
|
||||
|
||||
@router.get("/outbox")
|
||||
def list_outbox(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"),
|
||||
status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Filas de los dos outbox, para ver los fallos y su ``last_error``."""
|
||||
return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/retry")
|
||||
def retry_outbox(
|
||||
outbox_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
tipo: str = Query("file", description="Tabla de la fila: sync|file"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Reintento manual de una fila: la resetea a ``pending`` y la re-despacha.
|
||||
|
||||
Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend
|
||||
pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a
|
||||
la cola cuando no hay nada que entregar.
|
||||
"""
|
||||
ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Fila de outbox no encontrada")
|
||||
return {"status": "requeued", "id": outbox_id}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Conteo de los dos outbox por status (pending/sent/failed) para monitoreo."""
|
||||
return service.outbox_metrics(db, current_user["tenant_id"], company_id)
|
||||
768
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
768
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
@@ -0,0 +1,768 @@
|
||||
"""Carril CRM Agentes de Carga -> EFC: encolado, entrega y reconciliación.
|
||||
|
||||
Clon del gateway de Anexo22 (``anexo22/.../pedimentos/pedimento_gateway/service.py``), que es el
|
||||
carril de referencia ya en producción. Quien conozca uno debe poder leer el otro, así que la tabla
|
||||
de equivalencias va aquí:
|
||||
|
||||
====================================== ======================================
|
||||
Anexo22 CRM
|
||||
====================================== ======================================
|
||||
``replicate_pedimento_best_effort`` ``replicate_expediente_best_effort``
|
||||
``_enqueue_pedimento_outbox`` ``_enqueue_expediente_outbox``
|
||||
``_dispatch_delivery`` igual
|
||||
``deliver_row`` / ``_deliver_pedimento`` ``deliver_row`` / ``_deliver_expediente``
|
||||
``_register_failure`` **idéntico**
|
||||
``_ya_entregado(source_id, kind)`` ``_ya_entregado(source_table, source_id, kind)``
|
||||
``deliver_file_row`` **idéntico**, con ensure-then-upload y ``delete_local``
|
||||
``_register_file_failure`` **idéntico**
|
||||
``_resolve_org_id`` + ``_org_id_cache`` igual — dict módulo-global, por worker, sin invalidación
|
||||
``list_outbox`` / ``retry_outbox_row`` / ``outbox_metrics`` igual, para las dos tablas
|
||||
``find_pedimento_gaps`` ``find_expediente_gaps``
|
||||
====================================== ======================================
|
||||
|
||||
**La máquina de reintentos tiene tres capas y las tres se conservan:**
|
||||
|
||||
1. En el cliente HTTP: 3 intentos, backoff lineal ``0.15 * (attempt + 1)``, corte seco en 4xx.
|
||||
2. En el worker: ``deliver_row`` **nunca lanza**; registra el fallo en la propia fila.
|
||||
3. En el beat: barridos cada 120 s que re-despachan lo ``pending``.
|
||||
|
||||
No hay ``autoretry_for``, ``retry_backoff`` ni ``max_retries`` en las tareas: duplicarían el
|
||||
mecanismo que ya está en el cliente y en el barrido.
|
||||
|
||||
**Cuatro guardas de idempotencia**, en este orden:
|
||||
1. ``_ya_entregado(source_table, source_id, kind)`` antes de encolar.
|
||||
2. ``if row.status == STATUS_SENT: return`` al entrar a entregar.
|
||||
3. El ``crm_document_ref`` que viaja con la subida: EFC devuelve 200 con el que ya existía.
|
||||
4. El UNIQUE parcial del lado de EFC — la única que garantiza la base.
|
||||
|
||||
**Por qué ``find_expediente_gaps`` sigue aquí aunque el CRM sea mono-base.** En Anexo22 el outbox se
|
||||
commitea aparte del pedimento (dos bases distintas) y ese doble-commit es lo que obligó a inventar el
|
||||
barrido de huecos. Aquí la fila del outbox va en la MISMA transacción que el expediente, así que esa
|
||||
ventana no existe. El barrido se conserva porque cubre otras dos cosas: los expedientes creados
|
||||
**antes** de activar la integración, y cualquier crash. Queda escrito para que el siguiente que lo
|
||||
lea no lo borre creyendo que es redundante.
|
||||
"""
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.database import scoped_core_db
|
||||
from core.efc_client import EfcClient, EfcClientError, efc_client
|
||||
|
||||
from ..cases.models import Case
|
||||
from .models import (
|
||||
KIND_COMPLETAR,
|
||||
KIND_EXPEDIENTE,
|
||||
MAX_ATTEMPTS,
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
EfcSyncOutbox,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache de organización EFC por slug de tenant. Dict módulo-global: vive por worker y NO se
|
||||
# invalida, igual que el del carril de Anexo22. Es correcto porque la organización de un tenant no
|
||||
# cambia de id: el resolver de EFC es idempotente y devuelve siempre la misma. Si algún día pudiera
|
||||
# cambiar, reiniciar el worker la vuelve a resolver.
|
||||
_org_id_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _savepoint(db: Session):
|
||||
"""Aísla un encolado dentro de la transacción del usuario con un SAVEPOINT.
|
||||
|
||||
**Esto es lo único del encolado que NO se clona del carril de Anexo22, y la razón es de fondo.**
|
||||
Allá el outbox vive en otra base que el pedimento, así que su ``except`` podía hacer
|
||||
``db.rollback()`` sin consecuencias: revertía la sesión del outbox y la del pedimento ni se
|
||||
enteraba.
|
||||
|
||||
Aquí el CRM es mono-base y el encolado corre DENTRO de la transacción del usuario. Un
|
||||
``db.rollback()`` en el ``except`` se llevaría por delante la solicitud y el expediente que el
|
||||
usuario acaba de crear — exactamente lo contrario de best-effort, y sin un solo error visible
|
||||
para él. Con el SAVEPOINT, un fallo del encolado deshace **solo** la fila del outbox y la
|
||||
operación local sigue en pie para que el llamador la commitee.
|
||||
"""
|
||||
nested = db.begin_nested()
|
||||
try:
|
||||
yield nested
|
||||
except Exception:
|
||||
nested.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ══ Expediente: encolado y entrega ══════════════════════════════════════════
|
||||
|
||||
def replicate_expediente_best_effort(db: Session, expediente: Case) -> None:
|
||||
"""Encola la réplica del expediente a EFC y dispara la entrega inmediata.
|
||||
|
||||
Best-effort en todo: si EFC no está configurado, o si el encolado o el despacho fallan, **no se
|
||||
propaga el error**. El expediente local ya existe y la operación del usuario no se puede romper
|
||||
porque un sistema de terceros no conteste. El barrido periódico recoge lo que quede pendiente.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return
|
||||
row = _enqueue_expediente_outbox(db, expediente)
|
||||
if row is None:
|
||||
return
|
||||
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
|
||||
|
||||
|
||||
def _enqueue_expediente_outbox(db: Session, expediente: Case) -> Optional[EfcSyncOutbox]:
|
||||
"""Inserta la fila de outbox del expediente. Devuelve ``None`` si falla, sin romper nada.
|
||||
|
||||
A diferencia del original, **no commitea**: el CRM es mono-base, así que la fila viaja en la
|
||||
misma transacción que el expediente. Eso cierra de raíz la ventana del doble-commit que en
|
||||
Anexo22 obligó a inventar el barrido de huecos.
|
||||
"""
|
||||
try:
|
||||
if _expediente_ya_encolado(db, expediente.id):
|
||||
return None
|
||||
# Sin folio o sin token no hay nada que replicar: EFC exige los dos y responde
|
||||
# {'storage_token': ['This field may not be null.']}, que NO es un fallo transitorio.
|
||||
# Encolarlo de todos modos quemaría los 8 intentos para acabar en `failed`, ensuciando
|
||||
# el tablero de ops con algo que ningún reintento puede arreglar.
|
||||
#
|
||||
# Pasa de verdad en dos casos: expedientes nacidos antes de que existiera el carril
|
||||
# (los rellena la migración c5d6e7f8a9b0) y aquellos cuyo token no cabe en los 25
|
||||
# caracteres de `pedimento_app`. Se avisa en WARNING porque es una omisión silenciosa:
|
||||
# el expediente vive en el CRM y sus documentos nunca llegarán a EFC.
|
||||
if not expediente.reference or not expediente.efc_storage_token:
|
||||
logger.warning(
|
||||
"expediente_gateway: expediente id=%s SIN replicar — folio=%r token=%r. "
|
||||
"No se encola: EFC rechaza ambos nulos y el reintento no lo arregla.",
|
||||
expediente.id, expediente.reference, expediente.efc_storage_token,
|
||||
)
|
||||
return None
|
||||
# El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría
|
||||
# una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que
|
||||
# es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia.
|
||||
payload = {
|
||||
"source": "crm",
|
||||
"crm_company_id": expediente.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"storage_token": expediente.efc_storage_token,
|
||||
}
|
||||
row = EfcSyncOutbox(
|
||||
kind=KIND_EXPEDIENTE,
|
||||
payload=payload,
|
||||
expediente_ref=expediente.id,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=expediente.tenant_id,
|
||||
company_id=expediente.company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el expediente id=%s en el outbox",
|
||||
getattr(expediente, "id", None), exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _expediente_ya_encolado(db: Session, expediente_id: int) -> bool:
|
||||
"""¿Ya hay una fila viva de alta para este expediente? Evita encolar la misma réplica dos veces."""
|
||||
return (
|
||||
db.query(EfcSyncOutbox.id)
|
||||
.filter(
|
||||
EfcSyncOutbox.expediente_ref == expediente_id,
|
||||
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
||||
EfcSyncOutbox.status.in_((STATUS_PENDING, STATUS_SENT)),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def enqueue_completar_best_effort(db: Session, expediente: Case, campos: dict) -> None:
|
||||
"""Encola el completado del provisional en EFC con la data aduanera real."""
|
||||
if not settings.EFC_API_URL:
|
||||
return
|
||||
try:
|
||||
row = EfcSyncOutbox(
|
||||
kind=KIND_COMPLETAR,
|
||||
payload={
|
||||
"source": "crm",
|
||||
"crm_company_id": expediente.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"pedimento": campos,
|
||||
},
|
||||
expediente_ref=expediente.id,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=expediente.tenant_id,
|
||||
company_id=expediente.company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el completado del expediente id=%s",
|
||||
getattr(expediente, "id", None), exc_info=True,
|
||||
)
|
||||
return
|
||||
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
|
||||
|
||||
|
||||
def _dispatch_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Dispara la tarea de entrega propagando el contexto RLS por headers de Celery.
|
||||
|
||||
Best-effort: si el broker no responde, el barrido la recoge. Los headers son obligatorios —
|
||||
``core/celery_app.py`` materializa el contexto de RLS a partir de ellos, y sin ellos la tarea
|
||||
corre sin tenant y no ve nada.
|
||||
"""
|
||||
try:
|
||||
from .tasks import deliver_outbox_row # import diferido: evita ciclo con celery_app
|
||||
deliver_outbox_row.apply_async(
|
||||
args=[outbox_id, tenant_id, company_id],
|
||||
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo despachar la entrega outbox_id=%s (lo tomará el sweep)",
|
||||
outbox_id, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def deliver_row(db: Session, row: EfcSyncOutbox, client: Optional[EfcClient] = None) -> None:
|
||||
"""Entrega una fila del outbox de expedientes a EFC. Actualiza estado y ``attempts``.
|
||||
|
||||
**No lanza nunca**: los fallos se registran en la propia fila para reconciliación. Un fallo no
|
||||
puede matar al worker ni perder la intención de entregar.
|
||||
"""
|
||||
client = client or efc_client
|
||||
if not client.is_configured:
|
||||
logger.info("expediente_gateway: EFC no configurado; se deja pendiente row=%s", row.id)
|
||||
return
|
||||
if row.status == STATUS_SENT:
|
||||
return
|
||||
try:
|
||||
if row.kind == KIND_EXPEDIENTE:
|
||||
_deliver_expediente(db, row, client)
|
||||
elif row.kind == KIND_COMPLETAR:
|
||||
_deliver_completar(db, row, client)
|
||||
else:
|
||||
row.status = STATUS_FAILED
|
||||
row.last_error = f"kind desconocido: {row.kind}"
|
||||
db.commit()
|
||||
except EfcClientError as exc:
|
||||
_register_failure(db, row, exc, retryable=exc.retryable)
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo se registra, no rompe el worker
|
||||
_register_failure(db, row, exc, retryable=True)
|
||||
|
||||
|
||||
def _register_failure(db: Session, row: EfcSyncOutbox, exc: Exception, retryable: bool) -> None:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = str(exc)[:2000]
|
||||
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
|
||||
row.status = STATUS_FAILED
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"expediente_gateway: entrega falló row=%s attempts=%s retryable=%s status=%s: %s",
|
||||
row.id, row.attempts, retryable, row.status, exc,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_expediente(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
|
||||
payload = dict(row.payload or {})
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
payload["organizacion"] = {"efc_organizacion_id": org_id}
|
||||
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
|
||||
resp = client.ingest_expediente(payload)
|
||||
efc = (resp or {}).get("efc") or {}
|
||||
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
row.efc_pedimento_id = efc.get("pedimento_id")
|
||||
_stamp_expediente_link(db, row.expediente_ref, org_id, efc.get("pedimento_id"))
|
||||
db.commit()
|
||||
logger.info(
|
||||
"expediente_gateway: expediente replicado row=%s efc_pedimento_id=%s",
|
||||
row.id, row.efc_pedimento_id,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_completar(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
|
||||
payload = dict(row.payload or {})
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
payload["organizacion"] = {"efc_organizacion_id": org_id}
|
||||
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
|
||||
folio = payload.get("folio")
|
||||
client.completar_expediente(folio, payload)
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("expediente_gateway: expediente completado en EFC row=%s folio=%s", row.id, folio)
|
||||
|
||||
|
||||
def _stamp_expediente_link(db: Session, expediente_id: Optional[int], org_id: str,
|
||||
pedimento_id: Optional[str]) -> None:
|
||||
"""Refleja en la fila del expediente que EFC ya lo tiene, para que la UI lo pinte.
|
||||
|
||||
Es un espejo, no un handle: el CRM sigue hablando de este expediente por su ``folio``. Se guarda
|
||||
porque el proxy de descarga necesita el ``organizacion_id`` para preguntarle a EFC.
|
||||
"""
|
||||
if expediente_id is None:
|
||||
return
|
||||
expediente = db.query(Case).filter(Case.id == expediente_id).first()
|
||||
if expediente is None:
|
||||
return
|
||||
expediente.efc_organizacion_id = org_id
|
||||
if pedimento_id:
|
||||
expediente.efc_pedimento_id = pedimento_id
|
||||
expediente.efc_link_state = "LINKED"
|
||||
expediente.efc_error_code = None
|
||||
expediente.efc_error_detail = None
|
||||
|
||||
|
||||
# ══ Archivos: encolado y entrega ════════════════════════════════════════════
|
||||
|
||||
def _ya_entregado(db: Session, source_table: str, source_id: int, kind: str) -> bool:
|
||||
"""¿Este archivo ya se entregó al expediente? Evita re-encolar lo que ya está allá.
|
||||
|
||||
Sin esta guarda, un reintento encolaba otra entrega del mismo archivo — que además **falla al
|
||||
leer el objeto local, porque la primera entrega ya lo borró** con ``delete_local``. Ruido en el
|
||||
log y una fila del outbox condenada a ``failed``.
|
||||
|
||||
Lleva ``source_table`` además de ``source_id``, a diferencia del original: el CRM tiene dos
|
||||
tablas de documentos con secuencias independientes, así que el id solo es ambiguo y esta guarda
|
||||
se dispararía de más, saltándose la entrega de un documento distinto que casualmente comparte
|
||||
entero.
|
||||
"""
|
||||
return (
|
||||
db.query(EfcFileOutbox.id)
|
||||
.filter(
|
||||
EfcFileOutbox.source_table == source_table,
|
||||
EfcFileOutbox.source_id == source_id,
|
||||
EfcFileOutbox.kind == kind,
|
||||
EfcFileOutbox.status == STATUS_SENT,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def enqueue_file_best_effort(
|
||||
db: Session,
|
||||
*,
|
||||
kind: str,
|
||||
s3_key: str,
|
||||
file_name: str,
|
||||
content_type: Optional[str],
|
||||
efc_tipo: str,
|
||||
source_table: str,
|
||||
source_id: int,
|
||||
crm_document_ref: str,
|
||||
expediente_ref: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
delete_local: bool = True,
|
||||
) -> Optional[EfcFileOutbox]:
|
||||
"""Encola un archivo hacia el expediente de EFC. Devuelve la fila, o ``None`` si no se encoló.
|
||||
|
||||
**No commitea**: la fila va en la misma transacción que el documento que la origina, de modo que
|
||||
no puede existir un documento sin su intención de entrega ni al revés.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return None
|
||||
if _ya_entregado(db, source_table, source_id, kind):
|
||||
return None
|
||||
try:
|
||||
row = EfcFileOutbox(
|
||||
kind=kind,
|
||||
s3_key=s3_key,
|
||||
file_name=file_name,
|
||||
content_type=content_type,
|
||||
efc_tipo=efc_tipo,
|
||||
source_table=source_table,
|
||||
source_id=source_id,
|
||||
crm_document_ref=crm_document_ref,
|
||||
expediente_ref=expediente_ref,
|
||||
delete_local=delete_local,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el archivo %s (%s:%s)",
|
||||
s3_key, source_table, source_id, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
try:
|
||||
from .tasks import deliver_file_outbox_row # import diferido
|
||||
deliver_file_outbox_row.apply_async(
|
||||
args=[outbox_id, tenant_id, company_id],
|
||||
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo despachar entrega de archivo outbox_id=%s (lo tomará el sweep)",
|
||||
outbox_id, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Despacha la entrega de una fila ya COMMITEADA del outbox de archivos.
|
||||
|
||||
Está separado de ``enqueue_file_best_effort`` porque esa función no commitea: la fila viaja en
|
||||
la transacción de quien la origina, y despachar antes del commit haría que el worker buscara
|
||||
una fila que todavía no existe. El orden es siempre encolar → commit → despachar.
|
||||
|
||||
No despachar no pierde nada: ``sweep_file_outbox`` recoge lo que quede en ``pending``. Esto
|
||||
solo acelera la entrega del caso normal.
|
||||
"""
|
||||
_dispatch_file_delivery(outbox_id, tenant_id, company_id)
|
||||
|
||||
|
||||
def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None:
|
||||
"""Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia.
|
||||
|
||||
**Ensure-then-upload**: si EFC contesta 404 ``expediente_no_encontrado``, la creación del
|
||||
provisional puede venir en camino (el outbox de expedientes y el de archivos son colas
|
||||
distintas), así que se asegura el expediente y se reintenta el upload **una** vez.
|
||||
|
||||
**No lanza nunca**: como ``deliver_row``, registra el fallo en la propia fila.
|
||||
"""
|
||||
client = client or efc_client
|
||||
if not client.is_configured or row.status == STATUS_SENT:
|
||||
return
|
||||
try:
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
expediente = db.query(Case).filter(Case.id == row.expediente_ref).first()
|
||||
if expediente is None:
|
||||
raise EfcClientError(
|
||||
f"expediente {row.expediente_ref} no encontrado para el archivo '{row.kind}'",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
from core.storage_s3 import get_object_bytes
|
||||
content = get_object_bytes(row.s3_key)
|
||||
ct = row.content_type or "application/octet-stream"
|
||||
|
||||
try:
|
||||
resp = client.upload_documento(
|
||||
org_id, row.company_id, expediente.id, row.efc_tipo,
|
||||
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
|
||||
)
|
||||
except EfcClientError as exc:
|
||||
if exc.status_code == 404 and exc.code == "expediente_no_encontrado":
|
||||
# La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez.
|
||||
client.ingest_expediente({
|
||||
"source": "crm",
|
||||
"crm_tenant_slug": (_tenant_slug(row.tenant_id)[0] or ""),
|
||||
"crm_company_id": row.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"storage_token": expediente.efc_storage_token,
|
||||
"organizacion": {"efc_organizacion_id": org_id},
|
||||
})
|
||||
resp = client.upload_documento(
|
||||
org_id, row.company_id, expediente.id, row.efc_tipo,
|
||||
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
doc_id = resp.get("id") if isinstance(resp, dict) else None
|
||||
|
||||
if row.delete_local:
|
||||
try:
|
||||
from core.storage_s3 import delete_object_if_exists
|
||||
delete_object_if_exists(row.s3_key)
|
||||
except Exception:
|
||||
# Ya está en EFC: no poder borrar la copia local no invalida la entrega.
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo borrar el archivo local %s (ya en EFC)",
|
||||
row.s3_key, exc_info=True,
|
||||
)
|
||||
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
row.efc_document_id = doc_id
|
||||
db.commit()
|
||||
_marcar_documento_entregado(db, row, doc_id)
|
||||
logger.info(
|
||||
"expediente_gateway: archivo entregado row=%s kind=%s efc_document_id=%s",
|
||||
row.id, row.kind, doc_id,
|
||||
)
|
||||
except EfcClientError as exc:
|
||||
_register_file_failure(db, row, exc, exc.retryable)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_register_file_failure(db, row, exc, True)
|
||||
|
||||
|
||||
def _register_file_failure(db: Session, row: EfcFileOutbox, exc: Exception, retryable: bool) -> None:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = str(exc)[:2000]
|
||||
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
|
||||
row.status = STATUS_FAILED
|
||||
db.commit()
|
||||
_marcar_documento_fallido(db, row, exc)
|
||||
logger.warning(
|
||||
"expediente_gateway: entrega de archivo falló row=%s attempts=%s status=%s: %s",
|
||||
row.id, row.attempts, row.status, exc,
|
||||
)
|
||||
|
||||
|
||||
# El mapa (kind, source_table) -> modelo del documento de origen. Un par que NO esté aquí **no toca
|
||||
# nada**, en vez de caer por omisión sobre una tabla cualquiera: escribir con el id de otra tabla
|
||||
# vaciaría las columnas de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro,
|
||||
# sin un solo error visible.
|
||||
def _modelo_de_origen(source_table: str):
|
||||
if source_table == "crm.documents":
|
||||
from ..documents.models import Document
|
||||
return Document
|
||||
if source_table == "ops.shipment_documents":
|
||||
from api.v1.modules.ops.shipments.models import ShipmentDocument
|
||||
return ShipmentDocument
|
||||
return None
|
||||
|
||||
|
||||
def _fila_de_origen(db: Session, row: EfcFileOutbox):
|
||||
modelo = _modelo_de_origen(row.source_table)
|
||||
if modelo is None or row.source_id is None:
|
||||
return None
|
||||
return (
|
||||
db.query(modelo)
|
||||
.filter(
|
||||
modelo.id == row.source_id,
|
||||
modelo.tenant_id == row.tenant_id,
|
||||
modelo.company_id == row.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _marcar_documento_entregado(db: Session, row: EfcFileOutbox, doc_id) -> None:
|
||||
"""Cierra la entrega en la fila del documento: el badge de la UI pasa a «En expediente»."""
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_document_id = str(doc_id) if doc_id else None
|
||||
documento.efc_sync_state = "SYNCED"
|
||||
documento.efc_synced_at = datetime.now(timezone.utc)
|
||||
documento.efc_error_code = None
|
||||
documento.efc_error_detail = None
|
||||
if row.delete_local:
|
||||
# El objeto local ya no está: dejar la key apuntaría a algo inexistente y la descarga se
|
||||
# ramificaría por el camino equivocado.
|
||||
documento.file_key = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def _marcar_documento_fallido(db: Session, row: EfcFileOutbox, exc: Exception) -> None:
|
||||
"""Refleja el fallo en la fila del documento para que la ficha lo muestre sin ir a los logs."""
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_attempts = row.attempts
|
||||
documento.efc_error_detail = str(exc)[:2000]
|
||||
documento.efc_error_code = getattr(exc, "code", None)
|
||||
if row.status == STATUS_FAILED:
|
||||
documento.efc_sync_state = "FAILED"
|
||||
db.commit()
|
||||
|
||||
|
||||
# ══ Organización ════════════════════════════════════════════════════════════
|
||||
|
||||
def _resolve_org_id(client: EfcClient, tenant_id: int) -> str:
|
||||
slug, name = _tenant_slug(tenant_id)
|
||||
if not slug:
|
||||
raise EfcClientError(
|
||||
f"tenant {tenant_id} sin slug; no se puede resolver la organización EFC.",
|
||||
retryable=False,
|
||||
)
|
||||
if slug in _org_id_cache:
|
||||
return _org_id_cache[slug]
|
||||
resp = client.resolve_organizacion(slug, name)
|
||||
org_id = resp.get("id") if isinstance(resp, dict) else None
|
||||
if not org_id:
|
||||
raise EfcClientError("El resolver de organización de EFC no devolvió id.", retryable=True)
|
||||
_org_id_cache[slug] = org_id
|
||||
return org_id
|
||||
|
||||
|
||||
def _tenant_slug(tenant_id: int) -> tuple[Optional[str], Optional[str]]:
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
with scoped_core_db(tenant_id=tenant_id) as db:
|
||||
t = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if t is None:
|
||||
return None, None
|
||||
return t.slug, t.name
|
||||
|
||||
|
||||
# ══ Tablero de ops ══════════════════════════════════════════════════════════
|
||||
|
||||
def _outbox_to_dict(r: EfcSyncOutbox) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"tabla": "sync",
|
||||
"kind": r.kind,
|
||||
"status": r.status,
|
||||
"attempts": r.attempts,
|
||||
"last_error": r.last_error,
|
||||
"expediente_ref": r.expediente_ref,
|
||||
"efc_pedimento_id": r.efc_pedimento_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _file_outbox_to_dict(r: EfcFileOutbox) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"tabla": "file",
|
||||
"kind": r.kind,
|
||||
"status": r.status,
|
||||
"attempts": r.attempts,
|
||||
"last_error": r.last_error,
|
||||
"expediente_ref": r.expediente_ref,
|
||||
"file_name": r.file_name,
|
||||
"efc_tipo": r.efc_tipo,
|
||||
"source_table": r.source_table,
|
||||
"source_id": r.source_id,
|
||||
"crm_document_ref": r.crm_document_ref,
|
||||
"efc_document_id": r.efc_document_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_outbox(db: Session, tenant_id: int, company_id: int, tipo: Optional[str] = None,
|
||||
status: Optional[str] = None, limit: int = 100) -> list[dict]:
|
||||
"""Lista filas de los DOS outbox para el tablero de ops. ``tipo`` ∈ ``sync`` | ``file``."""
|
||||
salida: list[dict] = []
|
||||
|
||||
if tipo in (None, "", "sync"):
|
||||
q = db.query(EfcSyncOutbox).filter(
|
||||
EfcSyncOutbox.tenant_id == tenant_id, EfcSyncOutbox.company_id == company_id
|
||||
)
|
||||
if status:
|
||||
q = q.filter(EfcSyncOutbox.status == status)
|
||||
salida += [
|
||||
_outbox_to_dict(r)
|
||||
for r in q.order_by(EfcSyncOutbox.created_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
if tipo in (None, "", "file"):
|
||||
q = db.query(EfcFileOutbox).filter(
|
||||
EfcFileOutbox.tenant_id == tenant_id, EfcFileOutbox.company_id == company_id
|
||||
)
|
||||
if status:
|
||||
q = q.filter(EfcFileOutbox.status == status)
|
||||
salida += [
|
||||
_file_outbox_to_dict(r)
|
||||
for r in q.order_by(EfcFileOutbox.created_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
salida.sort(key=lambda d: (d.get("created_at") or ""), reverse=True)
|
||||
return salida[:limit]
|
||||
|
||||
|
||||
def retry_outbox_row(db: Session, outbox_id: int, tenant_id: int, company_id: int,
|
||||
tipo: str = "file") -> bool:
|
||||
"""Reintento manual: resetea la fila a ``pending`` (``attempts=0``) y la re-despacha.
|
||||
|
||||
Devuelve ``False`` si no existe para ese tenant/company — el llamador lo traduce a **404 con
|
||||
mensaje específico**, no a un 200 silencioso: es contrato con el frontend, que pinta el botón
|
||||
según lo que reciba.
|
||||
"""
|
||||
modelo = EfcSyncOutbox if tipo == "sync" else EfcFileOutbox
|
||||
r = (
|
||||
db.query(modelo)
|
||||
.filter(modelo.id == outbox_id, modelo.tenant_id == tenant_id, modelo.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if r is None:
|
||||
return False
|
||||
r.status = STATUS_PENDING
|
||||
r.attempts = 0
|
||||
r.last_error = None
|
||||
db.commit()
|
||||
if tipo == "sync":
|
||||
_dispatch_delivery(r.id, r.tenant_id, r.company_id)
|
||||
else:
|
||||
_reset_documento_pendiente(db, r)
|
||||
_dispatch_file_delivery(r.id, r.tenant_id, r.company_id)
|
||||
return True
|
||||
|
||||
|
||||
def _reset_documento_pendiente(db: Session, row: EfcFileOutbox) -> None:
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_sync_state = "PENDING"
|
||||
documento.efc_error_code = None
|
||||
documento.efc_error_detail = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def outbox_metrics(db: Session, tenant_id: int, company_id: int) -> dict:
|
||||
"""Conteo de los dos outbox por status (monitoreo). Los conteos suman las dos tablas."""
|
||||
from sqlalchemy import func
|
||||
|
||||
counts = {STATUS_PENDING: 0, STATUS_SENT: 0, STATUS_FAILED: 0}
|
||||
for modelo in (EfcSyncOutbox, EfcFileOutbox):
|
||||
rows = (
|
||||
db.query(modelo.status, func.count())
|
||||
.filter(modelo.tenant_id == tenant_id, modelo.company_id == company_id)
|
||||
.group_by(modelo.status)
|
||||
.all()
|
||||
)
|
||||
for estado, n in rows:
|
||||
counts[estado] = counts.get(estado, 0) + n
|
||||
return {
|
||||
"pending": counts.get(STATUS_PENDING, 0),
|
||||
"sent": counts.get(STATUS_SENT, 0),
|
||||
"failed": counts.get(STATUS_FAILED, 0),
|
||||
}
|
||||
|
||||
|
||||
def find_expediente_gaps(db: Session, limit: int = 200) -> list:
|
||||
"""Expedientes (no borrados) SIN ninguna fila de outbox que los referencie.
|
||||
|
||||
Nunca se encolaron: expedientes creados **antes** de activar la integración, o un crash. Se
|
||||
re-encolan para no perder la réplica.
|
||||
|
||||
Los ``failed`` **no son huecos** —existen como fila, son visibles y reintentables desde el
|
||||
tablero—, así que la fila los excluye por estar presente, no por su estado. Corre sin contexto
|
||||
de tenant (beat); cada expediente lleva el suyo.
|
||||
"""
|
||||
from sqlalchemy import exists
|
||||
|
||||
ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Case.id)
|
||||
return (
|
||||
db.query(Case)
|
||||
.filter(
|
||||
Case.deleted_at.is_(None),
|
||||
~ya_encolado,
|
||||
# Mismo criterio que el encolado: lo que le falta folio o token no es un hueco
|
||||
# recuperable, es algo que EFC rechazaría siempre. Sin este filtro la
|
||||
# reconciliación los reencola cada 5 minutos para verlos fallar de nuevo.
|
||||
Case.reference.isnot(None),
|
||||
Case.efc_storage_token.isnot(None),
|
||||
)
|
||||
.order_by(Case.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
39
backend/api/v1/modules/crm/expediente_gateway/storage.py
Normal file
39
backend/api/v1/modules/crm/expediente_gateway/storage.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""La llave de almacenamiento del expediente en EFC.
|
||||
|
||||
Vive en el carril y no en el módulo del expediente a propósito: el expediente (``crm.cases``) es
|
||||
del CRM y no sabe nada de EFC; esto es exclusivamente cómo EFC nombra su carpeta.
|
||||
|
||||
El generador de folios NO está aquí. Es ``crm/common/folios.py::next_folio``, que ya reserva el
|
||||
consecutivo mensual por ``(tenant, company, entidad, periodo)`` con bloqueo de fila. El carril lo
|
||||
consume, no lo reimplementa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Longitud de ``Pedimento.pedimento_app`` en EFC (api/customs/models.py). El token se guarda ahí.
|
||||
PEDIMENTO_APP_MAX = 25
|
||||
|
||||
|
||||
def storage_token(company_id: int, folio: str) -> str:
|
||||
"""``CRM-{company_id}-{folio}`` — la llave del pedimento provisional en EFC.
|
||||
|
||||
Empieza con letras, así que es imposible que colisione con la llave de un pedimento real, que
|
||||
es ``^\\d{2}-\\d{2}-\\d{4}-\\d{7}$``. El ``company_id`` va dentro porque el puente con EFC es
|
||||
tenant → organización 1:1 pero un tenant tiene N companies: sin él, dos companies del mismo
|
||||
tenant generarían el mismo ``EXP2026-08-001`` y chocarían en el ``unique_together`` de EFC.
|
||||
|
||||
PRESUPUESTO DE CARACTERES: ``CRM-`` (4) + company + ``-`` (1) + ``EXP2026-08-001`` (14) = 19 +
|
||||
los dígitos del company. En los 25 de ``pedimento_app`` caben hasta **6 dígitos** de company,
|
||||
no 7 como decía la primera versión de este docstring: con 7 salen 26 y el insert del lado de
|
||||
EFC reventaría. Un consecutivo de 4 dígitos (mes con más de 999 expedientes) gasta uno más.
|
||||
|
||||
Se valida en vez de truncar: un token recortado apuntaría a la carpeta de OTRO expediente y
|
||||
los documentos se mezclarían en silencio, que es peor que fallar aquí.
|
||||
"""
|
||||
token = f"CRM-{company_id}-{folio}"
|
||||
if len(token) > PEDIMENTO_APP_MAX:
|
||||
raise ValueError(
|
||||
f"storage_token de {len(token)} caracteres excede los {PEDIMENTO_APP_MAX} de "
|
||||
f"pedimento_app en EFC: {token!r}. Revisa el largo del company_id o del consecutivo."
|
||||
)
|
||||
return token
|
||||
143
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
143
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tareas Celery del carril CRM Agentes de Carga -> EFC.
|
||||
|
||||
- ``deliver_outbox_row`` / ``sweep_outbox``: expedientes (alta del provisional y completado).
|
||||
- ``deliver_file_outbox_row`` / ``sweep_file_outbox``: archivos.
|
||||
- ``sweep_expediente_gaps``: reconciliación de expedientes que nunca se encolaron.
|
||||
|
||||
**La trampa de RLS, que es lo que más fácil se pasa por alto.** ``core/celery_app.py`` materializa el
|
||||
contexto desde los headers ``rls_tenant_id`` / ``rls_company_id``. Por tanto:
|
||||
|
||||
- Las tareas **por fila** se despachan siempre con esos headers.
|
||||
- Los **barridos corren sin contexto de tenant**: leen los ids pendientes con una sesión sin scope y
|
||||
despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con
|
||||
scope e iterara, o no vería nada o se saltaría el aislamiento.
|
||||
|
||||
**Sin ``autoretry_for``, ``retry_backoff`` ni ``max_retries``**: duplicarían el mecanismo de
|
||||
reintento que ya está en el cliente (3 intentos con backoff lineal) y en el barrido (cada 120 s
|
||||
hasta ``MAX_ATTEMPTS``).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.config import settings
|
||||
from core.database import scoped_core_db
|
||||
|
||||
from . import service
|
||||
from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox
|
||||
|
||||
# ── Registro de modelos: NO son imports decorativos, no los quites ──────────────────────
|
||||
# El worker de Celery NO carga la app: importa este módulo y sus dependencias, y nada más.
|
||||
# SQLAlchemy resuelve las ForeignKey por NOMBRE de tabla contra su registro global, así que
|
||||
# si la clase del otro extremo nunca se importó, la configuración de mappers falla con
|
||||
#
|
||||
# Foreign key associated with column 'cases.account_id' could not find table 'crm.accounts'
|
||||
#
|
||||
# y la tarea muere con PendingRollbackError. El síntoma es cruel: la fila del outbox se
|
||||
# queda en `pending` con attempts=0 y SIN last_error —porque el fallo ocurre antes de poder
|
||||
# registrarlo—, así que el carril se ve encolando bien y no entrega nunca. En la app web no
|
||||
# pasa: `main.py` monta todos los routers y con ellos se importan todos los modelos.
|
||||
#
|
||||
# El juego es el mínimo verificado con `configure_mappers()` en un proceso limpio:
|
||||
# - accounts : cierra la FK cases.account_id, que es la que rompía;
|
||||
# - documents y ops.shipments : las dos fuentes del outbox de archivos;
|
||||
# - tenants : lo consulta el resolver de organización al entregar.
|
||||
from api.v1.modules.core.tenants import models as _m_tenants # noqa: F401
|
||||
from api.v1.modules.crm.accounts import models as _m_accounts # noqa: F401
|
||||
from api.v1.modules.crm.documents import models as _m_documents # noqa: F401
|
||||
from api.v1.modules.ops.shipments import models as _m_shipments # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Expedientes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.deliver_outbox_row")
|
||||
def deliver_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
with scoped_core_db(tenant_id, company_id) as db:
|
||||
row = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.id == outbox_id).first()
|
||||
if row is None:
|
||||
logger.warning(
|
||||
"expediente_gateway: outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
|
||||
)
|
||||
return
|
||||
service.deliver_row(db, row)
|
||||
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_outbox")
|
||||
def sweep_outbox(limit: int = 100) -> int:
|
||||
"""Re-despacha filas pendientes de expediente. Sin contexto de tenant: cada fila lleva el suyo."""
|
||||
with scoped_core_db() as db:
|
||||
rows = (
|
||||
db.query(EfcSyncOutbox.id, EfcSyncOutbox.tenant_id, EfcSyncOutbox.company_id)
|
||||
.filter(EfcSyncOutbox.status == STATUS_PENDING)
|
||||
.order_by(EfcSyncOutbox.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for rid, tid, cid in rows:
|
||||
deliver_outbox_row.apply_async(
|
||||
args=[rid, tid, cid],
|
||||
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
|
||||
)
|
||||
if rows:
|
||||
logger.info("expediente_gateway: sweep (expedientes) re-despachó %s filas pendientes", len(rows))
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ── Archivos ────────────────────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.deliver_file_outbox_row")
|
||||
def deliver_file_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
with scoped_core_db(tenant_id, company_id) as db:
|
||||
row = db.query(EfcFileOutbox).filter(EfcFileOutbox.id == outbox_id).first()
|
||||
if row is None:
|
||||
logger.warning(
|
||||
"expediente_gateway: file outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
|
||||
)
|
||||
return
|
||||
service.deliver_file_row(db, row)
|
||||
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_file_outbox")
|
||||
def sweep_file_outbox(limit: int = 100) -> int:
|
||||
"""Re-despacha archivos pendientes (EFC o el broker caídos cuando el usuario subió el archivo)."""
|
||||
with scoped_core_db() as db:
|
||||
rows = (
|
||||
db.query(EfcFileOutbox.id, EfcFileOutbox.tenant_id, EfcFileOutbox.company_id)
|
||||
.filter(EfcFileOutbox.status == STATUS_PENDING)
|
||||
.order_by(EfcFileOutbox.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
for rid, tid, cid in rows:
|
||||
deliver_file_outbox_row.apply_async(
|
||||
args=[rid, tid, cid],
|
||||
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
|
||||
)
|
||||
if rows:
|
||||
logger.info("expediente_gateway: sweep (archivos) re-despachó %s archivos pendientes", len(rows))
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ── Reconciliación de huecos ────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_expediente_gaps")
|
||||
def sweep_expediente_gaps(limit: int = 200) -> int:
|
||||
"""Detecta expedientes que nunca se encolaron a EFC y los re-encola.
|
||||
|
||||
No-op si la integración está apagada.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return 0
|
||||
n = 0
|
||||
with scoped_core_db() as db:
|
||||
gaps = service.find_expediente_gaps(db, limit=limit)
|
||||
for expediente in gaps:
|
||||
service.replicate_expediente_best_effort(db, expediente)
|
||||
n += 1
|
||||
if n:
|
||||
db.commit()
|
||||
if n:
|
||||
logger.info("expediente_gateway: sweep de huecos re-encoló %s expedientes", n)
|
||||
return n
|
||||
@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
status: str = Field("new", max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
|
||||
phone: str | None
|
||||
company_name: str | None
|
||||
source: str | None
|
||||
preferred_contact_method: str | None = None
|
||||
status: str
|
||||
estimated_value: Decimal | None
|
||||
owner_user_id: str | None
|
||||
|
||||
@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
|
||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Origen: web | referido | evento | llamada | email | otro
|
||||
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
|
||||
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Estado: new | contacted | qualified | unqualified | converted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
|
||||
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
|
||||
@@ -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):
|
||||
@@ -30,10 +31,13 @@ class OpportunityUpdate(BaseModel):
|
||||
probability: int | None = Field(None, ge=0, le=100)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
expected_close_date: date | None = None
|
||||
won_date: date | None = None
|
||||
lost_date: date | None = None
|
||||
lost_reason: str | None = Field(None, max_length=255)
|
||||
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):
|
||||
@@ -57,10 +61,16 @@ class OpportunityResponse(BaseModel):
|
||||
status: str
|
||||
expected_close_date: date | None
|
||||
closed_at: datetime | None
|
||||
won_date: date | None = None
|
||||
lost_date: date | None = None
|
||||
lost_reason: str | None
|
||||
source: str | None
|
||||
owner_user_id: str | None
|
||||
notes: str | None
|
||||
operation_type: str | None = None
|
||||
reference: str | None = None
|
||||
case_id: int | None = None
|
||||
converted_service_request_id: int | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
|
||||
@@ -34,7 +34,18 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
|
||||
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó
|
||||
lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió
|
||||
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
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...
|
||||
# Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo
|
||||
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True)
|
||||
# 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
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..cases import service as cases_service
|
||||
from ..common.folios import next_folio
|
||||
from ..contacts.models import Contact
|
||||
from ..pipelines.models import Pipeline, PipelineStage
|
||||
from .dto import OpportunityCreate, OpportunityUpdate
|
||||
@@ -46,14 +48,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None:
|
||||
opportunity.status = "won"
|
||||
opportunity.probability = 100
|
||||
opportunity.closed_at = datetime.now(timezone.utc)
|
||||
opportunity.won_date = opportunity.won_date or date.today()
|
||||
opportunity.lost_date = None
|
||||
elif stage.is_lost:
|
||||
opportunity.status = "lost"
|
||||
opportunity.probability = 0
|
||||
opportunity.closed_at = datetime.now(timezone.utc)
|
||||
opportunity.lost_date = opportunity.lost_date or date.today()
|
||||
opportunity.won_date = None
|
||||
else:
|
||||
opportunity.status = "open"
|
||||
opportunity.probability = stage.probability
|
||||
opportunity.closed_at = None
|
||||
opportunity.won_date = None
|
||||
opportunity.lost_date = None
|
||||
|
||||
|
||||
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
@@ -149,6 +157,15 @@ 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)
|
||||
# Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura
|
||||
if not opportunity.case_id:
|
||||
case = cases_service.create_case(
|
||||
db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad",
|
||||
)
|
||||
opportunity.case_id = case.id
|
||||
db.add(opportunity)
|
||||
db.commit()
|
||||
db.refresh(opportunity)
|
||||
|
||||
@@ -56,6 +56,7 @@ class QuoteBase(BaseModel):
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str = Field("USD", max_length=3)
|
||||
load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas")
|
||||
issue_date: date | None = None
|
||||
valid_until: date | None = None
|
||||
notes: str | None = None
|
||||
@@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel):
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
load_type: str | None = Field(None, max_length=10)
|
||||
issue_date: date | None = None
|
||||
valid_until: date | None = None
|
||||
notes: str | None = None
|
||||
@@ -83,6 +85,8 @@ class QuoteResponse(QuoteBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
service_request_reference: str | None = None # folio de la solicitud referenciada
|
||||
case_id: int | None = None
|
||||
status: str
|
||||
total_cost: Decimal
|
||||
total_sale: Decimal
|
||||
|
||||
@@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
|
||||
service_request_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
|
||||
)
|
||||
@@ -22,6 +23,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'"))
|
||||
# Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica)
|
||||
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
# borrador | enviada | aceptada | rechazada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
|
||||
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
@@ -59,6 +59,14 @@ def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) ->
|
||||
return obj
|
||||
|
||||
|
||||
def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None:
|
||||
"""Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada)."""
|
||||
head = ", ".join(p for p in (city, country) if p)
|
||||
if port:
|
||||
head = f"{head} ({port})" if head else port
|
||||
return head or None
|
||||
|
||||
|
||||
def _company_row(db: Session, company_id: int) -> dict:
|
||||
try:
|
||||
row = db.execute(
|
||||
@@ -139,7 +147,8 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int)
|
||||
route = [
|
||||
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
|
||||
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
|
||||
("Origen", sr.origin), ("Destino", sr.destination),
|
||||
("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)),
|
||||
("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)),
|
||||
("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None),
|
||||
]
|
||||
|
||||
|
||||
@@ -110,6 +110,24 @@ def create_quote(
|
||||
return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quotes/from-service-request",
|
||||
response_model=list[QuoteResponse],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_quotes_from_service_request(
|
||||
service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_quotes_from_service_request(
|
||||
db, service_request_id, tenant_id, company_id, _user_id(current_user)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/quotes/{quote_id}", response_model=QuoteResponse)
|
||||
def update_quote(
|
||||
quote_id: int,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -6,7 +6,11 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..service_requests.models import ServiceRequest
|
||||
from ..cases import service as cases_service
|
||||
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
|
||||
@@ -70,7 +74,18 @@ def get_quotes(
|
||||
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()
|
||||
quotes = query.order_by(Quote.created_at.desc()).all()
|
||||
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
|
||||
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
|
||||
if sr_ids:
|
||||
refs = dict(
|
||||
db.query(ServiceRequest.id, ServiceRequest.reference)
|
||||
.filter(ServiceRequest.id.in_(sr_ids))
|
||||
.all()
|
||||
)
|
||||
for q in quotes:
|
||||
q.service_request_reference = refs.get(q.service_request_id)
|
||||
return quotes
|
||||
|
||||
|
||||
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
|
||||
@@ -89,18 +104,142 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu
|
||||
return obj
|
||||
|
||||
|
||||
def _sr_direction(db: Session, service_request_id: int | None) -> str | None:
|
||||
"""Dirección impo/expo heredada de la solicitud asociada (para el folio)."""
|
||||
if not service_request_id:
|
||||
return None
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first()
|
||||
return sr.operation_type if sr else None
|
||||
|
||||
|
||||
def create_quote(
|
||||
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Quote:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
# Fecha de la cotización: por defecto hoy si no se capturó
|
||||
if obj.issue_date is None:
|
||||
obj.issue_date = date.today()
|
||||
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
|
||||
if not obj.reference:
|
||||
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
|
||||
# Expediente heredado de la solicitud
|
||||
if obj.service_request_id and not obj.case_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
|
||||
if sr:
|
||||
obj.case_id = sr.case_id
|
||||
cases_service.advance_stage(db, obj.case_id, "cotizacion")
|
||||
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),
|
||||
case_id=sr.case_id,
|
||||
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)
|
||||
|
||||
cases_service.advance_stage(db, sr.case_id, "cotizacion")
|
||||
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:
|
||||
|
||||
@@ -146,6 +146,10 @@ class CostRequest(BaseModel):
|
||||
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
|
||||
|
||||
@@ -255,3 +255,15 @@ def rate_quote(
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
options = service.quote_cost(db, tenant_id, company_id, req)
|
||||
return CostResult(request=req, options=options)
|
||||
|
||||
|
||||
@cost_router.get("/rate-locations")
|
||||
def rate_locations(
|
||||
mode: str = Query(...),
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return service.lane_locations(db, tenant_id, company_id, mode)
|
||||
|
||||
@@ -20,9 +20,11 @@ from .dto import (
|
||||
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")
|
||||
|
||||
|
||||
@@ -477,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
|
||||
return lines
|
||||
|
||||
|
||||
def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
|
||||
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
|
||||
|
||||
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
|
||||
de las lanes, por lo que el costeo siempre encontrará ruta).
|
||||
"""
|
||||
sheets = _sheet_query(db, tenant_id, company_id).filter(
|
||||
RateSheet.mode == mode, RateSheet.status == "activo",
|
||||
).all()
|
||||
origins: set[str] = set()
|
||||
destinations: set[str] = set()
|
||||
for sheet in sheets:
|
||||
lanes = db.query(RateLane).filter(
|
||||
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
|
||||
).all()
|
||||
for lane in lanes:
|
||||
origin = lane.origin or sheet.default_origin
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
if lane.destination:
|
||||
destinations.add(lane.destination)
|
||||
return {"origins": sorted(origins), "destinations": sorted(destinations)}
|
||||
|
||||
|
||||
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(
|
||||
@@ -518,11 +544,15 @@ def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -
|
||||
base = max(base, lane.min_charge or Decimal(0))
|
||||
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
|
||||
else: # aereo
|
||||
chargeable = max(gross, _volumetric_kg(req.volume_m3))
|
||||
# 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"
|
||||
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))
|
||||
|
||||
@@ -13,9 +13,11 @@ 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 .cases.routes import router as cases_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 .expediente_gateway.routes import router as expediente_gateway_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
|
||||
@@ -43,8 +45,13 @@ router.include_router(leads_router)
|
||||
router.include_router(pipelines_router)
|
||||
router.include_router(opportunities_router)
|
||||
router.include_router(activities_router)
|
||||
router.include_router(cases_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)
|
||||
# Tablero de operación del carril hacia EFC: cola pendiente, métricas y reintento manual.
|
||||
# No es una ruta de negocio; existe para que una persona vea y desatore la entrega sin
|
||||
# entrar a la base. Hereda el enforcement de crm.access del router agregador.
|
||||
router.include_router(expediente_gateway_router)
|
||||
|
||||
@@ -7,22 +7,66 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class ServiceRequestBase(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
opportunity_id: int | None = None
|
||||
operation_type: str = Field(..., max_length=20) # importacion | exportacion
|
||||
transport_mode: str | None = Field(None, max_length=20)
|
||||
service_type: str | None = Field(None, max_length=20)
|
||||
incoterm: str | None = Field(None, max_length=10)
|
||||
# Ruta legada (texto libre) — se conserva por compatibilidad
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
# Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto)
|
||||
origin_country: str | None = Field(None, max_length=3)
|
||||
origin_city: str | None = Field(None, max_length=120)
|
||||
origin_port: str | None = Field(None, max_length=20)
|
||||
destination_country: str | None = Field(None, max_length=3)
|
||||
destination_city: str | None = Field(None, max_length=120)
|
||||
destination_port: str | None = Field(None, max_length=20)
|
||||
pickup_location: str | None = Field(None, max_length=255)
|
||||
delivery_location: str | None = Field(None, max_length=255)
|
||||
cargo_type: str | None = Field(None, max_length=120)
|
||||
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto
|
||||
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
load_type: str | None = Field(None, max_length=10)
|
||||
load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS
|
||||
container_equipment: str | None = Field(None, max_length=120)
|
||||
container_count: int | None = Field(None, ge=0)
|
||||
commodity: str | None = None
|
||||
required_date: date | None = None
|
||||
request_date: date | None = None
|
||||
estimated_shipment_date: date | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
priority: str | None = Field(None, max_length=20)
|
||||
# Mercancía
|
||||
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
insurance_required: bool = False
|
||||
hs_code: str | None = Field(None, max_length=20)
|
||||
goods_origin_country: str | None = Field(None, max_length=3)
|
||||
hazardous_imo: bool = False
|
||||
refrigerated: bool = False
|
||||
stackable: bool = False
|
||||
# Dimensiones y bultos
|
||||
pieces_count: int | None = Field(None, ge=0)
|
||||
boxes_count: int | None = Field(None, ge=0)
|
||||
pallets_count: int | None = Field(None, ge=0)
|
||||
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
measurement_unit: str | None = Field(None, max_length=20)
|
||||
# LCL
|
||||
packaging_type: str | None = Field(None, max_length=20)
|
||||
oversized: bool = False
|
||||
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
# Servicios adicionales (códigos del catálogo servicio_adicional) y pago
|
||||
additional_services: list[str] | None = None
|
||||
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)
|
||||
@@ -38,8 +82,12 @@ class ServiceRequestContactInput(BaseModel):
|
||||
|
||||
|
||||
class ServiceRequestFromOpportunityInput(BaseModel):
|
||||
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
|
||||
operation_type: str = Field(..., max_length=20) # importacion | exportacion
|
||||
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).
|
||||
|
||||
La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es
|
||||
solo un respaldo para oportunidades antiguas que no la tengan capturada.
|
||||
"""
|
||||
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
|
||||
transport_mode: str | None = Field(None, max_length=20)
|
||||
service_type: str | None = Field(None, max_length=20)
|
||||
incoterm: str | None = Field(None, max_length=10)
|
||||
@@ -51,6 +99,7 @@ class ServiceRequestFromOpportunityInput(BaseModel):
|
||||
class ServiceRequestUpdate(BaseModel):
|
||||
reference: str | None = Field(None, max_length=40)
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
opportunity_id: int | None = None
|
||||
operation_type: str | None = Field(None, max_length=20)
|
||||
transport_mode: str | None = Field(None, max_length=20)
|
||||
@@ -58,15 +107,52 @@ class ServiceRequestUpdate(BaseModel):
|
||||
incoterm: str | None = Field(None, max_length=10)
|
||||
origin: str | None = Field(None, max_length=160)
|
||||
destination: str | None = Field(None, max_length=160)
|
||||
origin_country: str | None = Field(None, max_length=3)
|
||||
origin_city: str | None = Field(None, max_length=120)
|
||||
origin_port: str | None = Field(None, max_length=20)
|
||||
destination_country: str | None = Field(None, max_length=3)
|
||||
destination_city: str | None = Field(None, max_length=120)
|
||||
destination_port: str | None = Field(None, max_length=20)
|
||||
pickup_location: str | None = Field(None, max_length=255)
|
||||
delivery_location: str | None = Field(None, max_length=255)
|
||||
cargo_type: str | None = Field(None, max_length=120)
|
||||
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
load_type: str | None = Field(None, max_length=10)
|
||||
container_equipment: str | None = Field(None, max_length=120)
|
||||
container_count: int | None = Field(None, ge=0)
|
||||
commodity: str | None = None
|
||||
required_date: date | None = None
|
||||
request_date: date | None = None
|
||||
estimated_shipment_date: date | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
priority: str | None = Field(None, max_length=20)
|
||||
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
insurance_required: bool | None = None
|
||||
hs_code: str | None = Field(None, max_length=20)
|
||||
goods_origin_country: str | None = Field(None, max_length=3)
|
||||
hazardous_imo: bool | None = None
|
||||
refrigerated: bool | None = None
|
||||
stackable: bool | None = None
|
||||
pieces_count: int | None = Field(None, ge=0)
|
||||
boxes_count: int | None = Field(None, ge=0)
|
||||
pallets_count: int | None = Field(None, ge=0)
|
||||
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
|
||||
measurement_unit: str | None = Field(None, max_length=20)
|
||||
packaging_type: str | None = Field(None, max_length=20)
|
||||
oversized: bool | None = None
|
||||
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
|
||||
additional_services: list[str] | None = None
|
||||
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)
|
||||
@@ -76,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
case_id: int | None = None
|
||||
first_contact_at: datetime | None = None
|
||||
first_contact_notes: str | None = None
|
||||
tenant_id: int
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
@@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
|
||||
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
@@ -57,6 +58,57 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ----- Campos del documento maestro de cotización (T2026-08) -----
|
||||
# Datos generales
|
||||
contact_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
|
||||
)
|
||||
request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente
|
||||
# Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre)
|
||||
origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# Mercancía
|
||||
cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria
|
||||
goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía
|
||||
hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
# Dimensiones y bultos
|
||||
pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto)
|
||||
length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# FCL
|
||||
container_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# LCL
|
||||
packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
|
||||
volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
|
||||
# Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago
|
||||
additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
# 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)."""
|
||||
|
||||
@@ -4,7 +4,10 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..cases import service as cases_service
|
||||
from ..catalogs.data import INCOTERM_CODES
|
||||
from ..common.folios import next_folio
|
||||
from ..contacts.models import Contact
|
||||
from ..opportunities.models import Opportunity
|
||||
from ..suppliers.models import Supplier
|
||||
from .dto import (
|
||||
@@ -37,6 +40,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int
|
||||
def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
|
||||
if not _exists(db, Contact, data.get("contact_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto asociado no existe")
|
||||
if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
|
||||
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
|
||||
@@ -103,6 +108,15 @@ def create_service_request(
|
||||
data = payload.model_dump()
|
||||
_validate_request_refs(db, data, tenant_id, company_id)
|
||||
obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
# Folio S... auto-generado (mensual) si no viene uno explícito
|
||||
if not obj.reference:
|
||||
obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type)
|
||||
# Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí
|
||||
if not obj.case_id:
|
||||
case = cases_service.create_case(
|
||||
db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
|
||||
)
|
||||
obj.case_id = case.id
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
@@ -166,10 +180,24 @@ def create_from_opportunity(
|
||||
)
|
||||
if not opp:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
|
||||
|
||||
# Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud
|
||||
if opp.converted_service_request_id:
|
||||
existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id)
|
||||
return existing
|
||||
|
||||
# La dirección impo/expo se hereda de la oportunidad (respaldo: el payload)
|
||||
operation_type = opp.operation_type or payload.operation_type
|
||||
if not operation_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Define la dirección (importación/exportación) en la oportunidad para convertirla",
|
||||
)
|
||||
obj = ServiceRequest(
|
||||
account_id=opp.account_id,
|
||||
contact_id=opp.contact_id,
|
||||
opportunity_id=opp.id,
|
||||
operation_type=payload.operation_type,
|
||||
operation_type=operation_type,
|
||||
transport_mode=payload.transport_mode,
|
||||
service_type=payload.service_type,
|
||||
incoterm=payload.incoterm,
|
||||
@@ -178,12 +206,24 @@ def create_from_opportunity(
|
||||
status="nueva",
|
||||
notes=payload.notes,
|
||||
owner_user_id=opp.owner_user_id,
|
||||
reference=next_folio(db, tenant_id, company_id, "S", operation_type),
|
||||
case_id=opp.case_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
# Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente)
|
||||
if not obj.case_id:
|
||||
obj.case_id = cases_service.create_case(
|
||||
db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
|
||||
).id
|
||||
opp.case_id = obj.case_id
|
||||
cases_service.advance_stage(db, obj.case_id, "solicitud")
|
||||
# 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
|
||||
|
||||
@@ -7,16 +7,36 @@ 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 fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
||||
|
||||
from core.security import get_current_user
|
||||
from core.storage_s3 import presigned_get_url, put_object_bytes
|
||||
from core.storage_s3 import get_object_bytes, 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._-]+")
|
||||
|
||||
# Extensiones que el CRM acepta subir. Es una ALLOWLIST y no una lista de vetados: lo segundo
|
||||
# deja pasar todo lo que nadie pensó en prohibir.
|
||||
EXTENSIONES_PERMITIDAS = frozenset({
|
||||
# Documentos
|
||||
"pdf", "xml", "csv", "txt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
|
||||
# Imágenes (fotos de maniobras, sellos, evidencias)
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff",
|
||||
# Paquetes (juegos de documentos de un embarque)
|
||||
"zip", "rar", "7z",
|
||||
# Correo, que en comercio exterior se archiva como evidencia
|
||||
"msg", "eml",
|
||||
})
|
||||
|
||||
# Prefijos —ya dentro de ``tenants/{tid}/companies/{cid}/``— que son documentos del CRM. El
|
||||
# alcance de estos endpoints es «los archivos que el CRM subió», NO todo el almacén de la
|
||||
# company: ahí conviven los certificados de la FIEL y del CSD, los CFDI, los CSV de importación
|
||||
# y el branding, que nada tienen que ver con el permiso de módulo ``crm.access``.
|
||||
_PREFIJOS_DOCUMENTOS = ("crm-docs/",)
|
||||
_PATRON_DOCUMENTOS = re.compile(r"^expedientes/\d+/documents/")
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
base = (name or "archivo").strip().replace(" ", "_")
|
||||
@@ -24,6 +44,41 @@ def _safe_filename(name: str | None) -> str:
|
||||
return base[:120]
|
||||
|
||||
|
||||
def validar_extension(name: str | None) -> None:
|
||||
"""Rechaza lo que no esté en la allowlist. Un archivo sin extensión tampoco pasa.
|
||||
|
||||
Se valida el NOMBRE y no el ``content_type``: el segundo lo pone el navegador y quien sube
|
||||
el archivo lo controla, así que no es una comprobación.
|
||||
"""
|
||||
_, punto, extension = (name or "").rpartition(".")
|
||||
if not punto or extension.lower() not in EXTENSIONES_PERMITIDAS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Ese tipo de archivo no está permitido.",
|
||||
)
|
||||
|
||||
|
||||
def _validar_alcance(key: str, tenant_id: int, company_id: int) -> None:
|
||||
"""Comprueba que la key sea un documento del CRM de ESTE tenant y company.
|
||||
|
||||
Son dos guardas y la segunda no reemplaza a la primera. El aislamiento por
|
||||
tenant/company evita leer el almacén de otro cliente; el alcance por prefijo evita que el
|
||||
permiso de módulo del CRM sirva para firmar un objeto que pertenece a otro módulo.
|
||||
"""
|
||||
prefijo = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefijo):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance"
|
||||
)
|
||||
|
||||
relativa = key[len(prefijo):]
|
||||
if relativa.startswith(_PREFIJOS_DOCUMENTOS) or _PATRON_DOCUMENTOS.match(relativa):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/uploads")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -31,6 +86,7 @@ async def upload_file(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
validar_extension(file.filename)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
@@ -55,9 +111,32 @@ def get_upload_url(
|
||||
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")
|
||||
_validar_alcance(key, current_user["tenant_id"], company_id)
|
||||
return {"url": presigned_get_url(key)}
|
||||
|
||||
|
||||
@router.get("/uploads/download")
|
||||
def download_file(
|
||||
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),
|
||||
):
|
||||
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
|
||||
|
||||
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
|
||||
|
||||
Mismo alcance que ``/uploads/url``, y por la misma razón: este endpoint entrega los BYTES,
|
||||
así que dejarlo más abierto que el que solo firma una URL sería la puerta grande al lado de
|
||||
la que se acaba de cerrar.
|
||||
"""
|
||||
_validar_alcance(key, current_user["tenant_id"], company_id)
|
||||
try:
|
||||
data = get_object_bytes(key)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
|
||||
filename = key.rsplit("/", 1)[-1]
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura."""
|
||||
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Esquemas de respuesta de los catálogos del SAT (solo lectura)."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SatCatalogItem(BaseModel):
|
||||
"""Forma común de todo catálogo del SAT: clave + descripción."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
code: str
|
||||
description: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class TaxRegimeResponse(SatCatalogItem):
|
||||
"""``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen."""
|
||||
|
||||
applies_to_individual: bool # persona física
|
||||
applies_to_legal_entity: bool # persona moral
|
||||
|
||||
|
||||
class TaxResponse(SatCatalogItem):
|
||||
"""``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse."""
|
||||
|
||||
is_withholding: bool
|
||||
is_transferred: bool
|
||||
is_local: bool
|
||||
|
||||
|
||||
class UnitOfMeasureResponse(SatCatalogItem):
|
||||
"""``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo."""
|
||||
|
||||
description: str | None = None
|
||||
name: str
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class PaymentFormResponse(SatCatalogItem):
|
||||
"""``c_FormaPago``."""
|
||||
|
||||
|
||||
class ProductServiceResponse(SatCatalogItem):
|
||||
"""``c_ClaveProdServ``."""
|
||||
|
||||
|
||||
class VoucherTypeResponse(SatCatalogItem):
|
||||
"""``c_TipoDeComprobante``."""
|
||||
|
||||
|
||||
class PaymentMethodResponse(SatCatalogItem):
|
||||
"""``c_MetodoPago``."""
|
||||
|
||||
|
||||
class TaxObjectResponse(SatCatalogItem):
|
||||
"""``c_ObjetoImp``."""
|
||||
|
||||
|
||||
class CfdiUseResponse(SatCatalogItem):
|
||||
"""``c_UsoCFDI``."""
|
||||
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Modelos de los catálogos oficiales del SAT — schema ``sat``.
|
||||
|
||||
Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y
|
||||
compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT
|
||||
retira una clave, el registro se marca ``is_active = false`` para que las facturas
|
||||
históricas que la usan sigan resolviendo su descripción (de ahí que se use
|
||||
``BaseTimestampMixin``, sin ``deleted_at``).
|
||||
|
||||
La API los expone únicamente en modo lectura; el alta y la actualización pasan por
|
||||
``seed_data.sync_catalogs()``.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import BaseTimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class SatCatalogMixin(BaseTimestampMixin):
|
||||
"""Campos comunes a todo catálogo del SAT.
|
||||
|
||||
``code`` (la clave oficial) se declara en cada modelo porque su longitud
|
||||
cambia de catálogo en catálogo.
|
||||
"""
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
|
||||
|
||||
class TaxRegime(Base, SatCatalogMixin):
|
||||
"""``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI.
|
||||
|
||||
Las banderas indican a qué tipo de persona aplica el régimen: una persona física
|
||||
no puede declararse en el 601 (General de Ley Personas Morales) y viceversa.
|
||||
"""
|
||||
|
||||
__tablename__ = "tax_regimes"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
applies_to_individual: Mapped[bool] = mapped_column( # persona física
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
|
||||
|
||||
class Tax(Base, SatCatalogMixin):
|
||||
"""``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse."""
|
||||
|
||||
__tablename__ = "taxes"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
is_withholding: Mapped[bool] = mapped_column( # puede retenerse
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
is_transferred: Mapped[bool] = mapped_column( # puede trasladarse
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
# Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos
|
||||
# Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para
|
||||
# cuando el negocio defina ese catálogo.
|
||||
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
|
||||
class PaymentForm(Base, SatCatalogMixin):
|
||||
"""``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…)."""
|
||||
|
||||
__tablename__ = "payment_forms"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class UnitOfMeasure(Base, SatCatalogMixin):
|
||||
"""``c_ClaveUnidad`` — unidad de medida de la partida.
|
||||
|
||||
Único catálogo que separa nombre corto y definición: ``name`` es lo que se
|
||||
muestra al capturar y ``description`` la nota larga del SAT, que puede venir
|
||||
vacía.
|
||||
"""
|
||||
|
||||
__tablename__ = "units_of_measure"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
symbol: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo.
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class ProductService(Base, SatCatalogMixin):
|
||||
"""``c_ClaveProdServ`` — clave de producto o servicio de la partida."""
|
||||
|
||||
__tablename__ = "products_services"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class VoucherType(Base, SatCatalogMixin):
|
||||
"""``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago."""
|
||||
|
||||
__tablename__ = "voucher_types"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class PaymentMethod(Base, SatCatalogMixin):
|
||||
"""``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido)."""
|
||||
|
||||
__tablename__ = "payment_methods"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class TaxObject(Base, SatCatalogMixin):
|
||||
"""``c_ObjetoImp`` — si la partida es o no objeto de impuesto."""
|
||||
|
||||
__tablename__ = "tax_objects"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class CfdiUse(Base, SatCatalogMixin):
|
||||
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante.
|
||||
|
||||
Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen
|
||||
fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``).
|
||||
"""
|
||||
|
||||
__tablename__ = "cfdi_uses"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True)
|
||||
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Endpoints de los catálogos del SAT — **solo lectura**.
|
||||
|
||||
No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por
|
||||
el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API.
|
||||
|
||||
Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``,
|
||||
permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando
|
||||
``company_id`` en la query string.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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 (
|
||||
CfdiUseResponse,
|
||||
PaymentFormResponse,
|
||||
PaymentMethodResponse,
|
||||
ProductServiceResponse,
|
||||
TaxObjectResponse,
|
||||
TaxRegimeResponse,
|
||||
TaxResponse,
|
||||
UnitOfMeasureResponse,
|
||||
VoucherTypeResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SEARCH = Query(None, description="Búsqueda por clave o descripción")
|
||||
_ACTIVE_ONLY = Query(True, description="Solo claves vigentes")
|
||||
|
||||
|
||||
@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse])
|
||||
def list_tax_regimes(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
person_type: Literal["fisica", "moral"] | None = Query(
|
||||
None, description="Acota al régimen de persona física o moral"
|
||||
),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI."""
|
||||
return service.get_tax_regimes(db, search, active_only, person_type)
|
||||
|
||||
|
||||
@router.get("/catalogs/taxes", response_model=list[TaxResponse])
|
||||
def list_taxes(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_Impuesto`` — impuestos federales trasladados y retenidos."""
|
||||
return service.get_taxes(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse])
|
||||
def list_payment_forms(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_FormaPago`` — medio con el que se liquidó el comprobante."""
|
||||
return service.get_payment_forms(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse])
|
||||
def list_units_of_measure(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ClaveUnidad`` — unidad de medida de la partida."""
|
||||
return service.get_units_of_measure(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse])
|
||||
def list_products_services(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado."""
|
||||
return service.get_products_services(db, search, active_only, limit)
|
||||
|
||||
|
||||
@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse])
|
||||
def list_voucher_types(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago."""
|
||||
return service.get_voucher_types(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse])
|
||||
def list_payment_methods(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_MetodoPago`` — PUE o PPD."""
|
||||
return service.get_payment_methods(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse])
|
||||
def list_tax_objects(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ObjetoImp`` — si la partida es objeto de impuesto."""
|
||||
return service.get_tax_objects(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse])
|
||||
def list_cfdi_uses(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante."""
|
||||
return service.get_cfdi_uses(db, search, active_only)
|
||||
340
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
340
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Datos semilla de los catálogos del SAT y su sincronización idempotente.
|
||||
|
||||
Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el
|
||||
SAT corrige una descripción o publica una clave nueva, basta editar estas listas y
|
||||
volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema.
|
||||
|
||||
Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con
|
||||
los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la
|
||||
definición ORM, que sigue evolucionando.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
_metadata = sa.MetaData()
|
||||
|
||||
|
||||
def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table:
|
||||
"""Tabla mínima de catálogo: las columnas que toca el upsert, nada más."""
|
||||
return sa.Table(
|
||||
name,
|
||||
_metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("code", sa.String, nullable=False),
|
||||
sa.Column("description", sa.String),
|
||||
sa.Column("is_active", sa.Boolean),
|
||||
*extra_columns,
|
||||
schema="sat",
|
||||
)
|
||||
|
||||
|
||||
tax_regimes_table = _catalog_table(
|
||||
"tax_regimes",
|
||||
sa.Column("applies_to_individual", sa.Boolean),
|
||||
sa.Column("applies_to_legal_entity", sa.Boolean),
|
||||
)
|
||||
taxes_table = _catalog_table(
|
||||
"taxes",
|
||||
sa.Column("is_withholding", sa.Boolean),
|
||||
sa.Column("is_transferred", sa.Boolean),
|
||||
sa.Column("is_local", sa.Boolean),
|
||||
)
|
||||
payment_forms_table = _catalog_table("payment_forms")
|
||||
units_of_measure_table = _catalog_table(
|
||||
"units_of_measure",
|
||||
sa.Column("name", sa.String),
|
||||
sa.Column("symbol", sa.String),
|
||||
)
|
||||
products_services_table = _catalog_table("products_services")
|
||||
voucher_types_table = _catalog_table("voucher_types")
|
||||
payment_methods_table = _catalog_table("payment_methods")
|
||||
tax_objects_table = _catalog_table("tax_objects")
|
||||
cfdi_uses_table = _catalog_table("cfdi_uses")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_RegimenFiscal (CFDI 4.0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict:
|
||||
return {
|
||||
"code": code,
|
||||
"description": description,
|
||||
"applies_to_individual": individual,
|
||||
"applies_to_legal_entity": legal_entity,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
TAX_REGIMES: list[dict] = [
|
||||
_regime("601", "General de Ley Personas Morales", False, True),
|
||||
_regime("603", "Personas Morales con Fines no Lucrativos", False, True),
|
||||
_regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False),
|
||||
_regime("606", "Arrendamiento", True, False),
|
||||
_regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False),
|
||||
_regime("608", "Demás ingresos", True, False),
|
||||
_regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True),
|
||||
_regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False),
|
||||
_regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False),
|
||||
_regime("614", "Ingresos por intereses", True, False),
|
||||
_regime("615", "Régimen de los ingresos por obtención de premios", True, False),
|
||||
_regime("616", "Sin obligaciones fiscales", True, False),
|
||||
_regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True),
|
||||
_regime("621", "Incorporación Fiscal", True, False),
|
||||
_regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True),
|
||||
_regime("623", "Opcional para Grupos de Sociedades", False, True),
|
||||
_regime("624", "Coordinados", False, True),
|
||||
_regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False),
|
||||
_regime("626", "Régimen Simplificado de Confianza", True, True),
|
||||
# Claves publicadas por el SAT con vigencia a partir del 01-01-2024.
|
||||
_regime("628", "Hidrocarburos", False, True),
|
||||
_regime("629", "De los Regímenes Fiscales Preferentes y de las Empresas Multinacionales", True, False),
|
||||
_regime("630", "Enajenación de acciones en bolsa de valores", True, False),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_Impuesto
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_local queda en false para los tres: los impuestos locales (ISH y similares)
|
||||
# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen
|
||||
# a c_Impuesto. No se siembran registros locales inventados.
|
||||
|
||||
TAXES: list[dict] = [
|
||||
{"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True},
|
||||
{"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||
{"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_FormaPago
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PAYMENT_FORMS: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("01", "Efectivo"),
|
||||
("02", "Cheque nominativo"),
|
||||
("03", "Transferencia electrónica de fondos"),
|
||||
("04", "Tarjeta de crédito"),
|
||||
("05", "Monedero electrónico"),
|
||||
("06", "Dinero electrónico"),
|
||||
("08", "Vales de despensa"),
|
||||
("12", "Dación en pago"),
|
||||
("13", "Pago por subrogación"),
|
||||
("14", "Pago por consignación"),
|
||||
("15", "Condonación"),
|
||||
("17", "Compensación"),
|
||||
("23", "Novación"),
|
||||
("24", "Confusión"),
|
||||
("25", "Remisión de deuda"),
|
||||
("26", "Prescripción o caducidad"),
|
||||
("27", "A satisfacción del acreedor"),
|
||||
("28", "Tarjeta de débito"),
|
||||
("29", "Tarjeta de servicios"),
|
||||
("30", "Aplicación de anticipos"),
|
||||
("31", "Intermediario pagos"),
|
||||
("99", "Por definir"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_TipoDeComprobante
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VOUCHER_TYPES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("I", "Ingreso"),
|
||||
("E", "Egreso"),
|
||||
("T", "Traslado"),
|
||||
("N", "Nómina"),
|
||||
("P", "Pago"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_MetodoPago
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PAYMENT_METHODS: list[dict] = [
|
||||
{"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True},
|
||||
{"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ObjetoImp
|
||||
# ---------------------------------------------------------------------------
|
||||
# Versiones posteriores del catálogo incorporan las claves 05–07; no se siembran
|
||||
# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN).
|
||||
|
||||
TAX_OBJECTS: list[dict] = [
|
||||
{"code": "01", "description": "No objeto de impuesto", "is_active": True},
|
||||
{"code": "02", "description": "Sí objeto de impuesto", "is_active": True},
|
||||
{"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True},
|
||||
{"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ClaveUnidad — subset operativo
|
||||
# ---------------------------------------------------------------------------
|
||||
# description queda en NULL: es la nota larga del catálogo, que aquí no aporta.
|
||||
|
||||
UNITS_OF_MEASURE: list[dict] = [
|
||||
{"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True}
|
||||
for code, name, symbol in [
|
||||
("H87", "Pieza", "pz"),
|
||||
("E48", "Unidad de servicio", None),
|
||||
("ACT", "Actividad", None),
|
||||
("C62", "Uno", None),
|
||||
("KGM", "Kilogramo", "kg"),
|
||||
("TNE", "Tonelada métrica", "t"),
|
||||
("GRM", "Gramo", "g"),
|
||||
("LTR", "Litro", "l"),
|
||||
("MTR", "Metro", "m"),
|
||||
("MTK", "Metro cuadrado", "m²"),
|
||||
("MTQ", "Metro cúbico", "m³"),
|
||||
("KMT", "Kilómetro", "km"),
|
||||
("CMT", "Centímetro", "cm"),
|
||||
("DAY", "Día", "d"),
|
||||
("HUR", "Hora", "h"),
|
||||
("MON", "Mes", None),
|
||||
("XBX", "Caja", None),
|
||||
("XPK", "Paquete", None),
|
||||
("XPX", "Paleta / tarima", None),
|
||||
("XLT", "Lote", None),
|
||||
("E51", "Trabajo", None),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ClaveProdServ — subset de logística
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con
|
||||
# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo
|
||||
# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como
|
||||
# PENDIENTE DECISIÓN: no se deduce ni se inventa.
|
||||
|
||||
PRODUCTS_SERVICES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("78101500", "Transporte de carga por carretera"),
|
||||
("78101600", "Transporte de carga marítimo"),
|
||||
("78101700", "Transporte de carga por ferrocarril"),
|
||||
("78101800", "Transporte de carga aérea"),
|
||||
("78102200", "Servicios postales de paqueteo y courrier"),
|
||||
("78121600", "Embalaje"),
|
||||
("78131600", "Almacenaje"),
|
||||
("78141500", "Servicios de planificación logística"),
|
||||
("78141600", "Servicios de expedición de fletes"),
|
||||
("84131500", "Seguros de vida, salud y accidentes / seguros de carga"),
|
||||
("80101500", "Servicios de consultoría de negocios y administración corporativa"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_UsoCFDI
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catálogo del uso que el receptor da al comprobante. Se siembran clave y
|
||||
# descripción; **no** se cargan las banderas de persona física/moral ni la
|
||||
# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del
|
||||
# catálogo y equivocarla provoca rechazos al timbrar.
|
||||
#
|
||||
# Pendiente validación con área Fiscal antes de producción, igual que el subset de
|
||||
# c_ClaveProdServ.
|
||||
|
||||
CFDI_USES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("G01", "Adquisición de mercancías"),
|
||||
("G02", "Devoluciones, descuentos o bonificaciones"),
|
||||
("G03", "Gastos en general"),
|
||||
("I01", "Construcciones"),
|
||||
("I02", "Mobiliario y equipo de oficina por inversiones"),
|
||||
("I03", "Equipo de transporte"),
|
||||
("I04", "Equipo de cómputo y accesorios"),
|
||||
("I05", "Dados, troqueles, moldes, matrices y herramental"),
|
||||
("I06", "Comunicaciones telefónicas"),
|
||||
("I07", "Comunicaciones satelitales"),
|
||||
("I08", "Otra maquinaria y equipo"),
|
||||
("D01", "Honorarios médicos, dentales y gastos hospitalarios"),
|
||||
("D02", "Gastos médicos por incapacidad o discapacidad"),
|
||||
("D03", "Gastos funerales"),
|
||||
("D04", "Donativos"),
|
||||
("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"),
|
||||
("D06", "Aportaciones voluntarias al SAR"),
|
||||
("D07", "Primas por seguros de gastos médicos"),
|
||||
("D08", "Gastos de transportación escolar obligatoria"),
|
||||
("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"),
|
||||
("D10", "Pagos por servicios educativos (colegiaturas)"),
|
||||
("S01", "Sin efectos fiscales"),
|
||||
("CP01", "Pagos"),
|
||||
("CN01", "Nómina"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# Orden estable de sincronización: (tabla, filas).
|
||||
CATALOGS: list[tuple[sa.Table, list[dict]]] = [
|
||||
(tax_regimes_table, TAX_REGIMES),
|
||||
(taxes_table, TAXES),
|
||||
(payment_forms_table, PAYMENT_FORMS),
|
||||
(units_of_measure_table, UNITS_OF_MEASURE),
|
||||
(products_services_table, PRODUCTS_SERVICES),
|
||||
(voucher_types_table, VOUCHER_TYPES),
|
||||
(payment_methods_table, PAYMENT_METHODS),
|
||||
(tax_objects_table, TAX_OBJECTS),
|
||||
(cfdi_uses_table, CFDI_USES),
|
||||
]
|
||||
|
||||
|
||||
def sync_catalogs(connection) -> dict[str, int]:
|
||||
"""Sincroniza los catálogos del SAT contra la base, de forma idempotente.
|
||||
|
||||
Inserta las claves que faltan y actualiza descripción y banderas de las que ya
|
||||
existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para
|
||||
no romper los CFDI históricos que la referencian.
|
||||
|
||||
Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de
|
||||
la migración.
|
||||
|
||||
Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de
|
||||
migraciones desde cero, una migración antigua invoca esta misma función cuando los
|
||||
catálogos agregados después aún no se han creado. Cada uno se siembra en la
|
||||
migración que lo crea.
|
||||
|
||||
Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la
|
||||
conexión de una sesión en pruebas.
|
||||
"""
|
||||
inspector = sa.inspect(connection)
|
||||
# La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None
|
||||
# sobre SQLite), así que se resuelve el schema efectivo a mano.
|
||||
schema_map = connection.get_execution_options().get("schema_translate_map") or {}
|
||||
|
||||
inserted: dict[str, int] = {}
|
||||
for table, rows in CATALOGS:
|
||||
effective_schema = schema_map.get(table.schema, table.schema)
|
||||
if not inspector.has_table(table.name, schema=effective_schema):
|
||||
continue
|
||||
key = f"sat.{table.name}"
|
||||
inserted[key] = 0
|
||||
for row in rows:
|
||||
existing = connection.execute(
|
||||
sa.select(table.c.id).where(table.c.code == row["code"])
|
||||
).scalar()
|
||||
values = {k: v for k, v in row.items() if k != "code"}
|
||||
if existing is None:
|
||||
connection.execute(table.insert().values(code=row["code"], **values))
|
||||
inserted[key] += 1
|
||||
else:
|
||||
connection.execute(
|
||||
table.update().where(table.c.id == existing).values(**values)
|
||||
)
|
||||
return inserted
|
||||
136
backend/api/v1/modules/fin/catalogs/service.py
Normal file
136
backend/api/v1/modules/fin/catalogs/service.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Consultas de los catálogos del SAT.
|
||||
|
||||
Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas,
|
||||
cambios ni bajas, únicamente búsqueda para llenar los selectores de captura.
|
||||
"""
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
CfdiUse,
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
TaxRegime,
|
||||
UnitOfMeasure,
|
||||
VoucherType,
|
||||
)
|
||||
|
||||
# Catálogos que además del código y la descripción buscan por nombre corto.
|
||||
_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)}
|
||||
|
||||
|
||||
def find_by_code(db: Session, model, code: str | None, active_only: bool = True):
|
||||
"""Resuelve una clave del SAT a su fila del catálogo. ``None`` si no hay coincidencia.
|
||||
|
||||
Existe para traducir a id los datos fiscales que el CRM guarda como TEXTO. En
|
||||
``crm.accounts`` la forma y el método de pago son ``String(60)`` sin FK ni validación: lo
|
||||
normal es que traigan la clave del SAT ('03', 'PUE'), porque la ficha se llena con el
|
||||
``code`` de los catálogos del CRM, pero nada garantiza que no haya texto histórico como
|
||||
'Transferencia'.
|
||||
|
||||
Devuelve ``None`` en vez de lanzar, y es la decisión importante: una ficha de cliente mal
|
||||
capturada **no puede impedir crear una factura**. El faltante lo reporta la validación del
|
||||
timbrado, que acumula todos los pendientes y los entrega juntos — el mismo criterio que
|
||||
``stamping.service._code``.
|
||||
|
||||
``active_only`` por defecto: una clave que el SAT retiró no debe entrar en un comprobante
|
||||
nuevo.
|
||||
"""
|
||||
limpio = (code or "").strip().upper()
|
||||
if not limpio:
|
||||
return None
|
||||
# c_FormaPago son dos dígitos: una ficha con '3' en vez de '03' es la misma forma de pago.
|
||||
if model is PaymentForm and limpio.isdigit():
|
||||
limpio = limpio.zfill(2)
|
||||
|
||||
q = db.query(model).filter(model.code == limpio)
|
||||
if active_only:
|
||||
q = q.filter(model.is_active.is_(True))
|
||||
return q.first()
|
||||
|
||||
|
||||
def search_catalog(
|
||||
db: Session,
|
||||
model,
|
||||
search: str | None = None,
|
||||
active_only: bool = True,
|
||||
limit: int | None = None,
|
||||
) -> list:
|
||||
"""Devuelve las claves de un catálogo, filtradas por texto libre.
|
||||
|
||||
``search`` compara contra la clave o la descripción sin distinguir mayúsculas.
|
||||
"""
|
||||
q = db.query(model)
|
||||
if active_only:
|
||||
q = q.filter(model.is_active.is_(True))
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
fields = [model.code, model.description]
|
||||
for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()):
|
||||
fields.append(getattr(model, extra))
|
||||
q = q.filter(or_(*[f.ilike(term) for f in fields]))
|
||||
q = q.order_by(model.code.asc())
|
||||
if limit is not None:
|
||||
q = q.limit(limit)
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_tax_regimes(
|
||||
db: Session,
|
||||
search: str | None = None,
|
||||
active_only: bool = True,
|
||||
person_type: str | None = None,
|
||||
) -> list[TaxRegime]:
|
||||
"""``c_RegimenFiscal``, opcionalmente acotado al tipo de persona.
|
||||
|
||||
``person_type='fisica'`` deja solo los regímenes que puede usar una persona
|
||||
física; ``'moral'``, los de persona moral.
|
||||
"""
|
||||
q = db.query(TaxRegime)
|
||||
if active_only:
|
||||
q = q.filter(TaxRegime.is_active.is_(True))
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term)))
|
||||
if person_type == "fisica":
|
||||
q = q.filter(TaxRegime.applies_to_individual.is_(True))
|
||||
elif person_type == "moral":
|
||||
q = q.filter(TaxRegime.applies_to_legal_entity.is_(True))
|
||||
return q.order_by(TaxRegime.code.asc()).all()
|
||||
|
||||
|
||||
def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]:
|
||||
return search_catalog(db, Tax, search, active_only)
|
||||
|
||||
|
||||
def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]:
|
||||
return search_catalog(db, PaymentForm, search, active_only)
|
||||
|
||||
|
||||
def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]:
|
||||
return search_catalog(db, UnitOfMeasure, search, active_only)
|
||||
|
||||
|
||||
def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]:
|
||||
"""``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado."""
|
||||
return search_catalog(db, ProductService, search, active_only, limit=limit)
|
||||
|
||||
|
||||
def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]:
|
||||
return search_catalog(db, VoucherType, search, active_only)
|
||||
|
||||
|
||||
def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]:
|
||||
return search_catalog(db, PaymentMethod, search, active_only)
|
||||
|
||||
|
||||
def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]:
|
||||
return search_catalog(db, TaxObject, search, active_only)
|
||||
|
||||
|
||||
def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]:
|
||||
return search_catalog(db, CfdiUse, search, active_only)
|
||||
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Catálogo de conceptos de facturación por empresa."""
|
||||
110
backend/api/v1/modules/fin/concepts/dto.py
Normal file
110
backend/api/v1/modules/fin/concepts/dto.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Esquemas del catálogo de conceptos de facturación."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from ..catalogs.dto import (
|
||||
ProductServiceResponse,
|
||||
TaxObjectResponse,
|
||||
TaxResponse,
|
||||
UnitOfMeasureResponse,
|
||||
)
|
||||
|
||||
# Configuración fiscal por defecto del concepto: es lo que permite tener conceptos que no
|
||||
# causan IVA. La tasa va como FRACCIÓN (0.16), igual que en la partida y en el XML, NO como el
|
||||
# porcentaje de la factura (16.00).
|
||||
_FISCAL_DEFAULTS = ("default_tax_id", "default_tax_rate", "default_tax_factor")
|
||||
|
||||
|
||||
class _FiscalDefaultsMixin(BaseModel):
|
||||
"""Valida que la configuración fiscal esté completa o vacía, nunca a medias.
|
||||
|
||||
Espeja el CHECK de la base para que el error salga como un 422 legible en vez de un
|
||||
IntegrityError, y para que el service no tenga que adivinar un estado medio capturado.
|
||||
"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _valida_defaults_fiscales(self):
|
||||
puestos = {c for c in _FISCAL_DEFAULTS if getattr(self, c, None) is not None}
|
||||
if not puestos:
|
||||
return self
|
||||
if self.default_tax_id is None or self.default_tax_factor is None:
|
||||
raise ValueError(
|
||||
"La configuración fiscal del concepto necesita impuesto y tipo de factor"
|
||||
)
|
||||
if self.default_tax_factor == "Exento":
|
||||
if self.default_tax_rate:
|
||||
raise ValueError("Un concepto exento no lleva tasa")
|
||||
elif self.default_tax_rate is None:
|
||||
raise ValueError("Un concepto con factor Tasa necesita su tasa (0 para el 0%)")
|
||||
return self
|
||||
|
||||
|
||||
class ConceptBase(_FiscalDefaultsMixin):
|
||||
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
|
||||
description: str = Field(..., min_length=1, max_length=500)
|
||||
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
|
||||
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
|
||||
# con TasaOCuota, el segundo sí.
|
||||
default_tax_id: int | None = None
|
||||
default_tax_rate: Decimal | None = Field(
|
||||
None, ge=0, le=1, max_digits=8, decimal_places=6,
|
||||
description="Fracción, no porcentaje: 0.16 es el 16%",
|
||||
)
|
||||
default_tax_factor: Literal["Tasa", "Exento"] | None = None
|
||||
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str = Field("MXN", min_length=3, max_length=3)
|
||||
is_active: bool = True
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConceptCreate(ConceptBase):
|
||||
pass
|
||||
|
||||
|
||||
class ConceptUpdate(_FiscalDefaultsMixin):
|
||||
"""Actualización parcial: solo se tocan los campos enviados."""
|
||||
|
||||
code: str | None = Field(None, min_length=1, max_length=40)
|
||||
description: str | None = Field(None, min_length=1, max_length=500)
|
||||
product_service_id: int | None = None
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
|
||||
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
|
||||
# con TasaOCuota, el segundo sí.
|
||||
default_tax_id: int | None = None
|
||||
default_tax_rate: Decimal | None = Field(
|
||||
None, ge=0, le=1, max_digits=8, decimal_places=6,
|
||||
description="Fracción, no porcentaje: 0.16 es el 16%",
|
||||
)
|
||||
default_tax_factor: Literal["Tasa", "Exento"] | None = None
|
||||
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
is_active: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConceptResponse(ConceptBase):
|
||||
"""Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
product_service: ProductServiceResponse | None = None
|
||||
unit_of_measure: UnitOfMeasureResponse | None = None
|
||||
tax_object: TaxObjectResponse | None = None
|
||||
default_tax: TaxResponse | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
103
backend/api/v1/modules/fin/concepts/models.py
Normal file
103
backend/api/v1/modules/fin/concepts/models.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Catálogo de conceptos de facturación — ``fin.concepts``.
|
||||
|
||||
A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada
|
||||
concepto que la empresa factura (flete internacional, despacho, almacenaje…) se
|
||||
registra una vez y queda amarrado a la clave de producto/servicio del SAT que le
|
||||
corresponde.
|
||||
|
||||
La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos
|
||||
compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál
|
||||
descripción corresponde a la clave, así que la unicidad se garantiza por índice y se
|
||||
valida además en el service para devolver un 409 con mensaje entendible.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from ..catalogs.models import Tax, ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
|
||||
|
||||
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
|
||||
# lógica libera su clave y su código para uno nuevo.
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
|
||||
class Concept(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Concepto facturable de una empresa, ligado a una clave ProdServ del SAT."""
|
||||
|
||||
__tablename__ = "concepts"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_concepts_code",
|
||||
"tenant_id", "company_id", "code",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
Index(
|
||||
"uq_fin_concepts_product_service",
|
||||
"tenant_id", "company_id", "product_service_id",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
# Duplicados de la migración a propósito: las pruebas construyen el esquema con
|
||||
# ``create_all``, así que sin esto validarían una base distinta de la de producción.
|
||||
CheckConstraint(
|
||||
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
|
||||
name="ck_fin_concepts_default_tax_factor",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
|
||||
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
|
||||
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
|
||||
name="ck_fin_concepts_default_tax_coherente",
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
product_service_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True
|
||||
)
|
||||
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
||||
)
|
||||
tax_object_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
||||
)
|
||||
# ── Configuración fiscal por defecto ────────────────────────────────────────────────────
|
||||
# La partida hereda de aquí su impuesto cuando el concepto lo define, y entonces el % global
|
||||
# de la factura deja de aplicarle. Es lo que permite tener conceptos que no causan IVA:
|
||||
# exentos (factor 'Exento') o a tasa 0% (factor 'Tasa' con tasa 0), que fiscalmente NO son lo
|
||||
# mismo ni entre sí ni que un ObjetoImp 01 «no objeto de impuesto».
|
||||
default_tax_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.taxes.id"), nullable=True
|
||||
)
|
||||
# FRACCIÓN con 6 decimales (0.160000), igual que ``invoice_item_taxes.rate`` y que el
|
||||
# TasaOCuota del XML. NO es el porcentaje de ``invoices.tax_rate`` (16.00): el tipo coincide
|
||||
# con el destino justo para que la copia sea trivial y no haya un factor 100 en el camino.
|
||||
default_tax_rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True)
|
||||
default_tax_factor: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("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)
|
||||
|
||||
# Cargadas con selectinload para que el listado no dispare N+1 consultas.
|
||||
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
|
||||
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
|
||||
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
|
||||
default_tax: Mapped["Tax | None"] = relationship("Tax", lazy="selectin")
|
||||
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _uid(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/concepts",
|
||||
response_model=list[ConceptResponse],
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||
)
|
||||
def list_concepts(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None, description="Búsqueda por clave o descripción"),
|
||||
active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"),
|
||||
product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_concepts(
|
||||
db, current_user["tenant_id"], company_id, search, active_only, product_service_id
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/concepts/{concept_id}",
|
||||
response_model=ConceptResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||
)
|
||||
def get_concept(
|
||||
concept_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_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/concepts",
|
||||
response_model=ConceptResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.create"]))],
|
||||
)
|
||||
def create_concept(
|
||||
payload: ConceptCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/concepts/{concept_id}",
|
||||
response_model=ConceptResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))],
|
||||
)
|
||||
def update_concept(
|
||||
concept_id: int,
|
||||
payload: ConceptUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_concept(
|
||||
db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/concepts/{concept_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))],
|
||||
)
|
||||
def delete_concept(
|
||||
concept_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Baja lógica del concepto (``deleted_at``)."""
|
||||
service.delete_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||
159
backend/api/v1/modules/fin/concepts/service.py
Normal file
159
backend/api/v1/modules/fin/concepts/service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Lógica del catálogo de conceptos de facturación.
|
||||
|
||||
Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``:
|
||||
el catálogo es privado de cada empresa dentro de cada tenant.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import ProductService, Tax, TaxObject, UnitOfMeasure
|
||||
from .dto import ConceptCreate, ConceptUpdate
|
||||
from .models import Concept
|
||||
|
||||
|
||||
def _check_sat_refs(db: Session, data: dict) -> None:
|
||||
"""Verifica que las claves del SAT referidas existan antes de guardar."""
|
||||
for field, model, msg in [
|
||||
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
|
||||
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
|
||||
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
|
||||
("default_tax_id", Tax, "El impuesto por defecto no existe en el catálogo del SAT"),
|
||||
]:
|
||||
value = data.get(field)
|
||||
if field in data and value is not None:
|
||||
if db.query(model.id).filter(model.id == value).first() is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
# El impuesto por defecto del concepto es un TRASLADO —lo que se le cobra al cliente—, así
|
||||
# que tiene que ser trasladable. Un ISR aquí es un error de captura del catálogo, y atajarlo
|
||||
# en el concepto evita que se propague a cada partida que lo use.
|
||||
default_tax_id = data.get("default_tax_id")
|
||||
if default_tax_id is not None:
|
||||
tax = db.query(Tax).filter(Tax.id == default_tax_id).first()
|
||||
if tax is not None and not tax.is_transferred:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
|
||||
)
|
||||
|
||||
|
||||
def _check_unique(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
code: str | None,
|
||||
product_service_id: int | None,
|
||||
exclude_id: int | None = None,
|
||||
) -> None:
|
||||
"""Aplica en el service las mismas reglas que los índices únicos parciales.
|
||||
|
||||
Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se
|
||||
traduce a un 409 con mensaje en español.
|
||||
"""
|
||||
base = db.query(Concept).filter(
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.deleted_at.is_(None),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
base = base.filter(Concept.id != exclude_id)
|
||||
|
||||
if code is not None and base.filter(Concept.code == code).first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un concepto con la clave '{code}' en esta empresa",
|
||||
)
|
||||
# Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa.
|
||||
if product_service_id is not None and base.filter(
|
||||
Concept.product_service_id == product_service_id
|
||||
).first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa",
|
||||
)
|
||||
|
||||
|
||||
def get_concepts(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
active_only: bool | None = None,
|
||||
product_service_id: int | None = None,
|
||||
) -> list[Concept]:
|
||||
q = db.query(Concept).filter(
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.deleted_at.is_(None),
|
||||
)
|
||||
if active_only is not None:
|
||||
q = q.filter(Concept.is_active.is_(active_only))
|
||||
if product_service_id is not None:
|
||||
q = q.filter(Concept.product_service_id == product_service_id)
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term)))
|
||||
return q.order_by(Concept.code.asc()).all()
|
||||
|
||||
|
||||
def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept:
|
||||
obj = db.query(Concept).filter(
|
||||
Concept.id == concept_id,
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.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_concept(
|
||||
db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Concept:
|
||||
data = payload.model_dump()
|
||||
_check_sat_refs(db, data)
|
||||
_check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"])
|
||||
obj = Concept(**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_concept(
|
||||
db: Session,
|
||||
concept_id: int,
|
||||
payload: ConceptUpdate,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Concept:
|
||||
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_check_sat_refs(db, data)
|
||||
_check_unique(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
data.get("code"),
|
||||
data.get("product_service_id"),
|
||||
exclude_id=obj.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_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Baja lógica: libera la clave ProdServ y el código para un concepto nuevo."""
|
||||
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -1,7 +1,8 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator, computed_field
|
||||
|
||||
|
||||
class InvoiceClientReviewInput(BaseModel):
|
||||
@@ -10,7 +11,16 @@ class InvoiceClientReviewInput(BaseModel):
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InvoiceItemBase(BaseModel):
|
||||
class InvoiceItemSatFields(BaseModel):
|
||||
"""Claves fiscales de la partida. Opcionales: las facturas previas no las tienen."""
|
||||
|
||||
concept_id: int | None = None
|
||||
product_service_id: int | None = None
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
|
||||
|
||||
class InvoiceItemBase(InvoiceItemSatFields):
|
||||
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)
|
||||
@@ -19,9 +29,14 @@ class InvoiceItemBase(BaseModel):
|
||||
|
||||
class InvoiceItemCreate(InvoiceItemBase):
|
||||
invoice_id: int
|
||||
# Opcional solo si viene concept_id: el service copia la descripción del concepto.
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
# Opcional para poder heredar el precio del concepto: con el default 0 de InvoiceItemBase
|
||||
# siempre llegaría un valor y el service no podría distinguir "no lo capturó" de "capturó 0".
|
||||
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
|
||||
|
||||
class InvoiceItemUpdate(BaseModel):
|
||||
class InvoiceItemUpdate(InvoiceItemSatFields):
|
||||
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)
|
||||
@@ -69,13 +84,27 @@ class InvoiceBase(BaseModel):
|
||||
shipment_id: int | None = None
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str = Field("MXN", max_length=3)
|
||||
# Opcionales a propósito: con un default no nulo, ``model_dump()`` los incluiría siempre y
|
||||
# el service no podría distinguir "no lo eligió" de "eligió eso" — con lo que la herencia de
|
||||
# los datos del cliente nunca se activaría. Si ni el alta ni la ficha los traen, manda el
|
||||
# server_default de la columna.
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
# Tipo de cambio a MXN, obligatorio para timbrar si la moneda no es MXN.
|
||||
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
|
||||
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)
|
||||
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)
|
||||
# ----- Claves fiscales del CFDI (opcionales mientras no se timbre) -----
|
||||
voucher_type_id: int | None = None
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
# Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
|
||||
# ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
|
||||
stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
|
||||
|
||||
|
||||
class InvoiceCreate(InvoiceBase):
|
||||
@@ -88,24 +117,40 @@ class InvoiceUpdate(BaseModel):
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
|
||||
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)
|
||||
voucher_type_id: int | None = None
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
stamping_mode: Literal["pruebas", "produccion"] | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(InvoiceBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
case_id: int | None = None
|
||||
status: str
|
||||
# Se redeclaran porque en InvoiceBase son opcionales para habilitar la herencia; en la
|
||||
# respuesta corresponden a columnas NOT NULL y aflojarlas relajaría el contrato de salida.
|
||||
currency: str
|
||||
tax_rate: Decimal
|
||||
subtotal: Decimal
|
||||
tax_amount: Decimal
|
||||
# Impuestos retenidos: restan del total, igual que en el comprobante. Sin exponerlos, el
|
||||
# total no cuadraría con subtotal + tax_amount y nada explicaría la diferencia.
|
||||
withheld_amount: Decimal
|
||||
total: Decimal
|
||||
paid_amount: Decimal
|
||||
balance: Decimal
|
||||
# false en las facturas anteriores al cálculo por partida: conservan la fórmula del % global.
|
||||
taxes_per_item: bool
|
||||
ops_cost_total: Decimal | None = None
|
||||
sent_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
@@ -119,3 +164,42 @@ class InvoiceResponse(InvoiceBase):
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InvoiceItemTaxInput(BaseModel):
|
||||
"""Alta o ajuste de un impuesto de la partida.
|
||||
|
||||
El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
|
||||
pueda quedar un desglose que no cuadre con el importe del concepto.
|
||||
"""
|
||||
|
||||
tax_id: int
|
||||
# Nula sólo para un exento, que no lleva TasaOCuota en el comprobante. Una tasa 0 SÍ es un
|
||||
# valor válido y distinto: se declara con TasaOCuota="0.000000".
|
||||
rate: Decimal | None = Field(None, ge=0, le=1, max_digits=8, decimal_places=6)
|
||||
is_withholding: bool = False
|
||||
# 'Cuota' queda fuera a propósito: su importe es cuota × cantidad, no base × tasa, y
|
||||
# aceptarla sin esa fórmula daría importes plausibles y equivocados.
|
||||
factor: Literal["Tasa", "Exento"] = "Tasa"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _valida_tasa_contra_factor(self):
|
||||
if self.factor == "Exento":
|
||||
if self.rate:
|
||||
raise ValueError("Un impuesto exento no lleva tasa")
|
||||
elif self.rate is None:
|
||||
raise ValueError("Un impuesto con factor Tasa requiere la tasa (0 para el 0%)")
|
||||
return self
|
||||
|
||||
|
||||
class InvoiceItemTaxResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_item_id: int
|
||||
tax_id: int
|
||||
is_withholding: bool
|
||||
rate: Decimal | None = None
|
||||
amount: Decimal
|
||||
factor: str
|
||||
is_manual: bool
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
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
|
||||
|
||||
from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK)
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
UnitOfMeasure,
|
||||
VoucherType,
|
||||
)
|
||||
from ..concepts.models import Concept # noqa: F401
|
||||
|
||||
|
||||
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
||||
@@ -15,6 +38,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
|
||||
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
|
||||
shipment_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
|
||||
)
|
||||
@@ -25,13 +49,24 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
# Tipo de cambio a MXN. Obligatorio para timbrar cuando la moneda no es MXN (lo exige
|
||||
# c_Moneda del SAT vía CfdiData.validate); en MXN se queda en NULL y el CFDI no lo lleva.
|
||||
exchange_rate: Mapped[float | None] = mapped_column(Numeric(14, 6), nullable=True)
|
||||
# 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
|
||||
# % de IVA POR DEFECTO de las partidas nuevas objeto de impuesto. Con taxes_per_item activo
|
||||
# NO determina el total: el impuesto sale de las filas de invoice_item_taxes.
|
||||
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0"))
|
||||
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
# Impuestos retenidos. Restan del total, igual que en el comprobante.
|
||||
withheld_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
# Versiona el cálculo del impuesto. Las facturas nuevas nacen en true (suma por partida); las
|
||||
# que existían antes del cambio quedaron en false y conservan la fórmula con la que se
|
||||
# emitieron, para que su total no se mueva sola al registrarles un pago.
|
||||
taxes_per_item: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
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"))
|
||||
@@ -50,6 +85,25 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
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)
|
||||
# ----- Datos fiscales del CFDI (catálogos SAT) -----
|
||||
# Nullables: las facturas emitidas antes de existir los catálogos no los tienen.
|
||||
voucher_type_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.voucher_types.id"), nullable=True
|
||||
)
|
||||
payment_form_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.payment_forms.id"), nullable=True
|
||||
)
|
||||
payment_method_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
|
||||
)
|
||||
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
# ----- Modo de timbrado (por factura, no por entorno) -----
|
||||
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
|
||||
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
|
||||
# cambiarlo después falsearía el registro de con qué intención se emitió.
|
||||
stamping_mode: Mapped[str] = mapped_column(
|
||||
String(12), nullable=False, server_default=text("'pruebas'")
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
@@ -62,10 +116,72 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||
)
|
||||
# Texto libre histórico: lo consume el PDF actual y se conserva obligatorio.
|
||||
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"))
|
||||
# ----- Datos fiscales de la partida (catálogos SAT) -----
|
||||
concept_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True
|
||||
)
|
||||
product_service_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.products_services.id"), nullable=True
|
||||
)
|
||||
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
||||
)
|
||||
tax_object_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Impuesto trasladado o retenido de una partida de la factura.
|
||||
|
||||
**Es la fuente del impuesto de la factura**, no solo detalle para el CFDI: cuando
|
||||
``invoices.taxes_per_item`` está activo, ``tax_amount`` y ``withheld_amount`` son la suma de
|
||||
estas filas y el total sale de ahí. Antes el dinero salía de ``invoices.tax_rate`` aplicado
|
||||
al subtotal completo, y los dos planos podían divergir.
|
||||
|
||||
El índice único es por ``(invoice_item_id, tax_id, is_withholding)`` y **no incluye
|
||||
``factor``**: un IVA trasladado sigue siendo uno solo por partida, y pasar de Tasa a Exento
|
||||
es un UPDATE de esa fila, no una fila nueva.
|
||||
"""
|
||||
|
||||
__tablename__ = "invoice_item_taxes"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_invoice_item_taxes",
|
||||
"invoice_item_id", "tax_id", "is_withholding",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
# Declarado también aquí y no solo en la migración: las pruebas construyen el esquema con
|
||||
# ``Base.metadata.create_all`` y sin esto validarían una base distinta de la de producción.
|
||||
CheckConstraint(
|
||||
"factor IN ('Tasa', 'Cuota', 'Exento')", name="ck_fin_invoice_item_taxes_factor"
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
invoice_item_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True
|
||||
)
|
||||
tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False)
|
||||
# false = trasladado (se cobra al cliente); true = retenido
|
||||
is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
# c_TipoFactor. Un 'Exento' no lleva tasa ni importe en el XML y no suma a los totales; es
|
||||
# distinto de una tasa 0%, que sí se declara con TasaOCuota="0.000000".
|
||||
factor: Mapped[str] = mapped_column(String(7), nullable=False, server_default=text("'Tasa'"))
|
||||
# true = lo capturó una persona por el endpoint de impuestos de la partida. La derivación
|
||||
# automática no pisa lo manual, y esto lo registra como hecho en vez de inferirlo de la forma
|
||||
# de la fila (que ya no distingue: un IVA al 0% derivado y uno capturado son idénticos).
|
||||
is_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
|
||||
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
@@ -63,6 +63,8 @@ def _build_lines(
|
||||
total,
|
||||
paid,
|
||||
balance,
|
||||
tax_groups: Sequence[dict] | None = None,
|
||||
withheld=0,
|
||||
bank_info: str | None,
|
||||
notes: str | None,
|
||||
) -> list[tuple[str, int]]:
|
||||
@@ -84,14 +86,20 @@ def _build_lines(
|
||||
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)
|
||||
label = etiqueta_partida(concept, desc)[: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))
|
||||
# Un renglón por grupo (impuesto, factor, tasa), como los agrupa el comprobante. El % de
|
||||
# la factura dejó de servir aquí: una factura puede mezclar tasas, o traer una partida
|
||||
# exenta, y entonces no hay un único porcentaje que sea cierto.
|
||||
for g in tax_groups or []:
|
||||
L.append((_renglon_impuesto(g, currency), 11))
|
||||
if not tax_groups and Decimal(str(tax_amount or 0)) != 0:
|
||||
# Facturas con la fórmula anterior (un % global sobre el subtotal completo).
|
||||
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))
|
||||
@@ -108,6 +116,42 @@ def _build_lines(
|
||||
return L
|
||||
|
||||
|
||||
def etiqueta_partida(concept: str, description: str) -> str:
|
||||
"""Cómo se lee la partida en el renglón del PDF.
|
||||
|
||||
Los dos campos vienen del mismo texto cuando la partida usa un concepto del catálogo:
|
||||
``concept`` es la descripción recortada a 60 caracteres y ``description`` la completa.
|
||||
Imprimir ambos repetiría el texto —una vez cortado y otra entero—, así que se detecta por
|
||||
prefijo y se imprime sólo el largo.
|
||||
|
||||
Cuando son textos distintos —una clave genérica más el detalle que alguien escribió— se
|
||||
imprimen los dos, que es lo que hacía siempre.
|
||||
"""
|
||||
if not description:
|
||||
return concept
|
||||
if not concept or description.startswith(concept):
|
||||
return description
|
||||
return f"{concept} — {description}"
|
||||
|
||||
|
||||
def _renglon_impuesto(grupo: dict, currency: str) -> str:
|
||||
"""Un renglón del desglose de impuestos.
|
||||
|
||||
Los exentos se listan **con su base y sin importe**: es lo único que le explica al cliente por
|
||||
qué el total no es el subtotal por 1.16, que es justo la pregunta que llega por teléfono. Las
|
||||
retenciones van con signo negativo, porque restan del total igual que en el comprobante — antes
|
||||
no aparecían en el PDF y la factura impresa pedía un importe distinto al del CFDI.
|
||||
"""
|
||||
nombre = str(grupo.get("nombre") or "Impuesto")
|
||||
base = _money(grupo.get("base") or 0, currency)
|
||||
if grupo.get("factor") == "Exento":
|
||||
return f"{nombre} Exento (sobre {base}): —"
|
||||
tasa = Decimal(str(grupo.get("rate") or 0)) * 100
|
||||
importe = Decimal(str(grupo.get("amount") or 0))
|
||||
etiqueta = f"Ret. {nombre}" if grupo.get("is_withholding") else nombre
|
||||
signo = "-" if grupo.get("is_withholding") else ""
|
||||
return f"{etiqueta} {tasa:.2f}% (sobre {base}): {signo}{_money(importe, currency)}"
|
||||
|
||||
def build_invoice_pdf(**kwargs) -> bytes:
|
||||
"""Construye el PDF de la factura y devuelve los bytes."""
|
||||
lines = _build_lines(**kwargs)
|
||||
|
||||
@@ -4,12 +4,14 @@ from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from . import service, taxes_service
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemResponse,
|
||||
InvoiceItemTaxInput,
|
||||
InvoiceItemTaxResponse,
|
||||
InvoiceItemUpdate,
|
||||
InvoiceResponse,
|
||||
InvoiceUpdate,
|
||||
@@ -132,3 +134,26 @@ def create_payment(payload: PaymentCreate, company_id: int = Query(...), current
|
||||
@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)
|
||||
|
||||
|
||||
# ----- Impuestos por partida -----
|
||||
# El traslado de IVA se deriva del % de la factura; estos endpoints son para ajustarlo
|
||||
# (retenciones, tasas distintas) cuando el caso lo pide.
|
||||
|
||||
@router.get("/invoice-items/{item_id}/taxes", response_model=list[InvoiceItemTaxResponse])
|
||||
def list_item_taxes(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return taxes_service.list_item_taxes(db, item_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.put("/invoice-items/{item_id}/taxes", response_model=InvoiceItemTaxResponse)
|
||||
def set_item_tax(item_id: int, payload: InvoiceItemTaxInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""Alta o ajuste. La combinación impuesto + traslado/retención es única por partida."""
|
||||
return taxes_service.set_item_tax(
|
||||
db, item_id, payload.tax_id, payload.rate, payload.is_withholding,
|
||||
current_user["tenant_id"], company_id, factor=payload.factor,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/invoice-item-taxes/{tax_row_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_item_tax(tax_row_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
taxes_service.delete_item_tax(db, tax_row_id, current_user["tenant_id"], company_id)
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, 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.cases import service as cases_service
|
||||
from api.v1.modules.crm.common.folios import next_folio
|
||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||
from api.v1.modules.ops.shipments.models import Shipment
|
||||
|
||||
from ..catalogs import service as catalogs_service
|
||||
from ..catalogs.models import PaymentForm, PaymentMethod
|
||||
from ..concepts.models import Concept
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
@@ -17,9 +23,12 @@ from .dto import (
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
)
|
||||
from .models import Invoice, InvoiceItem, Payment
|
||||
from . import taxes_service
|
||||
from .models import Invoice, InvoiceItem, InvoiceItemTax, Payment
|
||||
from .pdf import build_invoice_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
|
||||
if _id is None:
|
||||
@@ -32,6 +41,18 @@ def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# Campos que el DTO acepta como nulos —para poder heredarlos del cliente— pero cuya columna es
|
||||
# NOT NULL con server_default. Un None explícito tiene que retirarse del payload para que mande
|
||||
# el default de la base, en vez de reventar en el flush.
|
||||
_COLUMNAS_CON_DEFAULT = ("currency", "tax_rate")
|
||||
|
||||
|
||||
def _drop_nulls_de_columnas_obligatorias(data: dict) -> None:
|
||||
for campo in _COLUMNAS_CON_DEFAULT:
|
||||
if campo in data and data[campo] is None:
|
||||
data.pop(campo)
|
||||
|
||||
|
||||
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"),
|
||||
@@ -42,20 +63,70 @@ def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) ->
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
|
||||
def _totales_por_partida(db: Session, invoice: Invoice) -> tuple[Decimal, Decimal, Decimal]:
|
||||
"""``(subtotal, trasladado, retenido)`` sumando partida por partida.
|
||||
|
||||
Se agrega en Python y no en SQL por dos razones. El redondeo por renglón —que es el que hace
|
||||
el comprobante— no se expresa igual en Postgres que en SQLite, donde corre la suite; y
|
||||
``func.sum`` devuelve float bajo SQLite, que es justo lo que no se quiere tocando dinero.
|
||||
Son unidades de partidas por factura, no miles.
|
||||
"""
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
subtotal = trasladado = retenido = Decimal("0.00")
|
||||
for item in items:
|
||||
subtotal += taxes_service.line_base(item)
|
||||
for t in (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(
|
||||
InvoiceItemTax.invoice_item_id == item.id,
|
||||
InvoiceItemTax.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
):
|
||||
# Los importes ya están en centavos: volver a redondear la suma no cambia nada y
|
||||
# esconde de dónde salió la precisión. Un exento tiene importe 0 y no suma.
|
||||
importe = Decimal(str(t.amount or 0))
|
||||
if t.is_withholding:
|
||||
retenido += importe
|
||||
else:
|
||||
trasladado += importe
|
||||
return subtotal, trasladado, retenido
|
||||
|
||||
|
||||
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()
|
||||
"""Recalcula los totales de la factura y su estado de cobranza.
|
||||
|
||||
El impuesto sale de los impuestos de cada partida (``taxes_per_item``), que es lo que declara
|
||||
el comprobante. Las facturas creadas antes de ese cambio conservan la fórmula del porcentaje
|
||||
global: recalcularlas movería el total con el que se emitieron y el que ya vio el cliente.
|
||||
"""
|
||||
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)
|
||||
paid = Decimal(str(paid or 0))
|
||||
|
||||
subtotal, trasladado, retenido = _totales_por_partida(db, invoice)
|
||||
|
||||
if invoice.taxes_per_item:
|
||||
tax = trasladado
|
||||
withheld = retenido
|
||||
else:
|
||||
# Fórmula histórica: el % global sobre el subtotal completo, con un solo redondeo. Las
|
||||
# retenciones no se contemplaban y se dejan fuera para no mover el total de una factura
|
||||
# vieja por un camino que no existía cuando se emitió.
|
||||
tax = (subtotal * Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
withheld = Decimal("0.00")
|
||||
|
||||
total = subtotal + tax - withheld
|
||||
invoice.subtotal = subtotal
|
||||
invoice.tax_amount = tax
|
||||
invoice.withheld_amount = withheld
|
||||
invoice.total = total
|
||||
invoice.paid_amount = paid
|
||||
invoice.balance = total - paid
|
||||
@@ -69,6 +140,11 @@ def _recompute(db: Session, invoice: Invoice) -> None:
|
||||
invoice.paid_at = None
|
||||
|
||||
|
||||
def recompute_invoice(db: Session, invoice: Invoice) -> None:
|
||||
"""Punto de entrada público de ``_recompute``, para los módulos que mueven impuestos."""
|
||||
_recompute(db, invoice)
|
||||
|
||||
|
||||
# ----- Invoices -----
|
||||
|
||||
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
|
||||
@@ -94,7 +170,18 @@ def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
||||
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)
|
||||
_inherit_account_billing(db, data, tenant_id, company_id)
|
||||
_drop_nulls_de_columnas_obligatorias(data)
|
||||
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
# Folio F... auto-generado (mensual) si no viene uno explícito
|
||||
if not obj.reference:
|
||||
obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
|
||||
# Expediente heredado del embarque (si la factura se genera de uno)
|
||||
if obj.shipment_id and not obj.case_id:
|
||||
sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
|
||||
if sh:
|
||||
obj.case_id = sh.case_id
|
||||
cases_service.advance_stage(db, obj.case_id, "facturacion")
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
_recompute(db, obj)
|
||||
@@ -107,16 +194,90 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
|
||||
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)
|
||||
_reject_if_stamped(db, obj, tenant_id, company_id, data=data)
|
||||
# Cambiar de cliente vuelve a heredar sus datos de facturación: facturar al cliente B con la
|
||||
# forma de pago del cliente A es un error silencioso. Se re-hereda ANTES del setattr y sólo
|
||||
# sobre lo que el PATCH no manda explícito, igual que update_item con el concepto.
|
||||
if "account_id" in data:
|
||||
for campo, _, _ in _ACCOUNT_INHERITED_BILLING:
|
||||
data.setdefault(campo, None)
|
||||
data.setdefault("currency", None)
|
||||
_inherit_account_billing(db, data, tenant_id, company_id)
|
||||
# Lo que no se pudo heredar se retira del PATCH para no pisar con NULL lo ya capturado.
|
||||
for campo in ("currency", *(c for c, _, _ in _ACCOUNT_INHERITED_BILLING)):
|
||||
if data.get(campo) is None:
|
||||
data.pop(campo, None)
|
||||
_drop_nulls_de_columnas_obligatorias(data)
|
||||
for f, v in data.items():
|
||||
setattr(obj, f, v)
|
||||
obj.updated_by = user_id
|
||||
db.flush()
|
||||
_recompute(db, obj) # tax_rate pudo cambiar
|
||||
if "tax_rate" in data:
|
||||
# El % es el valor por defecto de las partidas cuyo impuesto se deriva: al cambiarlo se
|
||||
# propaga a ésas. No toca las que tienen configuración fiscal de su concepto ni las
|
||||
# capturadas a mano.
|
||||
taxes_service.sync_invoice_taxes(db, obj)
|
||||
_recompute(db, obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# Campos del comprobante que dejan de ser editables en cuanto la factura tiene timbre. Son los
|
||||
# que el CFDI ya declaró ante el SAT: cambiarlos aquí haría que la factura y su comprobante
|
||||
# contaran cosas distintas, y el comprobante es el que vale.
|
||||
_INMUTABLES_CON_TIMBRE = (
|
||||
"account_id",
|
||||
"reference",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"issue_date",
|
||||
"payment_form_id",
|
||||
"payment_method_id",
|
||||
"expedition_zip_code",
|
||||
"voucher_type_id",
|
||||
"stamping_mode",
|
||||
"tax_rate",
|
||||
)
|
||||
|
||||
|
||||
def _reject_if_stamped(
|
||||
db, obj: Invoice, tenant_id, company_id, data: dict | None = None, motivo: str | None = None
|
||||
) -> None:
|
||||
"""Rechaza con 409 la edición de una factura ya timbrada.
|
||||
|
||||
Con ``data`` sólo protege los campos de ``_INMUTABLES_CON_TIMBRE`` y únicamente cuando el
|
||||
valor que llega es distinto del actual: guardar el encabezado sin tocarlos sigue permitido.
|
||||
Sin ``data`` no admite nada, y así se usa desde las partidas y sus impuestos — el desglose
|
||||
del comprobante no se corrige editándolo, se corrige cancelando y refacturando.
|
||||
|
||||
Cobrar NO pasa por aquí: registrar o borrar un pago no altera el CFDI.
|
||||
"""
|
||||
# Import diferido: stamping importa invoices, y al revés sería circular.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
if data is not None:
|
||||
cambiados = [
|
||||
campo
|
||||
for campo in _INMUTABLES_CON_TIMBRE
|
||||
if campo in data and data[campo] != getattr(obj, campo)
|
||||
]
|
||||
if not cambiados:
|
||||
return
|
||||
detalle = (
|
||||
f"La factura ya está timbrada: no se puede cambiar {', '.join(cambiados)}. "
|
||||
"El CFDI ya existe ante el SAT; para corregirlo hay que cancelarlo y refacturar."
|
||||
)
|
||||
else:
|
||||
detalle = (
|
||||
f"La factura ya está timbrada: {motivo or 'no admite cambios'}. El CFDI ya existe "
|
||||
"ante el SAT; para corregirlo hay que cancelarlo y refacturar."
|
||||
)
|
||||
|
||||
if get_stamp(db, obj.id, tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detalle)
|
||||
|
||||
|
||||
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)
|
||||
@@ -139,6 +300,50 @@ 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 _grupos_de_impuesto(db, invoice: Invoice) -> list[dict]:
|
||||
"""Impuestos de la factura agrupados por ``(impuesto, factor, tasa)``, con su base.
|
||||
|
||||
Es el mismo criterio con el que el comprobante arma su nodo ``Impuestos``, y por eso el
|
||||
desglose del PDF y el del CFDI dicen lo mismo. Vacío para las facturas con la fórmula
|
||||
anterior: ahí el único desglose que existió fue el porcentaje global.
|
||||
"""
|
||||
if not invoice.taxes_per_item:
|
||||
return []
|
||||
|
||||
from ..catalogs.models import Tax # noqa: PLC0415
|
||||
|
||||
grupos: dict[tuple, dict] = {}
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
for item in items:
|
||||
base = taxes_service.line_base(item)
|
||||
for t in (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(InvoiceItemTax.invoice_item_id == item.id, InvoiceItemTax.deleted_at.is_(None))
|
||||
.all()
|
||||
):
|
||||
tax = db.query(Tax).filter(Tax.id == t.tax_id).first()
|
||||
clave = (t.tax_id, t.factor, str(t.rate or 0), bool(t.is_withholding))
|
||||
g = grupos.setdefault(
|
||||
clave,
|
||||
{
|
||||
"nombre": (tax.description if tax else "Impuesto"),
|
||||
"factor": t.factor,
|
||||
"rate": Decimal(str(t.rate or 0)),
|
||||
"is_withholding": bool(t.is_withholding),
|
||||
"base": Decimal("0.00"),
|
||||
"amount": Decimal("0.00"),
|
||||
},
|
||||
)
|
||||
g["base"] += base
|
||||
g["amount"] += Decimal(str(t.amount or 0))
|
||||
# Traslados primero y retenciones después, como se leen en un comprobante.
|
||||
return sorted(grupos.values(), key=lambda g: (g["is_withholding"], g["nombre"]))
|
||||
|
||||
|
||||
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)
|
||||
@@ -162,6 +367,8 @@ def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
|
||||
total=invoice.total,
|
||||
paid=invoice.paid_amount,
|
||||
balance=invoice.balance,
|
||||
tax_groups=_grupos_de_impuesto(db, invoice),
|
||||
withheld=invoice.withheld_amount,
|
||||
bank_info=invoice.bank_info,
|
||||
notes=invoice.notes,
|
||||
)
|
||||
@@ -180,10 +387,20 @@ def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice
|
||||
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
|
||||
# Con timbre no se regenera el PDF: el documento que acompaña a un CFDI es el que se emitió
|
||||
# con él. Regenerarlo sobre la misma llave de MinIO reescribiría lo que el cliente ya recibió,
|
||||
# y con cualquier cambio posterior en la factura diría algo distinto del comprobante.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
ya_timbrada = get_stamp(db, obj.id, tenant_id, company_id) is not None
|
||||
if not (ya_timbrada and obj.pdf_file_key):
|
||||
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
|
||||
key = (
|
||||
f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/"
|
||||
f"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:
|
||||
@@ -279,12 +496,24 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
|
||||
if shipment.quote_id:
|
||||
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
|
||||
|
||||
# Datos de facturación del cliente. La moneda del EMBARQUE gana sobre la de la ficha: es la
|
||||
# que se coteó y en la que se operó de verdad, mientras la del cliente es una preferencia
|
||||
# comercial. El cliente entra sólo como último recurso, antes del default MXN.
|
||||
datos = {
|
||||
"account_id": shipment.account_id,
|
||||
"currency": (shipment.cost_currency or (quote.currency if quote else None)),
|
||||
}
|
||||
_inherit_account_billing(db, datos, tenant_id, company_id)
|
||||
|
||||
invoice = Invoice(
|
||||
reference=shipment.reference,
|
||||
case_id=shipment.case_id,
|
||||
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")),
|
||||
currency=(datos.get("currency") or "MXN"),
|
||||
payment_form_id=datos.get("payment_form_id"),
|
||||
payment_method_id=datos.get("payment_method_id"),
|
||||
ops_cost_total=shipment.actual_cost_total,
|
||||
status="borrador",
|
||||
tenant_id=tenant_id,
|
||||
@@ -294,6 +523,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
|
||||
)
|
||||
db.add(invoice)
|
||||
db.flush()
|
||||
cases_service.advance_stage(db, shipment.case_id, "facturacion")
|
||||
|
||||
if quote:
|
||||
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
|
||||
@@ -304,6 +534,14 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
))
|
||||
db.flush()
|
||||
# Las partidas se insertan directo, sin pasar por create_item, así que hay que derivar
|
||||
# sus impuestos a mano o la factura nacería con el desglose vacío.
|
||||
#
|
||||
# PENDIENTE: crm.quote_items no tiene concept_id, así que estas partidas nacen sin claves
|
||||
# fiscales (product_service_id, unit_of_measure_id, tax_object_id) y por tanto sin
|
||||
# impuestos. Mapear el texto libre del concepto de la cotización contra fin.concepts es su
|
||||
# propio ticket, con su propia decisión de qué hacer cuando el texto no coincide.
|
||||
taxes_service.sync_invoice_taxes(db, invoice)
|
||||
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
@@ -331,11 +569,141 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
||||
return obj
|
||||
|
||||
|
||||
# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
|
||||
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")
|
||||
|
||||
# Campos que la partida hereda del concepto con OTRO nombre: (campo de la partida, del concepto).
|
||||
_CONCEPT_RENAMED_FIELDS = (("unit_amount", "unit_price"),)
|
||||
|
||||
# Datos de facturación que la factura hereda de la ficha del cliente: (campo de la factura,
|
||||
# campo del Account, catálogo del SAT contra el que se resuelve la clave).
|
||||
_ACCOUNT_INHERITED_BILLING = (
|
||||
("payment_form_id", "payment_form", PaymentForm),
|
||||
("payment_method_id", "payment_method", PaymentMethod),
|
||||
)
|
||||
|
||||
|
||||
def _inherit_account_billing(db, data: dict, tenant_id, company_id) -> None:
|
||||
"""Completa los datos de facturación de la factura desde la ficha del cliente.
|
||||
|
||||
Hereda tres cosas y sólo tres: forma de pago, método de pago y moneda. Las dos primeras son
|
||||
justo las que detienen el timbrado en validación si quedan vacías, y estaban capturándose a
|
||||
mano en cada factura aunque ya vivieran en la ficha.
|
||||
|
||||
Dos reglas, las mismas que ``_resolve_item_concept``:
|
||||
|
||||
- **Lo que el cliente sí envía manda sobre la ficha**: sólo se escribe donde no hay valor.
|
||||
- **Completa, nunca borra**: si la ficha trae un texto que no resuelve a ninguna clave del
|
||||
SAT, no se asigna nada. Así cambiar de cliente no puede vaciar un dato ya capturado.
|
||||
|
||||
NO hereda ``due_date`` a partir de ``Account.credit_days``, ni ``commercial_terms`` hacia
|
||||
las notas. Se decidió dejarlos fuera: el vencimiento depende de la fecha de emisión, que
|
||||
puede no estar fijada todavía, y las notas de la factura son texto que alguien escribe.
|
||||
"""
|
||||
account_id = data.get("account_id")
|
||||
if not account_id:
|
||||
return
|
||||
account = (
|
||||
db.query(Account)
|
||||
.filter(
|
||||
Account.id == account_id,
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
return
|
||||
|
||||
for campo, campo_account, modelo in _ACCOUNT_INHERITED_BILLING:
|
||||
if data.get(campo) is not None:
|
||||
continue
|
||||
texto = getattr(account, campo_account, None)
|
||||
fila = catalogs_service.find_by_code(db, modelo, texto)
|
||||
if fila is not None:
|
||||
data[campo] = fila.id
|
||||
elif texto:
|
||||
# Se avisa para que se limpie el CRM: la factura se crea igual y el timbrado
|
||||
# reportará la clave faltante junto al resto de los pendientes.
|
||||
logger.info(
|
||||
"factura: la clave %r de %s del cliente %s no existe en el catálogo del SAT; "
|
||||
"no se hereda",
|
||||
texto, campo_account, account_id,
|
||||
)
|
||||
|
||||
if not data.get("currency"):
|
||||
moneda = (account.currency or "").strip().upper()[:3]
|
||||
if moneda:
|
||||
data["currency"] = moneda
|
||||
|
||||
|
||||
def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||
"""Completa la partida a partir del concepto del catálogo.
|
||||
|
||||
Hereda, siempre y sólo cuando el cliente no lo manda:
|
||||
|
||||
- ``concept`` y ``description``: la descripción del concepto va a las dos, recortada a 60 en
|
||||
la primera —que es lo que el PDF lee y lo que la columna admite— y completa en la segunda,
|
||||
que es la que el CFDI prefiere. Antes sólo se llenaba ``concept``, así que el comprobante
|
||||
declaraba el texto truncado aunque el catálogo lo tuviera entero.
|
||||
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
|
||||
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
|
||||
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
|
||||
para poder facturar una partida con una unidad distinta a la del concepto.
|
||||
- ``unit_amount`` desde el ``unit_price`` del concepto: si el catálogo ya tiene el precio,
|
||||
volver a teclearlo en cada partida es trabajo doble y una fuente de discrepancias. Se
|
||||
hereda en el service y no sólo en la pantalla, para que cualquier cliente de la API lo
|
||||
obtenga igual — antes el precio lo prellenaba únicamente el formulario web.
|
||||
|
||||
El impuesto NO se hereda aquí: vive en filas propias y lo resuelve
|
||||
``taxes_service.sync_item_taxes`` después del insert, que es quien sabe leer la
|
||||
configuración fiscal del concepto.
|
||||
"""
|
||||
concept_id = data.get("concept_id")
|
||||
if concept_id is not None:
|
||||
catalog_concept = db.query(Concept).filter(
|
||||
Concept.id == concept_id, Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id, Concept.deleted_at.is_(None),
|
||||
).first()
|
||||
if not catalog_concept:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El concepto del catálogo no existe en esta empresa",
|
||||
)
|
||||
if not data.get("concept"):
|
||||
data["concept"] = catalog_concept.description[:60]
|
||||
if not data.get("description"):
|
||||
# La descripción COMPLETA va al campo largo. El CFDI la prefiere sobre ``concept``,
|
||||
# que está recortado a 60 caracteres, así que sin esto el comprobante declaraba un
|
||||
# texto truncado de un concepto que el catálogo tiene entero.
|
||||
data["description"] = catalog_concept.description[:255]
|
||||
for field in _CONCEPT_INHERITED_FIELDS:
|
||||
if data.get(field) is None:
|
||||
data[field] = getattr(catalog_concept, field)
|
||||
for campo_partida, campo_concepto in _CONCEPT_RENAMED_FIELDS:
|
||||
if data.get(campo_partida) is None:
|
||||
data[campo_partida] = getattr(catalog_concept, campo_concepto)
|
||||
# unit_amount es NOT NULL con server_default: un None que nadie llenó se retira para que
|
||||
# mande el default de la columna, en vez de reventar en el flush.
|
||||
if "unit_amount" in data and data["unit_amount"] is None:
|
||||
data.pop("unit_amount")
|
||||
if not data.get("concept"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La partida requiere un concepto o una referencia al catálogo de conceptos",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
_reject_if_stamped(db, invoice, tenant_id, company_id, motivo="no se le pueden agregar partidas")
|
||||
data = payload.model_dump()
|
||||
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
@@ -344,10 +712,21 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
|
||||
|
||||
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():
|
||||
_reject_if_stamped(
|
||||
db, get_invoice(db, item.invoice_id, tenant_id, company_id), tenant_id, company_id,
|
||||
motivo="sus partidas no se pueden editar",
|
||||
)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
|
||||
# descripción y claves fiscales del concepto nuevo.
|
||||
if data.get("concept_id") is not None:
|
||||
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(item, f, v)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
||||
invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
@@ -356,6 +735,13 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
|
||||
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
|
||||
_reject_if_stamped(
|
||||
db, get_invoice(db, invoice_id, tenant_id, company_id), tenant_id, company_id,
|
||||
motivo="sus partidas no se pueden borrar",
|
||||
)
|
||||
# Los impuestos de la partida se van con ella: con el impuesto saliendo de esas filas,
|
||||
# dejarlas vivas sería seguir cobrando el IVA de una partida que ya no existe.
|
||||
taxes_service.clear_item_taxes(db, item.id)
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
|
||||
|
||||
355
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal file
355
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Impuestos de las partidas de la factura — **la fuente del impuesto**, no un detalle.
|
||||
|
||||
El impuesto se declara y se cobra por partida, igual que en el CFDI: ``invoices.tax_amount`` y
|
||||
``withheld_amount`` son la suma de estas filas, y de ahí sale el total. Antes el dinero salía de
|
||||
``invoices.tax_rate`` aplicado al subtotal completo, y los dos planos podían divergir — una
|
||||
partida que no causa IVA cobraba IVA, y una retención capturada dejaba la factura pidiendo un
|
||||
importe distinto del que declaraba el comprobante.
|
||||
|
||||
De dónde sale la tasa de cada partida, en orden:
|
||||
|
||||
1. Si la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), no lleva
|
||||
impuestos. Ni el nodo va en el XML ni el importe suma al total.
|
||||
2. Si alguien capturó impuestos a mano en esa partida (``is_manual``), no se toca nada.
|
||||
3. Si su concepto del catálogo trae configuración fiscal, esa manda: es la forma de tener
|
||||
conceptos exentos o a tasa 0% sin pelear con el % global de la factura.
|
||||
4. Si no, el traslado de IVA se deriva de ``invoices.tax_rate``.
|
||||
|
||||
Los importes se redondean **por renglón** y con ``ROUND_HALF_UP``, no al final y no sobre el
|
||||
subtotal agregado. Es lo que hace el comprobante: su ``SubTotal`` es la suma de los ``Importe``
|
||||
ya redondeados de cada concepto, y su ``TotalImpuestosTrasladados`` la suma de los ``Importe`` de
|
||||
cada traslado. El plano que manda es el XML, y el dinero se le alinea.
|
||||
"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import Tax, TaxObject
|
||||
from ..concepts.models import Concept
|
||||
from .models import Invoice, InvoiceItem, InvoiceItemTax
|
||||
|
||||
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante. El 01 «no objeto», el 03
|
||||
# «objeto no obligado al desglose» y el 04 «objeto que no causa impuesto» NO llevan nodo de
|
||||
# impuestos en el concepto, así que tampoco generan fila ni suman al total.
|
||||
_OBJETO_CON_DESGLOSE = {"02"}
|
||||
# c_Impuesto del IVA.
|
||||
_IVA = "002"
|
||||
|
||||
# c_TipoFactor admitidos al capturar. 'Cuota' se acepta en la base porque el catálogo del SAT lo
|
||||
# tiene, pero el service lo rechaza: su importe es cuota × cantidad, no base × tasa, y aceptarlo
|
||||
# sin esa fórmula daría importes plausibles y equivocados.
|
||||
FACTOR_TASA = "Tasa"
|
||||
FACTOR_EXENTO = "Exento"
|
||||
FACTORES_ADMITIDOS = (FACTOR_TASA, FACTOR_EXENTO)
|
||||
|
||||
|
||||
def cents(value: Decimal) -> Decimal:
|
||||
"""Redondea a centavos con la misma regla que el comprobante (``ROUND_HALF_UP``)."""
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
# Alias interno histórico; se conserva para no tocar los llamadores existentes de este módulo.
|
||||
_cents = cents
|
||||
|
||||
|
||||
def line_base(item: InvoiceItem) -> Decimal:
|
||||
"""Importe de la partida, ya redondeado.
|
||||
|
||||
Es el mismo valor que ``ConceptLine.amount`` del builder, y tiene que salir de una sola
|
||||
definición: si la factura sumara los productos sin redondear, su subtotal no coincidiría con
|
||||
la suma de los ``Importe`` del XML y el PAC rechazaría el comprobante.
|
||||
"""
|
||||
return cents(Decimal(str(item.quantity or 0)) * Decimal(str(item.unit_amount or 0)))
|
||||
|
||||
|
||||
def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
|
||||
return (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None))
|
||||
.order_by(InvoiceItemTax.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _tax_object_code(db: Session, item: InvoiceItem) -> str:
|
||||
if not item.tax_object_id:
|
||||
return ""
|
||||
row = db.query(TaxObject).filter(TaxObject.id == item.tax_object_id).first()
|
||||
return row.code if row else ""
|
||||
|
||||
|
||||
def _reject_if_stamped_item(
|
||||
db: Session, item: InvoiceItem, tenant_id: int, company_id: int, motivo: str
|
||||
) -> None:
|
||||
"""Bloquea la captura manual de impuestos sobre la partida de una factura ya timbrada.
|
||||
|
||||
El desglose viajó al CFDI y ahí quedó. Import diferido de ``service`` porque ese módulo
|
||||
importa a este; al revés sería circular.
|
||||
"""
|
||||
from . import service # noqa: PLC0415
|
||||
|
||||
invoice = service.get_invoice(db, item.invoice_id, tenant_id, company_id)
|
||||
service._reject_if_stamped(db, invoice, tenant_id, company_id, motivo=motivo)
|
||||
|
||||
|
||||
def clear_item_taxes(db: Session, item_id: int) -> None:
|
||||
"""Borra los impuestos de una partida.
|
||||
|
||||
Se llama al borrar la partida. Antes las filas quedaban vivas y era solo ruido; ahora que el
|
||||
impuesto de la factura sale de ellas, dejarlas sería un cobro fantasma sobre una partida que
|
||||
ya no existe.
|
||||
"""
|
||||
for t in _item_taxes(db, item_id):
|
||||
db.delete(t)
|
||||
|
||||
|
||||
def _default_fiscal_del_concepto(db: Session, item: InvoiceItem):
|
||||
"""``(tax_id, rate, factor)`` del concepto del catálogo, o ``None`` si no lo define."""
|
||||
if not item.concept_id:
|
||||
return None
|
||||
concepto = db.query(Concept).filter(Concept.id == item.concept_id).first()
|
||||
if not concepto or not concepto.default_tax_id or not concepto.default_tax_factor:
|
||||
return None
|
||||
factor = concepto.default_tax_factor
|
||||
rate = None if factor == FACTOR_EXENTO else Decimal(str(concepto.default_tax_rate or 0))
|
||||
return concepto.default_tax_id, rate, factor
|
||||
|
||||
|
||||
def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
|
||||
"""Deja el impuesto derivado de la partida al día. Ver el orden de precedencia arriba."""
|
||||
if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
|
||||
# Dejó de ser objeto de impuesto con desglose: se retiran TODOS sus impuestos, incluidos
|
||||
# los capturados a mano. Una retención sobre una partida que ya no es 02 es inexpresable
|
||||
# en el XML, y un ObjetoImp 01 con nodo de impuestos es motivo de rechazo.
|
||||
clear_item_taxes(db, item.id)
|
||||
return
|
||||
|
||||
existentes = _item_taxes(db, item.id)
|
||||
if any(t.is_manual for t in existentes):
|
||||
return # captura manual: el automatismo no pisa trabajo ajeno
|
||||
|
||||
default = _default_fiscal_del_concepto(db, item)
|
||||
if default is not None:
|
||||
tax_id, rate, factor = default
|
||||
else:
|
||||
iva = db.query(Tax).filter(Tax.code == _IVA).first()
|
||||
if not iva:
|
||||
return # sin catálogo no hay nada que derivar; el timbrado lo reportará
|
||||
tax_id, factor = iva.id, FACTOR_TASA
|
||||
rate = (Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(Decimal("0.000001"))
|
||||
if rate == 0:
|
||||
# Un cero aquí NO significa "IVA al 0%": significa que nadie capturó el porcentaje, y
|
||||
# son cosas distintas. No se inventa una tasa: la partida queda sin impuestos y el
|
||||
# timbrado falla ruidosamente pidiendo el desglose que un ObjetoImp 02 exige. Quien
|
||||
# de verdad quiere 0% lo declara en el concepto o por el endpoint de captura.
|
||||
_borra_derivados(db, existentes)
|
||||
return
|
||||
|
||||
# El derivado es uno solo por partida. Si cambió el impuesto (p. ej. el concepto pasó a
|
||||
# definir otro), el anterior se retira en vez de acumularse.
|
||||
for t in existentes:
|
||||
if t.is_withholding or t.tax_id != tax_id:
|
||||
db.delete(t)
|
||||
|
||||
traslado = next(
|
||||
(t for t in existentes if t.tax_id == tax_id and not t.is_withholding), None
|
||||
)
|
||||
if traslado is None:
|
||||
traslado = InvoiceItemTax(
|
||||
invoice_item_id=item.id,
|
||||
tax_id=tax_id,
|
||||
is_withholding=False,
|
||||
tenant_id=item.tenant_id,
|
||||
company_id=item.company_id,
|
||||
)
|
||||
db.add(traslado)
|
||||
|
||||
traslado.factor = factor
|
||||
traslado.is_manual = False
|
||||
if factor == FACTOR_EXENTO:
|
||||
# Un exento no lleva TasaOCuota ni Importe en el XML, y no suma a los totales.
|
||||
traslado.rate = None
|
||||
traslado.amount = Decimal("0.00")
|
||||
else:
|
||||
traslado.rate = rate
|
||||
traslado.amount = cents(line_base(item) * rate)
|
||||
|
||||
|
||||
def _borra_derivados(db: Session, filas: list[InvoiceItemTax]) -> None:
|
||||
for t in filas:
|
||||
if not t.is_manual:
|
||||
db.delete(t)
|
||||
|
||||
|
||||
def sync_invoice_taxes(db: Session, invoice: Invoice) -> None:
|
||||
"""Recalcula el IVA derivado de todas las partidas. Se llama al cambiar ``tax_rate``."""
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
for item in items:
|
||||
sync_item_taxes(db, item, invoice)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Ajuste manual
|
||||
# --------------------------------------------------------------------------------------
|
||||
def list_item_taxes(
|
||||
db: Session, item_id: int, tenant_id: int, company_id: int
|
||||
) -> list[InvoiceItemTax]:
|
||||
_get_item(db, item_id, tenant_id, company_id)
|
||||
return _item_taxes(db, item_id)
|
||||
|
||||
|
||||
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> InvoiceItem:
|
||||
item = (
|
||||
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 item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Partida no encontrada")
|
||||
return item
|
||||
|
||||
|
||||
def set_item_tax(
|
||||
db: Session,
|
||||
item_id: int,
|
||||
tax_id: int,
|
||||
rate: Decimal | None,
|
||||
is_withholding: bool,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
factor: str = FACTOR_TASA,
|
||||
) -> InvoiceItemTax:
|
||||
"""Alta o ajuste manual de un impuesto de la partida.
|
||||
|
||||
Marca la fila como ``is_manual``: desde ese momento la derivación automática no la vuelve a
|
||||
tocar, ni al cambiar el importe de la partida ni al cambiar el % de la factura.
|
||||
"""
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
|
||||
|
||||
if factor not in FACTORES_ADMITIDOS:
|
||||
# 'Cuota' existe en el catálogo del SAT pero su importe es cuota × cantidad, no
|
||||
# base × tasa. Sin esa fórmula, aceptarla produciría un importe plausible y equivocado.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Tipo de factor no admitido: {factor!r}. Por ahora sólo "
|
||||
f"{' y '.join(FACTORES_ADMITIDOS)}."
|
||||
),
|
||||
)
|
||||
# Un ObjetoImp distinto de 02 no lleva nodo de impuestos: capturar uno aquí sería armar un
|
||||
# comprobante que el PAC rechaza, y la derivación lo borraría en la siguiente edición.
|
||||
objeto = _tax_object_code(db, item)
|
||||
if objeto not in _OBJETO_CON_DESGLOSE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
"La partida no es objeto de impuesto con desglose (ObjetoImp "
|
||||
f"{objeto or 'sin capturar'}): no admite impuestos. Cámbiala a 02 primero."
|
||||
),
|
||||
)
|
||||
|
||||
tax = db.query(Tax).filter(Tax.id == tax_id).first()
|
||||
if not tax:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El impuesto indicado no existe en el catálogo del SAT",
|
||||
)
|
||||
if is_withholding and not tax.is_withholding:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El impuesto {tax.code} ({tax.description}) no puede retenerse",
|
||||
)
|
||||
if not is_withholding and not tax.is_transferred:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
|
||||
)
|
||||
if factor == FACTOR_TASA and rate is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Un impuesto con factor Tasa requiere la tasa",
|
||||
)
|
||||
|
||||
obj = (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(
|
||||
InvoiceItemTax.invoice_item_id == item_id,
|
||||
InvoiceItemTax.tax_id == tax_id,
|
||||
InvoiceItemTax.is_withholding == is_withholding,
|
||||
InvoiceItemTax.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if obj is None:
|
||||
obj = InvoiceItemTax(
|
||||
invoice_item_id=item_id,
|
||||
tax_id=tax_id,
|
||||
is_withholding=is_withholding,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
obj.factor = factor
|
||||
obj.is_manual = True
|
||||
if factor == FACTOR_EXENTO:
|
||||
# Se limpian tasa e importe: una fila exenta que conservara el importe de una tasa
|
||||
# anterior descuadraría el Total del comprobante, que excluye los exentos de sus totales.
|
||||
obj.rate = None
|
||||
obj.amount = Decimal("0.00")
|
||||
else:
|
||||
obj.rate = Decimal(str(rate)).quantize(Decimal("0.000001"))
|
||||
obj.amount = cents(line_base(item) * Decimal(str(rate)))
|
||||
|
||||
db.flush()
|
||||
_recalcula_factura(db, item)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _recalcula_factura(db: Session, item: InvoiceItem) -> None:
|
||||
"""Recalcula los totales de la factura dueña de la partida.
|
||||
|
||||
Import diferido: ``service`` importa este módulo, y al revés sería circular. Capturar o
|
||||
borrar un impuesto tiene que mover el total, o la factura queda mintiendo hasta que alguien
|
||||
toque otra cosa.
|
||||
"""
|
||||
from . import service # noqa: PLC0415
|
||||
|
||||
invoice = db.query(Invoice).filter(Invoice.id == item.invoice_id).first()
|
||||
if invoice is not None:
|
||||
service.recompute_invoice(db, invoice)
|
||||
|
||||
|
||||
def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(
|
||||
InvoiceItemTax.id == tax_row_id,
|
||||
InvoiceItemTax.tenant_id == tenant_id,
|
||||
InvoiceItemTax.company_id == company_id,
|
||||
InvoiceItemTax.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Impuesto no encontrado")
|
||||
item = _get_item(db, obj.invoice_item_id, tenant_id, company_id)
|
||||
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
|
||||
db.delete(obj)
|
||||
db.flush()
|
||||
_recalcula_factura(db, item)
|
||||
db.commit()
|
||||
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Datos fiscales del emisor por empresa."""
|
||||
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal file
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Carga y lectura del CSD (Certificado de Sello Digital) de la empresa.
|
||||
|
||||
El ``.key`` es material con el que se puede firmar a nombre de la empresa ante el SAT: no se
|
||||
devuelve nunca por la API, ni entero ni en partes. Sólo entra (al subirlo) y se usa del lado
|
||||
del servidor (al timbrar).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.crypto import SecretsNotConfigured, encrypt_secret
|
||||
from core.s3_keys import tenant_company_prefix
|
||||
|
||||
from ...fin.stamping import sealer
|
||||
from .models import IssuerSettings
|
||||
from .service import get_issuer_settings
|
||||
|
||||
# Tamaño máximo razonable: un .cer del SAT ronda los 2 KB y un .key los 2 KB. El tope evita
|
||||
# que alguien suba un archivo enorme por error o a propósito.
|
||||
_MAX_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _csd_keys(tenant_id: int, company_id: int) -> tuple[str, str]:
|
||||
"""Claves de almacenamiento del par. Estables: subir de nuevo reemplaza el anterior."""
|
||||
prefijo = tenant_company_prefix(tenant_id, company_id) + "certificates/"
|
||||
return prefijo + "cfdi.cer", prefijo + "cfdi.key"
|
||||
|
||||
|
||||
def _verify_pair(cer_bytes: bytes, key_bytes: bytes, password: str) -> str:
|
||||
"""Comprueba que la llave privada corresponde al certificado. Devuelve el NoCertificado.
|
||||
|
||||
Se hace firmando un dato de prueba con la llave y verificándolo con la pública del
|
||||
certificado. Es la única forma de saberlo antes de timbrar: si no cuadran, el error
|
||||
aparecería hasta que el PAC rechace el comprobante, con un mensaje que no menciona el CSD.
|
||||
"""
|
||||
try:
|
||||
cert_number, _ = sealer.read_certificate(cer_bytes)
|
||||
private_key = sealer.load_private_key(key_bytes, password)
|
||||
except sealer.SealingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
try:
|
||||
cert = load_der_x509_certificate(cer_bytes)
|
||||
except ValueError:
|
||||
cert = load_pem_x509_certificate(cer_bytes)
|
||||
|
||||
reto = b"verificacion-de-par-csd"
|
||||
firma = private_key.sign(reto, padding.PKCS1v15(), hashes.SHA256())
|
||||
try:
|
||||
cert.public_key().verify(firma, reto, padding.PKCS1v15(), hashes.SHA256())
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no corresponden"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
"La llave privada (.key) no corresponde al certificado (.cer). "
|
||||
"Verifica que ambos archivos sean del mismo CSD."
|
||||
),
|
||||
) from exc
|
||||
|
||||
return cert_number
|
||||
|
||||
|
||||
def upload_csd(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
cer_bytes: bytes,
|
||||
key_bytes: bytes,
|
||||
password: str,
|
||||
user_id: str | None = None,
|
||||
) -> IssuerSettings:
|
||||
"""Valida el par, lo guarda en almacenamiento y cifra la contraseña."""
|
||||
# Exige que ya existan los datos fiscales: el CSD pertenece a un emisor, no al aire.
|
||||
obj = get_issuer_settings(db, tenant_id, company_id)
|
||||
|
||||
if not password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La contraseña de la llave privada es obligatoria.",
|
||||
)
|
||||
for etiqueta, datos in (("certificado (.cer)", cer_bytes), ("llave privada (.key)", key_bytes)):
|
||||
if not datos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Falta el archivo del {etiqueta}.",
|
||||
)
|
||||
if len(datos) > _MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El archivo del {etiqueta} es demasiado grande para ser un CSD.",
|
||||
)
|
||||
|
||||
cert_number = _verify_pair(cer_bytes, key_bytes, password)
|
||||
|
||||
# La contraseña se cifra ANTES de subir los archivos: si no hay clave maestra, no se deja
|
||||
# material criptográfico en el almacenamiento a medio configurar.
|
||||
try:
|
||||
password_enc = encrypt_secret(password)
|
||||
except SecretsNotConfigured as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
from core.storage_s3 import (
|
||||
put_object_bytes,
|
||||
) # noqa: PLC0415 (import diferido: tests sin MinIO)
|
||||
|
||||
cer_key, key_key = _csd_keys(tenant_id, company_id)
|
||||
put_object_bytes(cer_key, cer_bytes, content_type="application/x-x509-ca-cert")
|
||||
put_object_bytes(key_key, key_bytes, content_type="application/octet-stream")
|
||||
|
||||
obj.csd_cer_file_key = cer_key
|
||||
obj.csd_key_file_key = key_key
|
||||
obj.csd_password_enc = password_enc
|
||||
obj.csd_cert_number = cert_number
|
||||
obj.csd_uploaded_at = datetime.now(timezone.utc)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_csd(
|
||||
db: Session, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> IssuerSettings:
|
||||
"""Desvincula el CSD de la empresa.
|
||||
|
||||
Se borran también los objetos del almacenamiento: dejar una llave privada huérfana es
|
||||
justo lo que no se quiere. Si el borrado remoto falla, la referencia se limpia igual —
|
||||
sin ella el sistema ya no puede firmar.
|
||||
"""
|
||||
obj = get_issuer_settings(db, tenant_id, company_id)
|
||||
claves = [k for k in (obj.csd_cer_file_key, obj.csd_key_file_key) if k]
|
||||
if claves:
|
||||
try:
|
||||
from core.storage_s3 import delete_object_if_exists # noqa: PLC0415
|
||||
|
||||
for k in claves:
|
||||
delete_object_if_exists(k)
|
||||
except Exception: # noqa: BLE001
|
||||
# No se propaga: la referencia se limpia igual y el CSD queda inutilizable.
|
||||
pass
|
||||
|
||||
obj.csd_cer_file_key = None
|
||||
obj.csd_key_file_key = None
|
||||
obj.csd_password_enc = None
|
||||
obj.csd_cert_number = None
|
||||
obj.csd_uploaded_at = None
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
71
backend/api/v1/modules/fin/issuer/dto.py
Normal file
71
backend/api/v1/modules/fin/issuer/dto.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Esquemas de los datos fiscales del emisor."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, computed_field, Field, field_validator
|
||||
|
||||
from ..catalogs.dto import TaxRegimeResponse
|
||||
|
||||
# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave.
|
||||
RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
|
||||
ZIP_PATTERN = re.compile(r"^\d{5}$")
|
||||
|
||||
|
||||
class IssuerSettingsInput(BaseModel):
|
||||
"""Alta o actualización de los datos fiscales del emisor."""
|
||||
|
||||
legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social")
|
||||
rfc: str = Field(..., max_length=13, description="RFC del emisor")
|
||||
tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)")
|
||||
zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición")
|
||||
|
||||
# mode="before": la normalización corre antes que el max_length del campo, para que
|
||||
# un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo.
|
||||
@field_validator("rfc", mode="before")
|
||||
@classmethod
|
||||
def _validate_rfc(cls, value: str) -> str:
|
||||
"""Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("El RFC debe ser texto")
|
||||
normalized = value.replace(" ", "").replace("-", "").upper()
|
||||
if not RFC_PATTERN.match(normalized):
|
||||
raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)")
|
||||
return normalized
|
||||
|
||||
@field_validator("zip_code")
|
||||
@classmethod
|
||||
def _validate_zip(cls, value: str | None) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not ZIP_PATTERN.match(normalized):
|
||||
raise ValueError("El código postal debe tener 5 dígitos")
|
||||
return normalized
|
||||
|
||||
|
||||
class IssuerSettingsResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
legal_name: str
|
||||
rfc: str
|
||||
tax_regime_id: int
|
||||
tax_regime: TaxRegimeResponse | None = None
|
||||
zip_code: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# ----- Estado del CSD -----
|
||||
# Se expone SI hay certificado cargado y cuál, nunca su contenido ni la contraseña: con el
|
||||
# .key se puede firmar a nombre de la empresa ante el SAT.
|
||||
csd_cert_number: str | None = None
|
||||
csd_uploaded_at: datetime | None = None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def has_csd(self) -> bool:
|
||||
"""Hay par de archivos y contraseña guardados, o sea que ya se puede timbrar."""
|
||||
return bool(self.csd_cert_number and self.csd_uploaded_at)
|
||||
57
backend/api/v1/modules/fin/issuer/models.py
Normal file
57
backend/api/v1/modules/fin/issuer/models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Datos fiscales del emisor — ``fin.issuer_settings``.
|
||||
|
||||
Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen
|
||||
fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente
|
||||
por empresa**, garantizada con un índice único parcial.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from ..catalogs.models import TaxRegime # noqa: F401 (resuelve la relación)
|
||||
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
|
||||
class IssuerSettings(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Configuración fiscal del emisor de la empresa."""
|
||||
|
||||
__tablename__ = "issuer_settings"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_issuer_settings_company",
|
||||
"tenant_id", "company_id",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
|
||||
rfc: Mapped[str] = mapped_column(String(13), nullable=False)
|
||||
tax_regime_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True
|
||||
)
|
||||
# CP del lugar de expedición del comprobante
|
||||
zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ----- CSD (Certificado de Sello Digital) de la empresa -----
|
||||
# Los archivos viven en MinIO; aquí sólo su clave. El .key es material con el que se puede
|
||||
# firmar a nombre de la empresa: no se expone nunca por la API, ni siquiera su contenido en
|
||||
# base64. Sólo se sube y se usa del lado del servidor.
|
||||
csd_cer_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
csd_key_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# Contraseña de la llave privada, cifrada con la clave maestra del entorno (core.crypto).
|
||||
# Nunca se devuelve en una respuesta; sólo se sabe si está puesta o no.
|
||||
csd_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Informativo, para mostrar en la pantalla qué certificado está cargado.
|
||||
csd_cert_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
csd_uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")
|
||||
97
backend/api/v1/modules/fin/issuer/routes.py
Normal file
97
backend/api/v1/modules/fin/issuer/routes.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import csd_service, service
|
||||
from .dto import IssuerSettingsInput, IssuerSettingsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings/issuer",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.view"]))],
|
||||
)
|
||||
def get_issuer_settings(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Devuelve 404 mientras la empresa no haya capturado sus datos fiscales."""
|
||||
return service.get_issuer_settings(db, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/settings/issuer",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
def save_issuer_settings(
|
||||
payload: IssuerSettingsInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Alta o actualización (upsert) de los datos fiscales del emisor."""
|
||||
return service.save_issuer_settings(
|
||||
db,
|
||||
payload,
|
||||
current_user["tenant_id"],
|
||||
company_id,
|
||||
current_user.get("sub") or current_user.get("id"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/settings/issuer/csd",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
async def upload_csd(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
cer: UploadFile = File(..., description="Certificado del CSD (.cer)"),
|
||||
key: UploadFile = File(..., description="Llave privada del CSD (.key)"),
|
||||
password: str = Form(..., description="Contraseña de la llave privada"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Carga el CSD de la empresa.
|
||||
|
||||
Antes de guardar nada se comprueba que la llave privada corresponde al certificado: si no,
|
||||
el error saldría hasta que el PAC rechace un comprobante, con un mensaje que no menciona
|
||||
el CSD. La contraseña se guarda cifrada y **no se devuelve nunca**.
|
||||
"""
|
||||
return csd_service.upload_csd(
|
||||
db,
|
||||
current_user["tenant_id"],
|
||||
company_id,
|
||||
await cer.read(),
|
||||
await key.read(),
|
||||
password,
|
||||
current_user.get("sub") or current_user.get("id"),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/settings/issuer/csd",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
def delete_csd(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Desvincula el CSD y borra sus archivos del almacenamiento."""
|
||||
return csd_service.delete_csd(
|
||||
db,
|
||||
current_user["tenant_id"],
|
||||
company_id,
|
||||
current_user.get("sub") or current_user.get("id"),
|
||||
)
|
||||
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Lógica de los datos fiscales del emisor.
|
||||
|
||||
Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no
|
||||
un alta que pueda duplicar filas.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import TaxRegime
|
||||
from .dto import IssuerSettingsInput
|
||||
from .models import IssuerSettings
|
||||
|
||||
|
||||
def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None:
|
||||
return db.query(IssuerSettings).filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
|
||||
def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings:
|
||||
obj = _find(db, tenant_id, company_id)
|
||||
if not obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="La empresa aún no tiene datos fiscales del emisor configurados",
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def save_issuer_settings(
|
||||
db: Session,
|
||||
payload: IssuerSettingsInput,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> IssuerSettings:
|
||||
"""Crea la configuración la primera vez y la actualiza en adelante."""
|
||||
if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El régimen fiscal indicado no existe en el catálogo del SAT",
|
||||
)
|
||||
|
||||
obj = _find(db, tenant_id, company_id)
|
||||
data = payload.model_dump()
|
||||
if obj is None:
|
||||
obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
else:
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -3,7 +3,7 @@
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "fin"
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ def register_permissions() -> None:
|
||||
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)
|
||||
# Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD,
|
||||
# así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio:
|
||||
# son globales y de solo lectura, basta con fin.access.
|
||||
registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view")
|
||||
registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit")
|
||||
|
||||
|
||||
register_permissions()
|
||||
|
||||
@@ -5,8 +5,16 @@ from fastapi import APIRouter, Depends
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos)
|
||||
from .catalogs.routes import router as catalogs_router
|
||||
from .concepts.routes import router as concepts_router
|
||||
from .invoices.routes import router as invoices_router
|
||||
from .issuer.routes import router as issuer_router
|
||||
from .stamping.routes import router as stamping_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(catalogs_router)
|
||||
router.include_router(concepts_router)
|
||||
router.include_router(issuer_router)
|
||||
router.include_router(invoices_router)
|
||||
router.include_router(stamping_router)
|
||||
|
||||
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""
|
||||
391
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal file
391
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Construcción del XML CFDI 4.0 de tipo ingreso.
|
||||
|
||||
El orden de los atributos **no es libre**: la cadena original se calcula recorriendo el
|
||||
comprobante en el orden del XSD, y el sello se hace sobre esa cadena. Aquí se respeta el
|
||||
mismo orden que usa el sistema legado (``CFDI.cs:14340-14394``), verificado contra el XSD.
|
||||
|
||||
Todo el dinero se maneja con ``Decimal``. Con ``float``, 0.1 + 0.2 no da 0.30 y el total del
|
||||
comprobante no cuadra con la suma de las partidas: el PAC lo rechaza.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from lxml import etree
|
||||
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
SCHEMA_LOCATION = (
|
||||
"http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
|
||||
)
|
||||
|
||||
VERSION = "4.0"
|
||||
TIPO_INGRESO = "I"
|
||||
|
||||
# Declaración XML escrita a mano, con comillas DOBLES. lxml emite la suya con comillas simples
|
||||
# (<?xml version='1.0' encoding='UTF-8'?>), que es XML válido —la especificación admite ambas—
|
||||
# pero Comercio Digital lo rechaza con el código 642 "la versión del XML no es 1.0" porque
|
||||
# compara la cadena literal version="1.0" en vez de parsear el prólogo. Por eso el comprobante
|
||||
# se serializa sin declaración y ésta se antepone.
|
||||
XML_DECLARATION = b'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
# c_Exportacion: "01" = No aplica. Es obligatorio en 4.0 y este módulo no emite exportaciones.
|
||||
EXPORTACION_NO_APLICA = "01"
|
||||
|
||||
# Decimales por moneda (c_Moneda). El SAT admite hasta ese número en importes.
|
||||
_DECIMALES = {"MXN": 2, "USD": 2, "EUR": 2}
|
||||
_DECIMALES_DEFECTO = 2
|
||||
|
||||
|
||||
class CfdiBuildError(Exception):
|
||||
"""El comprobante no se puede construir con los datos disponibles."""
|
||||
|
||||
def __init__(self, missing: list[str]):
|
||||
# Se acumulan TODOS los faltantes en vez de fallar en el primero: quien captura la
|
||||
# factura necesita la lista completa, no descubrirlos de uno en uno.
|
||||
self.missing = missing
|
||||
super().__init__("Faltan datos fiscales: " + "; ".join(missing))
|
||||
|
||||
|
||||
def _serialize(root: etree._Element) -> bytes:
|
||||
"""Serializa el comprobante en UTF-8 con la declaración que acepta el PAC."""
|
||||
# xml_declaration=False es obligatorio: con encoding distinto de ASCII, lxml la añade sola.
|
||||
return XML_DECLARATION + etree.tostring(root, xml_declaration=False, encoding="UTF-8")
|
||||
|
||||
|
||||
def _money(value: Decimal | float | int | None, currency: str) -> str:
|
||||
"""Importe con los decimales de la moneda, sin separador de miles."""
|
||||
dec = _DECIMALES.get(currency.upper(), _DECIMALES_DEFECTO)
|
||||
q = Decimal(1).scaleb(-dec)
|
||||
return str(Decimal(str(value or 0)).quantize(q, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _qty(value: Decimal | float | int | None) -> str:
|
||||
"""Cantidad: hasta 6 decimales, sin ceros finales innecesarios."""
|
||||
d = Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
|
||||
return format(d.normalize(), "f")
|
||||
|
||||
|
||||
def _rate(value: Decimal | float | int) -> str:
|
||||
"""Tasa o cuota: el SAT la exige con 6 decimales (p. ej. 0.160000)."""
|
||||
return str(Decimal(str(value)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaxLine:
|
||||
"""Impuesto trasladado o retenido de una partida."""
|
||||
|
||||
code: str # c_Impuesto: "002" = IVA
|
||||
rate: Decimal
|
||||
amount: Decimal
|
||||
is_withholding: bool = False
|
||||
factor: str = "Tasa" # c_TipoFactor: Tasa | Cuota | Exento
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConceptLine:
|
||||
"""Partida del comprobante."""
|
||||
|
||||
product_service_code: str # ClaveProdServ
|
||||
unit_code: str # ClaveUnidad
|
||||
description: str
|
||||
quantity: Decimal
|
||||
unit_price: Decimal
|
||||
tax_object: str # c_ObjetoImp: "01" no objeto, "02" sí objeto
|
||||
taxes: list[TaxLine] = field(default_factory=list)
|
||||
identification: str | None = None # NoIdentificacion
|
||||
|
||||
@property
|
||||
def amount(self) -> Decimal:
|
||||
return (self.quantity * self.unit_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CfdiData:
|
||||
"""Todo lo que necesita un CFDI 4.0 de ingreso, ya resuelto contra los catálogos."""
|
||||
|
||||
# Comprobante
|
||||
folio: str | None
|
||||
serie: str | None
|
||||
date: str # YYYY-MM-DDTHH:MM:SS, hora local del lugar de expedición
|
||||
payment_form: str # c_FormaPago
|
||||
payment_method: str # c_MetodoPago: PUE | PPD
|
||||
currency: str
|
||||
exchange_rate: Decimal | None
|
||||
expedition_zip: str # LugarExpedicion
|
||||
payment_conditions: str | None
|
||||
# Emisor
|
||||
issuer_rfc: str
|
||||
issuer_name: str
|
||||
issuer_tax_regime: str # c_RegimenFiscal
|
||||
# Receptor
|
||||
receiver_rfc: str
|
||||
receiver_name: str
|
||||
receiver_zip: str # DomicilioFiscalReceptor
|
||||
receiver_tax_regime: str # c_RegimenFiscal del receptor
|
||||
receiver_cfdi_use: str # c_UsoCFDI
|
||||
# Partidas
|
||||
concepts: list[ConceptLine]
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Acumula los faltantes obligatorios del CFDI 4.0 y los reporta juntos."""
|
||||
faltantes: list[str] = []
|
||||
obligatorios = {
|
||||
"fecha de emisión": self.date,
|
||||
"forma de pago (c_FormaPago)": self.payment_form,
|
||||
"método de pago (c_MetodoPago)": self.payment_method,
|
||||
"moneda": self.currency,
|
||||
"lugar de expedición (CP)": self.expedition_zip,
|
||||
"RFC del emisor": self.issuer_rfc,
|
||||
"razón social del emisor": self.issuer_name,
|
||||
"régimen fiscal del emisor": self.issuer_tax_regime,
|
||||
"RFC del receptor": self.receiver_rfc,
|
||||
"razón social del receptor": self.receiver_name,
|
||||
"domicilio fiscal del receptor (CP)": self.receiver_zip,
|
||||
"régimen fiscal del receptor": self.receiver_tax_regime,
|
||||
"uso de CFDI del receptor": self.receiver_cfdi_use,
|
||||
}
|
||||
for etiqueta, valor in obligatorios.items():
|
||||
if not valor:
|
||||
faltantes.append(f"falta {etiqueta}")
|
||||
|
||||
if not self.concepts:
|
||||
faltantes.append("la factura no tiene partidas")
|
||||
|
||||
for i, c in enumerate(self.concepts, start=1):
|
||||
if not c.product_service_code:
|
||||
faltantes.append(f"partida {i}: falta la clave de producto/servicio")
|
||||
if not c.unit_code:
|
||||
faltantes.append(f"partida {i}: falta la clave de unidad")
|
||||
if not c.tax_object:
|
||||
faltantes.append(f"partida {i}: falta el objeto de impuesto")
|
||||
if not c.description:
|
||||
faltantes.append(f"partida {i}: falta la descripción")
|
||||
if c.quantity is None or c.quantity <= 0:
|
||||
faltantes.append(f"partida {i}: la cantidad debe ser mayor que cero")
|
||||
# ObjetoImp "02" significa "sí objeto de impuesto": el SAT exige entonces el
|
||||
# desglose. Sin él, el comprobante se rechaza; no se inventa una tasa por defecto.
|
||||
if c.tax_object == "02" and not c.taxes:
|
||||
faltantes.append(
|
||||
f"partida {i}: es objeto de impuesto (02) pero no tiene impuestos capturados"
|
||||
)
|
||||
|
||||
if self.currency.upper() != "MXN" and not self.exchange_rate:
|
||||
faltantes.append("falta el tipo de cambio (moneda distinta de MXN)")
|
||||
|
||||
if faltantes:
|
||||
raise CfdiBuildError(faltantes)
|
||||
|
||||
# ----- Totales -----
|
||||
@property
|
||||
def subtotal(self) -> Decimal:
|
||||
return sum((c.amount for c in self.concepts), Decimal("0"))
|
||||
|
||||
@property
|
||||
def transferred(self) -> Decimal:
|
||||
# Los exentos se excluyen, igual que en ``_add_totals``: no llevan importe en el XML y no
|
||||
# entran en TotalImpuestosTrasladados. Sin este filtro, una fila exenta que trajera un
|
||||
# importe distinto de cero inflaría el Total y el comprobante quedaría inconsistente
|
||||
# consigo mismo.
|
||||
return sum(
|
||||
(
|
||||
t.amount
|
||||
for c in self.concepts
|
||||
for t in c.taxes
|
||||
if not t.is_withholding and t.factor != "Exento"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
@property
|
||||
def withheld(self) -> Decimal:
|
||||
return sum(
|
||||
(t.amount for c in self.concepts for t in c.taxes if t.is_withholding),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return self.subtotal + self.transferred - self.withheld
|
||||
|
||||
|
||||
def build_xml(data: CfdiData, cert_number: str = "", cert_b64: str = "") -> bytes:
|
||||
"""XML CFDI 4.0 de ingreso, **sin** el atributo ``Sello``.
|
||||
|
||||
``cert_number`` y ``cert_b64`` salen del ``.cer`` y **tienen que venir puestos aquí**, no
|
||||
después: la cadena original incluye ``NoCertificado``. Si se calculara la cadena con el
|
||||
atributo vacío y se rellenara luego, el sello firmaría un texto distinto del que verifica
|
||||
el SAT, y el comprobante se rechazaría con un error que no menciona el certificado.
|
||||
|
||||
``Sello`` sí se deja vacío —la cadena original no lo incluye, es su resultado— y lo inserta
|
||||
``apply_seal`` en su posición.
|
||||
"""
|
||||
data.validate()
|
||||
cur = data.currency.upper()
|
||||
|
||||
comprobante = etree.Element(
|
||||
f"{{{CFDI_NS}}}Comprobante",
|
||||
nsmap={"cfdi": CFDI_NS, "xsi": XSI_NS},
|
||||
)
|
||||
comprobante.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
|
||||
|
||||
# ORDEN DEL XSD. No reordenar: la cadena original -y por tanto el sello- depende de él.
|
||||
comprobante.set("Version", VERSION)
|
||||
if data.serie:
|
||||
comprobante.set("Serie", data.serie)
|
||||
if data.folio:
|
||||
comprobante.set("Folio", str(data.folio))
|
||||
# Sin desplazamiento horario: el "-06:00" es de CFDI 3.3 (ver CFDI.cs:12654). En 4.0 la
|
||||
# fecha va en hora local del lugar de expedición, a secas.
|
||||
comprobante.set("Fecha", data.date)
|
||||
# Sello vacío: reserva su posición en el orden del XSD para que apply_seal lo rellene sin
|
||||
# mover nada. lxml conserva el orden de inserción de los atributos.
|
||||
comprobante.set("Sello", "")
|
||||
comprobante.set("FormaPago", data.payment_form)
|
||||
comprobante.set("NoCertificado", cert_number)
|
||||
comprobante.set("Certificado", cert_b64)
|
||||
if data.payment_conditions:
|
||||
comprobante.set("CondicionesDePago", data.payment_conditions)
|
||||
comprobante.set("SubTotal", _money(data.subtotal, cur))
|
||||
comprobante.set("Moneda", cur)
|
||||
if cur != "MXN" and data.exchange_rate:
|
||||
comprobante.set(
|
||||
"TipoCambio", str(Decimal(str(data.exchange_rate)).quantize(Decimal("0.0001")))
|
||||
)
|
||||
comprobante.set("Total", _money(data.total, cur))
|
||||
comprobante.set("TipoDeComprobante", TIPO_INGRESO)
|
||||
comprobante.set("Exportacion", EXPORTACION_NO_APLICA)
|
||||
comprobante.set("MetodoPago", data.payment_method)
|
||||
comprobante.set("LugarExpedicion", data.expedition_zip)
|
||||
|
||||
emisor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Emisor")
|
||||
emisor.set("Rfc", data.issuer_rfc)
|
||||
emisor.set("Nombre", data.issuer_name)
|
||||
emisor.set("RegimenFiscal", data.issuer_tax_regime)
|
||||
|
||||
receptor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Receptor")
|
||||
receptor.set("Rfc", data.receiver_rfc)
|
||||
receptor.set("Nombre", data.receiver_name)
|
||||
receptor.set("DomicilioFiscalReceptor", data.receiver_zip)
|
||||
receptor.set("RegimenFiscalReceptor", data.receiver_tax_regime)
|
||||
receptor.set("UsoCFDI", data.receiver_cfdi_use)
|
||||
|
||||
conceptos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Conceptos")
|
||||
for c in data.concepts:
|
||||
nodo = etree.SubElement(conceptos, f"{{{CFDI_NS}}}Concepto")
|
||||
nodo.set("ClaveProdServ", c.product_service_code)
|
||||
if c.identification:
|
||||
nodo.set("NoIdentificacion", c.identification)
|
||||
nodo.set("Cantidad", _qty(c.quantity))
|
||||
nodo.set("ClaveUnidad", c.unit_code)
|
||||
nodo.set("Descripcion", c.description)
|
||||
nodo.set("ValorUnitario", _money(c.unit_price, cur))
|
||||
nodo.set("Importe", _money(c.amount, cur))
|
||||
nodo.set("ObjetoImp", c.tax_object)
|
||||
if c.taxes:
|
||||
_add_concept_taxes(nodo, c, cur)
|
||||
|
||||
if any(c.taxes for c in data.concepts):
|
||||
_add_totals(comprobante, data, cur)
|
||||
|
||||
return _serialize(comprobante)
|
||||
|
||||
|
||||
def _add_concept_taxes(nodo: etree._Element, c: ConceptLine, cur: str) -> None:
|
||||
"""Nodo ``Impuestos`` de una partida: primero Traslados, después Retenciones."""
|
||||
impuestos = etree.SubElement(nodo, f"{{{CFDI_NS}}}Impuestos")
|
||||
|
||||
traslados = [t for t in c.taxes if not t.is_withholding]
|
||||
if traslados:
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
|
||||
for t in traslados:
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
|
||||
el.set("Base", _money(c.amount, cur))
|
||||
el.set("Impuesto", t.code)
|
||||
el.set("TipoFactor", t.factor)
|
||||
# Un impuesto exento no lleva TasaOCuota ni Importe: ponerlos es motivo de rechazo.
|
||||
if t.factor != "Exento":
|
||||
el.set("TasaOCuota", _rate(t.rate))
|
||||
el.set("Importe", _money(t.amount, cur))
|
||||
|
||||
retenciones = [t for t in c.taxes if t.is_withholding]
|
||||
if retenciones:
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
|
||||
for t in retenciones:
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
|
||||
el.set("Base", _money(c.amount, cur))
|
||||
el.set("Impuesto", t.code)
|
||||
el.set("TipoFactor", t.factor)
|
||||
el.set("TasaOCuota", _rate(t.rate))
|
||||
el.set("Importe", _money(t.amount, cur))
|
||||
|
||||
|
||||
def _add_totals(comprobante: etree._Element, data: CfdiData, cur: str) -> None:
|
||||
"""Nodo ``Impuestos`` del comprobante: totales agrupados por impuesto, factor y tasa."""
|
||||
impuestos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Impuestos")
|
||||
|
||||
def agrupa(withholding: bool) -> dict[tuple[str, str, str], Decimal]:
|
||||
acc: dict[tuple[str, str, str], Decimal] = {}
|
||||
for c in data.concepts:
|
||||
for t in c.taxes:
|
||||
if t.is_withholding != withholding or t.factor == "Exento":
|
||||
continue
|
||||
clave = (t.code, t.factor, _rate(t.rate))
|
||||
acc[clave] = acc.get(clave, Decimal("0")) + t.amount
|
||||
return acc
|
||||
|
||||
# En el XSD, Retenciones va ANTES que Traslados dentro del nodo Impuestos del comprobante
|
||||
# —al revés que dentro del concepto—. Es una asimetría real del esquema, no un descuido.
|
||||
retenidos = agrupa(True)
|
||||
if retenidos:
|
||||
impuestos.set("TotalImpuestosRetenidos", _money(data.withheld, cur))
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
|
||||
for (code, _factor, _tasa), monto in sorted(retenidos.items()):
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
|
||||
el.set("Impuesto", code)
|
||||
el.set("Importe", _money(monto, cur))
|
||||
|
||||
trasladados = agrupa(False)
|
||||
if trasladados:
|
||||
impuestos.set("TotalImpuestosTrasladados", _money(data.transferred, cur))
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
|
||||
for (code, factor, tasa), monto in sorted(trasladados.items()):
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
|
||||
el.set(
|
||||
"Base",
|
||||
_money(
|
||||
sum(
|
||||
(
|
||||
c.amount
|
||||
for c in data.concepts
|
||||
for t in c.taxes
|
||||
if not t.is_withholding
|
||||
and t.code == code
|
||||
and t.factor == factor
|
||||
and _rate(t.rate) == tasa
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
cur,
|
||||
),
|
||||
)
|
||||
el.set("Impuesto", code)
|
||||
el.set("TipoFactor", factor)
|
||||
el.set("TasaOCuota", tasa)
|
||||
el.set("Importe", _money(monto, cur))
|
||||
|
||||
|
||||
def apply_seal(xml_bytes: bytes, seal: str) -> bytes:
|
||||
"""Inserta el ``Sello`` en el comprobante ya construido.
|
||||
|
||||
Sólo el sello: ``NoCertificado`` y ``Certificado`` ya venían de ``build_xml`` porque el
|
||||
primero entra en la cadena original que se acaba de firmar.
|
||||
"""
|
||||
root = etree.fromstring(xml_bytes)
|
||||
if root.get("NoCertificado") is None or not root.get("NoCertificado"):
|
||||
raise CfdiBuildError(
|
||||
["el comprobante llegó a sellarse sin NoCertificado: la cadena original sería inválida"]
|
||||
)
|
||||
root.set("Sello", seal)
|
||||
return _serialize(root)
|
||||
31
backend/api/v1/modules/fin/stamping/dto.py
Normal file
31
backend/api/v1/modules/fin/stamping/dto.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Esquemas del timbrado de CFDI."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class InvoiceStampResponse(BaseModel):
|
||||
"""Resultado de un timbrado.
|
||||
|
||||
No expone el XML completo: se descarga por URL firmada desde ``/stamp/xml-url``.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_id: int
|
||||
mode: str # pruebas | produccion
|
||||
status: str # pendiente | timbrado | error
|
||||
uuid: str | None = None
|
||||
stamped_at: datetime | None = None
|
||||
pac_rfc: str | None = None
|
||||
sat_cert_number: str | None = None
|
||||
pac_code: int | None = None
|
||||
pac_balance: int | None = None
|
||||
error_message: str | None = None
|
||||
xml_file_key: str | None = None
|
||||
# Rastro del intento: se descargan por URL firmada desde ``/stamp/attempts/{id}/xml-url``.
|
||||
request_xml_file_key: str | None = None
|
||||
response_xml_file_key: str | None = None
|
||||
created_at: datetime | None = None
|
||||
93
backend/api/v1/modules/fin/stamping/models.py
Normal file
93
backend/api/v1/modules/fin/stamping/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Timbrado de CFDI — ``fin.invoice_stamps``.
|
||||
|
||||
Una fila por **intento** de timbrado, incluidos los fallidos: sin ellos no hay forma de
|
||||
reconstruir por qué una factura no se timbró, y el error del PAC llega en un header HTTP que
|
||||
se pierde en cuanto termina la petición.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
# Modos de timbrado. El host del PAC se deriva de aquí y de ningún otro lado.
|
||||
MODE_TEST = "pruebas"
|
||||
MODE_PROD = "produccion"
|
||||
STAMPING_MODES = (MODE_TEST, MODE_PROD)
|
||||
|
||||
# RFC del proveedor de certificación según el entorno (CFDI.cs:16665 del sistema legado).
|
||||
# Sirve para verificar que el timbre recibido viene del entorno que se pidió.
|
||||
PAC_RFC_BY_MODE = {
|
||||
MODE_TEST: "SPR190613I52",
|
||||
MODE_PROD: "SCD110105654",
|
||||
}
|
||||
|
||||
# Estados del intento.
|
||||
STATUS_PENDING = "pendiente"
|
||||
STATUS_STAMPED = "timbrado"
|
||||
STATUS_ERROR = "error"
|
||||
|
||||
|
||||
class InvoiceStamp(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Intento de timbrado de una factura ante el PAC."""
|
||||
|
||||
__tablename__ = "invoice_stamps"
|
||||
__table_args__ = (
|
||||
# Un UUID no puede repetirse: el SAT lo emite una sola vez. El índice es parcial
|
||||
# sobre uuid IS NOT NULL porque los intentos fallidos no traen UUID y serían todos
|
||||
# "iguales" entre sí bajo un único convencional.
|
||||
Index(
|
||||
"uq_fin_invoice_stamps_uuid",
|
||||
"uuid",
|
||||
unique=True,
|
||||
postgresql_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
sqlite_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
),
|
||||
{"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
|
||||
)
|
||||
# Copiado de invoices.stamping_mode al transmitir y congelado aquí: es el registro de
|
||||
# contra qué entorno se timbró de verdad, aunque la factura cambie después.
|
||||
mode: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(12), nullable=False, server_default=text("'pendiente'"), index=True
|
||||
)
|
||||
|
||||
# ----- Datos del Timbre Fiscal Digital (sólo si el PAC timbró) -----
|
||||
uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
stamped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # FechaTimbrado
|
||||
pac_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) # RfcProvCertif
|
||||
sat_cert_number: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True
|
||||
) # NoCertificadoSAT
|
||||
sat_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloSAT
|
||||
cfd_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloCFD
|
||||
|
||||
# ----- Respuesta del PAC -----
|
||||
# El legado leía estos dos headers en una variable local que descartaba, así que su código
|
||||
# de respuesta y su saldo de folios se perdían siempre (CFDI.cs:19324-19336). Aquí se
|
||||
# persisten: sin ellos no se sabe cuántos folios quedan ni qué contestó el PAC.
|
||||
pac_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # header codigo
|
||||
pac_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) # header saldo
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) # header errmsg
|
||||
|
||||
# XML timbrado en MinIO. Sólo lo tienen los intentos exitosos: es el comprobante que se
|
||||
# descarga, y su clave lleva el UUID.
|
||||
xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# ----- Rastro del intento en MinIO -----
|
||||
# El par enviado/recibido de CADA intento, incluidos los rechazados. Es lo único que
|
||||
# permite reconstruir por qué el PAC rechazó un comprobante: el XML sellado se construye
|
||||
# en memoria y se pierde al terminar la petición, y el cuerpo de la respuesta también.
|
||||
request_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
response_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal file
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Cliente del PAC Comercio Digital — servicio ``timbrarV5``.
|
||||
|
||||
El contrato está tomado del sistema legado (``CFDI.cs:19274-19346``), que es la única fuente
|
||||
de verdad disponible: se transmite el XML **sellado** en crudo por POST y la respuesta trae el
|
||||
comprobante timbrado en el cuerpo y los metadatos en cabeceras HTTP.
|
||||
|
||||
Tres defectos del legado se corrigen aquí en vez de replicarse — ver ``_read_headers``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import MODE_TEST, STAMPING_MODES
|
||||
|
||||
# Códigos de error propios del cliente, con los mismos números que usa el legado para que los
|
||||
# reportes de ambos sistemas se puedan comparar.
|
||||
ERR_USER = 701 # usuario con longitud inválida
|
||||
ERR_PASSWORD = 702 # password vacío
|
||||
ERR_EMPTY_XML = 711 # XML vacío o demasiado corto
|
||||
ERR_NETWORK = 833 # excepción de red
|
||||
ERR_HTTP = 998 # respuesta HTTP distinta de 200
|
||||
|
||||
# El usrws de Comercio Digital tiene forma de RFC.
|
||||
_USER_MIN, _USER_MAX = 12, 13
|
||||
# Un CFDI sellado nunca baja de este tamaño; por debajo, es que algo se truncó.
|
||||
_MIN_XML_BYTES = 200
|
||||
|
||||
|
||||
class PacConfigError(Exception):
|
||||
"""Configuración inválida del PAC. Se detecta antes de tocar la red."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StampResult:
|
||||
"""Respuesta del PAC ante un intento de timbrado."""
|
||||
|
||||
ok: bool
|
||||
code: int | None
|
||||
error_message: str
|
||||
xml: str = ""
|
||||
uuid: str = ""
|
||||
balance: int | None = None
|
||||
email_error: str = ""
|
||||
|
||||
|
||||
def resolve_host(mode: str, host_test: str, host_prod: str) -> str:
|
||||
"""Host del PAC a partir del modo. **Es la única forma de elegirlo.**
|
||||
|
||||
No hay parámetro de host, ni de URL, ni forma de pasarlos desde la capa HTTP: el modo sale
|
||||
de ``invoices.stamping_mode`` y nada más. En el legado, ``CFDIPacUrl`` es un campo mutable
|
||||
que cualquier rama del código reasigna, y un host vacío cae silenciosamente al de pruebas
|
||||
(``CFDI.cs:19288``). Aquí un modo desconocido es un error, no un valor por defecto: el
|
||||
default equivocado emite un CFDI con validez fiscal real.
|
||||
"""
|
||||
if mode not in STAMPING_MODES:
|
||||
raise PacConfigError(
|
||||
f"Modo de timbrado inválido: {mode!r}. Sólo se admiten {STAMPING_MODES}."
|
||||
)
|
||||
return host_test if mode == MODE_TEST else host_prod
|
||||
|
||||
|
||||
def stamp(
|
||||
xml_bytes: bytes,
|
||||
*,
|
||||
mode: str,
|
||||
user: str,
|
||||
password: str,
|
||||
host_test: str,
|
||||
host_prod: str,
|
||||
email: str = "",
|
||||
timeout: int = 15,
|
||||
) -> StampResult:
|
||||
"""Transmite el CFDI sellado al PAC y devuelve el resultado.
|
||||
|
||||
Nunca lanza por causas de red o del PAC: esas se devuelven como ``StampResult`` con
|
||||
``ok=False``, porque el llamador tiene que persistir el intento fallido. Sí lanza
|
||||
``PacConfigError`` si el modo es inválido, que es un error de programación, no de operación.
|
||||
"""
|
||||
host = resolve_host(mode, host_test, host_prod)
|
||||
|
||||
# Validaciones previas: mismas condiciones y códigos que el legado, antes de salir a la red.
|
||||
if not user or not (_USER_MIN <= len(user) <= _USER_MAX):
|
||||
return StampResult(False, ERR_USER, f"{ERR_USER} Usuario del PAC inválido o no configurado")
|
||||
if not password:
|
||||
return StampResult(False, ERR_PASSWORD, f"{ERR_PASSWORD} Password del PAC no configurado")
|
||||
if not xml_bytes or len(xml_bytes) < _MIN_XML_BYTES:
|
||||
return StampResult(False, ERR_EMPTY_XML, f"{ERR_EMPTY_XML} Contenido XML vacío")
|
||||
|
||||
url = f"https://{host}/timbre4/timbrarV5"
|
||||
headers = {
|
||||
"usrws": user,
|
||||
"pwdws": password,
|
||||
"tipo": "XML",
|
||||
"Content-Type": "text/plain",
|
||||
}
|
||||
if email:
|
||||
headers["email"] = email.lower()
|
||||
|
||||
try:
|
||||
# El cuerpo va en crudo: ni base64 ni SOAP. httpx no reintenta por defecto, y así debe
|
||||
# ser: reintentar un timbrado puede consumir un folio y generar un CFDI duplicado.
|
||||
response = httpx.post(url, content=xml_bytes, headers=headers, timeout=timeout)
|
||||
except httpx.HTTPError as exc:
|
||||
return StampResult(
|
||||
False, ERR_NETWORK, f"{ERR_NETWORK} Error de transmisión a {host}: {exc}"
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# El cuerpo se conserva aunque el estado no sea 200: cuando el PAC contesta con un
|
||||
# error de servidor, lo que explica el rechazo suele venir precisamente ahí.
|
||||
return StampResult(
|
||||
False,
|
||||
ERR_HTTP,
|
||||
f"{ERR_HTTP} El PAC respondió HTTP {response.status_code}",
|
||||
response.text,
|
||||
)
|
||||
|
||||
return _read_headers(response)
|
||||
|
||||
|
||||
def _read_headers(response: httpx.Response) -> StampResult:
|
||||
"""Interpreta la respuesta del PAC.
|
||||
|
||||
Aquí se corrigen tres defectos del legado (``CFDI.cs:19324-19336``):
|
||||
|
||||
1. ``codigo`` **se lee y se conserva**. El legado lo asignaba a una variable local que
|
||||
descartaba, así que su parámetro de salida quedaba siempre en 999 o 991.
|
||||
2. ``saldo`` **también**. Mismo patrón: se perdía siempre, y con él la única señal de
|
||||
cuántos folios quedan.
|
||||
3. La presencia de una cabecera se comprueba de verdad. En .NET, ``GetResponseHeader``
|
||||
devuelve ``""`` y no ``null`` cuando falta, así que la rama ``== null`` del legado
|
||||
prácticamente nunca se cumplía.
|
||||
"""
|
||||
headers = response.headers
|
||||
error_message = (headers.get("errmsg") or "").strip()
|
||||
uuid = (headers.get("uuid") or "").strip()
|
||||
email_error = (headers.get("erremail") or "").strip()
|
||||
|
||||
def entero(nombre: str) -> int | None:
|
||||
crudo = (headers.get(nombre) or "").strip()
|
||||
if not crudo:
|
||||
return None
|
||||
try:
|
||||
return int(crudo)
|
||||
except ValueError:
|
||||
# El PAC mandó algo no numérico: se ignora el valor, pero no se rompe el timbrado
|
||||
# por ello. Queda como None, que es "no informado".
|
||||
return None
|
||||
|
||||
code = entero("codigo")
|
||||
balance = entero("saldo")
|
||||
|
||||
# Criterio de éxito del legado: errmsg vacío. Se le añade la exigencia de UUID, porque una
|
||||
# respuesta 200 sin errmsg y sin UUID no es un comprobante timbrado.
|
||||
if error_message:
|
||||
return StampResult(False, code, error_message, response.text, uuid, balance, email_error)
|
||||
if not uuid:
|
||||
return StampResult(
|
||||
False,
|
||||
code,
|
||||
"El PAC respondió sin error pero no devolvió UUID",
|
||||
response.text,
|
||||
"",
|
||||
balance,
|
||||
email_error,
|
||||
)
|
||||
|
||||
return StampResult(True, code, "", response.text, uuid, balance, email_error)
|
||||
95
backend/api/v1/modules/fin/stamping/routes.py
Normal file
95
backend/api/v1/modules/fin/stamping/routes.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Endpoints del timbrado de CFDI."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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 InvoiceStampResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _uid(cu: dict) -> str | None:
|
||||
return cu.get("sub") or cu.get("id")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/invoices/{invoice_id}/stamp",
|
||||
response_model=InvoiceStampResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def stamp_invoice(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Timbra la factura ante el PAC.
|
||||
|
||||
El modo (pruebas o producción) sale de ``invoices.stamping_mode`` y **no se puede pasar
|
||||
por aquí**: ni por cuerpo, ni por query, ni por cabecera. Es lo único que separa un timbre
|
||||
de prueba de un CFDI con validez fiscal ante el SAT.
|
||||
|
||||
Es idempotente: si la factura ya tiene timbre, lo devuelve sin volver a llamar al PAC.
|
||||
"""
|
||||
return service.stamp_invoice(
|
||||
db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp", response_model=InvoiceStampResponse)
|
||||
def get_invoice_stamp(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Timbre vigente de la factura."""
|
||||
stamp = service.get_stamp(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
if not stamp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="La factura no está timbrada"
|
||||
)
|
||||
return stamp
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/attempts", response_model=list[InvoiceStampResponse])
|
||||
def list_stamp_attempts(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Historial de intentos de timbrado, incluidos los rechazados por el PAC."""
|
||||
return service.list_attempts(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/attempts/{attempt_id}/xml-url")
|
||||
def get_stamp_attempt_xml_url(
|
||||
invoice_id: int,
|
||||
attempt_id: int,
|
||||
kind: str = Query(..., pattern="^(request|response)$"),
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""URL firmada del XML transmitido al PAC (``request``) o del que contestó (``response``)."""
|
||||
url = service.get_attempt_xml_url(
|
||||
db, invoice_id, attempt_id, kind, current_user["tenant_id"], company_id
|
||||
)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/xml-url")
|
||||
def get_stamp_xml_url(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""URL firmada para descargar el XML timbrado."""
|
||||
url = service.get_stamp_xml_url(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
return {"url": url}
|
||||
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal file
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Cadena original y sello del CFDI.
|
||||
|
||||
El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:
|
||||
|
||||
1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
|
||||
2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.
|
||||
|
||||
Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
|
||||
rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
from lxml import etree
|
||||
|
||||
_XSLT_DIR = Path(__file__).parent / "xslt"
|
||||
_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"
|
||||
|
||||
# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
|
||||
# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
|
||||
_transform: etree.XSLT | None = None
|
||||
_transform_lock = threading.Lock()
|
||||
|
||||
|
||||
class SealingError(Exception):
|
||||
"""Falla al calcular la cadena original o el sello."""
|
||||
|
||||
|
||||
def _get_transform() -> etree.XSLT:
|
||||
global _transform
|
||||
if _transform is None:
|
||||
with _transform_lock:
|
||||
if _transform is None: # otro hilo pudo compilarla mientras esperábamos
|
||||
if not _XSLT_CADENA.is_file():
|
||||
raise SealingError(
|
||||
f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
|
||||
)
|
||||
# Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
|
||||
# no hay resolución por red, ni aquí ni dentro de libxslt.
|
||||
_transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
|
||||
return _transform
|
||||
|
||||
|
||||
def build_original_string(xml_bytes: bytes) -> str:
|
||||
"""Cadena original del comprobante, vía el XSLT oficial del SAT.
|
||||
|
||||
``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
|
||||
``Certificado``: son justamente los que se calculan a partir de esta cadena.
|
||||
"""
|
||||
try:
|
||||
doc = etree.fromstring(xml_bytes)
|
||||
except etree.XMLSyntaxError as exc:
|
||||
raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc
|
||||
|
||||
cadena = str(_get_transform()(doc))
|
||||
# Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
|
||||
# plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
|
||||
# correcto sobre nada, así que se corta aquí.
|
||||
if not cadena.startswith("||") or not cadena.endswith("||"):
|
||||
raise SealingError(
|
||||
"La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
|
||||
"Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
|
||||
)
|
||||
return cadena
|
||||
|
||||
|
||||
def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
|
||||
"""Carga la llave privada del CSD.
|
||||
|
||||
El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
|
||||
PEM para no obligar a convertir llaves que ya estén en ese formato.
|
||||
"""
|
||||
if not password:
|
||||
raise SealingError(
|
||||
"No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
|
||||
)
|
||||
clave = password.encode("utf-8")
|
||||
try:
|
||||
key = serialization.load_der_private_key(key_der, password=clave)
|
||||
except ValueError:
|
||||
try:
|
||||
key = serialization.load_pem_private_key(key_der, password=clave)
|
||||
except ValueError as exc:
|
||||
# Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
|
||||
# los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
|
||||
# equivocado.
|
||||
raise SealingError(
|
||||
"No pude abrir la llave privada del CSD: el archivo no es una llave válida "
|
||||
"o la contraseña es incorrecta."
|
||||
) from exc
|
||||
if not isinstance(key, rsa.RSAPrivateKey):
|
||||
raise SealingError("La llave privada del CSD no es RSA.")
|
||||
return key
|
||||
|
||||
|
||||
def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
|
||||
"""Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.
|
||||
|
||||
- El ``.cer`` del SAT es X.509 **DER**.
|
||||
- El atributo ``Certificado`` del comprobante es el base64 de ese DER.
|
||||
- El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
|
||||
de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
|
||||
decodifica en vez de imprimirse en hexadecimal.
|
||||
"""
|
||||
try:
|
||||
cert = load_der_x509_certificate(cer_bytes)
|
||||
der = cer_bytes
|
||||
except ValueError:
|
||||
try:
|
||||
cert = load_pem_x509_certificate(cer_bytes)
|
||||
der = cert.public_bytes(serialization.Encoding.DER)
|
||||
except ValueError as exc:
|
||||
raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc
|
||||
|
||||
serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
|
||||
try:
|
||||
numero = serial_bytes.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise SealingError(
|
||||
"El número de serie del certificado no tiene el formato del SAT "
|
||||
"(sus bytes deben ser los 20 dígitos en ASCII)."
|
||||
) from exc
|
||||
if len(numero) != 20 or not numero.isdigit():
|
||||
raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")
|
||||
|
||||
return numero, base64.b64encode(der).decode("ascii")
|
||||
|
||||
|
||||
def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
|
||||
"""Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.
|
||||
|
||||
SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
|
||||
``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
|
||||
"""
|
||||
firma = private_key.sign(
|
||||
original_string.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(firma).decode("ascii")
|
||||
685
backend/api/v1/modules/fin/stamping/service.py
Normal file
685
backend/api/v1/modules/fin/stamping/service.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""Orquestación del timbrado: factura → XML → sello → PAC → persistencia."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import (
|
||||
STAMP_XML_KINDS,
|
||||
invoice_stamp_attempt_xml_key,
|
||||
invoice_stamp_xml_key,
|
||||
)
|
||||
|
||||
from ..catalogs.models import (
|
||||
CfdiUse,
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
TaxRegime,
|
||||
UnitOfMeasure,
|
||||
)
|
||||
from ..invoices.models import Invoice, InvoiceItem, InvoiceItemTax
|
||||
from ..issuer.models import IssuerSettings
|
||||
from . import cfdi_builder as builder
|
||||
from . import pac_comercio_digital as pac
|
||||
from . import sealer
|
||||
from .models import (
|
||||
PAC_RFC_BY_MODE,
|
||||
STAMPING_MODES,
|
||||
STATUS_ERROR,
|
||||
STATUS_STAMPED,
|
||||
InvoiceStamp,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital"
|
||||
|
||||
# Tipo con el que los XML del timbrado entran al expediente de EFC. Se reusa el de la factura en
|
||||
# vez de inventar uno: la lista de tipos está duplicada a mano en este repo y en EFC
|
||||
# (``TIPOS_DOCUMENTO_CRM``), y una clave que solo exista de este lado se rechaza allá. Los tres
|
||||
# archivos del CFDI —PDF, XML enviado y XML recibido— se distinguen por su nombre de archivo.
|
||||
_EFC_TIPO_CFDI = "factura_venta"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Lectura
|
||||
# --------------------------------------------------------------------------------------
|
||||
def get_stamp(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> InvoiceStamp | None:
|
||||
"""Timbre vigente de la factura, si lo hay. Sólo cuenta el exitoso."""
|
||||
return (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.status == STATUS_STAMPED,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(InvoiceStamp.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def list_attempts(
|
||||
db: Session, invoice_id: int, tenant_id: int, company_id: int
|
||||
) -> list[InvoiceStamp]:
|
||||
"""Todos los intentos de la factura, del más reciente al más antiguo.
|
||||
|
||||
A diferencia de ``get_stamp``, incluye los rechazados: son los que hay que consultar
|
||||
cuando el PAC devuelve un error y hace falta ver qué se le mandó.
|
||||
"""
|
||||
return (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(InvoiceStamp.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_attempt_xml_url(
|
||||
db: Session, invoice_id: int, attempt_id: int, kind: str, tenant_id: int, company_id: int
|
||||
) -> str:
|
||||
"""URL firmada del XML enviado o recibido en un intento concreto."""
|
||||
from core.storage_s3 import presigned_get_url # noqa: PLC0415
|
||||
|
||||
if kind not in STAMP_XML_KINDS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Tipo de XML inválido: {kind!r}. Sólo se admiten {list(STAMP_XML_KINDS)}.",
|
||||
)
|
||||
|
||||
attempt = (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.id == attempt_id,
|
||||
# invoice_id va en el filtro, no sólo en la ruta: sin él, el id de un intento de
|
||||
# otra factura de la misma empresa devolvería su XML.
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Intento no encontrado")
|
||||
|
||||
key = getattr(attempt, f"{kind}_xml_file_key")
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"El intento no tiene guardado el XML de {kind}",
|
||||
)
|
||||
return presigned_get_url(key)
|
||||
|
||||
|
||||
def _get_invoice(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> 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 _code(db: Session, model, pk: int | None) -> str:
|
||||
"""Clave del SAT de un catálogo, o cadena vacía si no está capturado.
|
||||
|
||||
Devolver "" en vez de lanzar es deliberado: la validación del builder acumula TODOS los
|
||||
faltantes y los reporta juntos, en vez de obligar a descubrirlos de uno en uno.
|
||||
"""
|
||||
if not pk:
|
||||
return ""
|
||||
row = db.query(model).filter(model.id == pk).first()
|
||||
return row.code if row else ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Armado de los datos fiscales
|
||||
# --------------------------------------------------------------------------------------
|
||||
def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int) -> builder.CfdiData:
|
||||
"""Reúne emisor, receptor y partidas resolviendo las claves contra los catálogos."""
|
||||
from ...crm.accounts.models import Account
|
||||
from ...crm.addresses.models import Address
|
||||
|
||||
issuer = (
|
||||
db.query(IssuerSettings)
|
||||
.filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not issuer:
|
||||
raise builder.CfdiBuildError(
|
||||
["no hay datos fiscales del emisor configurados para la empresa (fin.issuer_settings)"]
|
||||
)
|
||||
|
||||
account = None
|
||||
if invoice.account_id:
|
||||
account = db.query(Account).filter(Account.id == invoice.account_id).first()
|
||||
if not account:
|
||||
raise builder.CfdiBuildError(["la factura no tiene cliente asignado"])
|
||||
|
||||
# CP fiscal del receptor: vive en la dirección de tipo 'fiscal' de la cuenta.
|
||||
receiver_zip = ""
|
||||
direccion = (
|
||||
db.query(Address)
|
||||
.filter(
|
||||
Address.account_id == account.id,
|
||||
Address.address_type == "fiscal",
|
||||
Address.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if direccion and direccion.postal_code:
|
||||
receiver_zip = (direccion.postal_code or "").strip()[:5]
|
||||
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.order_by(InvoiceItem.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
concepts: list[builder.ConceptLine] = []
|
||||
for it in items:
|
||||
taxes: list[builder.TaxLine] = []
|
||||
for t in (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(InvoiceItemTax.invoice_item_id == it.id, InvoiceItemTax.deleted_at.is_(None))
|
||||
.order_by(InvoiceItemTax.id)
|
||||
.all()
|
||||
):
|
||||
taxes.append(
|
||||
builder.TaxLine(
|
||||
code=_code(db, Tax, t.tax_id),
|
||||
rate=Decimal(str(t.rate or 0)),
|
||||
amount=Decimal(str(t.amount or 0)),
|
||||
is_withholding=bool(t.is_withholding),
|
||||
# Sin pasar el factor, un exento se timbraría como gravado al 0%: un CFDI
|
||||
# incorrecto que el PAC acepta y que queda así ante el SAT.
|
||||
factor=t.factor or "Tasa",
|
||||
)
|
||||
)
|
||||
concepts.append(
|
||||
builder.ConceptLine(
|
||||
product_service_code=_code(db, ProductService, it.product_service_id),
|
||||
unit_code=_code(db, UnitOfMeasure, it.unit_of_measure_id),
|
||||
description=(it.description or it.concept or "").strip(),
|
||||
quantity=Decimal(str(it.quantity or 0)),
|
||||
unit_price=Decimal(str(it.unit_amount or 0)),
|
||||
tax_object=_code(db, TaxObject, it.tax_object_id),
|
||||
taxes=taxes,
|
||||
)
|
||||
)
|
||||
|
||||
# Fecha del comprobante: la de emisión si existe, y si no, ahora. Sin desplazamiento
|
||||
# horario, que en CFDI 4.0 no se pone (ver cfdi_builder).
|
||||
if invoice.issue_date:
|
||||
fecha = datetime.combine(invoice.issue_date, datetime.now().time())
|
||||
else:
|
||||
fecha = datetime.now()
|
||||
|
||||
return builder.CfdiData(
|
||||
folio=invoice.reference or str(invoice.id),
|
||||
serie=None,
|
||||
date=fecha.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
payment_form=_code(db, PaymentForm, invoice.payment_form_id),
|
||||
payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
|
||||
currency=(invoice.currency or "MXN").upper(),
|
||||
# En MXN queda en None y el comprobante no lleva TipoCambio; con otra moneda es
|
||||
# obligatorio y su ausencia la reporta la validación del builder junto al resto.
|
||||
exchange_rate=(
|
||||
Decimal(str(invoice.exchange_rate)) if invoice.exchange_rate is not None else None
|
||||
),
|
||||
expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
|
||||
payment_conditions=None,
|
||||
issuer_rfc=(issuer.rfc or "").strip().upper(),
|
||||
issuer_name=(issuer.legal_name or "").strip(),
|
||||
issuer_tax_regime=_code(db, TaxRegime, issuer.tax_regime_id),
|
||||
receiver_rfc=(account.rfc or "").strip().upper(),
|
||||
receiver_name=(account.name or "").strip(),
|
||||
receiver_zip=receiver_zip,
|
||||
receiver_tax_regime=_code(db, TaxRegime, account.tax_regime_id),
|
||||
receiver_cfdi_use=_code(db, CfdiUse, account.cfdi_use_id),
|
||||
concepts=concepts,
|
||||
)
|
||||
|
||||
|
||||
def _verifica_cuadre_con_la_factura(invoice: Invoice, data: builder.CfdiData) -> None:
|
||||
"""Comprueba que la factura y el comprobante digan el mismo total antes de sellar.
|
||||
|
||||
Con el impuesto por partida la igualdad es exacta por construcción: mismo importe de línea,
|
||||
mismos importes de impuesto y misma composición (subtotal + trasladado − retenido). Una
|
||||
diferencia aquí significa que los totales guardados quedaron desincronizados por un camino que
|
||||
el service no controla —una fila insertada por fuera, una migración a medias—.
|
||||
|
||||
Se **falla y no se corrige**: el timbrado es el punto donde el dinero se vuelve irreversible,
|
||||
y recalcular en silencio cambiaría montos dentro de la operación de timbrado, que es
|
||||
exactamente lo que no debe pasar sin que nadie lo vea. Sale como 422 junto al resto de los
|
||||
faltantes, por el ``except`` que ya envuelve la construcción.
|
||||
|
||||
Las facturas anteriores al cálculo por partida no se verifican: su total viene de la fórmula
|
||||
del porcentaje global y no tiene por qué coincidir con el desglose del comprobante.
|
||||
"""
|
||||
if not invoice.taxes_per_item:
|
||||
return
|
||||
guardado = Decimal(str(invoice.total or 0)).quantize(Decimal("0.01"))
|
||||
del_comprobante = data.total.quantize(Decimal("0.01"))
|
||||
if guardado != del_comprobante:
|
||||
raise builder.CfdiBuildError(
|
||||
[
|
||||
f"los totales de la factura no cuadran con el comprobante: la factura dice "
|
||||
f"{guardado} y el CFDI {del_comprobante}. Vuelve a guardar una partida para "
|
||||
f"recalcular antes de timbrar."
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
|
||||
"""Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.
|
||||
|
||||
Las rutas salen de ``fin.issuer_settings``, no de una convención fija: cada empresa carga
|
||||
su propio certificado desde la configuración fiscal.
|
||||
|
||||
Import diferido de ``storage_s3``: importar arriba abre conexión y rompe los tests, que
|
||||
corren sin MinIO. Es el mismo patrón que usa ``invoices.service``.
|
||||
"""
|
||||
from core.crypto import SecretDecryptionError, SecretsNotConfigured, decrypt_secret # noqa: PLC0415
|
||||
|
||||
issuer = (
|
||||
db.query(IssuerSettings)
|
||||
.filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not issuer or not issuer.csd_cer_file_key or not issuer.csd_key_file_key:
|
||||
raise builder.CfdiBuildError(
|
||||
[
|
||||
"la empresa no tiene CSD cargado: súbelo en Configuración de Facturación "
|
||||
"(certificado .cer, llave .key y su contraseña)"
|
||||
]
|
||||
)
|
||||
|
||||
# Contraseña por empresa; la global de entorno queda sólo como respaldo del esquema previo.
|
||||
if issuer.csd_password_enc:
|
||||
try:
|
||||
password = decrypt_secret(issuer.csd_password_enc)
|
||||
except (SecretDecryptionError, SecretsNotConfigured) as exc:
|
||||
raise builder.CfdiBuildError([str(exc)]) from exc
|
||||
elif settings.CSD_PASSWORD:
|
||||
password = settings.CSD_PASSWORD
|
||||
else:
|
||||
raise builder.CfdiBuildError(
|
||||
["la empresa no tiene guardada la contraseña de su CSD: vuelve a cargarlo"]
|
||||
)
|
||||
|
||||
from core.storage_s3 import get_object_bytes # noqa: PLC0415
|
||||
|
||||
try:
|
||||
cer = get_object_bytes(issuer.csd_cer_file_key)
|
||||
key = get_object_bytes(issuer.csd_key_file_key)
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no hay CSD utilizable"
|
||||
raise builder.CfdiBuildError(
|
||||
[f"no pude leer los archivos del CSD desde el almacenamiento: {exc}"]
|
||||
) from exc
|
||||
return cer, key, password
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Timbrado
|
||||
# --------------------------------------------------------------------------------------
|
||||
def stamp_invoice(
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> InvoiceStamp:
|
||||
"""Genera, sella y transmite el CFDI de la factura.
|
||||
|
||||
Es idempotente: si la factura ya tiene un timbre exitoso lo devuelve tal cual, **sin**
|
||||
llamar al PAC. Retimbrar cuesta un folio y genera un comprobante duplicado ante el SAT,
|
||||
que después hay que cancelar.
|
||||
"""
|
||||
invoice = _get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
|
||||
existente = get_stamp(db, invoice_id, tenant_id, company_id)
|
||||
if existente:
|
||||
return existente
|
||||
|
||||
if invoice.status == "cancelada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada"
|
||||
)
|
||||
|
||||
mode = (invoice.stamping_mode or "").strip()
|
||||
if mode not in STAMPING_MODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Modo de timbrado inválido en la factura: {mode!r}",
|
||||
)
|
||||
|
||||
if not settings.PAC_USER or not settings.PAC_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="No están configuradas las credenciales del PAC (PAC_USER / PAC_PASSWORD).",
|
||||
)
|
||||
|
||||
# ----- Datos, CSD, XML y sello -----
|
||||
try:
|
||||
data = _build_data(db, invoice, tenant_id, company_id)
|
||||
_verifica_cuadre_con_la_factura(invoice, data)
|
||||
cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
|
||||
cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
|
||||
xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)
|
||||
cadena = sealer.build_original_string(xml)
|
||||
private_key = sealer.load_private_key(key_bytes, csd_password)
|
||||
sello = sealer.sign(cadena, private_key)
|
||||
xml_sellado = builder.apply_seal(xml, sello)
|
||||
except builder.CfdiBuildError as exc:
|
||||
# 422 con la lista completa: son datos que falta capturar, no un fallo del sistema.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"message": "Faltan datos fiscales para timbrar", "missing": exc.missing},
|
||||
) from exc
|
||||
except sealer.SealingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
# ----- Transmisión -----
|
||||
resultado = pac.stamp(
|
||||
xml_sellado,
|
||||
mode=mode,
|
||||
user=settings.PAC_USER,
|
||||
password=settings.PAC_PASSWORD,
|
||||
host_test=settings.PAC_HOST_TEST,
|
||||
host_prod=settings.PAC_HOST_PROD,
|
||||
email=settings.PAC_NOTIFICATION_EMAIL,
|
||||
timeout=settings.PAC_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
stamp = InvoiceStamp(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
invoice_id=invoice.id,
|
||||
mode=mode,
|
||||
status=STATUS_ERROR,
|
||||
pac_code=resultado.code,
|
||||
pac_balance=resultado.balance,
|
||||
error_message=resultado.error_message or None,
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
# El id se necesita para nombrar los XML del intento, y sólo existe después del flush.
|
||||
db.add(stamp)
|
||||
db.flush()
|
||||
_store_attempt_xml(stamp, xml_sellado, resultado.xml)
|
||||
|
||||
if not resultado.ok:
|
||||
db.commit()
|
||||
db.refresh(stamp)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"message": "El PAC rechazó el comprobante",
|
||||
"pac_code": resultado.code,
|
||||
"pac_error": resultado.error_message,
|
||||
"stamp_id": stamp.id,
|
||||
},
|
||||
)
|
||||
|
||||
# ----- Verificación del timbre recibido -----
|
||||
try:
|
||||
tfd = _read_tfd(resultado.xml)
|
||||
except ValueError as exc:
|
||||
stamp.error_message = str(exc)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"El PAC devolvió un XML que no pude interpretar: {exc}",
|
||||
) from exc
|
||||
|
||||
esperado = PAC_RFC_BY_MODE[mode]
|
||||
if tfd["pac_rfc"] != esperado:
|
||||
# Red de seguridad final: se pidió un entorno y contestó otro. Nunca se da por bueno.
|
||||
stamp.error_message = (
|
||||
f"El timbre viene del PAC {tfd['pac_rfc']!r} y para el modo {mode!r} se esperaba "
|
||||
f"{esperado!r}: se timbró contra un entorno distinto del solicitado."
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=stamp.error_message)
|
||||
|
||||
# ----- Persistencia -----
|
||||
stamp.status = STATUS_STAMPED
|
||||
stamp.uuid = tfd["uuid"]
|
||||
stamp.stamped_at = tfd["stamped_at"]
|
||||
stamp.pac_rfc = tfd["pac_rfc"]
|
||||
stamp.sat_cert_number = tfd["sat_cert_number"]
|
||||
stamp.sat_seal = tfd["sat_seal"]
|
||||
stamp.cfd_seal = tfd["cfd_seal"]
|
||||
stamp.error_message = None
|
||||
|
||||
key = invoice_stamp_xml_key(tenant_id, company_id, invoice.id, tfd["uuid"])
|
||||
try:
|
||||
from core.storage_s3 import put_object_bytes # noqa: PLC0415
|
||||
|
||||
put_object_bytes(key, resultado.xml.encode("utf-8"), content_type="application/xml")
|
||||
stamp.xml_file_key = key
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# El comprobante YA está timbrado ante el SAT: perder el archivo no puede invalidar el
|
||||
# timbre ni provocar un retimbrado. Se guarda el registro sin la clave y se anota.
|
||||
stamp.error_message = f"Timbrado correcto, pero no se pudo guardar el XML: {exc}"
|
||||
|
||||
# Las filas del outbox van en ESTA transacción, junto con el timbre: así no puede quedar un
|
||||
# CFDI timbrado sin su intención de entrega al expediente, ni al revés.
|
||||
pendientes = _encolar_xml_al_expediente(db, invoice, stamp)
|
||||
|
||||
db.commit()
|
||||
db.refresh(stamp)
|
||||
|
||||
# Después del commit: el worker necesita encontrar las filas ya existentes.
|
||||
_despachar_xml_al_expediente(pendientes, tenant_id, company_id)
|
||||
return stamp
|
||||
|
||||
|
||||
def _encolar_xml_al_expediente(db: Session, invoice: Invoice, stamp: InvoiceStamp) -> list[int]:
|
||||
"""Encola hacia el expediente de EFC el XML transmitido al PAC y el que contestó.
|
||||
|
||||
Devuelve los ids de las filas del outbox, para despacharlas después del commit.
|
||||
|
||||
Se entrega el par del intento que SÍ obtuvo timbre; los rechazados quedan en el CRM y se
|
||||
consultan por ``/stamp/attempts/{id}/xml-url``. Un expediente fiscal con los comprobantes que
|
||||
el PAC rechazó no aporta respaldo, solo ruido.
|
||||
|
||||
``delete_local=False`` a diferencia de los documentos que sube el usuario: el XML timbrado es
|
||||
el comprobante fiscal y el CRM lo sirve por ``/stamp/xml-url``. El corte directo que borra la
|
||||
copia local aplica a un documento cuya única razón de existir es vivir en el expediente; aquí
|
||||
dejaría esos endpoints apuntando a un objeto inexistente.
|
||||
|
||||
Best-effort de punta a punta: si EFC está apagado o el encolado falla, el timbre ya es válido
|
||||
ante el SAT y no puede caerse por esto. Por eso NUNCA propaga: ``enqueue_file_best_effort`` ya
|
||||
se protege con un SAVEPOINT, pero todo lo que rodea a la llamada —resolver el expediente,
|
||||
armar los nombres— también tiene que ser incapaz de tumbar un CFDI ya timbrado.
|
||||
"""
|
||||
try:
|
||||
return _encolar_xml_al_expediente_inner(db, invoice, stamp)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"timbrado: falló el encolado de los XML del intento %s hacia EFC; el timbre no se toca",
|
||||
stamp.id,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _encolar_xml_al_expediente_inner(
|
||||
db: Session, invoice: Invoice, stamp: InvoiceStamp
|
||||
) -> list[int]:
|
||||
"""Cuerpo de ``_encolar_xml_al_expediente``; ver ahí el contrato y el porqué."""
|
||||
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
|
||||
from ...crm.expediente_gateway.doc_types import is_valid_doc_type # noqa: PLC0415
|
||||
from ...crm.expediente_gateway.models import ( # noqa: PLC0415
|
||||
FILE_KIND_CFDI_REQUEST,
|
||||
FILE_KIND_CFDI_RESPONSE,
|
||||
SOURCE_FIN_INVOICE_STAMPS,
|
||||
)
|
||||
|
||||
# El expediente nace con la oportunidad y se hereda vía ``case_id``. Una factura suelta —
|
||||
# capturada sin pasar por el ciclo comercial— no tiene a dónde entregar, y eso no es un error.
|
||||
if not invoice.case_id:
|
||||
logger.info(
|
||||
"timbrado: la factura %s no tiene expediente (case_id nulo); no se entregan los XML a EFC",
|
||||
invoice.id,
|
||||
)
|
||||
return []
|
||||
|
||||
# El tipo viaja al catálogo GLOBAL de EFC, compartido por todas las organizaciones. Se valida
|
||||
# contra el set cerrado para no crear ahí un tipo basura que nadie limpia después.
|
||||
if not is_valid_doc_type(_EFC_TIPO_CFDI):
|
||||
logger.error(
|
||||
"timbrado: %r no está en el catálogo de tipos que EFC acepta; no se entregan los XML",
|
||||
_EFC_TIPO_CFDI,
|
||||
)
|
||||
return []
|
||||
|
||||
partes = (
|
||||
(FILE_KIND_CFDI_REQUEST, stamp.request_xml_file_key, "envio", "CFDIREQ"),
|
||||
(FILE_KIND_CFDI_RESPONSE, stamp.response_xml_file_key, "respuesta", "CFDIRES"),
|
||||
)
|
||||
|
||||
filas: list[int] = []
|
||||
for kind, s3_key, sufijo, prefijo_ref in partes:
|
||||
if not s3_key:
|
||||
# El almacenamiento falló al guardar el intento: no hay objeto que entregar.
|
||||
logger.warning(
|
||||
"timbrado: el intento %s no tiene XML de %s guardado; no se entrega a EFC",
|
||||
stamp.id, sufijo,
|
||||
)
|
||||
continue
|
||||
row = gateway.enqueue_file_best_effort(
|
||||
db,
|
||||
kind=kind,
|
||||
s3_key=s3_key,
|
||||
file_name=f"CFDI-{stamp.uuid}-{sufijo}.xml",
|
||||
content_type="application/xml",
|
||||
efc_tipo=_EFC_TIPO_CFDI,
|
||||
source_table=SOURCE_FIN_INVOICE_STAMPS,
|
||||
source_id=stamp.id,
|
||||
crm_document_ref=f"{prefijo_ref}-{stamp.company_id}-{stamp.id}",
|
||||
expediente_ref=invoice.case_id,
|
||||
tenant_id=stamp.tenant_id,
|
||||
company_id=stamp.company_id,
|
||||
delete_local=False,
|
||||
)
|
||||
if row is not None:
|
||||
filas.append(row.id)
|
||||
return filas
|
||||
|
||||
|
||||
def _despachar_xml_al_expediente(outbox_ids: list[int], tenant_id: int, company_id: int) -> None:
|
||||
"""Despacha las filas ya commiteadas. Lo que no se despache lo recoge el sweep del beat."""
|
||||
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
|
||||
|
||||
for outbox_id in outbox_ids:
|
||||
gateway.dispatch_file_delivery(outbox_id, tenant_id, company_id)
|
||||
|
||||
|
||||
def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None:
|
||||
"""Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``.
|
||||
|
||||
Nunca propaga una excepción. Este rastro es para diagnóstico: si el almacenamiento está
|
||||
caído no puede tumbar un timbrado que el SAT ya dio por bueno, ni convertir el rechazo del
|
||||
PAC —que es lo que hay que contarle a quien factura— en un error de almacenamiento. Lo que
|
||||
no se pudo subir queda con la clave en NULL y en el log.
|
||||
"""
|
||||
from core.storage_s3 import put_object_bytes # noqa: PLC0415
|
||||
|
||||
# El de respuesta puede venir vacío: un fallo de red corta antes de que el PAC conteste.
|
||||
partes = [("request", sent), ("response", received.encode("utf-8") if received else b"")]
|
||||
for kind, cuerpo in partes:
|
||||
if not cuerpo:
|
||||
continue
|
||||
key = invoice_stamp_attempt_xml_key(
|
||||
stamp.tenant_id, stamp.company_id, stamp.invoice_id, stamp.id, kind
|
||||
)
|
||||
try:
|
||||
put_object_bytes(key, cuerpo, content_type="application/xml")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("No se pudo guardar el XML de %s del intento %s", kind, stamp.id)
|
||||
continue
|
||||
setattr(stamp, f"{kind}_xml_file_key", key)
|
||||
|
||||
|
||||
def _read_tfd(xml_text: str) -> dict:
|
||||
"""Extrae el Timbre Fiscal Digital del XML que devolvió el PAC."""
|
||||
from lxml import etree # noqa: PLC0415
|
||||
|
||||
try:
|
||||
root = etree.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text)
|
||||
except etree.XMLSyntaxError as exc:
|
||||
raise ValueError(f"XML mal formado: {exc}") from exc
|
||||
|
||||
nodo = root.find(f".//{{{TFD_NS}}}TimbreFiscalDigital")
|
||||
if nodo is None:
|
||||
raise ValueError("no trae el nodo TimbreFiscalDigital")
|
||||
|
||||
uuid = (nodo.get("UUID") or "").strip()
|
||||
if not uuid:
|
||||
raise ValueError("el TimbreFiscalDigital no trae UUID")
|
||||
|
||||
crudo = (nodo.get("FechaTimbrado") or "").strip()
|
||||
try:
|
||||
stamped_at = datetime.fromisoformat(crudo) if crudo else None
|
||||
except ValueError:
|
||||
# Fecha ilegible: no invalida el timbre, que ya existe ante el SAT. Se deja en NULL.
|
||||
stamped_at = None
|
||||
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"stamped_at": stamped_at,
|
||||
"pac_rfc": (nodo.get("RfcProvCertif") or "").strip(),
|
||||
"sat_cert_number": (nodo.get("NoCertificadoSAT") or "").strip() or None,
|
||||
"sat_seal": (nodo.get("SelloSAT") or "").strip() or None,
|
||||
"cfd_seal": (nodo.get("SelloCFD") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def get_stamp_xml_url(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> str:
|
||||
"""URL firmada del XML timbrado. Las presignadas caducan, así que se genera al vuelo."""
|
||||
from core.storage_s3 import presigned_get_url # noqa: PLC0415
|
||||
|
||||
stamp = get_stamp(db, invoice_id, tenant_id, company_id)
|
||||
if not stamp or not stamp.xml_file_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="La factura no tiene XML timbrado almacenado",
|
||||
)
|
||||
return presigned_get_url(stamp.xml_file_key)
|
||||
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal file
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# XSLT de la cadena original — CFDI 4.0
|
||||
|
||||
La cadena original es la secuencia de datos que se firma para producir el sello del comprobante.
|
||||
Sólo se obtiene aplicando la transformación oficial del SAT: no se construye a mano.
|
||||
|
||||
| Archivo | Origen |
|
||||
|---|---|
|
||||
| `cadenaoriginal_4_0.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0/cadenaoriginal_4_0.xslt` |
|
||||
| `utilerias.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/2/cadenaoriginal_2_0/utilerias.xslt` |
|
||||
| `sin_complemento.xslt` | Nuestro. Marcador de posición, ver abajo. |
|
||||
|
||||
## La única modificación: los `href` de los `xsl:include`
|
||||
|
||||
El archivo del SAT trae 33 `xsl:include` apuntando a URLs de `sat.gob.mx`. **Se reescribieron los
|
||||
`href` a rutas relativas locales**; no se tocó ni una plantilla, ni un `xsl:template`, ni el orden
|
||||
de los campos.
|
||||
|
||||
- El include de `utilerias.xslt` apunta al archivo local del mismo nombre.
|
||||
- Los otros 32, todos de complementos (Carta Porte, Comercio Exterior, Nómina, Pagos…), apuntan a
|
||||
`sin_complemento.xslt`, que es un stylesheet vacío.
|
||||
|
||||
**Por qué es inocuo:** cada XSLT de complemento sólo aporta plantillas que hacen `match` sobre nodos
|
||||
de su propio complemento. Este módulo emite CFDI 4.0 tipo ingreso **sin complementos**, así que esas
|
||||
plantillas nunca se invocan. Verificado: la cadena original que produce esta versión local es
|
||||
**idéntica, carácter a carácter**, a la que produce el archivo del SAT resolviendo los includes por
|
||||
red. Hay una prueba que lo fija en `backend/tests/test_fin_stamping.py`.
|
||||
|
||||
## Por qué se hizo así, y no con un resolver
|
||||
|
||||
Porque **`libxslt` sale a internet a resolver los `xsl:include` aunque el parser de lxml se cree con
|
||||
`no_network=True`**. Está comprobado: con el archivo original y sin resolver, la transformación
|
||||
completa funciona, lo que sólo es posible si descargó `utilerias.xslt` de `sat.gob.mx` en ese
|
||||
momento.
|
||||
|
||||
Eso es inaceptable aquí por tres razones: el timbrado dependería de que `sat.gob.mx` esté arriba y
|
||||
responda rápido; la cadena original —el dato que se firma— vendría de una descarga no verificada en
|
||||
tiempo de ejecución; y un cambio silencioso en el servidor del SAT cambiaría los sellos sin que
|
||||
nadie lo note.
|
||||
|
||||
Se intentó primero con un `etree.Resolver` personalizado, que es lo que hace el sistema legado
|
||||
(`CFDI.cs`, el bloque `SafeXsltResolver` comentado hacia la línea 8333). No funcionó: el resolver
|
||||
también intercepta la resolución del documento principal, y devolver un stylesheet vacío ahí deja la
|
||||
transformación sin plantillas y **la cadena original sale vacía, sin ningún error**. Un sello sobre
|
||||
una cadena vacía es un CFDI que el SAT rechaza — o peor, un sello que parece válido y no lo es.
|
||||
|
||||
Con los `href` locales el problema desaparece de raíz: no hay nada que resolver fuera del directorio.
|
||||
|
||||
## Cómo actualizar estos archivos
|
||||
|
||||
1. Descarga el original del SAT (URLs de la tabla).
|
||||
2. Reescribe los `href` de los `xsl:include`: el de `utilerias.xslt` al archivo local, el resto a
|
||||
`sin_complemento.xslt`.
|
||||
3. Corre `pytest tests/test_fin_stamping.py`. La prueba de la cadena original tiene que seguir
|
||||
verde: si cambió el orden o el número de campos, el sello cambia y hay que revisarlo con Fiscal
|
||||
antes de subir nada.
|
||||
|
||||
## Si algún día se soporta un complemento
|
||||
|
||||
Trae **su** XSLT oficial, déjalo en este directorio y apunta el `href` de ese include concreto al
|
||||
archivo real, en lugar de a `sin_complemento.xslt`. No basta con añadir el nodo al XML: sin su
|
||||
plantilla, el complemento no entra en la cadena original y el sello sale mal.
|
||||
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal file
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal file
@@ -0,0 +1,409 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions" xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:cce11="http://www.sat.gob.mx/ComercioExterior11" xmlns:cce20="http://www.sat.gob.mx/ComercioExterior20" xmlns:donat="http://www.sat.gob.mx/donat" xmlns:divisas="http://www.sat.gob.mx/divisas" xmlns:implocal="http://www.sat.gob.mx/implocal" xmlns:leyendasFisc="http://www.sat.gob.mx/leyendasFiscales" xmlns:pfic="http://www.sat.gob.mx/pfic" xmlns:tpe="http://www.sat.gob.mx/TuristaPasajeroExtranjero" xmlns:nomina12="http://www.sat.gob.mx/nomina12" xmlns:registrofiscal="http://www.sat.gob.mx/registrofiscal" xmlns:pagoenespecie="http://www.sat.gob.mx/pagoenespecie" xmlns:aerolineas="http://www.sat.gob.mx/aerolineas" xmlns:valesdedespensa="http://www.sat.gob.mx/valesdedespensa" xmlns:notariospublicos="http://www.sat.gob.mx/notariospublicos" xmlns:vehiculousado="http://www.sat.gob.mx/vehiculousado" xmlns:servicioparcial="http://www.sat.gob.mx/servicioparcialconstruccion" xmlns:decreto="http://www.sat.gob.mx/renovacionysustitucionvehiculos" xmlns:destruccion="http://www.sat.gob.mx/certificadodestruccion" xmlns:obrasarte="http://www.sat.gob.mx/arteantiguedades" xmlns:ine="http://www.sat.gob.mx/ine" xmlns:iedu="http://www.sat.gob.mx/iedu" xmlns:ventavehiculos="http://www.sat.gob.mx/ventavehiculos" xmlns:detallista="http://www.sat.gob.mx/detallista" xmlns:ecc12="http://www.sat.gob.mx/EstadoDeCuentaCombustible12" xmlns:consumodecombustibles11="http://www.sat.gob.mx/ConsumoDeCombustibles11" xmlns:gceh="http://www.sat.gob.mx/GastosHidrocarburos10" xmlns:ieeh="http://www.sat.gob.mx/IngresosHidrocarburos10" xmlns:cartaporte20="http://www.sat.gob.mx/CartaPorte20" xmlns:pago20="http://www.sat.gob.mx/Pagos20" xmlns:cartaporte30="http://www.sat.gob.mx/CartaPorte30" xmlns:cartaporte31="http://www.sat.gob.mx/CartaPorte31" xmlns:hidrocarburospetroliferos="http://www.sat.gob.mx/hidrocarburospetroliferos">
|
||||
|
||||
<!-- Con el siguiente método se establece que la salida deberá ser en texto -->
|
||||
<xsl:output method="text" version="1.0" encoding="UTF-8" indent="no"/>
|
||||
<!--
|
||||
En esta sección se define la inclusión de las plantillas de utilerías para colapsar espacios
|
||||
-->
|
||||
<xsl:include href="utilerias.xslt"/>
|
||||
<!--
|
||||
En esta sección se define la inclusión de las demás plantillas de transformación para
|
||||
la generación de las cadenas originales de los complementos fiscales
|
||||
-->
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
|
||||
<!-- Aquí iniciamos el procesamiento de la cadena original con su | inicial y el terminador || -->
|
||||
<xsl:template match="/">|<xsl:apply-templates select="/cfdi:Comprobante"/>||</xsl:template>
|
||||
<!-- Aquí iniciamos el procesamiento de los datos incluidos en el comprobante -->
|
||||
<xsl:template match="cfdi:Comprobante">
|
||||
<!-- Iniciamos el tratamiento de los atributos de comprobante -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Version"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Serie"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Folio"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Fecha"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@FormaPago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NoCertificado"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@CondicionesDePago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@SubTotal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Descuento"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Moneda"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TipoCambio"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Total"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoDeComprobante"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Exportacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@MetodoPago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@LugarExpedicion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Confirmacion"/>
|
||||
</xsl:call-template>
|
||||
<!--
|
||||
Llamadas para procesar al los sub nodos del comprobante
|
||||
-->
|
||||
<xsl:apply-templates select="./cfdi:InformacionGlobal"/>
|
||||
<xsl:for-each select="./cfdi:CfdiRelacionados">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
<xsl:apply-templates select="./cfdi:Emisor"/>
|
||||
<xsl:apply-templates select="./cfdi:Receptor"/>
|
||||
<xsl:apply-templates select="./cfdi:Conceptos"/>
|
||||
<xsl:apply-templates select="./cfdi:Impuestos"/>
|
||||
<xsl:apply-templates select="./cfdi:Complemento"/>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo InformacionGlobal -->
|
||||
<xsl:template match="cfdi:InformacionGlobal">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo InformacionGlobal -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Periodicidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Meses"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Año"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo CFDIRelacionados -->
|
||||
<xsl:template match="cfdi:CfdiRelacionados">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo CFDIRelacionados -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoRelacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:for-each select="./cfdi:CfdiRelacionado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@UUID"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Emisor -->
|
||||
<xsl:template match="cfdi:Emisor">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Emisor -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Rfc"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Nombre"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@FacAtrAdquirente"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Receptor -->
|
||||
<xsl:template match="cfdi:Receptor">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Receptor -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Rfc"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Nombre"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@DomicilioFiscalReceptor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@ResidenciaFiscal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NumRegIdTrib"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscalReceptor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@UsoCFDI"/>
|
||||
</xsl:call-template>
|
||||
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Conceptos -->
|
||||
<xsl:template match="cfdi:Conceptos">
|
||||
<!-- Llamada para procesar los distintos nodos tipo Concepto -->
|
||||
<xsl:for-each select="./cfdi:Concepto">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!--Manejador de nodos tipo Concepto-->
|
||||
<xsl:template match="cfdi:Concepto">
|
||||
<!-- Iniciamos el tratamiento de los atributos del Concepto -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Cantidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveUnidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Unidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Descripcion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ValorUnitario"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Descuento"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ObjetoImp"/>
|
||||
</xsl:call-template>
|
||||
|
||||
<!-- Manejo de sub nodos de información Traslado de Conceptos:Concepto:Impuestos:Traslados-->
|
||||
<xsl:for-each select="./cfdi:Impuestos/cfdi:Traslados/cfdi:Traslado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de sub nodos de Retencion por cada una de los Conceptos:Concepto:Impuestos:Retenciones-->
|
||||
<xsl:for-each select="./cfdi:Impuestos/cfdi:Retenciones/cfdi:Retencion">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de los distintos sub nodos a cuenta de terceros de forma indistinta a su grado de dependencia -->
|
||||
<xsl:for-each select="./cfdi:ACuentaTerceros">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de los distintos sub nodos de información aduanera de forma indistinta a su grado de dependencia -->
|
||||
<xsl:for-each select="./cfdi:InformacionAduanera">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Llamada al manejador de nodos de CuentaPredial en caso de existir -->
|
||||
<xsl:if test="./cfdi:CuentaPredial">
|
||||
<xsl:apply-templates select="./cfdi:CuentaPredial"/>
|
||||
</xsl:if>
|
||||
|
||||
<!-- Llamada al manejador de nodos de ComplementoConcepto en caso de existir -->
|
||||
<xsl:if test="./cfdi:ComplementoConcepto">
|
||||
<xsl:apply-templates select="./cfdi:ComplementoConcepto"/>
|
||||
</xsl:if>
|
||||
|
||||
<!-- Llamada al manejador de nodos de Parte en caso de existir -->
|
||||
<xsl:for-each select=".//cfdi:Parte">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo ACuentaTerceros -->
|
||||
<xsl:template match="cfdi:ACuentaTerceros">
|
||||
<!-- Manejo de los atributos del nodo tipo ACuentaTerceros -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RfcACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NombreACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscalACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@DomicilioFiscalACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Información Aduanera -->
|
||||
<xsl:template match="cfdi:InformacionAduanera">
|
||||
<!-- Manejo de los atributos de la información aduanera -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NumeroPedimento"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Información CuentaPredial -->
|
||||
<xsl:template match="cfdi:CuentaPredial">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Numero"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo ComplementoConcepto -->
|
||||
<xsl:template match="cfdi:ComplementoConcepto">
|
||||
<xsl:for-each select="./*">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Parte -->
|
||||
<xsl:template match="cfdi:Parte">
|
||||
<!-- Iniciamos el tratamiento de los atributos de Parte-->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Cantidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Unidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Descripcion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@ValorUnitario"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
|
||||
<!-- Manejador de nodos tipo InformacionAduanera-->
|
||||
<xsl:for-each select=".//cfdi:InformacionAduanera">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Complemento -->
|
||||
<xsl:template match="cfdi:Complemento">
|
||||
<xsl:for-each select="./*">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Domicilio fiscal -->
|
||||
<xsl:template match="cfdi:Impuestos">
|
||||
<!-- Manejo de sub nodos de Retencion por cada una de los Impuestos:Retenciones-->
|
||||
<xsl:for-each select="./cfdi:Retenciones/cfdi:Retencion">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosRetenidos-->
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TotalImpuestosRetenidos"/>
|
||||
</xsl:call-template>
|
||||
<!-- Manejo de sub nodos de información Traslado de Impuestos:Traslados-->
|
||||
<xsl:for-each select="./cfdi:Traslados/cfdi:Traslado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosTrasladados-->
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TotalImpuestosTrasladados"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Marcador de posicion para los complementos del CFDI que este modulo NO emite.
|
||||
|
||||
cadenaoriginal_4_0.xslt del SAT incluye 32 hojas de estilo de complementos (Carta Porte,
|
||||
Comercio Exterior, Nomina, Pagos...). Cada una solo aporta plantillas que hacen match sobre
|
||||
nodos de su complemento: si el comprobante no los lleva, nunca se invocan y su ausencia no
|
||||
cambia la cadena original ni un caracter.
|
||||
|
||||
Este modulo emite CFDI 4.0 tipo ingreso SIN complementos (ver el plan del ticket, seccion 1),
|
||||
asi que todos esos includes apuntan aqui. Si algun dia se soporta un complemento, hay que
|
||||
traer SU xslt oficial y apuntar el href de ese include al archivo real.
|
||||
-->
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>
|
||||
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal file
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions">
|
||||
|
||||
<!-- Manejador de datos requeridos -->
|
||||
<xsl:template name="Requerido">
|
||||
<xsl:param name="valor"/>|<xsl:call-template name="ManejaEspacios">
|
||||
<xsl:with-param name="s" select="$valor"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de datos opcionales -->
|
||||
<xsl:template name="Opcional">
|
||||
<xsl:param name="valor"/>
|
||||
<xsl:if test="$valor">|<xsl:call-template name="ManejaEspacios"><xsl:with-param name="s" select="$valor"/></xsl:call-template></xsl:if>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Normalizador de espacios en blanco -->
|
||||
<xsl:template name="ManejaEspacios">
|
||||
<xsl:param name="s"/>
|
||||
<xsl:value-of select="normalize-space(string($s))"/>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
case_id: int | None = None
|
||||
closed_at: datetime | None = None
|
||||
closed_by: str | None = None
|
||||
created_by: str | None = None
|
||||
|
||||
@@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
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
|
||||
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
|
||||
quote_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
@@ -65,11 +65,14 @@ def create_shipment(
|
||||
def create_shipment_from_quote(
|
||||
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
|
||||
return service.create_shipment_from_quote(
|
||||
db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type
|
||||
)
|
||||
|
||||
|
||||
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)
|
||||
|
||||
@@ -5,10 +5,15 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.crm.accounts.models import Account
|
||||
from api.v1.modules.crm.cases import service as cases_service
|
||||
from api.v1.modules.crm.common.folios import next_folio
|
||||
from api.v1.modules.crm.quotes.models import Quote
|
||||
from api.v1.modules.crm.service_requests.models import ServiceRequest
|
||||
from api.v1.modules.crm.suppliers.models import Supplier
|
||||
|
||||
# Direcciones válidas de la operación (para validar y sembrar hitos).
|
||||
_OPERATION_TYPES = ("importacion", "exportacion")
|
||||
|
||||
from .dto import (
|
||||
ShipmentCloseInput,
|
||||
ShipmentCreate,
|
||||
@@ -173,9 +178,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i
|
||||
|
||||
|
||||
def create_shipment_from_quote(
|
||||
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None,
|
||||
operation_type: str | None = None,
|
||||
) -> Shipment:
|
||||
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
|
||||
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.
|
||||
|
||||
La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se
|
||||
envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio
|
||||
``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3).
|
||||
"""
|
||||
if operation_type is not None and operation_type not in _OPERATION_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'",
|
||||
)
|
||||
quote = (
|
||||
db.query(Quote)
|
||||
.filter(
|
||||
@@ -198,12 +214,16 @@ def create_shipment_from_quote(
|
||||
if quote.service_request_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
|
||||
|
||||
# La dirección enviada al liberar manda; si no viene, se hereda de la solicitud
|
||||
resolved = operation_type or (sr.operation_type if sr else None)
|
||||
|
||||
shipment = Shipment(
|
||||
reference=quote.reference,
|
||||
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
|
||||
case_id=quote.case_id,
|
||||
quote_id=quote.id,
|
||||
service_request_id=quote.service_request_id,
|
||||
account_id=quote.account_id,
|
||||
operation_type=sr.operation_type if sr else None,
|
||||
operation_type=resolved,
|
||||
transport_mode=sr.transport_mode if sr else None,
|
||||
service_type=sr.service_type if sr else None,
|
||||
incoterm=sr.incoterm if sr else None,
|
||||
@@ -220,6 +240,14 @@ def create_shipment_from_quote(
|
||||
db.add(shipment)
|
||||
if sr:
|
||||
sr.status = "liberada"
|
||||
cases_service.advance_stage(db, quote.case_id, "operacion")
|
||||
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
|
||||
|
||||
@@ -96,6 +96,10 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||
celery_app.conf.update(
|
||||
include=[
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
# Sin esta línea el worker rechaza las tareas del carril con "Received unregistered
|
||||
# task of type 'expediente_gateway.deliver_outbox_row'": la fila queda en `pending`
|
||||
# con 0 intentos y NUNCA se drena, aunque el encolado se vea perfecto.
|
||||
"api.v1.modules.crm.expediente_gateway.tasks",
|
||||
# Agrega aquí las tareas de tu proyecto:
|
||||
# "api.v1.modules.example.tasks",
|
||||
]
|
||||
@@ -120,6 +124,25 @@ celery_app.conf.beat_schedule = {
|
||||
"task": "cleanup_orphan_layout_imports",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
# Carril CRM -> EFC. Los tres intervalos vienen del carril de referencia de Anexo22: 120 s para
|
||||
# las dos colas y 300 s para la reconciliación. El reintento NO es exponencial a propósito —el
|
||||
# backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo.
|
||||
#
|
||||
# Estos barridos son la red que atrapa la ventana entre el encolado y el commit: el despacho
|
||||
# inmediato puede llegar al worker antes de que la transacción confirme, no encontrar la fila
|
||||
# y darse por vencido. Aquí se recoge.
|
||||
"efc-sweep-outbox-every-2-min": {
|
||||
"task": "expediente_gateway.sweep_outbox",
|
||||
"schedule": 120.0,
|
||||
},
|
||||
"efc-sweep-file-outbox-every-2-min": {
|
||||
"task": "expediente_gateway.sweep_file_outbox",
|
||||
"schedule": 120.0,
|
||||
},
|
||||
"efc-sweep-expediente-gaps-every-5-min": {
|
||||
"task": "expediente_gateway.sweep_expediente_gaps",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user