Appearance
Exportación SICORE - Proceso de Implementación
Módulo: Compras → CtaCte Tipo: Process Estado: ✅ Implementado Fecha: 2026-03-27
Descripción del Proceso
Al finalizar cada período mensual, el agente de retención debe declarar ante ARCA todas las retenciones de Ganancias practicadas. La exportación SICORE genera dos archivos de texto de posición fija comprimidos en un ZIP que se importan en la aplicación SICORE de ARCA.
Este proceso es análogo al "Libro de IVA Digital" del módulo Compras/Ventas, pero orientado a retenciones.
Flujo del Proceso
┌─────────────────────────────────────────────────────────────────┐
│ EXPORTACIÓN SICORE - RETENCIONES DE GANANCIAS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. USUARIO ACCEDE A COMPRAS → UTILIDADES → EXP. SICORE │
│ └─ Abre modal desde el sidebar (ítem "sicore-compras") │
│ └─ Selecciona período (Mes/Año) │
│ └─ Hace clic en "Exportar" (siempre consolida) │
│ │ │
│ v │
│ 2. SISTEMA CONSULTA DATOS │
│ └─ detgan del período (fecha de ordcte) │
│ └─ JOIN ordcte → fecha, monto de la retención │
│ └─ JOIN ordcte_subdicom → subdicom → comprobante original │
│ └─ JOIN congan → código de régimen ARCA │
│ └─ JOIN cpdprov → CUIT, nombre, domicilio, insgana │
│ └─ JOIN comprob → código ARCA del tipo de comprobante │
│ │ │
│ v │
│ 3. SISTEMA VALIDA DATOS DE PROVEEDORES │
│ └─ Verifica localidad, cod_postal y provincia de cada CUIT │
│ └─ Si algún proveedor tiene datos incompletos: │
│ → Error bloqueante (RuntimeException) → HTTP 422 │
│ → El ZIP NO se genera │
│ → El usuario debe corregir la ficha del proveedor │
│ │ │
│ v (solo si todos los datos están completos) │
│ 4. SISTEMA GENERA DOS ARCHIVOS │
│ ├─ retenciones.txt (145 chars/línea, posición fija) │
│ └─ sujetos_retenidos.txt (83 chars/línea, posición fija) │
│ │ │
│ v │
│ 5. SISTEMA RESPONDE CON DESCARGA ZIP │
│ └─ sicore_consolidado_MMYYYY.zip con ambos .txt │
│ │
└─────────────────────────────────────────────────────────────────┘Implementación: Arquitectura Real
El feature usa arquitectura DDD de 5 capas del backend principal (no informes/).
Archivos involucrados
bautista-backend/
├── Routes/Compras/SicoreRetencionesRoute.php ← Route: registra GET endpoint
├── controller/modulo-compra/SicoreRetencionesController.php ← Controller: orquesta consolidación
├── models/modulo-compra/SicoreRetenciones.php ← Model: genera TXT + ZIP base64
└── Validators/Compras/SicoreRetencionesValidator.php ← Validator: valida mes y ano
bautista-app/
├── ts/compras/utilidades/views/SicoreRetencionesView.tsx ← Modal React (SicoreRetencionesForm)
└── ts/compras/utilidades/services/sicoreRetenciones.service.ts ← Service axiosEndpoint GET /mod-compra/sicore-retenciones
El endpoint recibe parámetros GET (mes, ano), siempre consolida todas las sucursales, y devuelve JSON:
json
{
"data": {
"retenciones": {
"file_name": "sicore_consolidado_032026.zip",
"zip": "<base64>"
}
}
}El frontend (modal SicoreRetencionesForm) decodifica el base64, construye un Blob y dispara la descarga del ZIP. No existe parámetro modo — el backend siempre itera todos los schemas con tabla detgan.
Query SQL Principal
sql
-- retenciones.txt: una fila por cada detgan del período
SELECT
-- Datos de la retención
d.numret,
d.codgan,
cg.codgan AS regimen_afip, -- 119
-- Datos de la orden de pago (ordcte)
o.id AS id_orden,
o.fecha AS fecha_retencion,
o.fecha AS fecha_op, -- [3-12] Fecha del comprobante informado a ARCA
o.nrocomp AS nrocomp_op, -- [13-28] Nro de orden (8 dígitos, ceros izq.)
op.debe AS debe_op, -- [29-44] Importe total de la orden de pago
op.zf::int AS codpro,
-- Sucursal de la empresa (para armar el nro de comprobante SICORE)
e.nrosuc AS nrosuc, -- [13-16] Sucursal (4 dígitos, ceros izq.)
-- Monto de la retención (desde el movimiento de retención en ordcte)
mr.debe AS importe_retencion,
op.debe AS base_calculo,
-- Datos del comprobante del proveedor (LATERAL — solo para contexto, no va a SICORE)
s.feccom AS fecha_comprobante,
s.nrocom AS nro_comprobante,
s.imptot AS total_comprobante,
s.tipcom AS tipcom,
-- Datos del proveedor
p.ccui AS cuit_proveedor,
p.cnom AS razon_social,
p.cdom1 AS domicilio,
l.nombre AS localidad, -- ⚠️ p.cloc es legacy, vacío en producción
l.cod_post AS cod_postal, -- ⚠️ p.cpos es legacy, vacío en producción
p.insgana AS inscripto,
pr.codigo_arca AS provincia_arca -- via localidades.id_prov → provincia.cpro
FROM {schema}.detgan d
-- Concepto de ganancia
JOIN public.congan cg ON cg.id = d.codgan
-- Orden de pago (la retención)
JOIN {schema}.ordcte o ON o.id = d.id_orden
-- Movimiento que contiene el monto de la retención
JOIN {schema}.ordcte mr ON mr.id = d.id_mov_ret
-- Proveedor
JOIN public.cpdprov p ON p.cnro = op.zf::int
-- Datos de la empresa (para nro de sucursal en el comprobante SICORE)
JOIN public.empres e ON true -- tabla única por schema empresa
-- Localidad y provincia (NO usar p.cloc / p.cpos — campos legacy vacíos)
LEFT JOIN public.localidades l ON l.id_loc = p.id_localidad
LEFT JOIN public.provincia pr ON pr.cpro = l.id_prov
-- Acumulado del período
LEFT JOIN {schema}.acugan agu
ON agu.codpro = o.cnro
AND agu.codgan = d.codgan
AND agu.mes = :mes
AND agu.ano = :ano
-- Comprobante del proveedor (LATERAL por proveedor, no por OP)
-- Solo se usa como contexto (no va a las posiciones 1-44 del SICORE)
-- ⚠️ La OP nunca está en ordcte_subdicom → NO usar os2.id_movimiento = op.id
-- Buscar el movimiento de débito más reciente del mismo proveedor (oc2.zf = op.zf)
LEFT JOIN LATERAL (
SELECT s2.feccom, s2.nrocom, s2.imptot, s2.tipcom
FROM {schema}.ordcte oc2
JOIN public.comprob cb2 ON cb2.id = oc2.id_tipo AND cb2.tipo = 'D'
JOIN {schema}.ordcte_subdicom os2 ON os2.id_movimiento = oc2.id
JOIN {schema}.subdicom s2 ON s2.id = os2.id_subdicom
WHERE oc2.zf = op.zf
ORDER BY oc2.fecha DESC
LIMIT 1
) s ON true
WHERE
EXTRACT(MONTH FROM o.fecha) = :mes
AND EXTRACT(YEAR FROM o.fecha) = :ano
ORDER BY o.fecha, d.numretsql
-- sujetos_retenidos.txt: un proveedor único por período
SELECT DISTINCT ON (p.ccui)
p.ccui AS cuit,
p.cnom AS razon_social,
p.cdom1 AS domicilio,
l.nombre AS localidad, -- NOT p.cloc (legacy vacío)
l.cod_post AS cod_postal, -- NOT p.cpos (legacy vacío)
pr.codigo_arca AS provincia_arca
FROM {schema}.detgan d
JOIN {schema}.ordcte op ON op.id = d.id_orden
JOIN public.cpdprov p ON p.cnro = op.zf::int
-- Localidad y provincia
LEFT JOIN public.localidades l ON l.id_loc = p.id_localidad
LEFT JOIN public.provincia pr ON pr.cpro = l.id_prov
WHERE
EXTRACT(MONTH FROM op.fecha) = :mes
AND EXTRACT(YEAR FROM op.fecha) = :ano
ORDER BY p.ccuiGeneración de Líneas de Posición Fija
Helpers PHP
php
// Texto alineado izquierda, rellenar con espacios
function padTexto(string $val, int $len): string {
return str_pad(mb_substr($val, 0, $len), $len, ' ', STR_PAD_RIGHT);
}
// Número alineado derecha, rellenar con ceros
function padEntero(string|int $val, int $len): string {
return str_pad((string)(int)$val, $len, '0', STR_PAD_LEFT);
}
// Decimal para SICORE: sin punto, 2 decimales implícitos
// Ej: 1234.56 → "00000000001234 56" (14 chars)
// NOTA: SICORE usa punto decimal explícito según v9.0; verificar con archivo de prueba
function padDecimal(float $val, int $len): string {
return str_pad(number_format(abs($val), 2, '.', ''), $len, '0', STR_PAD_LEFT);
}
// Fecha DD/MM/AAAA
function formatFechaSICORE(?string $fecha): string {
if (!$fecha) return str_repeat(' ', 10);
return date('d/m/Y', strtotime($fecha));
}
// CUIT sin guiones
function limpiarCUIT(string $cuit): string {
return str_replace(['-', ' '], '', $cuit);
}
// Número de comprobante SICORE: nrosuc (4) + nrocomp de la orden (8) sin separador
// Se construye inline en construirLineaRetencion() con str_pad sobre cada parte
// nrosuc → str_pad($nrosuc, 4, '0', STR_PAD_LEFT)
// nrocomp → str_pad($nrocomp, 8, '0', STR_PAD_LEFT)
// Resultado: 12 chars numéricos + 4 espacios de relleno hasta completar los 16 chars del campoConstrucción de línea de retención (145 chars)
php
function construirLineaRetencion(array $row): string {
$codComprobante = '06'; // [1-2] Fijo: orden de pago (código ARCA)
$fechaComprobante = formatFechaSICORE($row['fecha_op']); // [3-12] Fecha de la orden de pago
$nroComprobante = str_pad($row['nrosuc'], 4, '0', STR_PAD_LEFT) // [13-28] XXXX (sucursal) + XXXXXXXX (nro orden)
. str_pad($row['nrocomp_op'], 8, '0', STR_PAD_LEFT); // sin separador, sin espacios de relleno extra
$importeComp = padDecimal((float)($row['debe_op'] ?? 0), 16); // [29-44] Importe total de la orden de pago
$codImpuesto = padEntero(217, 4); // [45-48] Ganancias
$codRegimen = padEntero($row['regimen_afip'], 3); // [49-51] 119
$codOperacion = '1'; // [52-52] Retención
$baseCalculo = padDecimal((float)($row['base_calculo'] ?? 0), 14); // [53-66]
$fechaRetencion = formatFechaSICORE($row['fecha_retencion']); // [67-76]
$codCondicion = $row['inscripto'] === 'S' ? '01' : '02'; // [77-78]
$retSuspendido = '0'; // [79-79]
$importeRet = padDecimal((float)($row['importe_retencion'] ?? 0), 14); // [80-93]
$porExclusion = str_repeat('0', 6); // [94-99]
$fechaVigencia = str_repeat(' ', 10); // [100-109]
$tipoDoc = tipoDocumentoArcaDesde($row['cuit_proveedor']); // [110-111] Dinámico: '86' CUIL (20/23/24/27) | '80' CUIT (resto). Ver IdentificadorFiscal::codigoTipoDocumentoAfip()
$nroDoc = FormatoAfip::texto(limpiarCUIT($row['cuit_proveedor']), 20); // [112-131] Tipo Texto: alineado izquierda, espacios a la derecha
$nroCertificado = padEntero($row['numret'], 14); // [132-145]
$linea = $codComprobante . $fechaComprobante . $nroComprobante
. $importeComp . $codImpuesto . $codRegimen . $codOperacion
. $baseCalculo . $fechaRetencion . $codCondicion . $retSuspendido
. $importeRet . $porExclusion . $fechaVigencia
. $tipoDoc . $nroDoc . $nroCertificado;
// Validar longitud
assert(strlen($linea) === 145, "Línea retención debe tener 145 chars, tiene " . strlen($linea));
return $linea;
}Construcción de línea de sujeto retenido (83 chars)
php
function construirLineaSujeto(array $row): string {
$nroDoc = padTexto(limpiarCUIT($row['cuit']), 11); // [1-11]
$razonSocial = padTexto($row['razon_social'] ?? '', 20); // [12-31]
$domicilio = padTexto($row['domicilio'] ?? '', 20); // [32-51]
$localidad = padTexto($row['localidad'] ?? '', 20); // [52-71]
$provincia = padEntero($row['provincia_afip'] ?? 0, 2); // [72-73]
$codPostal = padTexto((string)($row['cod_postal'] ?? ''), 8); // [74-81]
$tipoDoc = tipoDocumentoArcaDesde($row['cuit']); // [82-83] '80' (CUIT) o '86' (CUIL) según prefijo del CUIT. Ver IdentificadorFiscal::codigoTipoDocumentoAfip()
$linea = $nroDoc . $razonSocial . $domicilio . $localidad
. $provincia . $codPostal . $tipoDoc;
assert(strlen($linea) === 83, "Línea sujeto debe tener 83 chars, tiene " . strlen($linea));
return $linea;
}Consideraciones Multi-Schema
El endpoint siempre consolida — no existe selector de modo. El controller itera todos los schemas con tabla detgan vía SchemaService.
| Comportamiento | Conexión de datos | Schemas consultados |
|---|---|---|
| Siempre consolidado | new Database($db, $schemaActual) por cada schema | Todos los schemas con tabla detgan en $db (vía SchemaService) — un ZIP único. Si hay una sola sucursal, el resultado equivale a un solo schema |
Nota sobre base de datos: SICORE opera exclusivamente sobre la base oficial (
$db). Nunca se usa$db . '_p'. No existe concepto de modo prueba para esta exportación — las retenciones son siempre datos reales declarados ante ARCA.
detgan, ordcte, acugan, ordcte_subdicom → nivel LEVEL_EMPRESA + LEVEL_SUCURSALcpdprov, comprob, congan, empres → nivel LEVEL_EMPRESA (public)
Integración en Permisos
Seed Permisos.php — seedCompras()
php
['id' => 6023, 'codigo' => 'COMPRAS_UTILS_EXP-SICORE',
'nombre' => 'Exportación SICORE Retenciones',
'descripcion' => 'Generación de archivos SICORE para informar retenciones de ganancias a ARCA',
'nivel' => 3, 'id_padre' => 6004],Casos de Borde
| Caso | Manejo |
|---|---|
| Proveedor sin CUIT | No generar línea; mostrar advertencia en response |
| Proveedor sin provincia mapeada | Usar 00 |
| Proveedor sin localidad/cod_postal/provincia | Error bloqueante: RuntimeException con listado de proveedores afectados → HTTP 422. El ZIP NO se genera hasta que se corrijan los datos en la ficha del proveedor |
| Período sin retenciones | RuntimeException con mensaje "No existen retenciones de Ganancias registradas para el período MM/YYYY." → HTTP 422 (no ZIP vacío) |
| CUIT con guiones | Limpiar con limpiarCUIT() |
| Importes negativos (anulaciones futuras) | Usar valor absoluto + gestionar cuando se implemente anulación |
| Carácter especial en nombre proveedor | Truncar a ASCII o UTF-8 limitado; SICORE no acepta caracteres extendidos |
| LATERAL SQL — OP no está en ordcte_subdicom | Buscar por proveedor (oc2.zf = op.zf) con comprob.tipo = 'D'; nunca os2.id_movimiento = op.id |
Extensibilidad para IIBB/SUSS (futuro)
El diseño debe permitir agregar nuevas fuentes de retenciones sin reescribir. Estructura sugerida:
php
// Interfaz para fuente de retenciones SICORE
interface FuenteRetencionSICORE {
public function getLineasRetenciones(PDO $conn, int $mes, int $ano): array;
public function getLineasSujetos(PDO $conn, int $mes, int $ano): array;
}
// Implementaciones
class GananciasRetencionSICORE implements FuenteRetencionSICORE { ... }
class IIBBRetencionSICORE implements FuenteRetencionSICORE { ... } // futuro
class SUSSRetencionSICORE implements FuenteRetencionSICORE { ... } // futuroPara la primera versión basta con la implementación directa en sicore-retenciones-datos.php.
Criterios de Aceptación
- [x] Los archivos son importables en SICORE de ARCA sin errores de formato
- [x] Cada
detgandel período genera exactamente una línea enretenciones.txt - [x] Cada proveedor único aparece exactamente una vez en
sujetos_retenidos.txt - [x] Todos los campos tienen exactamente el ancho especificado
- [x] La exportación siempre consolida todas las sucursales (sin selector de modo)
- [x] El permiso
COMPRAS_UTILS_EXP-SICOREcontrola el acceso - [x] Solo disponible cuando
modulo_compras,modulo_ctacteymodulo_tesoreriaestán habilitados - [x] Nombre del ZIP:
sicore_consolidado_MMYYYY.zip(ej:sicore_consolidado_032026.zip) — sin CUIT - [x] Proveedores sin CUIT generan advertencia, no error fatal
- [x]
localidadycod_postalprovienen depublic.localidadesviacpdprov.id_localidad— NO decpdprov.cloc/cpdprov.cpos - [x]
provincia_arcaproviene depublic.provincia.codigo_arcavialocalidades.id_prov → provincia.cpro - [x] El LATERAL SQL busca por proveedor (
oc2.zf = op.zf) concomprob.tipo = 'D'— nuncaos2.id_movimiento = op.id - [x] Período sin retenciones →
RuntimeExceptiondescriptiva → HTTP 422 (no ZIP vacío silencioso) - [x] Proveedores con datos incompletos (localidad/postal/provincia) generan error bloqueante:
RuntimeException→ HTTP 422 (no se genera ZIP) - [x] El frontend muestra el error de datos incompletos inline — igual que "período sin datos" — no hay bloque de advertencias post-descarga
Relaciones con Otras Features
- Índice SICORE — Descripción general + formatos
- Retenciones de Ganancias — Feature principal que genera los datos
- Proceso de Orden de Pago — Genera registros en
detgan - Subdiario de Compras Excel — Patrón de referencia similar
Sistema Bautista ERP - Módulo Compras