Appearance
ABM Clientes - Documentación Técnica Backend
Módulo: ventas Feature: abm-clientes Tipo: Resource Fecha: 2026-02-11
⚠️ DOCUMENTACIÓN RETROSPECTIVA - Generada a partir de código implementado el 2026-02-11
Referencias
- Requisitos de Negocio: [Pendiente de creación]
- Documentación Frontend: [Pendiente de creación]
Arquitectura Implementada
Estructura de Archivos
API Layer (Legacy):
backend/ordcon.php- Endpoint legacy para operaciones CRUD de clientes
Controller Layer:
controller/modulo-venta/ClienteController.php- Controlador con lógica de coordinación
Model Layer:
models/modulo-venta/Cliente.php- Modelo principal con acceso a datosmodels/modulo-ctacte/Cliente.php- Modelo auxiliar para consultas de cuenta corriente
Validation Layer:
Validators/Venta/ClienteValidator.php- Validador de estructura de datos
Database:
- Tabla:
ordcon(Clientes) - Nivel: EMPRESA + SUCURSAL (configurable)
Endpoints API
⚠️ NOTA: Este módulo usa endpoints legacy (PHP directo) en lugar de Slim Framework.
GET /backend/ordcon.php
Propósito: Listar clientes con diferentes modos de consulta
Parámetros Query:
| Parámetro | Tipo | Descripción |
|---|---|---|
serverSide | boolean | Habilita Server-Side Rendering para DataTables |
id | integer | Obtiene cliente específico por ID |
scope | string | Nivel de detalle: min (básico), max (completo) |
filter | string | Filtro de búsqueda (nombre, código, identificación) |
default | boolean | Obtiene cliente marcado como defecto |
first | boolean | Obtiene primer cliente (ordenado por código) |
last | boolean | Obtiene último cliente |
tiene_pendiente | boolean | Incluye flag de pedidos pendientes |
Respuesta (scope: min):
json
{
"status": 200,
"data": [
{
"id": 1,
"nombre": "Consumidor Final",
"identificacion": "20123456789",
"domicilio1": "Calle Principal 123",
"telefono1": "1234567890",
"email": "cliente@example.com",
"defecto": true
}
]
}Respuesta (scope: max):
json
{
"status": 200,
"data": {
"id": 1,
"nombre": "Consumidor Final",
"identificacion": "20123456789",
"domicilio1": "Calle Principal 123",
"domicilio2": null,
"telefono1": "1234567890",
"telefono2": null,
"condicion_iva": {
"id": 1,
"nombre": "Consumidor Final"
},
"comision": 0.00,
"nro_iibb": null,
"iibb": {
"id": 1,
"nombre": "No inscripto"
},
"email": "cliente@example.com",
"cartera": null,
"fecha_alta": "2024-01-15",
"margen_credito": 999999.00,
"margen_fact_imp": 9999,
"ultimo_mov": null,
"tiene_ctacte": false,
"lista": null,
"localidad": {
"id": 100,
"nombre": "Buenos Aires",
"cod_post": "1000",
"provincia": "Buenos Aires"
},
"rel_lista": "N",
"vendedor": {
"cven": 1,
"nombre": "Vendedor Principal"
},
"facturas_impagas": 0,
"defecto": true
}
}Respuesta (Server-Side Rendering - DataTables):
json
{
"draw": 1,
"recordsTotal": 150,
"recordsFiltered": 150,
"data": [
{
"id": 1,
"nombre": "Consumidor Final",
"identificacion": "20123456789",
"domicilio1": "Calle Principal 123",
"telefono1": "1234567890",
"email": "cliente@example.com",
"defecto": true
}
]
}Status Codes:
200- Consulta exitosa400- Parámetros inválidos500- Error del servidor
POST /backend/ordcon.php
Propósito: Crear nuevo cliente
Request Body:
json
{
"nombre": "Nuevo Cliente",
"identificacion": "20987654321",
"email": "nuevo@example.com",
"domicilio1": "Avenida Libertador 456",
"domicilio2": "Piso 3 Depto A",
"telefono1": "1145678901",
"telefono2": "1156789012",
"localidad": {
"id": 100
},
"condicion_iva": {
"id": 2
},
"iibb": {
"id": 1
},
"nro_iibb": "123456789",
"vendedor": {
"cven": 1
},
"comision": 5.00,
"cartera": {
"id": 1
},
"tiene_ctacte": true,
"margen_credito": 50000.00,
"margen_fact_imp": 30,
"rel_lista": "N",
"lista": null
}Respuesta:
json
{
"status": 201,
"data": {
"id": 152
}
}Validaciones de Negocio:
- Identificación única (no puede existir otro cliente con el mismo CUIT/DNI)
- Auto-generación de código (MAX(cnro) + 1)
- Fecha de alta automática (CURRENT_DATE)
- Campos obligatorios: nombre, localidad, condición IVA, IIBB, vendedor
Status Codes:
201- Cliente creado exitosamente400- Datos inválidos (validación estructural)405- Duplicado (identificación ya existe)500- Error del servidor
PUT /backend/ordcon.php
Propósito: Actualizar cliente completo
Request Body:
json
{
"id": 152,
"nombre": "Cliente Actualizado",
"identificacion": "20987654321",
"email": "actualizado@example.com",
"domicilio1": "Nueva Dirección 789",
"domicilio2": null,
"telefono1": "1134567890",
"telefono2": null,
"localidad": {
"id": 101
},
"condicion_iva": {
"id": 3
},
"iibb": {
"id": 2
},
"nro_iibb": "987654321",
"vendedor": {
"cven": 2
},
"comision": 7.50,
"cartera": {
"id": 2
},
"tiene_ctacte": true,
"margen_credito": 75000.00,
"margen_fact_imp": 45,
"rel_lista": "S",
"lista": 2
}Respuesta:
json
{
"status": 204
}Validaciones de Negocio:
- Cliente debe existir
- Identificación única (excluyendo el cliente actual)
- Si
tiene_ctacte = false, se mantienen valores anteriores demargen_creditoymargen_fact_imp
Status Codes:
204- Actualización exitosa400- Datos inválidos404- Cliente no encontrado405- Duplicado (identificación ya existe)500- Error del servidor
PATCH /backend/ordcon.php
Propósito: Actualización parcial de cliente (principalmente para campo defecto)
Request Body:
json
{
"id": 152,
"defecto": true
}Respuesta:
json
{
"status": 204
}Lógica de Negocio:
- Solo un cliente puede ser
defecto = truea la vez - Al marcar un cliente como defecto, automáticamente se desmarca el anterior
- Si se intenta desmarcar el único cliente defecto, la operación se rechaza
Status Codes:
204- Actualización exitosa400- Datos inválidos500- Error del servidor
⚠️ OBSERVACIÓN: Existe el método partialUpdateCliente() en el modelo que permite actualización parcial de múltiples campos, pero actualmente NO tiene endpoint expuesto.
Capa de Controlador
ClienteController
Ubicación: controller/modulo-venta/ClienteController.php
Responsabilidades:
- Coordinar llamadas entre routes y model
- Agregar lógica adicional (validación de pendientes)
- No realiza transacciones (delegadas al route)
Métodos Principales:
getClientesSSR(array $data): array
Obtiene clientes con paginación Server-Side para DataTables.
Parámetros:
$data: Array con parámetros de DataTables (draw, start, length, search, order)
Retorno: Array en formato DataTables con draw, recordsTotal, recordsFiltered, data
getClienteById(int|array $id, string $scope = 'max', array $options = []): array
Obtiene cliente(s) por ID con scope configurable.
Parámetros:
$id: ID único o array de IDs$scope:'min'(campos básicos) o'max'(todos los campos)$options: Opciones adicionales (ej:tiene_pendiente)
Retorno: Array con datos del cliente o array de clientes
getClientes(array $options, string $scope = 'min'): array
Obtiene listado de clientes con filtros.
Opciones soportadas:
filter: Búsqueda por nombre, código o identificacióndefault: Obtiene cliente marcado como defectofirst: Primer cliente (orden por código)last: Último clientetiene_pendiente: Incluye validación de pedidos pendientes
Retorno: Array de clientes
insertCliente(array $data): array
Crea nuevo cliente.
Retorno: Array con ['id' => X]
Validaciones:
- Identificación única
- Generación automática de código
updateCliente(int $id, array $data): bool
Actualiza cliente completo.
Retorno: true si exitoso, exception si falla
partialUpdate(int $id, array $data): bool
Actualización parcial (campo defecto).
Lógica especial:
- Desmarca otros clientes si
defecto = true
Capa de Modelo
Cliente (Ventas)
Ubicación: models/modulo-venta/Cliente.php
Tabla: ordcon
Scopes Definidos:
Scope min (Campos básicos para listados):
php
[
'id', 'nombre', 'identificacion',
'domicilio1', 'telefono1', 'email', 'defecto'
]Scope max (Campos completos para formularios):
php
[
'id', 'nombre', 'identificacion', 'domicilio1', 'domicilio2',
'telefono1', 'telefono2', 'condicion_iva', 'comision',
'nro_iibb', 'iibb', 'email', 'cartera', 'fecha_alta',
'margen_credito', 'margen_fact_imp', 'ultimo_mov',
'tiene_ctacte', 'lista', 'localidad', 'rel_lista',
'vendedor', 'facturas_impagas', 'defecto'
]Métodos Principales:
getClientesSSR(array $data): array
Server-Side Rendering para DataTables con búsqueda y ordenamiento.
Búsqueda: Nombre (ILIKE), código (LIKE), identificación (LIKE)
Ordenamiento: Por columna seleccionada (cnro, cnom, ccui)
getClienteById(int|array $id, string $scope = 'max'): array
Obtiene cliente(s) con resolución de relaciones.
Relaciones resueltas:
localidad→Localidad::getById()vendedor→Vendedor::getById()condicion_iva→CondicionIva::getById()iibb→IIBB::getById()
Conversiones de tipos:
margen_credito: string → floatmargen_fact_imp: string → intcomision: string → float
getClientes(array $options, string $scope = 'min'): array
Búsqueda flexible con múltiples opciones.
Filtros:
filter: Búsqueda parcial por nombre/código/identificacióndefault: WHERE defecto IS TRUE LIMIT 1first: ORDER BY cnro ASC LIMIT 1last: ORDER BY cnro DESC LIMIT 1
Límites:
- Con
filter: LIMIT 10 (autocomplete) - Con
default/first/last: LIMIT 1 - Sin filtros: Sin límite
insertCliente(array $data): array
Inserción con validaciones y auto-generación de código.
SQL de generación de código:
sql
SELECT
CASE
WHEN MAX(CNRO) IS NULL THEN 1
ELSE MAX(CNRO) + 1
END AS CNRO
FROM ordcon⚠️ RIESGO DE CONCURRENCIA: Uso de MAX(cnro) + 1 sin transacción explícita puede generar duplicados en alta concurrencia.
Validaciones:
- Duplicidad de identificación antes de insertar
Campos auto-generados:
cnro: MAX + 1cfin: CURRENT_DATE
updateCliente(int $id, array $data): bool
Actualización completa con validaciones.
Validaciones:
- Identificación única (excluyendo cliente actual)
Lógica condicional de cuenta corriente:
sql
clim = CASE WHEN :tiene_ctacte = 1 THEN :margenCredito ELSE clim END,
climdia = CASE WHEN :tiene_ctacte = 1 THEN :margen_fact_imp ELSE climdia ENDpartialUpdate(int $id, array $data): bool
Actualización del campo defecto con lógica de exclusividad.
Lógica:
sql
-- Si defecto = true, primero desmarca todos
UPDATE ordcon SET defecto = FALSE
-- Luego marca el seleccionado
UPDATE ordcon SET defecto = :defecto WHERE cnro = :idpartialUpdateCliente(int $id, array $data): bool
⚠️ MÉTODO SIN ENDPOINT: Actualización parcial flexible de múltiples campos.
Campos soportados:
- Escalares: nombre, email, domicilio1/2, telefono1/2, identificacion, nro_iibb, comision, tiene_ctacte, margen_credito, margen_fact_imp, rel_lista, lista
- Objetos: localidad, condicion_iva, iibb, vendedor, cartera
Validaciones:
- Duplicidad de identificación si se modifica
Cliente (CtaCte)
Ubicación: models/modulo-ctacte/Cliente.php
Propósito: Consultas específicas para módulo de cuenta corriente
Métodos:
getAll(array $options): array
Obtiene clientes con campos mínimos y flag de cuenta corriente.
getById(int $id): array
Obtiene cliente específico con flag de cuenta corriente.
hasCarteras(): bool
Verifica si la tabla ordcon tiene columna ccar (manejo de carteras).
Implementación:
sql
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table AND column_name = 'ccar'Validaciones
Validador Estructural
Ubicación: Validators/Venta/ClienteValidator.php
⚠️ OBSERVACIÓN: El validador existe pero NO se aplica en el endpoint legacy /backend/ordcon.php.
Reglas Definidas:
| Campo | Reglas | Mensaje de Error |
|---|---|---|
nombre | required, string, max:55, min:3 | El nombre es requerido / debe tener entre 3-55 caracteres |
identificacion | sometimes, dniOrCuit | Debe ser un DNI o CUIT válido |
email | sometimes, email, max:50 | Debe ser email válido de máximo 50 caracteres |
domicilio1 | sometimes, string, max:35 | Máximo 35 caracteres |
domicilio2 | sometimes, string, max:35 | Máximo 35 caracteres |
telefono1 | sometimes, numeric | Debe ser numérico |
telefono2 | sometimes, numeric | Debe ser numérico |
localidad | required, array | La localidad es requerida |
localidad.id | required, integer | La localidad es inválida |
condicion_iva | required, array | La condición de IVA es requerida |
condicion_iva.id | required, integer | La condición de IVA es inválida |
iibb | required, array | Los Ingresos Brutos son requeridos |
iibb.id | required, integer | Los Ingresos Brutos son inválidos |
nro_iibb | sometimes, string, max:13 | Máximo 13 caracteres |
vendedor | required, array | El vendedor es requerido |
vendedor.cven | required, integer | El vendedor seleccionado es inválido |
comision | sometimes, numeric | Debe ser un número |
cartera | sometimes, array | La cartera seleccionada es inválida |
cartera.id | sometimes, integer | La cartera seleccionada es inválida |
Regla Personalizada:
dniOrCuit: Valida formato de DNI (8 dígitos) o CUIT (11 dígitos con formato 20-12345678-9)
Validaciones de Negocio
En insertCliente():
- Duplicidad de identificación:
sql
SELECT COUNT(*) AS cantidad
FROM ordcon
WHERE ccui = :identificacion- Si cantidad > 0 → Exception 405: "Ya existe un cliente con ese número de identificación"
En updateCliente():
- Duplicidad de identificación (excluyendo cliente actual):
sql
SELECT cnro::int
FROM ordcon
WHERE ccui = :identificacion AND cnro != :id- Si rowCount > 0 → Exception 405: "Ya existe un cliente con ese número de identificación"
En partialUpdate():
- Exclusividad de cliente defecto:
- Solo un cliente puede tener
defecto = true - Al marcar uno, se desmarcan automáticamente los demás
Esquema de Base de Datos
Tabla: ordcon
Nivel: EMPRESA + SUCURSAL (configurable dinámicamente)
Configuración Personalizada Común: Solo EMPRESA (compartir clientes entre sucursales)
Campos Principales:
| Campo | Tipo | Constraints | Descripción |
|---|---|---|---|
| cnro | DECIMAL(6) | PRIMARY KEY | Código del cliente |
| cnom | VARCHAR(55) | NULL | Nombre del cliente |
| email_cliente | CHAR(40) | NULL | Email del cliente |
| ccui | BIGINT | NULL | CUIT/DNI del cliente |
| cdom1 | VARCHAR(35) | NULL | Domicilio principal |
| cdom2 | VARCHAR(35) | NULL | Domicilio secundario |
| ctel1 | VARCHAR(18) | NULL | Teléfono principal |
| ctel2 | VARCHAR(18) | NULL | Teléfono secundario |
| id_localidad | INTEGER | NULL | FK → localidades.cloc |
| civa | DECIMAL(1) | NULL | FK → ordiva.civa (Condición IVA) |
| ciib | DECIMAL(2) | NULL | FK → ordiib.ciib (Tipo de IIBB) |
| cnib | VARCHAR(13) | NULL | Número de ingresos brutos |
| cven | DECIMAL(3) | NULL | FK → ordven.cven (Vendedor) |
| cpor | DECIMAL(16,5) | NULL | Comisión del vendedor |
| ccar | DECIMAL(1) | NULL | FK → carteras.ccar |
| cfin | DATE | NULL | Fecha de alta |
| cful | DATE | NULL | Fecha último movimiento |
| ccta | DECIMAL(1) | NULL | Marca de Cuenta Corriente (1=Tiene, 0=No tiene) |
| clim | DECIMAL(16,5) | NULL | Límite de crédito |
| climdia | DECIMAL(4) | NULL | Margen de facturas impagas |
| nrolis | VARCHAR(3) | NULL | Lista de precios propia |
| relisp | CHAR(1) | DEFAULT 'N' | Relaciona lista propia (S/N) |
| defecto | BOOLEAN | NOT NULL DEFAULT false | Cliente por defecto |
Campos Sin Uso (legacy):
- cdni, cpos, cloc, ctdo, cpro, cfax, ctex, ccon, ciibant, csact, cfna, cnota, cjubil, ccat, cpos2, csini, verinq, czon, cnomfan, vtocui, coridat, id_tipo_cliente, id_actividad_cliente, clj, agencia, conynom, conydni, conydnif, conydnid, empres, empresdom, emprestel, refe1, refe2, cdnif, cdnid, conyfenac, titufenac, estado_civil, password, id_agencia
Índices:
- Primary Key:
cnro
⚠️ FALTA: Índice en ccui para mejorar performance de validación de duplicados
Foreign Keys (no definidas en migración, solo referencias lógicas):
id_localidad→localidades.clocciva→ordiva.civaciib→ordiib.ciibcven→ordven.cvenccar→carteras.ccar
Tablas Relacionadas:
rel_ordcon_categoria- Relación con categorías de clientesrel_ordcon_disciplina- Relación con disciplinas (módulo membresías)rel_ordcon_producto- Relación con productos favoritosmembresia_ordcon_data- Datos adicionales de membresías
Multi-Tenancy
Niveles Configurables
Por Defecto: [EMPRESA, SUCURSAL]
- Clientes separados por sucursal
Configuración Común: [EMPRESA]
- Clientes compartidos entre todas las sucursales
Comando para configurar:
bash
php manage-table-levels.php --set --database=mi_empresa --table=ordcon --levels=1Propagación de Schema
Backend:
- ConnectionManager configura
search_pathautomáticamente - Schema obtenido del JWT payload (
$payload['schema'])
Request Flow:
Cliente → X-Schema Header → AuthMiddleware → JWT Payload → ConnectionManager → SET search_pathIntegración con Otros Módulos
Módulo de Cuenta Corriente (CtaCte)
Dependencia: Campos ccta, clim, climdia, cful
Validación de Permisos:
- Frontend verifica
permisos.modulo_ctacteantes de mostrar campos
Modelo Auxiliar:
models/modulo-ctacte/Cliente.phppara consultas específicas de CtaCte
Módulo de Pedidos
Método: ClienteController::tienePendiente()
Lógica:
- Verifica si empresa tiene módulo de pedidos habilitado (
empres.pedido) - Consulta
Pendiente::hasByCliente($cliente_id) - Retorna boolean
Uso: Endpoint GET con parámetro tiene_pendiente=true
Módulo de Carteras
Campo: ccar
Validación Dinámica:
sql
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'ordcon' AND column_name = 'ccar'Integración Frontend:
- Componente React:
CarteraSelect.tsx - Solo se muestra si
empres.cartera = true
Decisiones Arquitectónicas
Por qué endpoints legacy en lugar de Slim?
Observación: Otros módulos usan Slim Framework, pero clientes usa /backend/ordcon.php.
Posibles Razones:
- Tabla
ordconcompartida entre múltiples módulos (ventas, membresías, CRM) - Código legacy anterior a la adopción de Slim
- Migración progresiva aún no completada
Por qué MAX(cnro) + 1 sin transacción explícita?
Riesgo Detectado: Alta concurrencia puede generar duplicados.
Mitigación Actual:
- Transacción iniciada en
backend/ordcon.php(línea 50:$conn->beginTransaction()) - Rollback en caso de error (línea 63:
$conn->rollBack())
Mejora Sugerida: Usar secuencia PostgreSQL o SERIAL para auto-incremento.
Por qué dos modelos Cliente (Venta y CtaCte)?
Separación de Responsabilidades:
Venta\Cliente: CRUD completo, scope rico, lógica de negocioCtaCte\Cliente: Consultas simples, campos mínimos, flag de cuenta corriente
Ventaja: Módulo CtaCte puede consumir datos de clientes sin dependencia directa del módulo Ventas.
Por qué validador sin aplicar?
Hipótesis:
- Validador creado para migración futura a Slim
- Endpoint legacy usa validaciones directas en modelo
- Preparación para refactorización
Testing Strategy
Tests Necesarios (No Implementados)
Unit Tests (Controller):
ClienteController::getClienteById()con scope min/maxClienteController::getClientes()con diferentes filtrosClienteController::tienePendiente()con/sin pedidos
Unit Tests (Model):
Cliente::insertCliente()- validación de duplicadosCliente::updateCliente()- lógica condicional de CtaCteCliente::partialUpdate()- exclusividad de defectoCliente::getClientesSSR()- paginación DataTables
Integration Tests:
- POST /backend/ordcon.php → INSERT con transacción
- PUT /backend/ordcon.php → UPDATE con validación de duplicados
- PATCH /backend/ordcon.php → Actualización de defecto exclusivo
- GET /backend/ordcon.php?serverSide=true → Server-Side Rendering
Edge Cases:
- Inserción concurrente de clientes (race condition en MAX + 1)
- Actualización de identificación a valor duplicado
- Intento de desmarcar único cliente defecto
- Cliente con carteras en empresa sin módulo de carteras
Performance
Optimizaciones Actuales
Server-Side Rendering:
- DataTables con paginación server-side
- Reduce carga de datos en frontend
- Búsqueda con ILIKE optimizada
Scopes:
min: 7 campos para listadosmax: 23 campos para formularios- Reduce transferencia de datos
Mejoras Recomendadas
Índices Faltantes:
sql
-- Mejora validación de duplicados
CREATE UNIQUE INDEX idx_ordcon_ccui ON ordcon(ccui) WHERE ccui IS NOT NULL;
-- Mejora búsquedas de autocomplete
CREATE INDEX idx_ordcon_cnom_trgm ON ordcon USING gin(cnom gin_trgm_ops);
-- Mejora filtro de cliente defecto
CREATE INDEX idx_ordcon_defecto ON ordcon(defecto) WHERE defecto = TRUE;N+1 Queries:
- Actualmente: 1 query por cliente para resolver relaciones (localidad, vendedor, condicion_iva, iibb)
- Mejora: JOIN en query principal o carga en batch
Caching:
- Datos de configuración (condiciones IVA, IIBB, vendedores) raramente cambian
- Candidatos para cache con invalidación por eventos
Seguridad
Sanitización de Inputs
Prepared Statements: ✅ Todos los queries usan parámetros preparados
Validación de Tipos: ✅ Casting explícito (int, float, bool)
Autorización
Nivel de Endpoint: ⚠️ No implementado (usa AuthMiddleware genérico de JWT)
Nivel de Campo:
- Frontend valida
permisos.modulo_ctactepara campos de cuenta corriente - Frontend valida
empres.carterapara campo de carteras
Mejora Recomendada: Middleware de permisos por endpoint (ej: can:clientes.create, can:clientes.update)
Auditoría
⚠️ NO IMPLEMENTADO: El módulo NO registra auditoría de operaciones CUD.
Mejora Recomendada: Implementar AuditableInterface y Auditable trait en service layer.
Preguntas Técnicas Pendientes
⚠️ Aclaraciones Requeridas: Hay aspectos técnicos que requieren validación.
Ver: Preguntas sobre ABM Clientes
Referencias
Código Fuente:
- Backend:
backend/ordcon.php,controller/modulo-venta/ClienteController.php,models/modulo-venta/Cliente.php - Validador:
Validators/Venta/ClienteValidator.php - Migración:
migrations/tenancy/20240823200741_new_table_ordcon.php
- Backend:
Documentación Relacionada:
⚠️ NOTA IMPORTANTE: Esta documentación fue generada automáticamente analizando el código implementado. Validar cambios futuros contra este baseline.
Versión: 1.0 Última Actualización: 2026-02-11 Analizado por: implemented-code-documenter