feat(billing): implementacion consolidacion mensual comisiones DIAN, cron corte y exportacion CSV
This commit is contained in:
parent
31b8fc8f79
commit
c2e1a1cb3a
File diff suppressed because it is too large
Load Diff
216
scripts/deploy_billing_update.py
Normal file
216
scripts/deploy_billing_update.py
Normal file
@ -0,0 +1,216 @@
|
||||
import paramiko
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import io
|
||||
import time
|
||||
import tarfile
|
||||
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
HOST = "161.97.93.156"
|
||||
USER = "carlos"
|
||||
PASSWORD = "Innova1978+*?"
|
||||
REMOTE_TEMP_TAR = "/home/carlos/pacifictrans_update.tar.gz"
|
||||
|
||||
LOCAL_BACKEND = r"c:\Users\Carlos\dev\almeira-web\transporte-cali-buenaventura\backend"
|
||||
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\pacifictrans-app\public\PacifictransGo.apk"
|
||||
LOCAL_TAR = r"c:\Users\Carlos\dev\almeira-web\pacifictrans_update.tar.gz"
|
||||
|
||||
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
|
||||
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
|
||||
INSTANCE_NAME = "AutomataAlmeira"
|
||||
TARGET_PHONE = "573167455877"
|
||||
APK_URL = "https://pacifictrans.almeira.com.co/PacifictransGo.apk"
|
||||
API_URL = "https://pacifictrans.almeira.com.co"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def create_bundle_tar():
|
||||
print("[1/5] Creando empaquetado de actualización de backend...", flush=True)
|
||||
if os.path.exists(LOCAL_TAR):
|
||||
os.remove(LOCAL_TAR)
|
||||
|
||||
with tarfile.open(LOCAL_TAR, "w:gz") as tar:
|
||||
# Añadir dist
|
||||
dist_path = os.path.join(LOCAL_BACKEND, "dist")
|
||||
if os.path.exists(dist_path):
|
||||
tar.add(dist_path, arcname="dist")
|
||||
# Añadir public
|
||||
public_path = os.path.join(LOCAL_BACKEND, "public")
|
||||
if os.path.exists(public_path):
|
||||
tar.add(public_path, arcname="public")
|
||||
# Añadir migrations
|
||||
mig_path = os.path.join(LOCAL_BACKEND, "migrations")
|
||||
if os.path.exists(mig_path):
|
||||
tar.add(mig_path, arcname="migrations")
|
||||
# Añadir data
|
||||
data_path = os.path.join(LOCAL_BACKEND, "data")
|
||||
if os.path.exists(data_path):
|
||||
tar.add(data_path, arcname="data")
|
||||
# Añadir node_modules de node-cron y csv-writer
|
||||
for pkg in ['node-cron', 'csv-writer']:
|
||||
pkg_path = os.path.join(LOCAL_BACKEND, "node_modules", pkg)
|
||||
if os.path.exists(pkg_path):
|
||||
tar.add(pkg_path, arcname=f"node_modules/{pkg}")
|
||||
# package.json
|
||||
pkg_json = os.path.join(LOCAL_BACKEND, "package.json")
|
||||
if os.path.exists(pkg_json):
|
||||
tar.add(pkg_json, arcname="package.json")
|
||||
|
||||
size_mb = os.path.getsize(LOCAL_TAR) / (1024 * 1024)
|
||||
print(f" -> Archivo comprimido creado: {LOCAL_TAR} ({size_mb:.2f} MB)", flush=True)
|
||||
|
||||
def deploy_to_vps():
|
||||
print("[2/5] Conectando por SSH al VPS y desplegando actualización...", flush=True)
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
|
||||
# Subir tar
|
||||
sftp = ssh.open_sftp()
|
||||
print(f" -> Subiendo {REMOTE_TEMP_TAR} al VPS...", flush=True)
|
||||
sftp.put(LOCAL_TAR, REMOTE_TEMP_TAR)
|
||||
|
||||
# Subir APK al path público de respaldo
|
||||
if os.path.exists(LOCAL_APK):
|
||||
print(" -> Subiendo APK actualizado al VPS...", flush=True)
|
||||
sftp.put(LOCAL_APK, "/home/carlos/apps/dripzone-app/public/PacifictransGo.apk")
|
||||
sftp.close()
|
||||
|
||||
# Detectar contenedores activos de Pacific Trans
|
||||
stdin, stdout, stderr = ssh.exec_command("docker ps --format '{{.Names}}'")
|
||||
containers = [c.strip() for c in stdout.read().decode('utf-8').splitlines() if c.strip()]
|
||||
pacific_containers = [c for c in containers if any(k in c.lower() for k in ['tayk', 'pacific'])]
|
||||
|
||||
if not pacific_containers:
|
||||
# Fallback a cualquier contenedor con drip o web si pacific no tiene nombre explícito
|
||||
pacific_containers = [c for c in containers if 'tayk' in c.lower()]
|
||||
|
||||
print(f" -> Contenedores destino encontrados: {pacific_containers}", flush=True)
|
||||
|
||||
for c in pacific_containers:
|
||||
print(f" -> Aplicando actualización en contenedor Coolify: {c}...", flush=True)
|
||||
# Copiar tar dentro del contenedor y descomprimir
|
||||
ssh.exec_command(f"docker cp {REMOTE_TEMP_TAR} {c}:/app/pacifictrans_update.tar.gz")
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
f"docker exec {c} sh -c 'cd /app && tar -xzf pacifictrans_update.tar.gz && rm -f pacifictrans_update.tar.gz'"
|
||||
)
|
||||
stdout.channel.recv_exit_status()
|
||||
|
||||
# Copiar APK
|
||||
ssh.exec_command(f"docker cp /home/carlos/apps/dripzone-app/public/PacifictransGo.apk {c}:/app/public/PacifictransGo.apk")
|
||||
|
||||
# Reiniciar contenedor en Coolify
|
||||
print(f" -> Reiniciando contenedor {c}...", flush=True)
|
||||
stdin, stdout, stderr = ssh.exec_command(f"docker restart {c}")
|
||||
stdout.channel.recv_exit_status()
|
||||
print(f" [OK] Contenedor {c} reiniciado con nueva versión.", flush=True)
|
||||
|
||||
# Limpiar tar temporal en VPS
|
||||
ssh.exec_command(f"rm -f {REMOTE_TEMP_TAR}")
|
||||
ssh.close()
|
||||
|
||||
def verify_endpoints():
|
||||
print("[3/5] Verificando endpoints online en producción...", flush=True)
|
||||
time.sleep(5)
|
||||
|
||||
# 1. Healthcheck
|
||||
try:
|
||||
req = urllib.request.Request(f"{API_URL}/api/health")
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
|
||||
data = json.loads(res.read().decode('utf-8'))
|
||||
print(f" [OK] /api/health: {data.get('status')} ({res.status})", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [Aviso Healthcheck]: {e}", flush=True)
|
||||
|
||||
# 2. Reporte Mensual
|
||||
try:
|
||||
req = urllib.request.Request(f"{API_URL}/api/admin/billing/reporte-mensual?mes=10&anio=2026")
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
|
||||
data = json.loads(res.read().decode('utf-8'))
|
||||
print(f" [OK] /api/admin/billing/reporte-mensual: HTTP {res.status}, {data.get('count', 0)} cierres encontrados", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [Aviso Reporte]: {e}", flush=True)
|
||||
|
||||
# 3. CSV Export
|
||||
try:
|
||||
req = urllib.request.Request(f"{API_URL}/api/admin/billing/reporte-mensual?mes=10&anio=2026&format=csv")
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
|
||||
first_line = res.readline().decode('utf-8', errors='replace').strip()
|
||||
print(f" [OK] /api/admin/billing/reporte-mensual?format=csv: HTTP {res.status}, Cabeceras: {first_line[:60]}...", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [Aviso CSV]: {e}", flush=True)
|
||||
|
||||
def notify_whatsapp():
|
||||
print("[4/5] Enviando notificación de consolidación y facturación a WhatsApp (+57 316 745 5877)...", flush=True)
|
||||
|
||||
text_msg = (
|
||||
"📑 *Pacific Trans GO: Consolidación Mensual de Comisiones & Facturación DIAN*\n\n"
|
||||
"¡Hola Carlos! Se ha implementado con éxito la arquitectura de consolidación mensual y control fiscal en la plataforma:\n\n"
|
||||
"1️⃣ *Esquema y Migración de Base de Datos:*\n"
|
||||
"• Tabla: `cierres_facturacion_mensual` (UUID PK, Conductor ID, NIT/Cédula, Razón Social, Correo, Mes, Año, Comisiones Acumuladas, Total Viajes, Estado Facturación, Fecha Cierre).\n"
|
||||
"• Migración SQL creada en `migrations/001_create_cierres_facturacion_mensual.sql` y Modelo Mongoose con persistencia autónoma.\n\n"
|
||||
"2️⃣ *Cron Job de Corte Mensual Automático:*\n"
|
||||
"• Programado para ejecutarse el día 1 de cada mes a las 00:00 horas (`0 0 1 * *`).\n"
|
||||
"• ⚠️ *Cálculo Estricto:* Basado *ÚNICAMENTE* en la comisión de software cobrada/retenida a los conductores y *NUNCA* en el valor total de los viajes.\n"
|
||||
"• Registro inicial en estado `'pendiente_proceso'`.\n\n"
|
||||
"3️⃣ *Endpoint de Exportación a Excel / CSV (DIAN):*\n"
|
||||
"• `GET /api/admin/billing/reporte-mensual?mes=X&anio=Y&format=csv`\n"
|
||||
"• Columnas exactas para copiado/pegado masivo en el portal DIAN: `ID_Cierre, NIT/Cédula, Conductor, Correo, Total_Comisión, Cantidad_Viajes` con codificación UTF-8 BOM.\n\n"
|
||||
"4️⃣ *Acciones en Masa y Control de Estados:*\n"
|
||||
"• `POST /api/admin/billing/marcar-facturado` recibiendo arreglo de `IDs_Cierre`.\n"
|
||||
"• Actualiza el estado a `'facturado_manual'` para evitar duplicidad de facturas en la DIAN.\n\n"
|
||||
"5️⃣ *Panel Administrativo Web:*\n"
|
||||
"• Sub-pestaña `📑 6. Cierres & Facturación DIAN` con selector de mes/año, KPIs fiscales, disparador manual de corte, selección múltiple y descarga directa del archivo CSV.\n\n"
|
||||
f"📲 *APK Android Actualizado:* {APK_URL}"
|
||||
)
|
||||
|
||||
req_text = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
|
||||
data=json.dumps({"number": TARGET_PHONE, "text": text_msg, "delay": 300}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_text, context=ctx, timeout=40) as resp:
|
||||
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Aviso texto WhatsApp]:", e, flush=True)
|
||||
|
||||
# Envío del archivo binario APK
|
||||
print("[5/5] Enviando archivo APK como documento a WhatsApp...", flush=True)
|
||||
req_media = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
|
||||
data=json.dumps({
|
||||
"number": TARGET_PHONE,
|
||||
"mediaMessage": {
|
||||
"mediatype": "document",
|
||||
"caption": "📲 PacifictransGo.apk - Instalador Oficial Android (Pacific Trans GO)",
|
||||
"media": APK_URL,
|
||||
"fileName": "PacifictransGo.apk"
|
||||
}
|
||||
}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_media, context=ctx, timeout=60) as resp:
|
||||
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Aviso documento APK WhatsApp]:", e, flush=True)
|
||||
|
||||
print("\n✅ [DESPLIEGUE Y NOTIFICACIÓN DIAN COMPLETADOS EXITOSAMENTE]", flush=True)
|
||||
|
||||
def main():
|
||||
create_bundle_tar()
|
||||
deploy_to_vps()
|
||||
verify_endpoints()
|
||||
notify_whatsapp()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
73
scripts/notify_billing_whatsapp.py
Normal file
73
scripts/notify_billing_whatsapp.py
Normal file
@ -0,0 +1,73 @@
|
||||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import io
|
||||
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
|
||||
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
|
||||
INSTANCE_NAME = "AutomataAlmeira"
|
||||
TARGET_PHONE = "573167455877"
|
||||
APK_URL = "https://pacifictrans.almeira.com.co/PacifictransGo.apk"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
text_msg = (
|
||||
"📑 *Pacific Trans GO: Consolidación Mensual de Comisiones & Facturación DIAN*\n\n"
|
||||
"¡Hola Carlos! Se ha implementado con éxito la arquitectura completa de consolidación mensual y control fiscal en la plataforma:\n\n"
|
||||
"1️⃣ *Esquema y Migración de Base de Datos:*\n"
|
||||
"• Tabla: `cierres_facturacion_mensual` (UUID PK, Conductor ID, NIT/Cédula, Razón Social, Correo, Mes, Año, Comisiones Acumuladas, Total Viajes, Estado Facturación, Fecha Cierre).\n"
|
||||
"• Migración SQL creada en `migrations/001_create_cierres_facturacion_mensual.sql` y Modelo Mongoose con persistencia autónoma.\n\n"
|
||||
"2️⃣ *Cron Job de Corte Mensual Automático:*\n"
|
||||
"• Programado para ejecutarse el día 1 de cada mes a las 00:00 horas (`0 0 1 * *`).\n"
|
||||
"• ⚠️ *Cálculo Estricto:* Basado *ÚNICAMENTE* en la comisión de software cobrada/retenida a los conductores y *NUNCA* en el valor total de los viajes.\n"
|
||||
"• Registro inicial en estado `'pendiente_proceso'`.\n\n"
|
||||
"3️⃣ *Endpoint de Exportación a Excel / CSV (DIAN):*\n"
|
||||
"• `GET /api/admin/billing/reporte-mensual?mes=X&anio=Y&format=csv`\n"
|
||||
"• Columnas exactas para copiado/pegado masivo en el portal DIAN: `ID_Cierre, NIT/Cédula, Conductor, Correo, Total_Comisión, Cantidad_Viajes` con codificación UTF-8 BOM.\n\n"
|
||||
"4️⃣ *Acciones en Masa y Control de Estados:*\n"
|
||||
"• `POST /api/admin/billing/marcar-facturado` recibiendo arreglo de `IDs_Cierre`.\n"
|
||||
"• Actualiza el estado a `'facturado_manual'` para evitar duplicidad de facturas en la DIAN.\n\n"
|
||||
"5️⃣ *Panel Administrativo Web:*\n"
|
||||
"• Sub-pestaña `📑 6. Cierres & Facturación DIAN` con selector de mes/año, KPIs fiscales, disparador manual de corte, selección múltiple y descarga directa del archivo CSV.\n\n"
|
||||
f"📲 *APK Android Oficial:* {APK_URL}"
|
||||
)
|
||||
|
||||
print("Enviando mensaje de texto a WhatsApp...", flush=True)
|
||||
req_text = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
|
||||
data=json.dumps({"number": TARGET_PHONE, "text": text_msg, "delay": 300}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_text, context=ctx, timeout=60) as resp:
|
||||
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Error texto WhatsApp]:", e, flush=True)
|
||||
|
||||
print("Enviando APK como documento adjunto a WhatsApp...", flush=True)
|
||||
req_media = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
|
||||
data=json.dumps({
|
||||
"number": TARGET_PHONE,
|
||||
"mediatype": "document",
|
||||
"mimetype": "application/vnd.android.package-archive",
|
||||
"fileName": "PacifictransGo.apk",
|
||||
"caption": "📦 *PacifictransGo.apk* - Instalador Oficial Pacific Trans GO",
|
||||
"media": APK_URL
|
||||
}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_media, context=ctx, timeout=120) as resp:
|
||||
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Error documento WhatsApp]:", e, flush=True)
|
||||
|
||||
print("Notificación WhatsApp finalizada.", flush=True)
|
||||
@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "9b7f5f3a-4605-4186-81a3-e150c941e647",
|
||||
"conductor_id": "cond-test-math-verification-1791996727700",
|
||||
"nit_o_cedula": "901.884.223-1",
|
||||
"razon_social": "Transportes Especiales del Valle SAS",
|
||||
"correo_electronico": "contabilidad@valletransportes.com.co",
|
||||
"mes_calendario": 10,
|
||||
"anio_calendario": 2026,
|
||||
"total_comisiones_acumuladas": 52500,
|
||||
"total_viajes_realizados": 3,
|
||||
"estado_facturacion": "facturado_manual",
|
||||
"fecha_cierre": "2026-10-14T16:52:07.708Z",
|
||||
"createdAt": "2026-10-14T16:52:07.709Z",
|
||||
"updatedAt": "2026-10-14T16:52:27.790Z"
|
||||
},
|
||||
{
|
||||
"id": "337fd324-12ce-478d-a0e8-d99bd9a0ab1c",
|
||||
"conductor_id": "cond-test-math-verification-1791996960113",
|
||||
"nit_o_cedula": "901.884.223-1",
|
||||
"razon_social": "Transportes Especiales del Valle SAS",
|
||||
"correo_electronico": "contabilidad@valletransportes.com.co",
|
||||
"mes_calendario": 10,
|
||||
"anio_calendario": 2026,
|
||||
"total_comisiones_acumuladas": 52500,
|
||||
"total_viajes_realizados": 3,
|
||||
"estado_facturacion": "facturado_manual",
|
||||
"fecha_cierre": "2026-10-14T16:56:00.117Z",
|
||||
"createdAt": "2026-10-14T16:56:00.117Z",
|
||||
"updatedAt": "2026-10-14T16:56:00.120Z"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"id": "usr-superadmin-platform",
|
||||
"_id": "usr-superadmin-platform",
|
||||
"appwriteId": "usr-superadmin-platform",
|
||||
"nombre": "Superadministrador Principal",
|
||||
"telefono": "573167455877",
|
||||
"email": "admin@pacifictrans.almeira.com.co",
|
||||
"documentoIdentidad": "1000000001",
|
||||
"direccionTexto": "Cali, Valle del Cauca",
|
||||
"ciudad": "Cali",
|
||||
"rol": "ADMIN",
|
||||
"esSuperAdmin": true,
|
||||
"phoneVerified": true,
|
||||
"saldoPenalidad": 0,
|
||||
"isActive": true,
|
||||
"twoFactorEnabled": true,
|
||||
"twoFactorMethod": "whatsapp_otp",
|
||||
"totalViajesCompletados": 0,
|
||||
"totalViajesCancelados": 0,
|
||||
"lastLogin": "2026-10-14T16:21:00.856Z",
|
||||
"createdAt": "2026-10-14T16:21:00.856Z",
|
||||
"updatedAt": "2026-10-14T16:21:00.856Z"
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,52 @@
|
||||
-- ==============================================================================
|
||||
-- Migración 001: Esquema de Consolidación y Facturación Mensual de Comisiones
|
||||
-- Tabla: cierres_facturacion_mensual
|
||||
-- Plataforma: Pacific Trans GO (Transporte Cali - Buenaventura)
|
||||
-- ==============================================================================
|
||||
|
||||
-- 1. Crear Tipo Enumerado para el Estado de Facturación (si no existe)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'estado_facturacion_cierre_enum') THEN
|
||||
CREATE TYPE estado_facturacion_cierre_enum AS ENUM (
|
||||
'pendiente_proceso',
|
||||
'facturado_manual',
|
||||
'enviado_api'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. Crear Tabla de Cierres de Facturación Mensual
|
||||
CREATE TABLE IF NOT EXISTS cierres_facturacion_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conductor_id VARCHAR(64) NOT NULL,
|
||||
nit_o_cedula VARCHAR(64) NOT NULL,
|
||||
razon_social VARCHAR(255) NOT NULL,
|
||||
correo_electronico VARCHAR(255) NOT NULL,
|
||||
mes_calendario INTEGER NOT NULL CHECK (mes_calendario BETWEEN 1 AND 12),
|
||||
anio_calendario INTEGER NOT NULL CHECK (anio_calendario >= 2020),
|
||||
total_comisiones_acumuladas NUMERIC(14, 2) NOT NULL DEFAULT 0.00,
|
||||
total_viajes_realizados INTEGER NOT NULL DEFAULT 0 CHECK (total_viajes_realizados >= 0),
|
||||
estado_facturacion estado_facturacion_cierre_enum NOT NULL DEFAULT 'pendiente_proceso',
|
||||
fecha_cierre TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Restricción de unicidad: Un conductor solo tiene un corte oficial por mes y año
|
||||
CONSTRAINT uq_cierre_conductor_mes_anio UNIQUE (conductor_id, mes_calendario, anio_calendario)
|
||||
);
|
||||
|
||||
-- 3. Índices de rendimiento para consultas del Administrador y Exportación DIAN
|
||||
CREATE INDEX IF NOT EXISTS idx_cierres_mes_anio
|
||||
ON cierres_facturacion_mensual (mes_calendario, anio_calendario);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cierres_estado_facturacion
|
||||
ON cierres_facturacion_mensual (estado_facturacion);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cierres_conductor
|
||||
ON cierres_facturacion_mensual (conductor_id);
|
||||
|
||||
-- 4. Comentarios informativos sobre el diseño de la tabla
|
||||
COMMENT ON TABLE cierres_facturacion_mensual IS 'Consolidado mensual de comisiones cobradas a conductores por uso de la plataforma para facturación DIAN.';
|
||||
COMMENT ON COLUMN cierres_facturacion_mensual.total_comisiones_acumuladas IS 'Suma acumulada ÚNICAMENTE de comisiones de intermediación de software retenidas o cobradas, NUNCA la tarifa bruta de los viajes.';
|
||||
COMMENT ON COLUMN cierres_facturacion_mensual.estado_facturacion IS 'Estado de gestión fiscal: pendiente_proceso, facturado_manual (portal DIAN) o enviado_api (proveedor tecnológico).';
|
||||
@ -8,13 +8,16 @@
|
||||
"name": "transporte-cali-buenaventura-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"csv-writer": "^1.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"mongoose": "^8.24.5",
|
||||
"node-appwrite": "^14.1.0"
|
||||
"node-appwrite": "^14.1.0",
|
||||
"node-cron": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
@ -466,6 +469,13 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/request": {
|
||||
"version": "2.48.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
|
||||
@ -849,6 +859,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csv-writer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
|
||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@ -1987,63 +2003,6 @@
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/gaxios": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
|
||||
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/gcp-metadata": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
|
||||
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
|
||||
"extraneous": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^5.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/mongodb": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
|
||||
@ -2126,6 +2085,15 @@
|
||||
"node-fetch-native-with-agent": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
|
||||
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
|
||||
@ -9,13 +9,16 @@
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"csv-writer": "^1.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"mongoose": "^8.24.5",
|
||||
"node-appwrite": "^14.1.0"
|
||||
"node-appwrite": "^14.1.0",
|
||||
"node-cron": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
|
||||
@ -421,14 +421,19 @@
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="sender-name">Nombre del Remitente</label>
|
||||
<input type="text" class="form-input" id="sender-name" placeholder="Ej: Carlos Almeira" value="">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="sender-phone">Teléfono Remitente (WhatsApp)</label>
|
||||
<input type="tel" class="form-input" id="sender-phone" placeholder="Ej: 300 123 4567" value="">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="receiver-phone">Teléfono Destinatario (WhatsApp)</label>
|
||||
<input type="tel" class="form-input" id="receiver-phone" placeholder="Ej: 310 987 6543" value="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="receiver-phone">Teléfono Destinatario (WhatsApp)</label>
|
||||
<input type="tel" class="form-input" id="receiver-phone" placeholder="Ej: 310 987 6543" value="">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -712,6 +717,9 @@
|
||||
<button type="button" id="btn-admin-subtab-administradores" class="admin-pill-btn">
|
||||
👥 5. Equipo de Administradores
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-facturacion" class="admin-pill-btn">
|
||||
📑 6. Cierres & Facturación DIAN
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 5: PANEL ADMINISTRATIVO (EXCLUSIVO PERSONAL ADMINISTRATIVO) -->
|
||||
@ -738,6 +746,9 @@
|
||||
<button type="button" id="btn-admin-subtab-administradores" class="admin-pill-btn">
|
||||
👥 5. Equipo de Administradores
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-facturacion" class="admin-pill-btn">
|
||||
📑 6. Cierres & Facturación DIAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1301,6 +1312,134 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUB-PANEL 6: CIERRES MENSUALES & FACTURACIÓN DIAN -->
|
||||
<div id="admin-view-facturacion" style="display: none;">
|
||||
<!-- BANNER GESTIÓN DE FACTURACIÓN -->
|
||||
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(16, 185, 129, 0.18)); border: 1px solid rgba(16, 185, 129, 0.4); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.75rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.35rem;">
|
||||
<span class="superadmin-badge" style="background: rgba(16, 185, 129, 0.2); color: #34d399; border-color: #10b981;">📑 Facturación & Fiscal</span>
|
||||
<strong style="color: #fff; font-size: 1.15rem;">Consolidación Mensual de Comisiones & Reportes DIAN</strong>
|
||||
</div>
|
||||
<p style="color: #cbd5e1; font-size: 0.88rem; margin: 0; max-width: 760px;">
|
||||
Consolidación oficial de comisiones de software cobradas a cada conductor por mes calendario. Estructurado para facilitar el copiado/pegado masivo o importación en el <strong>portal gratuito de la DIAN</strong> y futura transición a proveedor tecnológico (API).
|
||||
</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button type="button" id="btn-billing-run-cut" class="btn-primary" style="padding: 0.6rem 1.25rem; font-size: 0.85rem; width: auto; background: linear-gradient(135deg, #10b981, #059669);">
|
||||
⚡ Ejecutar Corte Mensual Ahora
|
||||
</button>
|
||||
<button type="button" id="btn-billing-download-csv" class="btn-toggle-payment" style="padding: 0.6rem 1.25rem; font-size: 0.85rem; width: auto; background: rgba(56, 189, 248, 0.15); border-color: #38bdf8; color: #38bdf8;">
|
||||
📥 Descargar CSV para DIAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BARRA DE FILTROS Y CONTROL DE PERÍODO -->
|
||||
<div style="background: rgba(15, 23, 42, 0.75); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.25rem; margin-bottom: 1.75rem; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: #94a3b8; display: block; margin-bottom: 0.25rem;">Mes Calendario:</label>
|
||||
<select id="select-billing-month" class="form-input" style="padding: 0.45rem 0.75rem; font-size: 0.88rem; width: auto; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px;">
|
||||
<option value="1">01 - Enero</option>
|
||||
<option value="2">02 - Febrero</option>
|
||||
<option value="3">03 - Marzo</option>
|
||||
<option value="4">04 - Abril</option>
|
||||
<option value="5">05 - Mayo</option>
|
||||
<option value="6">06 - Junio</option>
|
||||
<option value="7">07 - Julio</option>
|
||||
<option value="8">08 - Agosto</option>
|
||||
<option value="9">09 - Septiembre</option>
|
||||
<option value="10" selected>10 - Octubre</option>
|
||||
<option value="11">11 - Noviembre</option>
|
||||
<option value="12">12 - Diciembre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: #94a3b8; display: block; margin-bottom: 0.25rem;">Año Calendario:</label>
|
||||
<select id="select-billing-year" class="form-input" style="padding: 0.45rem 0.75rem; font-size: 0.88rem; width: auto; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px;">
|
||||
<option value="2025">2025</option>
|
||||
<option value="2026" selected>2026</option>
|
||||
<option value="2027">2027</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="align-self: flex-end;">
|
||||
<button type="button" id="btn-billing-filter" class="btn-toggle-payment" style="padding: 0.5rem 1rem; font-size: 0.85rem;">
|
||||
🔄 Consultar Período
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACCIONES EN MASA -->
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<button type="button" id="btn-billing-mark-invoiced" class="btn-primary" style="padding: 0.5rem 1.15rem; font-size: 0.85rem; width: auto; background: #0284c7;" disabled>
|
||||
✅ Marcar Seleccionados como Facturado Manual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIS DE FACTURACIÓN -->
|
||||
<div class="financial-summary-grid" style="margin-bottom: 1.75rem;">
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #10b981;">
|
||||
<div class="fin-metric-label">Conductores Liquidados</div>
|
||||
<div class="fin-metric-val" id="kpi-billing-conductores" style="color: #34d399;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">Con corte en este período</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #38bdf8;">
|
||||
<div class="fin-metric-label">Total Comisiones de Software</div>
|
||||
<div class="fin-metric-val" id="kpi-billing-total-comisiones" style="color: #38bdf8;">$0</div>
|
||||
<span style="font-size: 0.72rem; color: #34d399;">Base de facturación (no viajes)</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #fbbf24;">
|
||||
<div class="fin-metric-label">Total Viajes Realizados</div>
|
||||
<div class="fin-metric-val" id="kpi-billing-total-viajes" style="color: #fbbf24;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">Agrupados en el mes</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #ec4899;">
|
||||
<div class="fin-metric-label">Pendientes por Facturar</div>
|
||||
<div class="fin-metric-val" id="kpi-billing-pendientes" style="color: #f43f5e;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;" id="kpi-billing-facturados-label">0 facturados manual</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABLA DE CIERRES MENSUALES -->
|
||||
<div style="background: rgba(15, 23, 42, 0.75); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h3 style="color: #fff; font-size: 1.15rem; margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>📋</span> Consolidado de Cierres para la DIAN
|
||||
</h3>
|
||||
<span id="billing-results-badge" style="font-size: 0.8rem; color: #94a3b8;">0 registros</span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px; text-align: center;">
|
||||
<input type="checkbox" id="chk-billing-select-all" title="Seleccionar todos">
|
||||
</th>
|
||||
<th>ID Cierre</th>
|
||||
<th>NIT / Cédula</th>
|
||||
<th>Conductor (Razón Social)</th>
|
||||
<th>Correo Electrónico</th>
|
||||
<th>Total Comisión</th>
|
||||
<th>Cantidad Viajes</th>
|
||||
<th>Estado Facturación</th>
|
||||
<th>Fecha Cierre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-billing-cierres-body">
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; padding: 2rem; color: #94a3b8;">
|
||||
Cargando información fiscal...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -185,6 +185,10 @@ function initTabs() {
|
||||
if (tabPasajeros) tabPasajeros.classList.add('active');
|
||||
if (tabFletes) tabFletes.classList.remove('active');
|
||||
}
|
||||
|
||||
if (currentUser && typeof window.autofillFormsWithUserData === 'function') {
|
||||
window.autofillFormsWithUserData(currentUser);
|
||||
}
|
||||
};
|
||||
|
||||
const btnServicePasajeros = document.getElementById('btn-service-pasajeros');
|
||||
@ -322,6 +326,7 @@ function initTabs() {
|
||||
const viewLiquidaciones = document.getElementById('admin-view-liquidaciones');
|
||||
const viewCupones = document.getElementById('admin-view-cupones');
|
||||
const viewAdministradores = document.getElementById('admin-view-administradores');
|
||||
const viewFacturacion = document.getElementById('admin-view-facturacion');
|
||||
|
||||
function setAdminSubTab(targetViewId) {
|
||||
const allViews = [
|
||||
@ -329,7 +334,8 @@ function initTabs() {
|
||||
{ id: 'tarifas', el: viewTarifas },
|
||||
{ id: 'liquidaciones', el: viewLiquidaciones },
|
||||
{ id: 'cupones', el: viewCupones },
|
||||
{ id: 'administradores', el: viewAdministradores }
|
||||
{ id: 'administradores', el: viewAdministradores },
|
||||
{ id: 'facturacion', el: viewFacturacion }
|
||||
];
|
||||
|
||||
allViews.forEach(v => {
|
||||
@ -357,6 +363,9 @@ function initTabs() {
|
||||
if (targetViewId === 'administradores' && window.fetchAdministrators) {
|
||||
window.fetchAdministrators();
|
||||
}
|
||||
if (targetViewId === 'facturacion' && window.fetchMonthlyBillingReport) {
|
||||
window.fetchMonthlyBillingReport();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('#btn-admin-subtab-radar').forEach(b => b.addEventListener('click', () => setAdminSubTab('radar')));
|
||||
@ -364,6 +373,7 @@ function initTabs() {
|
||||
document.querySelectorAll('#btn-admin-subtab-liquidaciones').forEach(b => b.addEventListener('click', () => setAdminSubTab('liquidaciones')));
|
||||
document.querySelectorAll('#btn-admin-subtab-cupones').forEach(b => b.addEventListener('click', () => setAdminSubTab('cupones')));
|
||||
document.querySelectorAll('#btn-admin-subtab-administradores').forEach(b => b.addEventListener('click', () => setAdminSubTab('administradores')));
|
||||
document.querySelectorAll('#btn-admin-subtab-facturacion').forEach(b => b.addEventListener('click', () => setAdminSubTab('facturacion')));
|
||||
|
||||
// Sub-tabs de la sección del conductor
|
||||
const subDriverWalletBtn = document.getElementById('btn-driver-subtab-billetera');
|
||||
@ -709,6 +719,51 @@ async function checkPersistedSession() {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper centralizado para rellenar por defecto los formularios con la información del usuario autenticado
|
||||
function autofillFormsWithUserData(user) {
|
||||
if (!user) return;
|
||||
|
||||
// 1. VIAJES DE PASAJEROS: Dirección principal y datos de Pasajero 1 (Titular)
|
||||
const caliAddrInput = document.getElementById('cali-address');
|
||||
if (caliAddrInput && (!caliAddrInput.value || caliAddrInput.value.trim() === '') && user.direccionTexto) {
|
||||
caliAddrInput.value = user.direccionTexto;
|
||||
}
|
||||
|
||||
if (typeof window.setPassenger1Data === 'function') {
|
||||
window.setPassenger1Data(user);
|
||||
} else {
|
||||
const p1Name = document.getElementById('passenger-1-name');
|
||||
const p1Doc = document.getElementById('passenger-1-doc');
|
||||
const p1Phone = document.getElementById('passenger-1-phone');
|
||||
const p1Addr = document.getElementById('passenger-1-addr');
|
||||
|
||||
if (p1Name && (!p1Name.value || p1Name.value.trim() === '' || p1Name.value.startsWith('Usuario '))) {
|
||||
p1Name.value = user.nombre || '';
|
||||
}
|
||||
if (p1Doc && (!p1Doc.value || p1Doc.value.trim() === '') && user.documentoIdentidad) {
|
||||
p1Doc.value = user.documentoIdentidad;
|
||||
}
|
||||
if (p1Phone && (!p1Phone.value || p1Phone.value.trim() === '')) {
|
||||
p1Phone.value = user.telefono || '';
|
||||
}
|
||||
if (p1Addr && (!p1Addr.value || p1Addr.value.trim() === '') && user.direccionTexto) {
|
||||
p1Addr.value = user.direccionTexto;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. MENSAJERÍA & ENCOMIENDAS (FLETES): Datos del Remitente
|
||||
const senderNameInput = document.getElementById('sender-name');
|
||||
const senderPhoneInput = document.getElementById('sender-phone');
|
||||
|
||||
if (senderNameInput && (!senderNameInput.value || senderNameInput.value.trim() === '') && user.nombre) {
|
||||
senderNameInput.value = user.nombre;
|
||||
}
|
||||
if (senderPhoneInput && (!senderPhoneInput.value || senderPhoneInput.value.trim() === '') && user.telefono) {
|
||||
senderPhoneInput.value = user.telefono;
|
||||
}
|
||||
}
|
||||
window.autofillFormsWithUserData = autofillFormsWithUserData;
|
||||
|
||||
function updateUserUI(user) {
|
||||
const btnAuthOpen = document.getElementById('btn-open-auth-modal');
|
||||
const userBadge = document.getElementById('user-profile-badge');
|
||||
@ -718,14 +773,10 @@ function updateUserUI(user) {
|
||||
const navBtnAdmin = document.getElementById('nav-btn-open-admin');
|
||||
const navBtnDriver = document.getElementById('nav-btn-open-driver');
|
||||
|
||||
const inputUserName = document.getElementById('user-name');
|
||||
const inputUserPhone = document.getElementById('user-phone');
|
||||
const inputSenderPhone = document.getElementById('sender-phone');
|
||||
|
||||
if (user) {
|
||||
if (btnAuthOpen) btnAuthOpen.style.display = 'none';
|
||||
if (userBadge) userBadge.style.display = 'inline-flex';
|
||||
if (userNameEl) userNameEl.textContent = user.nombre.split(' ')[0];
|
||||
if (userNameEl) userNameEl.textContent = (user.nombre || 'Usuario').split(' ')[0];
|
||||
if (userRoleBadge) {
|
||||
if (user.esSuperAdmin) {
|
||||
userRoleBadge.className = 'badge-verified superadmin-badge';
|
||||
@ -750,15 +801,8 @@ function updateUserUI(user) {
|
||||
navBtnDriver.style.display = (user.rol === 'CONDUCTOR' || user.rol === 'ADMIN') ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// Autofill en formularios de la plataforma
|
||||
if (inputUserName && !inputUserName.value) inputUserName.value = user.nombre;
|
||||
if (inputUserPhone && !inputUserPhone.value) inputUserPhone.value = user.telefono;
|
||||
if (inputSenderPhone && !inputSenderPhone.value) inputSenderPhone.value = user.telefono;
|
||||
|
||||
const p1Name = document.getElementById('passenger-1-name');
|
||||
const p1Phone = document.getElementById('passenger-1-phone');
|
||||
if (p1Name && !p1Name.value) p1Name.value = user.nombre;
|
||||
if (p1Phone && !p1Phone.value) p1Phone.value = user.telefono;
|
||||
// Rellenar por defecto los formularios de viajes y encomiendas con los datos del usuario autenticado
|
||||
autofillFormsWithUserData(user);
|
||||
|
||||
// Actualizar vista actual si está en zona protegida
|
||||
const hash = (window.location.hash || '').toLowerCase();
|
||||
@ -1331,13 +1375,53 @@ function initPassengerCalculator() {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper para asignar los datos del Pasajero 1 (Titular) desde el usuario autenticado
|
||||
window.setPassenger1Data = function (user) {
|
||||
if (!user) return;
|
||||
if (passengersData && passengersData[0]) {
|
||||
if (!passengersData[0].nombre || passengersData[0].nombre.startsWith('Usuario ')) {
|
||||
passengersData[0].nombre = user.nombre || '';
|
||||
}
|
||||
if (!passengersData[0].phone) passengersData[0].phone = user.telefono || '';
|
||||
if (!passengersData[0].doc && user.documentoIdentidad) passengersData[0].doc = user.documentoIdentidad;
|
||||
if (!passengersData[0].address && user.direccionTexto) passengersData[0].address = user.direccionTexto;
|
||||
}
|
||||
|
||||
const p1Name = document.getElementById('passenger-1-name');
|
||||
const p1Doc = document.getElementById('passenger-1-doc');
|
||||
const p1Phone = document.getElementById('passenger-1-phone');
|
||||
const p1Addr = document.getElementById('passenger-1-addr');
|
||||
|
||||
if (p1Name && (!p1Name.value || p1Name.value.trim() === '' || p1Name.value.startsWith('Usuario '))) {
|
||||
p1Name.value = user.nombre || '';
|
||||
}
|
||||
if (p1Doc && (!p1Doc.value || p1Doc.value.trim() === '') && user.documentoIdentidad) {
|
||||
p1Doc.value = user.documentoIdentidad;
|
||||
}
|
||||
if (p1Phone && (!p1Phone.value || p1Phone.value.trim() === '')) {
|
||||
p1Phone.value = user.telefono || '';
|
||||
}
|
||||
if (p1Addr && (!p1Addr.value || p1Addr.value.trim() === '') && user.direccionTexto) {
|
||||
p1Addr.value = user.direccionTexto;
|
||||
}
|
||||
|
||||
const caliAddr = document.getElementById('cali-address');
|
||||
if (caliAddr && (!caliAddr.value || caliAddr.value.trim() === '') && user.direccionTexto) {
|
||||
caliAddr.value = user.direccionTexto;
|
||||
}
|
||||
};
|
||||
|
||||
function renderPassengerCards(count) {
|
||||
if (!passengersContainer) return;
|
||||
|
||||
// Si hay usuario logueado y el pasajero 1 no tiene nombre, prellenar
|
||||
if (currentUser && !passengersData[0].nombre) {
|
||||
passengersData[0].nombre = currentUser.nombre || '';
|
||||
passengersData[0].phone = currentUser.telefono || '';
|
||||
// Si hay usuario logueado, prellenar por defecto los datos del Pasajero 1 (Titular)
|
||||
if (currentUser) {
|
||||
if (!passengersData[0].nombre || passengersData[0].nombre.startsWith('Usuario ')) {
|
||||
passengersData[0].nombre = currentUser.nombre || '';
|
||||
}
|
||||
if (!passengersData[0].phone) passengersData[0].phone = currentUser.telefono || '';
|
||||
if (!passengersData[0].doc && currentUser.documentoIdentidad) passengersData[0].doc = currentUser.documentoIdentidad;
|
||||
if (!passengersData[0].address && currentUser.direccionTexto) passengersData[0].address = currentUser.direccionTexto;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
@ -1610,6 +1694,11 @@ function initPassengerCalculator() {
|
||||
renderPassengerCards(selectedSeats);
|
||||
updateSummary();
|
||||
|
||||
// Si el usuario ya está autenticado al cargar el módulo, prellenar sus datos en Pasajero 1 y dirección
|
||||
if (currentUser) {
|
||||
window.setPassenger1Data(currentUser);
|
||||
}
|
||||
|
||||
// Envío del Formulario y Reserva en MongoDB con Pooling
|
||||
if (bookBtn) {
|
||||
bookBtn.addEventListener('click', async (e) => {
|
||||
@ -1982,11 +2071,12 @@ function initFreightCalculator() {
|
||||
});
|
||||
}
|
||||
|
||||
// Despacho / Creación de Encomienda con Bold y Cupón
|
||||
// Despacho / Creación de Encomienda con Pago Seguro y Cupón
|
||||
if (btnBookFreight) {
|
||||
btnBookFreight.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const senderPhone = document.getElementById('sender-phone')?.value.trim();
|
||||
const senderName = document.getElementById('sender-name')?.value.trim() || currentUser?.nombre || '';
|
||||
const senderPhone = document.getElementById('sender-phone')?.value.trim() || currentUser?.telefono || '';
|
||||
const receiverPhone = document.getElementById('receiver-phone')?.value.trim();
|
||||
const packageDesc = document.getElementById('package-desc')?.value.trim();
|
||||
|
||||
@ -2012,6 +2102,9 @@ function initFreightCalculator() {
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
remitenteId: currentUser ? (currentUser._id || currentUser.id) : undefined,
|
||||
remitenteNombre: senderName || 'Remitente',
|
||||
remitenteTelefono: senderPhone,
|
||||
tipoPaquete: packageTypeSelect ? packageTypeSelect.options[packageTypeSelect.selectedIndex].text : 'Paquete Pequeño',
|
||||
factor: parseFloat(packageTypeSelect?.value || '1.2'),
|
||||
valorDeclarado: parseFloat(declaredValueInput?.value || '100000'),
|
||||
@ -2058,6 +2151,14 @@ function initFreightCalculator() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prellenar datos del remitente si el usuario ya está autenticado al cargar el módulo
|
||||
if (currentUser) {
|
||||
const senderName = document.getElementById('sender-name');
|
||||
const senderPhone = document.getElementById('sender-phone');
|
||||
if (senderName && (!senderName.value || senderName.value.trim() === '')) senderName.value = currentUser.nombre || '';
|
||||
if (senderPhone && (!senderPhone.value || senderPhone.value.trim() === '')) senderPhone.value = currentUser.telefono || '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@ -4224,5 +4325,318 @@ window.deleteAdmin = async function (adminId, adminName) {
|
||||
}
|
||||
};
|
||||
|
||||
// ==============================================================================
|
||||
// GESTIÓN DE CIERRES MENSUALES & FACTURACIÓN DIAN (PACIFIC TRANS GO)
|
||||
// ==============================================================================
|
||||
let currentBillingCierres = [];
|
||||
let selectedBillingIds = new Set();
|
||||
|
||||
window.fetchMonthlyBillingReport = async function(customMonth, customYear) {
|
||||
const monthSelect = document.getElementById('select-billing-month');
|
||||
const yearSelect = document.getElementById('select-billing-year');
|
||||
const tbody = document.getElementById('table-billing-cierres-body');
|
||||
const btnMark = document.getElementById('btn-billing-mark-invoiced');
|
||||
const chkAll = document.getElementById('chk-billing-select-all');
|
||||
|
||||
const mes = customMonth || (monthSelect ? monthSelect.value : (new Date().getMonth() + 1));
|
||||
const anio = customYear || (yearSelect ? yearSelect.value : new Date().getFullYear());
|
||||
|
||||
if (monthSelect) monthSelect.value = mes;
|
||||
if (yearSelect) yearSelect.value = anio;
|
||||
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; padding: 2rem; color: #94a3b8;">Cargando cierres fiscales del período ${mes}/${anio}...</td></tr>`;
|
||||
}
|
||||
selectedBillingIds.clear();
|
||||
if (chkAll) chkAll.checked = false;
|
||||
if (btnMark) {
|
||||
btnMark.disabled = true;
|
||||
btnMark.textContent = '✅ Marcar Seleccionados como Facturado Manual';
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('pt_token') || '';
|
||||
const res = await fetch(`/api/admin/billing/reporte-mensual?mes=${mes}&anio=${anio}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
if (tbody) tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; padding: 2rem; color: #ef4444;">Error al cargar reporte: ${data.error || 'Desconocido'}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentBillingCierres = data.cierres || [];
|
||||
renderMonthlyBillingTable(currentBillingCierres, mes, anio);
|
||||
} catch (err) {
|
||||
console.error('Error fetching monthly billing report:', err);
|
||||
if (tbody) tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; padding: 2rem; color: #ef4444;">Error de conexión con el servidor.</td></tr>`;
|
||||
}
|
||||
};
|
||||
|
||||
function renderMonthlyBillingTable(cierres, mes, anio) {
|
||||
const tbody = document.getElementById('table-billing-cierres-body');
|
||||
const badge = document.getElementById('billing-results-badge');
|
||||
const kpiConductores = document.getElementById('kpi-billing-conductores');
|
||||
const kpiComisiones = document.getElementById('kpi-billing-total-comisiones');
|
||||
const kpiViajes = document.getElementById('kpi-billing-total-viajes');
|
||||
const kpiPendientes = document.getElementById('kpi-billing-pendientes');
|
||||
const kpiFacturadosLabel = document.getElementById('kpi-billing-facturados-label');
|
||||
|
||||
if (badge) badge.textContent = `${cierres.length} registros`;
|
||||
|
||||
let totalComisiones = 0;
|
||||
let totalViajes = 0;
|
||||
let pendientesCount = 0;
|
||||
let facturadosCount = 0;
|
||||
|
||||
cierres.forEach(c => {
|
||||
totalComisiones += Number(c.total_comisiones_acumuladas || 0);
|
||||
totalViajes += Number(c.total_viajes_realizados || 0);
|
||||
if (c.estado_facturacion === 'facturado_manual') {
|
||||
facturadosCount++;
|
||||
} else {
|
||||
pendientesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (kpiConductores) kpiConductores.textContent = cierres.length;
|
||||
if (kpiComisiones) kpiComisiones.textContent = `$${totalComisiones.toLocaleString('es-CO')}`;
|
||||
if (kpiViajes) kpiViajes.textContent = totalViajes;
|
||||
if (kpiPendientes) kpiPendientes.textContent = pendientesCount;
|
||||
if (kpiFacturadosLabel) kpiFacturadosLabel.textContent = `${facturadosCount} facturados manual (DIAN)`;
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
if (cierres.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; padding: 2.5rem; color: #94a3b8;">
|
||||
No se encontraron cierres para ${mes}/${anio}. Haz clic en <strong>⚡ Ejecutar Corte Mensual Ahora</strong> para consolidar las comisiones de este mes.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = cierres.map(c => {
|
||||
const id = c.id || c._id;
|
||||
const isFacturado = c.estado_facturacion === 'facturado_manual';
|
||||
const isApi = c.estado_facturacion === 'enviado_api';
|
||||
|
||||
let estadoBadge = `<span class="badge-status-pending" style="background: rgba(245, 158, 11, 0.18); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.35); padding: 2px 8px; border-radius: 6px; font-size: 0.75rem; font-weight: 600;">⏳ Pendiente Proceso</span>`;
|
||||
if (isFacturado) {
|
||||
estadoBadge = `<span class="badge-status-completed" style="background: rgba(16, 185, 129, 0.18); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.35); padding: 2px 8px; border-radius: 6px; font-size: 0.75rem; font-weight: 600;">✅ Facturado Manual (DIAN)</span>`;
|
||||
} else if (isApi) {
|
||||
estadoBadge = `<span class="badge-status-completed" style="background: rgba(56, 189, 248, 0.18); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.35); padding: 2px 8px; border-radius: 6px; font-size: 0.75rem; font-weight: 600;">🌐 Enviado API</span>`;
|
||||
}
|
||||
|
||||
const fechaCierreFmt = c.fecha_cierre ? new Date(c.fecha_cierre).toLocaleDateString('es-CO') : '-';
|
||||
const checked = selectedBillingIds.has(id) ? 'checked' : '';
|
||||
|
||||
return `
|
||||
<tr data-cierre-id="${id}" style="${isFacturado ? 'opacity: 0.85;' : ''}">
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" class="chk-billing-item" value="${id}" ${checked}>
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.78rem; color: #94a3b8;" title="${id}">
|
||||
${id.slice(0, 8)}...
|
||||
</td>
|
||||
<td style="font-weight: 600; color: #f1f5f9;">
|
||||
${c.nit_o_cedula || 'NO-REGISTRADO'}
|
||||
</td>
|
||||
<td>
|
||||
<strong style="color: #fff;">${c.razon_social || 'Conductor Pacific Trans'}</strong>
|
||||
</td>
|
||||
<td style="color: #cbd5e1; font-size: 0.82rem;">
|
||||
${c.correo_electronico || 'info@pacifictrans.com'}
|
||||
</td>
|
||||
<td style="color: #38bdf8; font-weight: 700; font-size: 0.95rem;">
|
||||
$${Number(c.total_comisiones_acumuladas || 0).toLocaleString('es-CO')}
|
||||
</td>
|
||||
<td style="text-align: center; font-weight: 600; color: #fbbf24;">
|
||||
${c.total_viajes_realizados || 0}
|
||||
</td>
|
||||
<td>
|
||||
${estadoBadge}
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: #94a3b8;">
|
||||
${fechaCierreFmt}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
attachBillingItemCheckboxListeners();
|
||||
}
|
||||
|
||||
function attachBillingItemCheckboxListeners() {
|
||||
const itemCheckboxes = document.querySelectorAll('.chk-billing-item');
|
||||
const btnMark = document.getElementById('btn-billing-mark-invoiced');
|
||||
const chkAll = document.getElementById('chk-billing-select-all');
|
||||
|
||||
itemCheckboxes.forEach(chk => {
|
||||
chk.addEventListener('change', () => {
|
||||
if (chk.checked) {
|
||||
selectedBillingIds.add(chk.value);
|
||||
} else {
|
||||
selectedBillingIds.delete(chk.value);
|
||||
}
|
||||
if (btnMark) {
|
||||
btnMark.disabled = selectedBillingIds.size === 0;
|
||||
btnMark.textContent = selectedBillingIds.size > 0
|
||||
? `✅ Marcar (${selectedBillingIds.size}) como Facturado Manual`
|
||||
: '✅ Marcar Seleccionados como Facturado Manual';
|
||||
}
|
||||
if (chkAll) {
|
||||
chkAll.checked = itemCheckboxes.length > 0 && selectedBillingIds.size === itemCheckboxes.length;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Inicialización de Eventos de Facturación DIAN
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btnFilter = document.getElementById('btn-billing-filter');
|
||||
const btnDownloadCsv = document.getElementById('btn-billing-download-csv');
|
||||
const btnRunCut = document.getElementById('btn-billing-run-cut');
|
||||
const chkAll = document.getElementById('chk-billing-select-all');
|
||||
const btnMarkInvoiced = document.getElementById('btn-billing-mark-invoiced');
|
||||
const monthSelect = document.getElementById('select-billing-month');
|
||||
const yearSelect = document.getElementById('select-billing-year');
|
||||
|
||||
if (btnFilter) {
|
||||
btnFilter.addEventListener('click', () => {
|
||||
window.fetchMonthlyBillingReport();
|
||||
});
|
||||
}
|
||||
|
||||
if (monthSelect) {
|
||||
monthSelect.addEventListener('change', () => window.fetchMonthlyBillingReport());
|
||||
}
|
||||
if (yearSelect) {
|
||||
yearSelect.addEventListener('change', () => window.fetchMonthlyBillingReport());
|
||||
}
|
||||
|
||||
// Descarga directa del archivo CSV con columnas requeridas por DIAN
|
||||
if (btnDownloadCsv) {
|
||||
btnDownloadCsv.addEventListener('click', () => {
|
||||
const mes = monthSelect ? monthSelect.value : (new Date().getMonth() + 1);
|
||||
const anio = yearSelect ? yearSelect.value : new Date().getFullYear();
|
||||
const downloadUrl = `/api/admin/billing/reporte-mensual?mes=${mes}&anio=${anio}&format=csv`;
|
||||
window.open(downloadUrl, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
// Ejecución del corte mensual bajo demanda
|
||||
if (btnRunCut) {
|
||||
btnRunCut.addEventListener('click', async () => {
|
||||
const mes = monthSelect ? monthSelect.value : (new Date().getMonth() + 1);
|
||||
const anio = yearSelect ? yearSelect.value : new Date().getFullYear();
|
||||
|
||||
if (!confirm(`¿Deseas consolidar y generar el corte mensual de comisiones para ${mes}/${anio}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
btnRunCut.disabled = true;
|
||||
btnRunCut.textContent = '⏳ Ejecutando corte...';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('pt_token') || '';
|
||||
const res = await fetch('/api/admin/billing/ejecutar-cierre', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ mes: parseInt(mes, 10), anio: parseInt(anio, 10) })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
alert(`✅ Corte mensual completado exitosamente para ${mes}/${anio}.\nTotal Conductores: ${data.totalConductores}\nTotal Comisiones de Software: $${Number(data.totalComisiones).toLocaleString('es-CO')} COP`);
|
||||
window.fetchMonthlyBillingReport(mes, anio);
|
||||
} else {
|
||||
alert(`Error al ejecutar corte: ${data.error || 'Fallo interno'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error executing billing cut:', err);
|
||||
alert('Error de comunicación con el servidor.');
|
||||
} finally {
|
||||
btnRunCut.disabled = false;
|
||||
btnRunCut.textContent = '⚡ Ejecutar Corte Mensual Ahora';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox Seleccionar Todos
|
||||
if (chkAll) {
|
||||
chkAll.addEventListener('change', () => {
|
||||
const itemCheckboxes = document.querySelectorAll('.chk-billing-item');
|
||||
itemCheckboxes.forEach(chk => {
|
||||
chk.checked = chkAll.checked;
|
||||
if (chkAll.checked) {
|
||||
selectedBillingIds.add(chk.value);
|
||||
} else {
|
||||
selectedBillingIds.delete(chk.value);
|
||||
}
|
||||
});
|
||||
if (btnMarkInvoiced) {
|
||||
btnMarkInvoiced.disabled = selectedBillingIds.size === 0;
|
||||
btnMarkInvoiced.textContent = selectedBillingIds.size > 0
|
||||
? `✅ Marcar (${selectedBillingIds.size}) como Facturado Manual`
|
||||
: '✅ Marcar Seleccionados como Facturado Manual';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Marcar seleccionados como facturados manualmente
|
||||
if (btnMarkInvoiced) {
|
||||
btnMarkInvoiced.addEventListener('click', async () => {
|
||||
if (selectedBillingIds.size === 0) return;
|
||||
|
||||
const count = selectedBillingIds.size;
|
||||
if (!confirm(`¿Confirmas marcar ${count} cierres seleccionados como 'Facturado Manual' en la DIAN? Esto evitará duplicidades fiscales.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
btnMarkInvoiced.disabled = true;
|
||||
btnMarkInvoiced.textContent = '⏳ Actualizando...';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('pt_token') || '';
|
||||
const res = await fetch('/api/admin/billing/marcar-facturado', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ IDs_Cierre: Array.from(selectedBillingIds) })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
alert(`✅ ${data.updatedCount} cierres actualizados a estado 'facturado_manual'.`);
|
||||
selectedBillingIds.clear();
|
||||
window.fetchMonthlyBillingReport();
|
||||
} else {
|
||||
alert(`Error al actualizar estado: ${data.error || 'No permitido'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error marking closures as invoiced:', err);
|
||||
alert('Error de conexión con el servidor.');
|
||||
} finally {
|
||||
if (btnMarkInvoiced) {
|
||||
btnMarkInvoiced.disabled = selectedBillingIds.size === 0;
|
||||
btnMarkInvoiced.textContent = '✅ Marcar Seleccionados como Facturado Manual';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import { statsReviewsService } from './services/stats-reviews.service';
|
||||
import { settlementService } from './services/settlement.service';
|
||||
import { whatsAppNotificationService } from './services/whatsapp-notification.service';
|
||||
import { couponService } from './services/coupon.service';
|
||||
import { billingConsolidationService } from './services/billing-consolidation.service';
|
||||
import { Trip } from './models/Trip';
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
@ -536,6 +537,70 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendJson(200, { success: true, solicitud });
|
||||
}
|
||||
|
||||
// 13.5. CONSOLIDACIÓN Y FACTURACIÓN MENSUAL DIAN
|
||||
// 13.5.1. Consultar reporte mensual de cierres y exportación descargable CSV/Excel para la DIAN
|
||||
if (pathname === '/api/admin/billing/reporte-mensual' && req.method === 'GET') {
|
||||
const mesParam = url.searchParams.get('mes');
|
||||
const anioParam = url.searchParams.get('anio');
|
||||
const format = (url.searchParams.get('format') || '').toLowerCase();
|
||||
const acceptHeader = req.headers.accept || '';
|
||||
|
||||
const mes = mesParam ? parseInt(mesParam, 10) : undefined;
|
||||
const anio = anioParam ? parseInt(anioParam, 10) : undefined;
|
||||
|
||||
const reporte = await billingConsolidationService.obtenerReporteMensual(mes, anio);
|
||||
|
||||
// Si se solicita exportación en CSV/Excel
|
||||
if (format === 'csv' || format === 'excel' || acceptHeader.includes('text/csv')) {
|
||||
const csvContent = billingConsolidationService.generarCsvReporte(reporte.cierres);
|
||||
const filename = `Reporte_Facturacion_DIAN_${reporte.mes}_${reporte.anio}.csv`;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
});
|
||||
res.end(csvContent);
|
||||
return;
|
||||
}
|
||||
|
||||
return sendJson(200, {
|
||||
success: true,
|
||||
mes: reporte.mes,
|
||||
anio: reporte.anio,
|
||||
count: reporte.cierres.length,
|
||||
cierres: reporte.cierres,
|
||||
});
|
||||
}
|
||||
|
||||
// 13.5.2. Marcar en masa cierres como facturados manualmente en la DIAN
|
||||
if (pathname === '/api/admin/billing/marcar-facturado' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const ids = body.IDs_Cierre || body.idsCierre || body.ids || [];
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return sendJson(400, {
|
||||
success: false,
|
||||
error: "Se requiere un arreglo 'IDs_Cierre' con los identificadores a actualizar",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await billingConsolidationService.marcarComoFacturados(ids);
|
||||
return sendJson(200, {
|
||||
success: true,
|
||||
mensaje: `${result.updatedCount} cierres marcados como 'facturado_manual'`,
|
||||
updatedCount: result.updatedCount,
|
||||
ids: result.ids,
|
||||
});
|
||||
}
|
||||
|
||||
// 13.5.3. Disparar corte mensual de comisiones bajo demanda
|
||||
if (pathname === '/api/admin/billing/ejecutar-cierre' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const mes = body.mes ? parseInt(body.mes, 10) : undefined;
|
||||
const anio = body.anio ? parseInt(body.anio, 10) : undefined;
|
||||
|
||||
const resultado = await billingConsolidationService.ejecutarCierreMensual(mes, anio);
|
||||
return sendJson(200, resultado);
|
||||
}
|
||||
|
||||
// 14. Servidor de Archivos Estáticos para la Plataforma Web & APK
|
||||
const serveStaticFile = (filePath: string, contentType: string): boolean => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export type EstadoFacturacion = 'pendiente_proceso' | 'facturado_manual' | 'enviado_api';
|
||||
|
||||
export interface ICierreFacturacionMensual extends Document {
|
||||
id: string; // UUID Primary Key
|
||||
conductor_id: string;
|
||||
nit_o_cedula: string;
|
||||
razon_social: string;
|
||||
correo_electronico: string;
|
||||
mes_calendario: number;
|
||||
anio_calendario: number;
|
||||
total_comisiones_acumuladas: number;
|
||||
total_viajes_realizados: number;
|
||||
estado_facturacion: EstadoFacturacion;
|
||||
fecha_cierre: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const CierreFacturacionMensualSchema = new Schema<ICierreFacturacionMensual>(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: () => crypto.randomUUID(),
|
||||
unique: true,
|
||||
index: true,
|
||||
required: true,
|
||||
},
|
||||
conductor_id: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
nit_o_cedula: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
default: 'NO-REGISTRADO',
|
||||
},
|
||||
razon_social: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
correo_electronico: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
},
|
||||
mes_calendario: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 12,
|
||||
},
|
||||
anio_calendario: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 2020,
|
||||
},
|
||||
total_comisiones_acumuladas: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
total_viajes_realizados: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
estado_facturacion: {
|
||||
type: String,
|
||||
enum: ['pendiente_proceso', 'facturado_manual', 'enviado_api'],
|
||||
default: 'pendiente_proceso',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
fecha_cierre: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (_doc, ret: any) {
|
||||
if (!ret.id && ret._id) ret.id = ret._id.toString();
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Índice compuesto para acelerar búsquedas de mes/año y evitar duplicidades por conductor en el mismo período
|
||||
CierreFacturacionMensualSchema.index(
|
||||
{ conductor_id: 1, mes_calendario: 1, anio_calendario: 1 },
|
||||
{ unique: true }
|
||||
);
|
||||
|
||||
CierreFacturacionMensualSchema.index({ mes_calendario: 1, anio_calendario: 1 });
|
||||
|
||||
export const CierreFacturacionMensual: Model<ICierreFacturacionMensual> =
|
||||
mongoose.models.CierreFacturacionMensual ||
|
||||
mongoose.model<ICierreFacturacionMensual>(
|
||||
'CierreFacturacionMensual',
|
||||
CierreFacturacionMensualSchema,
|
||||
'cierres_facturacion_mensual'
|
||||
);
|
||||
@ -0,0 +1,430 @@
|
||||
import cron from 'node-cron';
|
||||
import crypto from 'crypto';
|
||||
import { CierreFacturacionMensual, ICierreFacturacionMensual, EstadoFacturacion } from '../models/CierreFacturacionMensual';
|
||||
import { Trip, ITrip } from '../models/Trip';
|
||||
import { User, IUser } from '../models/User';
|
||||
import { DriverProfile } from '../models/DriverProfile';
|
||||
import { tariffsService } from './tariffs.service';
|
||||
import { localBillingStore, LocalCierreRecord } from './local-billing-store';
|
||||
import { localUserStore } from './local-user-store';
|
||||
import { isDatabaseConnected } from '../config/database';
|
||||
|
||||
export interface ReporteItemDIAN {
|
||||
ID_Cierre: string;
|
||||
'NIT/Cédula': string;
|
||||
Conductor: string;
|
||||
Correo: string;
|
||||
Total_Comisión: number;
|
||||
Cantidad_Viajes: number;
|
||||
Estado_Facturación?: string;
|
||||
Fecha_Cierre?: string;
|
||||
}
|
||||
|
||||
export interface CierreConsolidadoResult {
|
||||
success: boolean;
|
||||
mes: number;
|
||||
anio: number;
|
||||
totalConductores: number;
|
||||
totalComisiones: number;
|
||||
totalViajes: number;
|
||||
cierres: ICierreFacturacionMensual[] | LocalCierreRecord[];
|
||||
}
|
||||
|
||||
export class BillingConsolidationService {
|
||||
private static instance: BillingConsolidationService;
|
||||
private cronScheduled = false;
|
||||
|
||||
private constructor() {
|
||||
this.iniciarCronJob();
|
||||
}
|
||||
|
||||
public static getInstance(): BillingConsolidationService {
|
||||
if (!BillingConsolidationService.instance) {
|
||||
BillingConsolidationService.instance = new BillingConsolidationService();
|
||||
}
|
||||
return BillingConsolidationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programa el Cron Job para ejecutarse de forma automática
|
||||
* el primer día de cada mes a las 00:00 horas ('0 0 1 * *').
|
||||
*/
|
||||
public iniciarCronJob(): void {
|
||||
if (this.cronScheduled) return;
|
||||
|
||||
// Patrón: 00:00 del primer día de cada mes
|
||||
cron.schedule('0 0 1 * *', async () => {
|
||||
console.log('⏰ [Cron Job Facturación] Iniciando corte mensual automático del primer día del mes a las 00:00 hrs...');
|
||||
try {
|
||||
const { mes, anio } = this.calcularMesAnterior();
|
||||
const resultado = await this.ejecutarCierreMensual(mes, anio);
|
||||
console.log(`✅ [Cron Job Facturación] Corte completado con éxito para ${mes}/${anio}: ${resultado.totalConductores} conductores consolidados.`);
|
||||
} catch (err: any) {
|
||||
console.error('❌ [Cron Job Facturación] Error ejecutando corte mensual programado:', err);
|
||||
}
|
||||
});
|
||||
|
||||
this.cronScheduled = true;
|
||||
console.log('🕒 [BillingConsolidation] Cron Job mensual programado para el día 1 de cada mes a las 00:00 hrs.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene mes y año del mes inmediatamente anterior al actual.
|
||||
*/
|
||||
public calcularMesAnterior(): { mes: number; anio: number } {
|
||||
const hoy = new Date();
|
||||
let mes = hoy.getMonth(); // 0-indexado, representa el mes anterior (ej: si es Octubre (9), devuelve 9 -> mes 10 calendario menos 1 = 9)
|
||||
let anio = hoy.getFullYear();
|
||||
|
||||
if (mes === 0) {
|
||||
mes = 12;
|
||||
anio -= 1;
|
||||
}
|
||||
|
||||
return { mes, anio };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el corte y consolidación mensual de comisiones para un mes y año dados.
|
||||
* Regla de Negocio Crítica:
|
||||
* - Agrupa y suma ÚNICAMENTE las comisiones de intermediación de la plataforma
|
||||
* (comisionAdmin / retenidas / descontadas) y NUNCA el valor bruto total de los viajes.
|
||||
*/
|
||||
public async ejecutarCierreMensual(
|
||||
mesParam?: number,
|
||||
anioParam?: number
|
||||
): Promise<CierreConsolidadoResult> {
|
||||
const { mes: mesDef, anio: anioDef } = this.calcularMesAnterior();
|
||||
const mes = Number(mesParam) || mesDef;
|
||||
const anio = Number(anioParam) || anioDef;
|
||||
|
||||
console.log(`📊 [BillingConsolidation] Iniciando consolidación de comisiones para Mes: ${mes}, Año: ${anio}...`);
|
||||
|
||||
// Rango de fechas del mes calendario en UTC
|
||||
const startDate = new Date(Date.UTC(anio, mes - 1, 1, 0, 0, 0, 0));
|
||||
const endDate = new Date(Date.UTC(anio, mes, 0, 23, 59, 59, 999));
|
||||
|
||||
// Mapa para acumular por conductor: conductorId -> { sumComisiones, viajesCount }
|
||||
const conductorMap = new Map<
|
||||
string,
|
||||
{
|
||||
totalComisiones: number;
|
||||
viajesCount: number;
|
||||
trips: any[];
|
||||
}
|
||||
>();
|
||||
|
||||
const tariffs = await tariffsService.getTarifasActivas().catch(() => ({ porcentajeComisionAdmin: 15 }));
|
||||
const defaultComisionPct = tariffs.porcentajeComisionAdmin || 15;
|
||||
|
||||
// 1. Consultar viajes completados/liquidados en MongoDB (si está conectado)
|
||||
if (isDatabaseConnected()) {
|
||||
try {
|
||||
const tripsQuery = {
|
||||
conductorId: { $exists: true, $ne: null },
|
||||
estado: { $in: ['COMPLETED', 'DISPATCHED_POOL', 'ACTIVE'] },
|
||||
$or: [
|
||||
{ 'liquidacion.fechaLiquidacion': { $gte: startDate, $lte: endDate } },
|
||||
{ fechaHoraSalidaProgramada: { $gte: startDate, $lte: endDate } },
|
||||
{ createdAt: { $gte: startDate, $lte: endDate } },
|
||||
],
|
||||
};
|
||||
|
||||
const trips = await Trip.find(tripsQuery).lean().exec().catch(() => []);
|
||||
|
||||
for (const trip of trips) {
|
||||
if (!trip.conductorId) continue;
|
||||
const cId = trip.conductorId.toString();
|
||||
|
||||
// CÁLCULO EXCLUSIVO DE COMISIÓN DE PLATAFORMA
|
||||
// Nunca sumar tarifaTotal, únicamente comisionAdmin
|
||||
let comision = 0;
|
||||
if (typeof trip.comisionAdmin === 'number' && trip.comisionAdmin > 0) {
|
||||
comision = trip.comisionAdmin;
|
||||
} else if (trip.liquidacion && typeof trip.liquidacion.comisionAdmin === 'number') {
|
||||
comision = trip.liquidacion.comisionAdmin;
|
||||
} else if (trip.tarifaTotal && trip.tarifaTotal > 0) {
|
||||
comision = Math.round((trip.tarifaTotal * defaultComisionPct) / 100);
|
||||
}
|
||||
|
||||
if (!conductorMap.has(cId)) {
|
||||
conductorMap.set(cId, { totalComisiones: 0, viajesCount: 0, trips: [] });
|
||||
}
|
||||
const item = conductorMap.get(cId)!;
|
||||
item.totalComisiones += comision;
|
||||
item.viajesCount += 1;
|
||||
item.trips.push(trip);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('⚠️ [BillingConsolidation] Error consultando MongoDB para viajes:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si no hubo viajes de MongoDB pero hay conductores registrados, asegurar datos consistentes
|
||||
const driversToProcess = new Set<string>(conductorMap.keys());
|
||||
|
||||
// Obtener usuarios/conductores de MongoDB y LocalUserStore
|
||||
let localUsers = localUserStore.getAll().filter((u) => u.rol === 'CONDUCTOR');
|
||||
for (const u of localUsers) {
|
||||
driversToProcess.add(u.id);
|
||||
}
|
||||
|
||||
if (isDatabaseConnected()) {
|
||||
try {
|
||||
const mongoDrivers = await User.find({ rol: 'CONDUCTOR' }).lean().exec().catch(() => []);
|
||||
for (const d of mongoDrivers) {
|
||||
driversToProcess.add(d._id.toString());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const resultadoCierres: any[] = [];
|
||||
let granTotalComisiones = 0;
|
||||
let granTotalViajes = 0;
|
||||
|
||||
for (const conductorId of driversToProcess) {
|
||||
const agg = conductorMap.get(conductorId) || { totalComisiones: 0, viajesCount: 0, trips: [] };
|
||||
|
||||
// Obtener datos del conductor (NIT/Cédula, Razón Social, Correo)
|
||||
let nombre = 'Conductor Pacific Trans';
|
||||
let nit = 'NO-REGISTRADO';
|
||||
let correo = 'info@pacifictrans.com';
|
||||
|
||||
// Buscar en Mongo
|
||||
try {
|
||||
const u = await User.findById(conductorId).lean().exec().catch(() => null);
|
||||
if (u) {
|
||||
nombre = u.nombre || nombre;
|
||||
nit = u.documentoIdentidad || nit;
|
||||
correo = u.email || (u.telefono ? `${u.telefono}@pacifictrans.com` : correo);
|
||||
} else {
|
||||
const lu = localUserStore.findById(conductorId) || localUserStore.findByPhone(conductorId);
|
||||
if (lu) {
|
||||
nombre = lu.nombre || nombre;
|
||||
nit = lu.documentoIdentidad || nit;
|
||||
correo = lu.email || (lu.telefono ? `${lu.telefono}@pacifictrans.com` : correo);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const lu = localUserStore.findById(conductorId) || localUserStore.findByPhone(conductorId);
|
||||
if (lu) {
|
||||
nombre = lu.nombre || nombre;
|
||||
nit = lu.documentoIdentidad || nit;
|
||||
correo = lu.email || (lu.telefono ? `${lu.telefono}@pacifictrans.com` : correo);
|
||||
}
|
||||
}
|
||||
|
||||
const totalComisiones = Math.round(agg.totalComisiones * 100) / 100;
|
||||
const totalViajes = agg.viajesCount;
|
||||
|
||||
granTotalComisiones += totalComisiones;
|
||||
granTotalViajes += totalViajes;
|
||||
|
||||
// Generar o actualizar registro en MongoDB
|
||||
let cierreGuardado: any = null;
|
||||
if (isDatabaseConnected()) {
|
||||
try {
|
||||
const existing = await CierreFacturacionMensual.findOne({
|
||||
conductor_id: conductorId,
|
||||
mes_calendario: mes,
|
||||
anio_calendario: anio,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.nit_o_cedula = nit;
|
||||
existing.razon_social = nombre;
|
||||
existing.correo_electronico = correo;
|
||||
existing.total_comisiones_acumuladas = totalComisiones;
|
||||
existing.total_viajes_realizados = totalViajes;
|
||||
existing.fecha_cierre = new Date();
|
||||
// No sobreescribir si ya está facturado_manual
|
||||
if (existing.estado_facturacion !== 'facturado_manual') {
|
||||
existing.estado_facturacion = 'pendiente_proceso';
|
||||
}
|
||||
await existing.save();
|
||||
cierreGuardado = existing.toJSON();
|
||||
} else {
|
||||
const nuevo = new CierreFacturacionMensual({
|
||||
id: crypto.randomUUID(),
|
||||
conductor_id: conductorId,
|
||||
nit_o_cedula: nit,
|
||||
razon_social: nombre,
|
||||
correo_electronico: correo,
|
||||
mes_calendario: mes,
|
||||
anio_calendario: anio,
|
||||
total_comisiones_acumuladas: totalComisiones,
|
||||
total_viajes_realizados: totalViajes,
|
||||
estado_facturacion: 'pendiente_proceso',
|
||||
fecha_cierre: new Date(),
|
||||
});
|
||||
await nuevo.save();
|
||||
cierreGuardado = nuevo.toJSON();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ [BillingConsolidation] Error guardando cierre en MongoDB para conductor ${conductorId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar también en almacén local resiliente
|
||||
const localRecord = localBillingStore.upsertCierre({
|
||||
id: cierreGuardado?.id,
|
||||
conductor_id: conductorId,
|
||||
nit_o_cedula: nit,
|
||||
razon_social: nombre,
|
||||
correo_electronico: correo,
|
||||
mes_calendario: mes,
|
||||
anio_calendario: anio,
|
||||
total_comisiones_acumuladas: totalComisiones,
|
||||
total_viajes_realizados: totalViajes,
|
||||
estado_facturacion: cierreGuardado?.estado_facturacion || 'pendiente_proceso',
|
||||
fecha_cierre: new Date(),
|
||||
});
|
||||
|
||||
resultadoCierres.push(cierreGuardado || localRecord);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ [BillingConsolidation] Consolidación ${mes}/${anio} finalizada: ` +
|
||||
`${resultadoCierres.length} registros, Total Comisiones: $${granTotalComisiones.toLocaleString('es-CO')} COP`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mes,
|
||||
anio,
|
||||
totalConductores: resultadoCierres.length,
|
||||
totalComisiones: granTotalComisiones,
|
||||
totalViajes: granTotalViajes,
|
||||
cierres: resultadoCierres,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta los cierres mensuales de un mes y año dados.
|
||||
* Si no existen registros aún, intenta calcularlos automáticamente.
|
||||
*/
|
||||
public async obtenerReporteMensual(
|
||||
mesParam?: number,
|
||||
anioParam?: number
|
||||
): Promise<{ mes: number; anio: number; cierres: any[] }> {
|
||||
const { mes: mesDef, anio: anioDef } = this.calcularMesAnterior();
|
||||
const mes = Number(mesParam) || mesDef;
|
||||
const anio = Number(anioParam) || anioDef;
|
||||
|
||||
let cierres: any[] = [];
|
||||
|
||||
if (isDatabaseConnected()) {
|
||||
try {
|
||||
cierres = await CierreFacturacionMensual.find({
|
||||
mes_calendario: mes,
|
||||
anio_calendario: anio,
|
||||
})
|
||||
.sort({ total_comisiones_acumuladas: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
} catch {
|
||||
// Fallback a almacenamiento local
|
||||
}
|
||||
}
|
||||
|
||||
if (!cierres || cierres.length === 0) {
|
||||
cierres = localBillingStore.findByMesAnio(mes, anio);
|
||||
}
|
||||
|
||||
// Si aún está vacío, ejecutar la agregación
|
||||
if (!cierres || cierres.length === 0) {
|
||||
const res = await this.ejecutarCierreMensual(mes, anio);
|
||||
cierres = res.cierres;
|
||||
}
|
||||
|
||||
return { mes, anio, cierres };
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el archivo CSV formateado con las columnas exactas requeridas para DIAN:
|
||||
* ID_Cierre, NIT/Cédula, Conductor, Correo, Total_Comisión, Cantidad_Viajes
|
||||
* Incluye BOM UTF-8 (\uFEFF) para máxima compatibilidad con Microsoft Excel y el portal de la DIAN.
|
||||
*/
|
||||
public generarCsvReporte(cierres: any[]): string {
|
||||
const BOM = '\uFEFF';
|
||||
const headers = [
|
||||
'ID_Cierre',
|
||||
'NIT/Cédula',
|
||||
'Conductor',
|
||||
'Correo',
|
||||
'Total_Comisión',
|
||||
'Cantidad_Viajes',
|
||||
'Estado_Facturación',
|
||||
'Fecha_Cierre',
|
||||
];
|
||||
|
||||
const escapeCsv = (val: any): string => {
|
||||
if (val === undefined || val === null) return '""';
|
||||
const str = String(val).replace(/"/g, '""');
|
||||
return `"${str}"`;
|
||||
};
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(headers.join(','));
|
||||
|
||||
for (const c of cierres) {
|
||||
const row = [
|
||||
escapeCsv(c.id || c._id),
|
||||
escapeCsv(c.nit_o_cedula || 'NO-REGISTRADO'),
|
||||
escapeCsv(c.razon_social || 'Conductor Pacific Trans'),
|
||||
escapeCsv(c.correo_electronico || 'info@pacifictrans.com'),
|
||||
typeof c.total_comisiones_acumuladas === 'number'
|
||||
? c.total_comisiones_acumuladas.toFixed(2)
|
||||
: '0.00',
|
||||
String(c.total_viajes_realizados || 0),
|
||||
escapeCsv(c.estado_facturacion || 'pendiente_proceso'),
|
||||
escapeCsv(c.fecha_cierre ? new Date(c.fecha_cierre).toISOString() : ''),
|
||||
];
|
||||
rows.push(row.join(','));
|
||||
}
|
||||
|
||||
return BOM + rows.join('\r\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza en masa el estado a 'facturado_manual' para los IDs_Cierre suministrados.
|
||||
* Evita duplicidad de facturación en el portal DIAN.
|
||||
*/
|
||||
public async marcarComoFacturados(
|
||||
idsCierre: string[]
|
||||
): Promise<{ updatedCount: number; ids: string[] }> {
|
||||
if (!Array.isArray(idsCierre) || idsCierre.length === 0) {
|
||||
return { updatedCount: 0, ids: [] };
|
||||
}
|
||||
|
||||
const uniqueIds = Array.from(new Set(idsCierre.filter(Boolean)));
|
||||
let updatedInMongo = 0;
|
||||
|
||||
if (isDatabaseConnected()) {
|
||||
try {
|
||||
const res = await CierreFacturacionMensual.updateMany(
|
||||
{ id: { $in: uniqueIds } },
|
||||
{ $set: { estado_facturacion: 'facturado_manual', fecha_cierre: new Date() } }
|
||||
);
|
||||
updatedInMongo = res.modifiedCount || 0;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ [BillingConsolidation] Error actualizando en MongoDB:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar también en el almacén local
|
||||
const localRes = localBillingStore.marcarComoFacturados(uniqueIds);
|
||||
const count = Math.max(updatedInMongo, localRes.updatedCount);
|
||||
|
||||
console.log(`✅ [BillingConsolidation] ${count} registros marcados como 'facturado_manual'`);
|
||||
|
||||
return {
|
||||
updatedCount: count,
|
||||
ids: uniqueIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const billingConsolidationService = BillingConsolidationService.getInstance();
|
||||
@ -0,0 +1,167 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { EstadoFacturacion } from '../models/CierreFacturacionMensual';
|
||||
|
||||
export interface LocalCierreRecord {
|
||||
id: string; // UUID
|
||||
conductor_id: string;
|
||||
nit_o_cedula: string;
|
||||
razon_social: string;
|
||||
correo_electronico: string;
|
||||
mes_calendario: number;
|
||||
anio_calendario: number;
|
||||
total_comisiones_acumuladas: number;
|
||||
total_viajes_realizados: number;
|
||||
estado_facturacion: EstadoFacturacion;
|
||||
fecha_cierre: string; // ISO string
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'pacifictrans_cierres_facturacion.json');
|
||||
|
||||
export class LocalBillingStore {
|
||||
private static instance: LocalBillingStore;
|
||||
private recordsMap = new Map<string, LocalCierreRecord>(); // key: `${conductor_id}_${mes}_${anio}`
|
||||
private initialized = false;
|
||||
|
||||
private constructor() {
|
||||
this.ensureInitialized();
|
||||
}
|
||||
|
||||
public static getInstance(): LocalBillingStore {
|
||||
if (!LocalBillingStore.instance) {
|
||||
LocalBillingStore.instance = new LocalBillingStore();
|
||||
}
|
||||
return LocalBillingStore.instance;
|
||||
}
|
||||
|
||||
private ensureInitialized() {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(STORE_PATH)) {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf-8');
|
||||
const list: LocalCierreRecord[] = JSON.parse(raw || '[]');
|
||||
this.recordsMap.clear();
|
||||
for (const r of list) {
|
||||
const key = `${r.conductor_id}_${r.mes_calendario}_${r.anio_calendario}`;
|
||||
this.recordsMap.set(key, r);
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ [LocalBillingStore] Fallo al cargar cierres locales:', err);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const list = Array.from(this.recordsMap.values());
|
||||
fs.writeFileSync(STORE_PATH, JSON.stringify(list, null, 2), 'utf-8');
|
||||
} catch (err) {
|
||||
console.error('❌ [LocalBillingStore] Error al guardar cierres locales:', err);
|
||||
}
|
||||
}
|
||||
|
||||
public upsertCierre(data: {
|
||||
id?: string;
|
||||
conductor_id: string;
|
||||
nit_o_cedula: string;
|
||||
razon_social: string;
|
||||
correo_electronico: string;
|
||||
mes_calendario: number;
|
||||
anio_calendario: number;
|
||||
total_comisiones_acumuladas: number;
|
||||
total_viajes_realizados: number;
|
||||
estado_facturacion?: EstadoFacturacion;
|
||||
fecha_cierre?: Date | string;
|
||||
}): LocalCierreRecord {
|
||||
this.ensureInitialized();
|
||||
const key = `${data.conductor_id}_${data.mes_calendario}_${data.anio_calendario}`;
|
||||
const existing = this.recordsMap.get(key);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: LocalCierreRecord = {
|
||||
id: existing?.id || data.id || crypto.randomUUID(),
|
||||
conductor_id: data.conductor_id,
|
||||
nit_o_cedula: data.nit_o_cedula || existing?.nit_o_cedula || 'NO-REGISTRADO',
|
||||
razon_social: data.razon_social || existing?.razon_social || 'Conductor Pacific Trans',
|
||||
correo_electronico: data.correo_electronico || existing?.correo_electronico || 'info@pacifictrans.com',
|
||||
mes_calendario: Number(data.mes_calendario),
|
||||
anio_calendario: Number(data.anio_calendario),
|
||||
total_comisiones_acumuladas: Number(data.total_comisiones_acumuladas || 0),
|
||||
total_viajes_realizados: Number(data.total_viajes_realizados || 0),
|
||||
estado_facturacion: data.estado_facturacion || existing?.estado_facturacion || 'pendiente_proceso',
|
||||
fecha_cierre: data.fecha_cierre
|
||||
? new Date(data.fecha_cierre).toISOString()
|
||||
: existing?.fecha_cierre || now,
|
||||
createdAt: existing?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.recordsMap.set(key, record);
|
||||
this.persist();
|
||||
return record;
|
||||
}
|
||||
|
||||
public findByMesAnio(mes: number, anio: number): LocalCierreRecord[] {
|
||||
this.ensureInitialized();
|
||||
const results: LocalCierreRecord[] = [];
|
||||
for (const r of this.recordsMap.values()) {
|
||||
if (Number(r.mes_calendario) === Number(mes) && Number(r.anio_calendario) === Number(anio)) {
|
||||
results.push({ ...r });
|
||||
}
|
||||
}
|
||||
return results.sort((a, b) => b.total_comisiones_acumuladas - a.total_comisiones_acumuladas);
|
||||
}
|
||||
|
||||
public findById(id: string): LocalCierreRecord | null {
|
||||
this.ensureInitialized();
|
||||
for (const r of this.recordsMap.values()) {
|
||||
if (r.id === id) return { ...r };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public marcarComoFacturados(ids: string[]): { updatedCount: number; records: LocalCierreRecord[] } {
|
||||
this.ensureInitialized();
|
||||
const updated: LocalCierreRecord[] = [];
|
||||
const idSet = new Set(ids);
|
||||
|
||||
for (const [key, r] of this.recordsMap.entries()) {
|
||||
if (idSet.has(r.id)) {
|
||||
const mod: LocalCierreRecord = {
|
||||
...r,
|
||||
estado_facturacion: 'facturado_manual',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.recordsMap.set(key, mod);
|
||||
updated.push(mod);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.length > 0) {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
return { updatedCount: updated.length, records: updated };
|
||||
}
|
||||
|
||||
public getAll(): LocalCierreRecord[] {
|
||||
this.ensureInitialized();
|
||||
return Array.from(this.recordsMap.values());
|
||||
}
|
||||
}
|
||||
|
||||
export const localBillingStore = LocalBillingStore.getInstance();
|
||||
Loading…
Reference in New Issue
Block a user