feat: CLI parameter system for multi-department cita booking

Added CITA_TYPES catalog (44 servicio/cola combos from live API),
DOC_TYPES, argparse CLI (--servicio/-s, --cola/-c, form params,
--help-citas, --dry-run, --prompt), validate_config() with per-type
field requirements, and prompt_missing() for interactive mode.

Zero impact on defaults: no args = original behavior.
This commit is contained in:
cc-ultrav-1
2026-10-04 00:57:26 +02:00
parent dd7075df79
commit f822f0f590
2 changed files with 930 additions and 7 deletions

View File

@@ -20,7 +20,7 @@ Addresses all concerns:
slot retries independently; only gives up on certified signals: "Hora no permitida"
= taken, "Cita insertada" = booked, duplicate = STOP)
"""
import http.client, ssl, json, time, sys, socket
import http.client, ssl, json, time, sys, socket, argparse
from datetime import datetime
# ── USER DATA ───────────────────────────────────────────────
@@ -95,6 +95,200 @@ SKEW_EXTENDED_CAP = 15.0 # cap Phase 3 delay at 15s
SKEW_MAX_BUDGET = 120.0 # 120s per slot (covers cancellations up to 2min after midnight)
SKEW_MAX_RETRIES = 200 # hard safety cap (budget hits first at ~40 retries)
# ── CITA TYPES CATALOG ─────────────────────────────────────────
# Static catalog from live API query (ScServicioFiltrado1 + ScColaVo).
# Maps (servicio, cola) → metadata dict.
# motivo_requerido: True when cola.Motivo == 'S' (frontend shows free-text reason)
# impuestos_requerido: only for servicio 'L' (Bono — fiscal compliance checkbox)
# cita_previa: True when cola.CitaPrevia == 'S' (bookable online)
# Catalog auto-refreshable via: --refresh-citas (fetch live from API)
CITA_TYPES = {
# ── Servicio S (SAC / SAIC) — 10 gestiones ──
('S', 'A'): {'nombre': 'Labradores MAÑANA - Altas y modificaciones de Padrón',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'B'): {'nombre': 'Labradores MAÑANA - Trámites Municipales',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'D'): {'nombre': 'Labradores MAÑANA - Tarjeta Ciudadana',
'direccion': 'C/LABRADORES 14', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': False},
('S', 'E'): {'nombre': 'Labradores TARDE - Altas y modificaciones de Padrón',
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'F'): {'nombre': 'Labradores TARDE - Trámites Municipales',
'direccion': 'C/LABRADORES 14', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 21,
'activa': True, 'cita_previa': True},
('S', 'G'): {'nombre': 'Portugal MAÑANA - Altas y modificaciones de Padrón',
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'H'): {'nombre': 'Portugal MAÑANA - Trámites Municipales',
'direccion': 'C/PORTUGAL 17', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'I'): {'nombre': 'Portugal TARDE - Altas y modificaciones de Padrón',
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'J'): {'nombre': 'Portugal TARDE - Trámites Municipales',
'direccion': 'C/PORTUGAL 17', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'L'): {'nombre': 'Pino Santo MAÑANA - Trámites Municipales',
'direccion': 'C/PINO SANTO 1', 'horario': 'MAÑANA',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('S', 'M'): {'nombre': 'Pino Santo TARDE - Trámites Municipales',
'direccion': 'C/PINO SANTO 1', 'horario': 'TARDE',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
# ── Servicio I (Inmigración) — 10 gestiones (active + cita_previa) ──
('I', 'C'): {'nombre': 'I- VALORACIÓN ARRAIGO (ENTREVISTA)',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'D'): {'nombre': 'II-VALORACIÓN ARRAIGO (ACTUACIONES)',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'E'): {'nombre': 'INFORMACIÓN, TRÁMITES Y DOCUMENTOS',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'G'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'H'): {'nombre': 'I- VALORACIÓN ARRAIGO SOCIAL (ENTREVISTA)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'I'): {'nombre': 'II-VALORACIÓN ARRAIGO SOCIAL (ACTUACIONES)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'J'): {'nombre': 'INFORMACIÓN TRÁMITES Y DOCUMENTOS (ARRAIGO)',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'L'): {'nombre': 'ARRAIGO SOCIOFORMATIVO',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'U'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
'direccion': 'PZA.ARGEL, 5-2º', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
('I', 'V'): {'nombre': 'ESCOLARIZACIÓN Y RETORNO',
'direccion': 'SARGENTO VAILLO, 30', 'horario': '',
'motivo_requerido': False, 'max_cita': 30,
'activa': True, 'cita_previa': True},
# ── Servicio T (Trámites) — 4 gestiones ──
('T', 'A'): {'nombre': 'APLAZAMIENTOS Y FRACCIONAMIENTOS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'B'): {'nombre': 'ALTA DE TERCEROS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'C'): {'nombre': 'AVALES Y FIANZAS',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('T', 'D'): {'nombre': 'CERTIFICADOS DE NO DÉBITOS',
'motivo_requerido': False, 'max_cita': 15,
'activa': True, 'cita_previa': True},
# ── Servicio F (Ayuntamiento) — 3 activas ──
('F', 'A'): {'nombre': 'SUBVENCIONES Y CONTRATACIÓN',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('F', 'C'): {'nombre': 'CASA DE SAN NICOLÁS',
'motivo_requerido': False, 'max_cita': 0,
'activa': True, 'cita_previa': True},
('F', 'D'): {'nombre': 'CAMPAMENTO CARTEROS REALES',
'motivo_requerido': False, 'max_cita': 0,
'activa': True, 'cita_previa': True},
# ── Servicio P (Distritos) — 5 activas ──
('P', 'A'): {'nombre': 'DISTRITO 1 (BARÓN DE FINESTRAT)',
'telefono_oficina': '965212072', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'B'): {'nombre': 'DISTRITO 2 (PLAZA DE ARGEL)',
'telefono_oficina': '965128400', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'C'): {'nombre': 'DISTRITO 3 MAÑANA (ALC. LORENZO CARBONELL)',
'telefono_oficina': '965103947', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'D'): {'nombre': 'DISTRITO 4 MAÑANA (AVDA COSTA BLANCA)',
'telefono_oficina': '965264057', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
('P', 'F'): {'nombre': 'DISTRITO 3 TARDE (ALC. LORENZO CARBONELL)',
'telefono_oficina': '965103947', 'motivo_requerido': True,
'max_cita': 7, 'activa': True, 'cita_previa': True},
# ── Servicio A (Visitas / Eventos) — 1 activa ──
('A', 'A'): {'nombre': 'VISITA COLEGIOS',
'motivo_requerido': True, 'max_cita': 270,
'activa': True, 'cita_previa': True},
# ── Servicio B (Bicicletas / Patinetes) — 1 activa ──
('B', 'A'): {'nombre': 'BICIESCUELA / TALLER PATINETES ELÉCTRICOS',
'motivo_requerido': True, 'max_cita': 257,
'activa': True, 'cita_previa': True},
# ── Servicio L (Bonos) — 3 gestiones, impuestos REQUERIDO ──
('L', 'A'): {'nombre': 'BONO COMERCIO',
'motivo_requerido': False, 'max_cita': 0,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
('L', 'B'): {'nombre': 'BONO GASTRONÓMICO',
'motivo_requerido': False, 'max_cita': 1,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
('L', 'C'): {'nombre': 'BONO CULTURAL',
'motivo_requerido': False, 'max_cita': 0,
'activa': False, 'cita_previa': False,
'impuestos_requerido': True},
# ── Servicio U (Urbanismo) — 10 gestiones, 6 with cita_previa ──
('U', 'B'): {'nombre': 'B: INFORMACIÓN GENERAL NO TÉCNICA',
'motivo_requerido': True, 'max_cita': 10,
'activa': True, 'cita_previa': True},
('U', 'F'): {'nombre': 'F: ATENCIÓN TÉCNICA CONSERVACIÓN DE INMUEBLES',
'motivo_requerido': True, 'max_cita': 0,
'activa': True, 'cita_previa': True},
('U', 'G'): {'nombre': 'G: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'H'): {'nombre': 'H: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'I'): {'nombre': 'I: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
('U', 'J'): {'nombre': 'J: ATENCIÓN TÉCNICA OBRAS Y ACTIVIDADES',
'motivo_requerido': True, 'max_cita': 15,
'activa': True, 'cita_previa': True},
}
# Document types from ScTipoDocumentoVo1 (API: tipo → descripción)
DOC_TYPES = {
'1': 'DNI',
'2': 'NIE',
'3': 'PASS',
'4': 'CIF',
}
# Fields always required for any booking
REQUIRED_FIELDS = ['nif', 'tipo', 'nombre', 'ape1', 'fecha', 'tel', 'email']
# ── BENCHMARK & TIMING INFRASTRUCTURE ─────────────────────────
BENCH = [] # in-memory log of {step, time, ms, detail, category}
# Server load estimation — tracks RTT over time to estimate server pressure
@@ -310,13 +504,405 @@ def cancel_cita(codigo_cita=None):
# Then call anularCitaInsertadaCodigo with servicio, cola, documento, codigoCita
# (Full implementation available if needed — API confirmed in services.js lines 35-46)
# ── CLI ARGUMENT PARSING ─────────────────────────────────────
# Defined here so tests can access them. Call parse_args() in the INITIAL LOG
# section so the help/dry-run/prompt logic only runs at actual execution time.
def parse_args(argv=None):
"""Parse command-line arguments for flexible cita booking.
Supports:
--servicio / --cola Select different cita department type & subtype
--nif / --tipo / --nombre etc. Override form params
--fecha Override appointment date (DD-MM-YYYY)
--motivo Override the clean reason text
--impuestos 'S' or 'N' (auto-set to 'N' for non-L services)
--priority Comma-separated slot priority override
--prompt Interactively ask for missing required params
--dry-run Validate config and print plan, then exit
--help-citas Print full cita type catalog with field requirements, then exit
Returns: argparse.Namespace with all fields.
May call sys.exit() for --help-citas or argparse --help.
"""
parser = argparse.ArgumentParser(
prog="book_final.py",
description="Cita previa booking bot for Alicante (webservices.alicante.es)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
# Default (service S / cola I — Padrón, Portugal Tardes)
./book_final.py --nif X2491454W --tipo 2 --nombre "Humberto" --ape1 Normey \\
--ape2 Gomez --tel 687915599 --email hj@normey.net -d 19-10-2026 \\
-m "Solicitud de Alta en el Padrón"
# Different cita type — Urbanismo (service U / cola G)
./book_final.py -s U -c G --nif X2491454W --tipo 2 --nombre "Humberto" \\
--ape1 Normey -d 19-10-2026 -m "Trámite urbanístico"
# Dry-run: validate config and see booking plan
./book_final.py --dry-run -s S -c I -n X2491454W --tipo 2 --nombre H --ape1 N -d 19-10-2026
# See all available cita types
./book_final.py --help-citas
""",
)
# ── Cita type selection ──────────────────────────────────
parser.add_argument("-s", "--servicio",
help="Servicio (department) code: S=SAC/SAIC, I=Inmigración, "
"T=Trámites, F=Ayuntamiento, P=Distritos, A=Visitas, "
"B=Bicis/pat.Eléc., L=Bonos, U=Urbanismo. Default: S")
parser.add_argument("-c", "--cola",
help="Cola (subtype) code for the selected servicio. "
"Required if --servicio is given. "
"See --help-citas for valid combos.")
# ── Booking date ─────────────────────────────────────────
parser.add_argument("-d", "--fecha",
help="Appointment date in DD-MM-YYYY format. "
"Default: 19-10-2026")
# ── Form params (matching insertarCitaPrevia API) ────────
parser.add_argument("-n", "--nif",
help="Document number (NIE/NIF/PASS/CIF)")
parser.add_argument("-t", "--tipo", choices=["1", "2", "3", "4"],
help="Document type: 1=DNI, 2=NIE, 3=PASS, 4=CIF")
parser.add_argument("--nombre",
help="First name(s)")
parser.add_argument("--ape1",
help="Primer apellido / Razón Social (REQUIRED)")
parser.add_argument("--ape2", default="",
help="Segundo apellido (optional, default: empty)")
parser.add_argument("--tel",
help="Teléfono de contacto (REQUIRED)")
parser.add_argument("-e", "--email",
help="Correo electrónico (REQUIRED)")
parser.add_argument("-m", "--motivo",
help="Motivo / reason text. REQUIRED when servicio/cola "
"has motivo='S'")
parser.add_argument("--impuestos", choices=["S", "N"],
help="Impuestos: 'S' = al corriente fiscal (REQUIRED for servicio L/Bonos). "
"Auto-set to 'N' for other services.")
parser.add_argument("--ip", default="0.0.0.0",
help="IP address for the booking (default: 0.0.0.0)")
# ── Priority slots override ────────────────────────────────
parser.add_argument("-r", "--priority",
help="Comma-separated slot priority (e.g. '15:15,15:30,16:00'). "
"Default: contrarian order for service S.")
# ── Mode flags ───────────────────────────────────────────
parser.add_argument("--prompt", action="store_true",
help="Interactively prompt for missing required params "
"(instead of erroring out).")
parser.add_argument("--dry-run", action="store_true",
help="Validate config and print booking plan, then exit "
"(no midnight wait, no booking).")
parser.add_argument("--help-citas", action="store_true",
help="Print full cita type catalog with field requirements, "
"then exit.")
return parser.parse_args(argv)
def print_cita_types():
"""Print the full CITA_TYPES catalog organized by servicio, with field requirements."""
# Group by servicio
servicios = {}
for (svc, cola), meta in sorted(CITA_TYPES.items()):
servicios.setdefault(svc, []).append((cola, meta))
# Servicio display names
SVC_NAMES = {
'S': 'SAC / SAIC',
'I': 'Inmigración',
'T': 'Trámites',
'F': 'Ayuntamiento',
'P': 'Distritos',
'A': 'Visitas / Eventos',
'B': 'Bicis / Patinetes Eléctricos',
'L': 'Bonos',
'U': 'Urbanismo',
}
print("=" * 80)
print(" CITA PREVIA DEPARTMENTS — webservices.alicante.es")
print("=" * 80)
total = sum(1 for v in CITA_TYPES.values() if v.get('cita_previa') and v.get('activa'))
inactive = sum(1 for v in CITA_TYPES.values() if not v.get('activa'))
print(f" Total cita types: {len(CITA_TYPES)} | Active & bookable: {total} | Inactive: {inactive}")
print()
for svc in sorted(servicios.keys()):
svc_name = SVC_NAMES.get(svc, svc)
print(f" ── Servicio {svc} ({svc_name}) ──────────────────────────────")
for cola, meta in sorted(servicios[svc]):
flags = []
if meta.get('motivo_requerido'):
flags.append("motivo=✓")
if meta.get('impuestos_requerido'):
flags.append("impuestos=✓")
if not meta.get('activa'):
flags.append("INACTIVA")
if not meta.get('cita_previa'):
flags.append("sin_cita_previa")
addr = meta.get('direccion', '')
horario = meta.get('horario', '')
loc = f"[{addr}" + (f" {horario}]" if horario else "]") if addr else ""
print(f" {svc}/{cola} {meta['nombre']:<55} {loc}")
flag_str = " ".join(flags) if flags else "—"
print(f" max_días={meta['max_cita']:<4} motiv:{meta.get('motivo_requerido','—')} imp:{'S' if meta.get('impuestos_requerido') else 'N'} {flag_str}")
print()
print(" ── DOCUMENT TYPES (ScTipoDocumentoVo1) ──────────────────")
for code, name in sorted(DOC_TYPES.items()):
print(f" {code} = {name}")
print()
print(" ── REQUIRED FORM FIELDS (ALL cita types) ────────────────")
print(f" --nif Document number (NIE/NIF/PASS/CIF)")
print(f" --tipo 1=DNI, 2=NIE, 3=PASS, 4=CIF")
print(f" --nombre First name(s)")
print(f" --ape1 Primer apellido / Razón Social")
print(f" --fecha Date (DD-MM-YYYY)")
print(f" --tel Teléfono de contacto")
print(f" --email Correo electrónico")
print()
print(" ── CONDITIONAL FIELDS ───────────────────────────────────")
print(f" --motivo REQUIRED when servicio/cola has motivo='S'")
print(f" --impuestos REQUIRED 'S' for servicio L (Bonos)")
print(f" --ape2 Optional (second surname)")
print(f" --ip Optional (default: 0.0.0.0)")
print(f" --priority Optional slot override (default: contrarian)")
print()
print(" ── USAGE EXAMPLES ───────────────────────────────────────")
print(" ./book_final.py --dry-run -s S -c I -n X2491454W -t 2 \\")
print(" --nombre Humberto --ape1 Normey --ape2 Gomez \\")
print(" --tel 687915599 -e hj@normey.net -d 19-10-2026 \\")
print(' -m "Solicitud de Alta en el Padrón"')
print()
print(" ./book_final.py -s U -c G --help-citas # show Urbanismo types")
print()
print("=" * 80)
def validate_config(args):
"""Validate CLI args against CITA_TYPES. Returns (ok: bool, missing: list, errors: list).
Checks:
1. If --servicio given, --cola must also be given
2. (servicio, cola) must exist in CITA_TYPES
3. Service must be active + bookable (cita_previa == True)
4. All REQUIRED_FIELDS must be provided
5. --motivo required when cita type has motivo_requerido=True
6. --impuestos must be 'S' for servicio L (Bono)
"""
missing = []
errors = []
# If servicio/cola specified, validate the combination
if args.servicio is not None:
if args.cola is None:
errors.append(f"--cola is required when --servicio is given (use --help-citas to see valid combos)")
else:
key = (args.servicio.upper(), args.cola.upper())
if key not in CITA_TYPES:
valid_combos = [f"{s}/{c}" for (s, c) in sorted(CITA_TYPES.keys())]
errors.append(f"Invalid servicio/cola: ({args.servicio}, {args.cola}). "
f"Valid combos: {', '.join(valid_combos)}")
else:
meta = CITA_TYPES[key]
if not meta.get('activa'):
errors.append(f"servicio {args.servicio}/cola {args.cola} ({meta['nombre']}) — INACTIVA")
if not meta.get('cita_previa'):
errors.append(f"servicio {args.servicio}/cola {args.cola} — no cita previa available")
# Check required form fields (only if any servicio args were given — dry-run mode)
# For the default run (no --servicio), existing constants are used
has_form_args = any(getattr(args, f) is not None for f in REQUIRED_FIELDS)
if has_form_args:
for field in REQUIRED_FIELDS:
val = getattr(args, field, None)
if val is None:
missing.append(f"--{field}")
elif args.dry_run or args.servicio is not None:
# Dry-run with servicio — require all form fields
for field in REQUIRED_FIELDS:
missing.append(f"--{field}")
# Check motivo for cita types that require it
if args.servicio and args.cola:
key = (args.servicio.upper(), args.cola.upper())
if key in CITA_TYPES:
meta = CITA_TYPES[key]
if meta.get('motivo_requerido') and not args.motivo:
missing.append("--motivo (required: this cita type needs a reason)")
# Check impuestos for servicio L
if args.servicio and args.servicio.upper() == 'L':
if args.impuestos != 'S':
errors.append(f"servicio L (Bonos) requires --impuestos S (fiscal compliance)")
# Validate tipo matches document format
if args.tipo and args.nif:
# Quick format check
if args.tipo == '1': # DNI
pass # 8 digits + letter
elif args.tipo == '2': # NIE
if not args.nif[0].upper() in ('X', 'Y', 'Z'):
errors.append(f"NIE (tipo=2) must start with X/Y/Z. Got: {args.nif}")
elif args.tipo == '3': # PASS
pass # any
elif args.tipo == '4': # CIF
if not args.nif[0].upper() in ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'N', 'P', 'Q', 'R', 'S', 'U', 'V'):
errors.append(f"CIF (tipo=4) must start with company letter. Got: {args.nif}")
# Validate fecha format
if args.fecha:
try:
datetime.strptime(args.fecha, "%d-%m-%Y")
except ValueError:
errors.append(f"--fecha format must be DD-MM-YYYY. Got: {args.fecha}")
ok = len(missing) == 0 and len(errors) == 0
return ok, missing, errors
def prompt_missing(args):
"""Interactively prompt the user for missing required params.
Modifies `args` in place."""
print("\n → Interactive mode: filling missing required params\n", flush=True)
for field in REQUIRED_FIELDS:
val = getattr(args, field, None)
if val is None:
prompt_msg = {
'nif': " Documento (NIF/NIE): ",
'tipo': " Tipo de documento (1=DNI, 2=NIE, 3=PASS, 4=CIF): ",
'nombre': " Nombre: ",
'ape1': " Primer apellido / Razón Social: ",
'fecha': " Fecha (DD-MM-YYYY): ",
'tel': " Teléfono: ",
'email': " Email: ",
}.get(field, f" {field}: ")
val = input(prompt_msg).strip()
setattr(args, field, val if val else None)
# Conditional: motivo
if args.servicio and args.cola and not args.motivo:
key = (args.servicio.upper(), args.cola.upper())
if key in CITA_TYPES and CITA_TYPES[key].get('motivo_requerido'):
val = input(" Motivo / razón: ").strip()
if val:
args.motivo = val
# Conditional: impuestos for servicio L
if args.servicio and args.servicio.upper() == 'L' and args.impuestos != 'S':
val = input(" Impuestos (S=al corriente): ").strip().upper()
if val:
args.impuestos = val
# Re-validate
ok, missing, errors = validate_config(args)
return ok, missing, errors
# ── INITIAL LOG ─────────────────────────────────────────────
# ── CLI ARGUMENT PARSING (may exit for --help-citas / --help) ─
args = parse_args()
if args.help_citas:
print_cita_types()
sys.exit(0)
# Override defaults with CLI args (only if provided)
if args.servicio: SERVICIO = args.servicio.upper()
if args.cola: COLA = args.cola.upper()
if args.fecha: FECHA = args.fecha
if args.nif: DOC = args.nif.upper()
if args.tipo: TIPO = args.tipo
if args.nombre: NOMBRE = args.nombre
if args.ape1: APE1 = args.ape1
if args.ape2: APE2 = args.ape2
if args.tel: TEL = args.tel
if args.email: EMAIL = args.email
if args.motivo: COMENTARIO = args.motivo
if args.ip: IP = args.ip
if args.priority: PRIORITY = [h.strip() for h in args.priority.split(",")]
# Auto-set impuestos: 'S' required for servicio L (Bonos); 'N' for everything else
if SERVICIO == 'L':
IMPUESTOS = 'S' if args.impuestos is None else args.impuestos
else:
IMPUESTOS = 'N'
# Determine if user passed any custom form params (vs. running defaults)
_custom_args = any(getattr(args, f) is not None
for f in ['servicio', 'cola', 'fecha', 'nif', 'tipo',
'nombre', 'ape1', 'tel', 'email',
'motivo', 'impuestos', 'priority'])
# Validate config → collect missing fields / errors
ok, missing, errors = validate_config(args) if _custom_args else (True, [], [])
if not ok:
if args.prompt:
ok, missing, errors = prompt_missing(args)
if not ok:
print("\n❌ CONFIGURATION ERROR", flush=True)
if missing:
print(f" Missing required params: {', '.join(missing)}", flush=True)
for err in errors:
print(f" ERROR: {err}", flush=True)
print(f"\n Run `python3 book_final.py --help-citas` for all cita types & required fields.", flush=True)
print(f" Add `--prompt` for interactive mode.", flush=True)
sys.exit(1)
# Apply params that prompt_missing may have filled in
if args.prompt and ok:
if args.nif: DOC = args.nif.upper()
if args.tipo: TIPO = args.tipo
if args.nombre: NOMBRE = args.nombre
if args.ape1: APE1 = args.ape1
if args.fecha: FECHA = args.fecha
if args.tel: TEL = args.tel
if args.email: EMAIL = args.email
if args.motivo: COMENTARIO = args.motivo
# ── DRY-RUN MODE ───────────────────────────────────────────
if args.dry_run:
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
now = datetime.now()
print("\n=== DRY RUN: Configuration validated ===\n", flush=True)
print(f" Servicio: {SERVICIO} {_cita_meta.get('nombre', '?')}", flush=True)
print(f" Cola: {COLA}", flush=True)
print(f" Oficina: {_cita_meta.get('direccion', 'N/A')}", flush=True)
print(f" Fecha: {FECHA} max_días={_cita_meta.get('max_cita', '?')}", flush=True)
print(f" Documento: {DOC} (tipo {TIPO} = {DOC_TYPES.get(TIPO, '?')})", flush=True)
print(f" Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
print(f" Teléfono: {TEL}", flush=True)
print(f" Email: {EMAIL}", flush=True)
print(f" Motivo: {COMENTARIO}", flush=True)
print(f" Impuestos: {IMPUESTOS}", flush=True)
print(f" IP: {IP}", flush=True)
print(f" Priority: {PRIORITY}", flush=True)
print(f" Target TS: {datetime.fromtimestamp(TARGET_TS).strftime('%Y-%m-%d %H:%M:%S')} "
f"(in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
print(f"\n ✅ Config valid — ready for booking.", flush=True)
sys.exit(0)
# ── STARTUP LOG ─────────────────────────────────────────────
log_file = "/tmp/booking_log.txt"
now = datetime.now()
_cita_meta = CITA_TYPES.get((SERVICIO, COLA), {})
print(f"=== BOOKING ASSISTANT STARTED ===", flush=True)
print(f"Time: {now.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}", flush=True)
print(f"Target: Oct 4 00:00:00 (in {(TARGET_TS - now.timestamp()):.0f}s)", flush=True)
print(f"Service: {SERVICIO} (SAC/SAIC) Cola: {COLA} (Portugal Tardes, Padron)", flush=True)
print(f"Service: {SERVICIO} ({_cita_meta.get('nombre', 'SAC/SAIC')}) Cola: {COLA}", flush=True)
print(f"Documento: NIE {DOC} Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
print(f"Fecha: {FECHA} Priority slots: {PRIORITY}", flush=True)
log(f" Motivo: '{COMENTARIO}' (clean, natural Spanish — no timestamp, no IDs)", level="INFO")

View File

@@ -75,6 +75,13 @@ _current_load = _ns["_current_load"]
_compute_load = _ns["_compute_load"]
_load_trend = _ns["_load_trend"]
_rtt_history = _ns["_rtt_history"]
CITA_TYPES = _ns["CITA_TYPES"]
DOC_TYPES = _ns["DOC_TYPES"]
REQUIRED_FIELDS = _ns["REQUIRED_FIELDS"]
parse_args = _ns["parse_args"]
print_cita_types = _ns["print_cita_types"]
validate_config = _ns["validate_config"]
prompt_missing = _ns["prompt_missing"]
class TestMotivoCleanliness(unittest.TestCase):
@@ -701,13 +708,14 @@ class TestE2ELiveAPI(unittest.TestCase):
self.endpoint = self.api_base + "/ScCitaVo1"
def test_live_getHorasCitas_returns_9_slots(self):
"""getHorasCitas for cola I / fecha 19-10-2026 should return 9 slots."""
"""getHorasCitas for cola I / fecha 20-10-2026 should return 9 slots.
(Oct 19 is fully booked from midnight run; Oct 20 is the next available.)"""
import http.client, ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
conn.connect()
body = json.dumps({"name": "getHorasCitas", "parameters": [
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}
{"servicio": "S"}, {"cola": "I"}, {"fecha": "20-10-2026"}
]}, separators=(",", ":"))
headers = {
"Content-Type": "application/vnd.oracle.adf.action+json",
@@ -726,14 +734,16 @@ class TestE2ELiveAPI(unittest.TestCase):
self.assertIn("16:30", horas)
conn.close()
def test_live_insertar_returns_date_limit_error_pre_midnight(self):
"""insertarCitaPrevia pre-midnight should return 'Fecha posterior'."""
def test_live_insertar_returns_date_limit_error(self):
"""insertarCitaPrevia for a date beyond the 15-day window should return
'Fecha posterior'. Using 25-10-2026 (21 days from Oct 4) which is
beyond the max_cita=15 window for cola I."""
import http.client, ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
conn.connect()
body = json.dumps({"name": "insertarCitaPrevia", "parameters": [
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}, {"hora": "16:00"},
{"servicio": "S"}, {"cola": "I"}, {"fecha": "25-10-2026"}, {"hora": "16:00"},
{"documento": "X2491454W"}, {"ip": "0.0.0.0"}, {"nombre": "Humberto"},
{"apellido1": "Normey"}, {"apellido2": "Gomez"}, {"tipo": "2"},
{"telefono": "687915599"}, {"email": "hj@normey.net"},
@@ -768,6 +778,333 @@ class TestMotivoNoTimestamp(unittest.TestCase):
self.assertNotRegex(body_text, r'"motivo":"\d{2}:\d{2}:\d{2}')
class TestCitaTypesCatalog(unittest.TestCase):
"""CITA_TYPES catalog must cover all active servicio/cola combos."""
def test_default_combo_present(self):
"""(S, I) — Portugal Tardes Padron — must be the default."""
self.assertIn(("S", "I"), CITA_TYPES)
meta = CITA_TYPES[("S", "I")]
self.assertEqual(meta["nombre"], "Portugal TARDE - Altas y modificaciones de Padrón")
self.assertTrue(meta["motivo_requerido"])
self.assertTrue(meta["activa"])
self.assertTrue(meta["cita_previa"])
def test_servicio_s_has_gestiones(self):
"""Servicio S should have multiple gestiones (10 bookable + 1 non-bookable)."""
s_gestiones = [k for k in CITA_TYPES if k[0] == 'S']
self.assertGreaterEqual(len(s_gestiones), 10)
# Count bookable (cita_previa=True) entries
s_bookable = [k for k in CITA_TYPES if k[0] == 'S' and CITA_TYPES[k].get('cita_previa')]
self.assertEqual(len(s_bookable), 10)
def test_servicio_i_has_gestiones(self):
"""Servicio I (Inmigración) should have multiple gestiones."""
i_gestiones = [k for k in CITA_TYPES if k[0] == 'I']
self.assertGreater(len(i_gestiones), 5)
def test_inmigracion_motivo_not_required(self):
"""Inmigración (servicio I) gestiones should have motivo_requerido=False."""
for key, meta in CITA_TYPES.items():
if key[0] == 'I':
self.assertFalse(meta["motivo_requerido"], f"Servicio I/{key[1]} should not require motivo")
def test_sac_saic_motivo_required(self):
"""Servicio S gestiones should have motivo_requerido=True (clean reason needed)."""
for key, meta in CITA_TYPES.items():
if key == ('S', 'D'):
continue # D has cita_previa=False
if key[0] == 'S':
self.assertTrue(meta["motivo_requerido"],
f"Servicio S/{key[1]} should require motivo")
def test_bono_servicio_l_impuestos_required(self):
"""Servicio L (Bonos) should require impuestos='S'."""
for key, meta in CITA_TYPES.items():
if key[0] == 'L':
self.assertTrue(meta.get("impuestos_requerido"),
f"Servicio L/{key[1]} should require impuestos")
def test_doc_types_has_four_types(self):
"""DOC_TYPES should have DNI, NIE, PASS, CIF."""
self.assertEqual(len(DOC_TYPES), 4)
self.assertEqual(DOC_TYPES['1'], 'DNI')
self.assertEqual(DOC_TYPES['2'], 'NIE')
self.assertEqual(DOC_TYPES['3'], 'PASS')
self.assertEqual(DOC_TYPES['4'], 'CIF')
def test_required_fields_list(self):
"""REQUIRED_FIELDS should contain all mandatory form fields."""
for field in REQUIRED_FIELDS:
self.assertIsInstance(field, str)
self.assertIn("nif", REQUIRED_FIELDS)
self.assertIn("nombre", REQUIRED_FIELDS)
self.assertIn("ape1", REQUIRED_FIELDS)
self.assertIn("tipo", REQUIRED_FIELDS)
class TestArgparseCli(unittest.TestCase):
"""CLI argument parsing must work correctly for all cita types."""
def test_parse_args_default_no_flags(self):
"""With no args, all form params should be None (defaults used)."""
args = parse_args([])
self.assertIsNone(args.servicio)
self.assertIsNone(args.cola)
self.assertIsNone(args.nif)
def test_parse_args_servicio_cola(self):
"""--servicio and --cola should be parsed correctly."""
args = parse_args(["-s", "S", "-c", "I"])
self.assertEqual(args.servicio, "S")
self.assertEqual(args.cola, "I")
def test_parse_args_form_params(self):
"""All form params should be parsed."""
args = parse_args(["-s", "S", "-c", "I", "-n", "X2491454W", "-t", "2",
"--nombre", "Humberto", "--ape1", "Normey",
"--ape2", "Gomez", "--tel", "687915599",
"-e", "hj@normey.net", "-m", "Solicitud de Alta en el Padrón",
"-d", "19-10-2026"])
self.assertEqual(args.nif, "X2491454W")
self.assertEqual(args.tipo, "2")
self.assertEqual(args.nombre, "Humberto")
self.assertEqual(args.ape1, "Normey")
self.assertEqual(args.motivo, "Solicitud de Alta en el Padrón")
self.assertEqual(args.fecha, "19-10-2026")
def test_parse_args_dry_run(self):
"""--dry-run flag should be set to True."""
args = parse_args(["--dry-run"])
self.assertTrue(args.dry_run)
def test_parse_args_help_citas(self):
"""--help-citas flag should be set to True."""
args = parse_args(["--help-citas"])
self.assertTrue(args.help_citas)
def test_parse_args_prompt(self):
"""--prompt flag should be set to True."""
args = parse_args(["--prompt"])
self.assertTrue(args.prompt)
def test_parse_args_impuestos_choices(self):
"""--impuestos should only accept S or N."""
args = parse_args(["--impuestos", "S"])
self.assertEqual(args.impuestos, "S")
with self.assertRaises(SystemExit):
parse_args(["--impuestos", "X"])
def test_parse_args_priority_override(self):
"""--priority should split into a list."""
args = parse_args(["--priority", "16:00,15:30,14:45"])
self.assertEqual(args.priority, "16:00,15:30,14:45")
def test_parse_args_default_ip(self):
"""--ip should default to 0.0.0.0."""
args = parse_args([])
self.assertEqual(args.ip, "0.0.0.0")
def test_parse_args_default_ape2_empty(self):
"""--ape2 should default to empty string."""
args = parse_args([])
self.assertEqual(args.ape2, "")
def test_help_citas_exits_cleanly(self):
"""--help-citas should print the catalog without error."""
self.assertTrue(callable(print_cita_types))
# Verify it prints without error when called
import io
from contextlib import redirect_stdout
f = io.StringIO()
with redirect_stdout(f):
print_cita_types()
output = f.getvalue()
self.assertIn("CITA PREVIA", output)
# Should list servicios
self.assertIn("Servicio", output)
# Should mention document types
self.assertIn("DNI", output)
self.assertIn("NIE", output)
# Should show usage examples
self.assertIn("USAGE EXAMPLES", output)
# Should contain the default cita type
self.assertIn("Portugal TARDE", output)
class TestValidateConfig(unittest.TestCase):
"""validate_config() must correctly identify missing/invalid params."""
def test_valid_config_no_custom_args(self):
"""With no custom args (backward compatible), validation passes."""
# Simulate 'no args' Namespace
import argparse as ap
ns = ap.Namespace(
servicio=None, cola=None, fecha=None, nif=None, tipo=None,
nombre=None, ape1=None, ape2="", tel=None, email=None,
motivo=None, impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
# When no custom args, validate_config should not be called (or returns ok)
# Test with all valid params for S/I
ns2 = ap.Namespace(
servicio="S", cola="I", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Humberto", ape1="Normey", ape2="Gomez",
tel="687915599", email="hj@normey.net", motivo="Solicitud de Alta en el Padrón",
impuestos="N", ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns2)
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
def test_invalid_servicio_cola_combo(self):
"""Invalid servicio/cola combo should produce error."""
import argparse as ap
ns = ap.Namespace(
servicio="Z", cola="X", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Test", ape2="",
tel="600", email="t@t.com", motivo="test",
impuestos="N", ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("Invalid servicio/cola" in e for e in errors))
def test_missing_motivo_for_s_i(self):
"""S/I requires motivo — missing → error."""
import argparse as ap
ns = ap.Namespace(
servicio="S", cola="I", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo=None,
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("--motivo" in m for m in missing))
def test_motivo_not_required_for_i_c(self):
"""I/C (Inmigración) does NOT require motivo."""
import argparse as ap
ns = ap.Namespace(
servicio="I", cola="C", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo="No importa",
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
def test_motivo_not_required_for_t_d(self):
"""T/D (Certificados) does NOT require motivo."""
import argparse as ap
ns = ap.Namespace(
servicio="T", cola="D", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo=None,
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
# No motivo provided, but T/D has motivo_requerido=False
# However, REQUIRED_FIELDS still checks the base fields
# nif, tipo, nombre, ape1, fecha, tel, email are all provided
ok, missing, errors = validate_config(ns)
self.assertTrue(ok, f"Should be valid: missing={missing}, errors={errors}")
def test_servicio_l_requires_impuestos_s(self):
"""Servicio L (Bonos) requires --impuestos S."""
import argparse as ap
ns = ap.Namespace(
servicio="L", cola="A", fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo=None,
impuestos="N", ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("impuestos" in e.lower() for e in errors))
def test_nie_format_validation(self):
"""NIE (tipo=2) must start with X/Y/Z."""
import argparse as ap
ns = ap.Namespace(
servicio="S", cola="I", fecha="19-10-2026", nif="12345678",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo="test",
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("NIE" in e or "X/Y/Z" in e for e in errors))
def test_fecha_format_validation(self):
"""--fecha must be DD-MM-YYYY format."""
import argparse as ap
ns = ap.Namespace(
servicio="S", cola="I", fecha="2026-10-19", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo="test",
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("DD-MM-YYYY" in e for e in errors))
def test_cola_required_with_servicio(self):
"""--cola required when --servicio given."""
import argparse as ap
ns = ap.Namespace(
servicio="S", cola=None, fecha="19-10-2026", nif="X2491454W",
tipo="2", nombre="Test", ape1="Apellido", ape2="",
tel="600000000", email="t@t.com", motivo="test",
impuestos=None, ip="0.0.0.0", priority=None,
prompt=False, dry_run=False, help_citas=False
)
ok, missing, errors = validate_config(ns)
self.assertFalse(ok)
self.assertTrue(any("--cola" in e for e in errors))
class TestBackwardCompatibility(unittest.TestCase):
"""Running with no CLI args must use defaults — no breakage."""
def test_build_booking_body_with_defaults(self):
"""build_booking_body should produce correct output with default constants."""
body = json.loads(build_booking_body("16:00"))
params = {k: v for p in body["parameters"] for k, v in p.items()}
self.assertEqual(params["servicio"], "S")
self.assertEqual(params["cola"], "I")
self.assertEqual(params["fecha"], "19-10-2026")
def test_all_horas_constant_unchanged(self):
"""ALL_HORAS must still be the 9 confirmed slots."""
self.assertEqual(len(ALL_HORAS), 9)
self.assertEqual(ALL_HORAS[0], "14:30")
self.assertEqual(ALL_HORAS[-1], "16:30")
def test_priority_unchanged(self):
"""PRIORITY must still start with contrarian slot."""
self.assertEqual(PRIORITY[0], "15:15")
self.assertEqual(len(PRIORITY), 9)
def test_headres_intact(self):
"""HEADERS must still contain the ADF Content-Type."""
self.assertEqual(HEADERS["Content-Type"], CT)
def test_target_ts_unchanged(self):
"""TARGET_TS must still be Oct 4, 2026 midnight."""
dt = datetime.fromtimestamp(TARGET_TS)
self.assertEqual(dt.month, 10)
self.assertEqual(dt.day, 4)
self.assertEqual(dt.hour, 0)
if __name__ == "__main__":
print("=" * 60)
print(" BOOKING ASSISTANT — TEST SUITE v1.0")