Skip to content

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 axios

Endpoint 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.numret
sql
-- 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.ccui

Generació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 campo

Construcció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.

ComportamientoConexión de datosSchemas consultados
Siempre consolidadonew Database($db, $schemaActual) por cada schemaTodos 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.phpseedCompras()

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

CasoManejo
Proveedor sin CUITNo generar línea; mostrar advertencia en response
Proveedor sin provincia mapeadaUsar 00
Proveedor sin localidad/cod_postal/provinciaError 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 retencionesRuntimeException con mensaje "No existen retenciones de Ganancias registradas para el período MM/YYYY." → HTTP 422 (no ZIP vacío)
CUIT con guionesLimpiar con limpiarCUIT()
Importes negativos (anulaciones futuras)Usar valor absoluto + gestionar cuando se implemente anulación
Carácter especial en nombre proveedorTruncar a ASCII o UTF-8 limitado; SICORE no acepta caracteres extendidos
LATERAL SQL — OP no está en ordcte_subdicomBuscar 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 { ... } // futuro

Para 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 detgan del período genera exactamente una línea en retenciones.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-SICORE controla el acceso
  • [x] Solo disponible cuando modulo_compras, modulo_ctacte y modulo_tesoreria está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] localidad y cod_postal provienen de public.localidades via cpdprov.id_localidad — NO de cpdprov.cloc/cpdprov.cpos
  • [x] provincia_arca proviene de public.provincia.codigo_arca via localidades.id_prov → provincia.cpro
  • [x] El LATERAL SQL busca por proveedor (oc2.zf = op.zf) con comprob.tipo = 'D' — nunca os2.id_movimiento = op.id
  • [x] Período sin retenciones → RuntimeException descriptiva → 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


Sistema Bautista ERP - Módulo Compras