// ────────────────────────────────────────────────────────────────── // MOTOR CONTABLE — implementación de `documentos/implementación_contable.md` // // Núcleo único (cierre v2 §0.3): TODAS las órdenes se versionan / facturan / // contabilizan / cierran a través de estos servicios. Ninguna ventana escribe // versiones, incrementales, documentos ni cierres por su cuenta. // // Expone en window: // · OrdenService (§8.1) crear/verificar/cambiarMonto/imputar/ // preFactura/emitir/provisionSAP/anular/pago // · CierreService (§8.2) día hábil, previsualizar/ejecutar/reabrir // · SAPConnector mock keys-free (provisión / contabilización / pago) // · FacturadorSII mock keys-free (folio CAF local / recepción SII) // · SCMotor consts + helpers compartidos por las ventanas // // Todo va dentro de un IIFE: los scripts type="text/babel" comparten scope // global, así que sólo se publican nombres bajo window.* (sin colisiones). // ────────────────────────────────────────────────────────────────── (function () { "use strict"; // ── Claves localStorage ────────────────────────────────────────── // ── Nota: acá vivían once bloques de migración ────────────────────────── // Corrían al CARGAR el archivo y escribían a la base a través del // write-through. Arreglaban filas que venían de un localStorage viejo, de // cuando localStorage ERA la base. Se verificó contra producción, una // condición por bloque, y las once dieron cero: el esquema ya no tiene las // columnas que limpiaban y los datos ya están en el modelo nuevo. // // Además habían dejado de ser seguras: razonaban sobre «qué falta» mirando // sólo las tablas que ESTE módulo declara, y con la separación cada módulo // baja las suyas. migrarCanalMedio leía sc.medio en Programación —que no la // pide—, concluía que faltaban todos los medios e intentaba recrearlos. Así // entraron a sc.medio cinco filas con el id igual al nombre. // // Si vuelve a hacer falta corregir datos, va como SQL versionado contra la // base, no como código que corre al abrir una página. // ──────────────────────────────────────────────────────────────────────── const K = { ORD: "sc.orden_de_venta", // cabecera (se le agregan fecha_imputacion, esta_pagado en sitio) VER: "sc.version_orden_venta", // Las de producción son el mismo modelo sobre otras tablas; las escribe la // misma API (/api/orden/produccion/…). ORD_PROD: "sc.orden_de_produccion", VER_PROD: "sc.version_orden_produccion", // imputacion_pep_ceco fue ELIMINADA del modelo. Las órdenes de compra y de // producción imputan en orden_de_compra_pep_ceco / orden_de_produccion_pep_ceco; // las de VENTA se quedaron sin tabla donde guardar su PEP/CECO. DOC: "sc.documento_tributario", PF: "sc.pre_factura", RS: "sc.razon_social", FER: "sc.feriado", MONEDA: "sc.moneda", CM: "sc.cierre_mensual", CD: "sc.cierre_detalle", CA: "sc.cierre_auditoria", AREA: "sc.monetizacion", PROD: "sc.producto", ELEM: "sc.elemento_venta", PROP: "sc.propuesta", PEP: "sc.pep_ceco", CLASEDOC: "sc.clase_de_documento", // catálogo de clases de documento tributario TPEP: "sc.tipo_pep_ceco", // catálogo del tipo de PEP/CECO (id = código SAP) CLI: "sc.clientes", OP: "sc.oportunidad", // el saneador del responsable la necesita }; // ── Estados unificados (cierre v2 §2) ──────────────────────────── const ESTADOS = [ { id: "creada", label: "Creada", color: "#4b5366", bg: "#eef0f4" }, { id: "verificada", label: "Verificada", color: "#0d6e3f", bg: "#dcfce7" }, { id: "facturada", label: "Facturada", color: "#1d4ed8", bg: "#dbeafe" }, { id: "por_refacturar", label: "Por refacturar", color: "#a16207", bg: "#fef9c3" }, { id: "pagada", label: "Pagada", color: "#5b21b6", bg: "#ede9fe" }, { id: "cancelada", label: "Cancelada", color: "#b42318", bg: "#fee4e2" }, ]; const EST_BY = Object.fromEntries(ESTADOS.map(e => [e.id, e])); // ── Helpers LS ─────────────────────────────────────────────────── function read(key) { try { const r = localStorage.getItem(key); if (!r) return []; const p = JSON.parse(r); return Array.isArray(p) ? p : []; } catch (e) { return []; } } function write(key, arr) { try { localStorage.setItem(key, JSON.stringify(arr)); } catch (e) {} } // ── La API de órdenes es el ÚNICO escritor de las tablas de órdenes ───── // Regla dura: ninguna operación sobre órdenes de venta o de producción toca la // base directo. `sc.orden_de_venta` / `sc.version_orden_venta` (y sus gemelas de // producción) están en `db.CLAVES_SOLO_API`: el PUT de tabla completa las // rechaza y el write-through ni las intenta. Lo que queda en localStorage es // CACHÉ DE LECTURA — se refresca con lo que devuelve la API, nunca al revés. // // Por eso las operaciones del motor son asíncronas: el número de versión y el // incremental los calcula el servidor, y hasta que no contesta no se sabe qué // quedó escrito. Las ventanas esperan la promesa y recién ahí redibujan. const API = window.__SC_BASE + "api/orden"; async function apiOrden(metodo, ruta, cuerpo) { const r = await fetch(API + ruta, { method: metodo, headers: { "Content-Type": "application/json" }, body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo), }); let payload = null; try { payload = await r.json(); } catch (e) { payload = null; } if (!r.ok) { const d = payload && payload.detail; const msg = (d && (d.mensaje || d.error)) || (typeof d === "string" ? d : "") || ("HTTP " + r.status); return { ok: false, error: msg, status: r.status, detalle: d }; } return payload || { ok: true }; } // Refresca la caché local con la fila que devolvió la API (merge por id). No es // una escritura a la base: `scQueuePut` saltea estas claves. function _fusionar(key, fila) { if (!fila || !fila.id) return; const arr = read(key); const i = arr.findIndex(x => x.id === fila.id); if (i >= 0) arr[i] = fila; else arr.push(fila); write(key, arr); } function aplicarRespuesta(res, tipo) { if (!res || res.ok === false) return res; const kOrd = (tipo === "produccion") ? K.ORD_PROD : K.ORD; const kVer = (tipo === "produccion") ? K.VER_PROD : K.VER; _fusionar(kOrd, res.orden); _fusionar(kVer, res.version); return res; } // Vuelve a bajar una orden completa (cabecera + todas sus versiones) y deja la // caché igual a la base. Se usa después de una marca en lote, donde la API // devuelve conteos y no filas. async function recargarOrden(tipo, idOrden) { const kOrd = (tipo === "produccion") ? K.ORD_PROD : K.ORD; const kVer = (tipo === "produccion") ? K.VER_PROD : K.VER; const res = await apiOrden("GET", "/" + tipo + "/" + encodeURIComponent(idOrden)); if (!res || res.ok === false) return res; _fusionar(kOrd, res.orden); const otras = read(kVer).filter(v => v[tipo === "produccion" ? "id_orden_de_produccion" : "id_orden_de_venta"] !== idOrden); write(kVer, otras.concat(res.versiones || [])); return res; } async function recargarOrdenes(tipo, ids) { for (const id of (ids || [])) await recargarOrden(tipo, id); } function nextId(arr, prefix, field) { field = field || "id"; const max = arr.reduce((m, r) => Math.max(m, parseInt(String(r[field] || "").replace(/\D/g, ""), 10) || 0), 0); return prefix + (max + 1); } function uid(prefix) { return prefix + Math.abs(Math.round((performance.now() * 1000)) % 1e9) + "_" + read(K.VER).length; } const nowISO = () => new Date().toISOString(); const today = () => new Date().toISOString().slice(0, 10); const periodoDe = (fecha) => (fecha || today()).slice(0, 7); // YYYY-MM // ── Derivaciones zona / RUT emisor ─────────────────────────────── function elById() { return Object.fromEntries(read(K.ELEM).map(e => [e.id, e])); } function prodById() { return Object.fromEntries(read(K.PROD).map(p => [p.id, p])); } function areaById() { return Object.fromEntries(read(K.AREA).map(a => [a.id, a])); } function rsById() { return Object.fromEntries(read(K.RS).map(r => [r.id, r])); } // zona de monetización de un elemento = producto.id_monetizacion (§1.6) function zonaDeElemento(elemento, _prodById) { if (!elemento) return null; const pb = _prodById || prodById(); const prod = pb[elemento.id_producto]; return prod ? prod.id_monetizacion : null; } function zonaDeOrden(orden, _elById, _prodById) { const eb = _elById || elById(); return zonaDeElemento(eb[orden.id_elemento_venta], _prodById); } // RUT emisor de una orden: elemento → producto → área.id_razon_social → razon_social function razonSocialDeOrden(orden, ctx) { ctx = ctx || {}; const eb = ctx.elById || elById(); const pb = ctx.prodById || prodById(); const ab = ctx.areaById || areaById(); const rb = ctx.rsById || rsById(); const zona = zonaDeOrden(orden, eb, pb); const area = ab[zona]; if (!area || !area.id_razon_social) return null; return rb[area.id_razon_social] || null; } function versionesDeOrden(id, _ver) { return (_ver || read(K.VER)).filter(v => v.id_orden_de_venta === id) .sort((a, b) => a.numero_version - b.numero_version); } function ultimaVersion(id, _ver) { const vs = versionesDeOrden(id, _ver); return vs[vs.length - 1] || null; } // Importes vigentes de una orden, leídos de su última versión (toda orden tiene ≥ v0). function valorOriginalDeOrden(o, _ver) { const v = ultimaVersion(o && o.id, _ver); return v ? (Number(v.valor_neto_moneda_original) || 0) : 0; } function montoDeOrden(o, _ver) { const v = ultimaVersion(o && o.id, _ver); return v ? (Number(v.valor_neto) || 0) : 0; } // La moneda de una orden: la de su última versión —que es el registro de en qué // moneda se facturó— y, si no hay versión, la del ELEMENTO. Ya no cae en // `o.id_moneda`: la cabecera dejó de guardarla (migración 53) porque todas las // órdenes de un elemento se emiten en la misma moneda, la del elemento. function monedaDeOrden(o, _ver) { const v = ultimaVersion(o && o.id, _ver); if (v && v.id_moneda) return v.id_moneda; const el = o ? elById()[o.id_elemento_venta] : null; return (el && el.id_moneda) || "CLP"; } function tipoCambioDeOrden(o, _ver) { const v = ultimaVersion(o && o.id, _ver); return v ? (Number(v.tipo_de_cambio) || 1) : 1; } // ═══════════════════════════════════════════════════════════════ // SAPConnector / FacturadorSII — adaptadores mock (keys-free) // ═══════════════════════════════════════════════════════════════ const SAPConnector = { contabilizarDocumento(doc) { return { ok: true, asiento_sap: "SAP-DOC-" + doc.id }; }, consultarPago(doc) { return { ok: true, esta_pagado: !!doc._simularPago }; }, }; // Clase de documento tributario (catálogo sc.clase_de_documento). El id ES el // código de clase (DA/DE/YC/ND/BV/DI/DO/DM) y es lo que guarda // documento_tributario.id_clase_de_documento. `incluye_iva` decide si el // documento lleva IVA: exentas y exportación no lo llevan. const CLASE_DOC = { FACTURA: "DA", FACTURA_EXENTA: "DE", NOTA_CREDITO: "YC", NOTA_DEBITO: "ND", BOLETA: "BV", FACTURA_EXPORTACION: "DI", NOTA_CREDITO_EXPORTACION: "DO", NOTA_DEBITO_EXPORTACION: "DM" }; const claseDocById = () => Object.fromEntries(read(K.CLASEDOC).map(c => [c.id, c])); // Sin catálogo cargado se asume CON IVA (el caso mayoritario) para no subfacturar. const claseIncluyeIva = (idClase, byId) => { const c = (byId || claseDocById())[idClase]; return c ? c.incluye_iva !== false : true; }; const FacturadorSII = { // Interacción 1 (cierre v2 §6): el sistema PIDE AL SISTEMA EXTERNO un número disponible // de factura/NC/ND. Aquí se SIMULA con esta función interna (no hay pool CAF): entrega el // siguiente correlativo por tipo + razón social. El externo siempre devuelve un número. solicitarNumero(tipo, idRazonSocial) { const base = { DA: 1000, YC: 500, ND: 300, DI: 110001, DE: 2000, BV: 700, DO: 120001, DM: 130001 }[tipo] || 1; const usados = read(K.DOC).filter(d => d.id_clase_de_documento === tipo && d.id_razon_social === idRazonSocial).length; return base + usados; }, // Interacción 2a: recepción del DTE en el SII. recepcionar(doc) { return { ok: true, track_id_sii: "SII-" + String(doc.id_clase_de_documento || "").toUpperCase() + "-" + doc.folio }; }, }; // ═══════════════════════════════════════════════════════════════ // OrdenService (§8.1) — único escritor de órdenes/versiones/docs // ═══════════════════════════════════════════════════════════════ const IVA_RATE = 0.19; function nuevaVersionRow(orden, prev, patch) { const ver = read(K.VER); const num = prev ? prev.numero_version + 1 : 1; // Modelo de valores (§3): si se entrega valor en moneda original y/o tipo de cambio, // el monto en pesos se CALCULA: valor_neto = valor_neto_moneda_original × tipo_de_cambio. const hayMoneda = patch.valor_neto_moneda_original != null || patch.tipo_de_cambio != null; const valOrig = patch.valor_neto_moneda_original != null ? Number(patch.valor_neto_moneda_original) : (prev ? prev.valor_neto_moneda_original : (patch.monto_total_neto != null ? Number(patch.monto_total_neto) : 0)); const tc = patch.tipo_de_cambio != null ? Number(patch.tipo_de_cambio) : (prev ? prev.tipo_de_cambio : 1); const monto = hayMoneda ? Math.round(valOrig * tc) : (patch.monto_total_neto != null ? Number(patch.monto_total_neto) : (prev ? prev.valor_neto : 0)); const prevOrig = prev ? (Number(prev.valor_neto_moneda_original) || 0) : 0; const prevMonto = prev ? (Number(prev.valor_neto) || 0) : 0; return { id: "VER" + (ver.length + 1) + "_" + num, id_orden_de_venta: orden.id, numero_version: num, fecha_version: today(), estado_version: patch.estado_version || "verificada", motivo_cambio: patch.motivo_cambio || (prev ? "precio" : "inicial"), id_moneda: patch.id_moneda || (prev ? prev.id_moneda : "CLP"), valor_neto_moneda_original: valOrig, valor_neto_moneda_original_incremental: valOrig - prevOrig, tipo_de_cambio: tc, valor_neto: monto, valor_neto_incremental: monto - prevMonto, esta_pagado: false, }; } const OrdenService = { K, ESTADOS, EST_BY, read, write, zonaDeElemento, zonaDeOrden, razonSocialDeOrden, versionesDeOrden, ultimaVersion, getOrden(id) { return read(K.ORD).find(o => o.id === id) || null; }, // Nombre del tipo de orden en la API. Todas las ventanas de hoy operan sobre // órdenes de VENTA; las de producción usan el mismo servicio pasando "produccion". _tipo(tipo) { return tipo === "produccion" ? "produccion" : "venta"; }, // Alta en 'creada' → cabecera + versión 0 (borrador). Toda orden nace con su v0. // El id lo sigue proponiendo el cliente (OP1, OP2, …) para no romper las pantallas // que lo usan al volver; el servidor lo acepta y rechaza el duplicado por PK. async crear(datos, tipo) { datos = datos || {}; const t = this._tipo(tipo); const ord = read(t === "produccion" ? K.ORD_PROD : K.ORD); const cuerpo = Object.assign({ id: nextId(ord, "OP", "id"), fecha_creacion: today(), fecha_imputacion: today(), estado: "creada", }, datos); // los importes y el pago viven en la versión: la API los ignora en la cabecera, // pero se limpian igual para no mandar ruido. delete cuerpo.esta_pagado; delete cuerpo.valor_neto; delete cuerpo.fecha_generacion; const res = await apiOrden("POST", "/" + t, cuerpo); return aplicarRespuesta(res, t); }, // Edición genérica. La API decide qué hacer con el cambio: en 'creada' corrige // la versión 0 en sitio (un borrador no genera historia contable); fuera de // 'creada' abre una versión nueva SI el cambio mueve el valor; un cambio que // sólo toca la cabecera (fechas, descripción) nunca versiona. async editar(id, cambios, tipo, motivo) { const t = this._tipo(tipo); const res = await apiOrden("PUT", "/" + t + "/" + encodeURIComponent(id), Object.assign({ motivo_cambio: motivo || "edicion" }, cambios || {})); return aplicarRespuesta(res, t); }, editarBorrador(id, cambios, tipo) { return this.editar(id, cambios, tipo, "borrador"); }, // Verificar → la API crea la v1 (incremental = total), fija fecha_imputacion y // deja la orden 'verificada'. El período cerrado se comprueba acá porque el // calendario de cierres vive en el navegador (CierreService); lo demás lo // valida el servidor. async verificar(id, opts, tipo) { opts = opts || {}; const t = this._tipo(tipo); const o = (t === "produccion" ? read(K.ORD_PROD) : read(K.ORD)).find(x => x.id === id); if (o && window.CierreService && window.CierreService.periodoCerrado(opts.fecha_imputacion || o.fecha_imputacion)) return { ok: false, error: "El período de servicio está cerrado." }; const res = await apiOrden("POST", "/" + t + "/" + encodeURIComponent(id) + "/verificar", opts); return aplicarRespuesta(res, t); }, // La tabla imputacion_pep_ceco fue eliminada, así que una orden de VENTA no // tiene dónde guardar su PEP/CECO. Los helpers quedan como punto único donde // reconectar el dato el día que exista orden_de_venta_pep_ceco (las de compra y // producción ya tienen su tabla). Hoy devuelven null en vez de inventar un valor. _imputarInicial(orden, version, opts) { /* sin tabla de imputación de venta */ }, pepCecoDeOrden(orden) { return null; }, proyectoDeOrden(orden) { return null; }, imputarPepCeco(idOrden, idPepCeco, monto) { console.warn("[motor] imputarPepCeco: las órdenes de venta no tienen tabla de imputación " + "(imputacion_pep_ceco fue eliminada); el PEP/CECO elegido no se persiste."); return { ok: false, error: "sin tabla de imputación para órdenes de venta" }; }, // Cambiar monto → la API decide si abre versión: sólo si el cambio MUEVE el // valor. Desde 'facturada' la deja 'por_refacturar' (el incremental es la nota // de débito/crédito). Desde 'creada' corrige la versión 0 en sitio. async cambiarMonto(id, patch, tipo) { patch = patch || {}; const t = this._tipo(tipo); const cuerpo = Object.assign({ motivo_cambio: patch.motivo_cambio || "precio" }, patch); // `monto_total_neto` era el nombre viejo del valor en pesos; la API sólo // acepta el valor en moneda original (el peso lo calcula ella). if (cuerpo.valor_neto_moneda_original == null && cuerpo.monto_total_neto != null) cuerpo.valor_neto_moneda_original = cuerpo.monto_total_neto; delete cuerpo.monto_total_neto; const res = await apiOrden("PUT", "/" + t + "/" + encodeURIComponent(id), cuerpo); if (res && res.ok !== false && res.versiono === false) res.noop = true; return aplicarRespuesta(res, t); }, async anular(id, tipo) { const t = this._tipo(tipo); const res = await apiOrden("POST", "/" + t + "/" + encodeURIComponent(id) + "/anular", { motivo_cambio: "anulacion" }); return aplicarRespuesta(res, t); }, // Marca contable en lote sobre versiones existentes (factura, provisión, cierre, // pago). No mueve importes — eso sólo pasa creando una versión. async marcar(idsVersion, marca, tipo) { const t = this._tipo(tipo); const res = await apiOrden("POST", "/" + t + "/marcar", Object.assign({ ids_version: idsVersion || [] }, marca || {})); if (res && res.ok !== false) await recargarOrdenes(t, res.ids_orden); return res; }, // Le da su versión inicial a las órdenes viejas que no tienen ninguna. Lo hace // el servidor y queda persistido; antes lo inventaba el navegador en cada carga. async backfillVersiones(tipo) { const t = this._tipo(tipo); const res = await apiOrden("POST", "/" + t + "/backfill-versiones"); if (res && res.ok !== false && res.creadas) { await recargarOrdenes(t, (res.detalle || []).map(d => d.id_orden)); } return res; }, // ── Facturación (§7.2) ───────────────────────────────────────── // tipo derivado del signo del incremental (§7.2a). tipoDocumentoDeVersion(v, orden) { if (v.estado_version === "cancelada") return v.valor_neto_incremental < 0 ? CLASE_DOC.NOTA_CREDITO : null; if ((orden && (orden.estado === "facturada" || orden.estado === "por_refacturar")) && v.numero_version > 1) { if (v.valor_neto_incremental > 0) return CLASE_DOC.NOTA_DEBITO; if (v.valor_neto_incremental < 0) return CLASE_DOC.NOTA_CREDITO; return null; // cero → no se emite (§7.2a) } const cli = read("sc.empresa").find(c => c.id === (window.SCFact ? window.SCFact.cliFactDeOrden(orden) : (orden && orden.id_empresa_facturacion))); return (cli && cli.id_pais && cli.id_pais !== "CL") ? CLASE_DOC.FACTURA_EXPORTACION : CLASE_DOC.FACTURA; }, // Versiones ya imputadas a un documento (tienen línea en pre_factura). // ¿Alguno de estos elementos tiene ya una orden de venta FACTURADA? // // Sostiene el candado de la moneda: la moneda se declara en la propuesta y la // heredan sus elementos y las órdenes de venta de esos elementos. Se puede // cambiar libremente mientras no se haya facturado nada; en cuanto sale el // primer documento tributario queda congelada, porque ese documento se emitió // en una moneda y la versión que lo respalda tiene que seguir diciendo la // misma. Facturada = tiene una versión imputada por una pre-factura. hayOrdenFacturada(idsElemento) { const ids = new Set((idsElemento || []).filter(Boolean)); if (!ids.size) return false; const ordenes = read(K.ORD).filter(o => ids.has(o.id_elemento_venta)); if (!ordenes.length) return false; const deOrden = new Set(ordenes.map(o => o.id)); const imputadas = this.versionesImputadas(); return read(K.VER).some(v => deOrden.has(v.id_orden_de_venta) && imputadas.has(v.id)); }, versionesImputadas() { return new Set(read(K.PF).map(p => p.id_version_orden_venta).filter(Boolean)); }, // Emitir (§7.2b, modelo de imputación): agrupa versiones facturables → 1 documento_tributario // (cabecera con iva/monto/tipo/cliente) + N líneas pre_factura (imputación version→documento, // monto_neto_imputado). Encadena folio → SII → SAP; versiones/orden → facturada. // Sustituye al antiguo par crearPreFactura()/emitirFactura(idPF): ya no hay borrador ni estado. async emitirFactura(idsVersion, opts) { opts = opts || {}; const ver = read(K.VER); const versiones = ver.filter(v => (idsVersion || []).includes(v.id)); if (!versiones.length) return { ok: false, error: "Sin versiones." }; const ordById = Object.fromEntries(read(K.ORD).map(o => [o.id, o])); const yaImputadas = this.versionesImputadas(); // Arrastre (§6): sumar las demás versiones no canceladas ni imputadas de las mismas órdenes. new Set(versiones.filter(v => v.estado_version !== "cancelada").map(v => v.id_orden_de_venta)).forEach(oid => { ver.filter(v => v.id_orden_de_venta === oid && !yaImputadas.has(v.id) && v.estado_version !== "cancelada" && v.estado_version !== "creada") .forEach(v => { if (!versiones.some(x => x.id === v.id)) versiones.push(v); }); }); versiones.sort((a, b) => a.numero_version - b.numero_version); const primera = ordById[versiones[0].id_orden_de_venta]; const rs = razonSocialDeOrden(primera); // Un documento = un solo cliente + una sola razón social + un solo tipo (§6). const clientes = new Set(versiones.map(v => window.SCFact ? window.SCFact.cliFactDeOrden(ordById[v.id_orden_de_venta]) : ordById[v.id_orden_de_venta].id_empresa_facturacion)); const rsSet = new Set(versiones.map(v => { const r = razonSocialDeOrden(ordById[v.id_orden_de_venta]); return r ? r.id : null; })); if (clientes.size > 1) return { ok: false, error: "Un documento = un solo cliente destino." }; if (rsSet.size > 1) return { ok: false, error: "Un documento = una sola razón social emisora." }; const tiposSet = new Set(versiones.map(v => this.tipoDocumentoDeVersion(v, ordById[v.id_orden_de_venta])).filter(Boolean)); if (tiposSet.size > 1) return { ok: false, error: "Un documento = un solo tipo (no mezclar factura con NC/ND)." }; const tipo = opts.tipo || [...tiposSet][0] || this.tipoDocumentoDeVersion(versiones[0], primera); if (!tipo) return { ok: false, error: "Incremental cero: no se emite documento." }; const neto = versiones.reduce((s, v) => s + v.valor_neto_incremental, 0); // El IVA lo decide el catálogo (incluye_iva), no una comparación contra un // string de tipo: así una clase exenta nueva no necesita tocar el motor. const iva = claseIncluyeIva(tipo) ? Math.round(neto * IVA_RATE) : 0; // Interacción 1: número disponible del sistema externo (simulado). const numero = FacturadorSII.solicitarNumero(tipo, rs ? rs.id : null); const doc = read(K.DOC); const row = { id: nextId(doc, "DOC"), id_clase_de_documento: tipo, folio: numero, id_razon_social: rs ? rs.id : null, id_empresa_facturacion: window.SCFact ? window.SCFact.cliFactDeOrden(primera) : primera.id_empresa_facturacion, fecha_emision: opts.fecha || today(), monto_total_neto: neto, iva, monto_total: neto + iva, retencion: opts.retencion || null, oc: opts.oc || "", das: opts.das || "", hes: opts.hes || "", track_id_sii: null, asiento_sap: null, esta_pagado: false, estado_externo: "numero_asignado", }; // Interacción 2a: SII. const sii = FacturadorSII.recepcionar(row); row.track_id_sii = sii.track_id_sii; row.estado_externo = "recepcion_sii_ok"; // Interacción 2b: SAP + reversa de provisión. const sap = SAPConnector.contabilizarDocumento(row); row.asiento_sap = sap.asiento_sap; row.estado_externo = "contabilizado_sap"; write(K.DOC, doc.concat([row])); // N líneas de pre_factura: sólo el vínculo versión → documento. El monto NO // se guarda (migración 54): siempre era el incremental de la versión, así // que el total del documento es la suma de los incrementales que cubre. // Una versión va en UN solo documento; un documento junta varias versiones. let pf = read(K.PF); const lineas = versiones.map(v => { const l = { id: nextId(pf, "PF"), id_version_orden_venta: v.id, id_documento_tributario: row.id }; pf = pf.concat([l]); return l; }); write(K.PF, pf); // Versiones incluidas → facturada; provisión reversada; orden → facturada. // Esto toca las tablas de órdenes, así que va por la API: acá NO se escribe. const marca = await this.marcar(versiones.map(v => v.id), { estado_version: "facturada", estado_provision: "reversada", estado_orden: "facturada", }); if (marca && marca.ok === false) return marca; return { ok: true, documento: row, lineas }; }, async sincronizarPago(idDoc, opts) { opts = opts || {}; const doc = read(K.DOC); const d = doc.find(x => x.id === idDoc); if (!d) return { ok: false, error: "Documento inexistente." }; const res = SAPConnector.consultarPago(Object.assign({}, d, { _simularPago: opts.pagado !== false })); d.esta_pagado = res.esta_pagado; if (res.esta_pagado) d.estado_externo = "pagado"; write(K.DOC, doc); if (res.esta_pagado) { // Las versiones cubiertas por este documento quedan pagadas, y su orden // pasa a 'pagada' SÓLO si estaba 'facturada' (una 'por_refacturar' tiene // un saldo pendiente y no se da por pagada). Va por la API. const idsVer = read(K.PF).filter(p => p.id_documento_tributario === idDoc) .map(p => p.id_version_orden_venta).filter(Boolean); const marca = await this.marcar(idsVer, { esta_pagado: true, estado_orden: "pagada", estado_orden_desde: "facturada", }); if (marca && marca.ok === false) return marca; } return { ok: true, documento: d }; }, // ── Provisión a SAP: NO es de la orden de venta ───────────────── // `universoProvision` y `generarProvisionSAP` vivían acá y corrían sobre // versiones de VENTA. Se retiran: la provisión a SAP, la verificación y el // cierre mensual son del circuito de la orden de PRODUCCIÓN. El de la venta // es más corto —se crea la orden, se emite el documento tributario, se // paga— y su estado se lee de ese documento, no de un campo de provisión. // // La migración 52 suelta `estado_provision`, `id_sap_feed_provision` y // `id_cierre_mensual_reconocido` de sc.version_orden_venta. Sin ellas este // código habría seguido corriendo con su filtro // `estado_provision !== "provisionada"` siempre verdadero: cada versión de // venta habría entrado en cada lote de provisión, una y otra vez. No tenía // llamador, así que no se pierde ninguna funcionalidad en uso. // // Cuando se arme la provisión de la orden de producción, va sobre // sc.version_orden_produccion y no acá. }; // ═══════════════════════════════════════════════════════════════ // CierreService (§8.2, R4) // ═══════════════════════════════════════════════════════════════ // Parámetros del cierre: fijos. El día concreto se define al correr el cierre (GC-06), // no en un catálogo. Aquí sólo el default (5.º día hábil del mes siguiente) que GC-06 pre-rellena. function parametroCierre() { return { dia_habil_cierre: 5, cierre_en_mes_siguiente: true, incluir_sabado: false, tolerancia_redondeo_clp: 1 }; } const CierreService = { K, read, write, parametro: parametroCierre, // n-ésimo día hábil de un mes (YYYY-MM) según parámetros + feriados. calcularFechaCierre(periodoServicio) { const par = parametroCierre(); const feriados = new Set(read(K.FER).filter(f => f.activo !== false).map(f => f.fecha)); const [y, m] = periodoServicio.split("-").map(Number); let mesCierre = m + (par.cierre_en_mes_siguiente ? 1 : 0), anio = y; if (mesCierre > 12) { mesCierre = 1; anio++; } let habiles = 0, dia = 0; while (dia < 45) { dia++; const d = new Date(Date.UTC(anio, mesCierre - 1, dia)); if (d.getUTCMonth() !== mesCierre - 1) break; const dow = d.getUTCDay(); // 0 dom, 6 sáb const esHabil = dow !== 0 && (dow !== 6 || par.incluir_sabado); const fs = d.toISOString().slice(0, 10); if (esHabil && !feriados.has(fs)) { habiles++; if (habiles >= (par.dia_habil_cierre || 5)) return fs; } } return new Date(Date.UTC(anio, mesCierre, 0)).toISOString().slice(0, 10); // fin de mes fallback }, periodoCerrado(fechaServicio) { if (!fechaServicio) return false; const per = periodoDe(fechaServicio); return read(K.CM).some(c => c.periodo === per && c.estado_cierre === "cerrado"); }, // Corte de información del último cierre 'cerrado' de este período (o null en el // primer cierre). La ventana de información del PRÓXIMO cierre arranca justo // después de este corte → encadenado automático, sin gaps ni solapes. Esto es lo // que vuelve consistente el cierre con sólo 3 parámetros (inicio/fin servicio + // fecha de cierre): el inicio de la ventana de información es el último corte. ultimoCorte(periodo) { let max = null; read(K.CM).forEach(c => { if (c.periodo !== periodo || c.estado_cierre !== "cerrado") return; const f = c.fecha_fin_info || c.fecha_cierre_calculada; if (f && (!max || f > max)) max = f; }); return max; }, // Clasificación por versión, AS-OF fechaCierre (§7.5). Devuelve grupos + totales. previsualizar(periodo, fechaCierre) { fechaCierre = fechaCierre || this.calcularFechaCierre(periodo); const inicio = periodo + "-01"; const ver = read(K.VER); const ordById = Object.fromEntries(read(K.ORD).map(o => [o.id, o])); const pfByVer = Object.fromEntries(read(K.PF).filter(p => p.id_version_orden_venta).map(p => [p.id_version_orden_venta, p])); const docById = Object.fromEntries(read(K.DOC).map(d => [d.id, d])); const v0ByOrden = {}; ver.forEach(v => { if (v.numero_version === 0) v0ByOrden[v.id_orden_de_venta] = v; }); const eb = elById(), pb = prodById(); const grupos = { Pendiente: [], Provision: [], Diferencias: [], Facturado: [] }; // Pendiente = órdenes 'creada' (sólo su v0 borrador) con fecha_imputacion en el período. read(K.ORD).forEach(o => { if (o.estado !== "creada") return; if (periodoDe(o.fecha_imputacion) !== periodo) return; const kv = o.key_value_data_produccion || {}; const v0 = v0ByOrden[o.id] || {}; grupos.Pendiente.push({ id_orden: o.id, numero_version: null, zona: zonaDeOrden(o, eb, pb), monto: Number(v0.valor_neto_moneda_original) || Number(kv.valor_estimado) || 0, grupo: "Pendiente" }); }); // Ventana de información (auto-encadenada): sólo las versiones grabadas DESPUÉS // del corte del último cierre de este período y HASTA la fecha de cierre. Sin // límite inferior, cierres sucesivos re-contarían lo ya reconocido (inconsistente). const inicioInfo = this.ultimoCorte(periodo); ver.forEach(v => { const o = ordById[v.id_orden_de_venta]; if (!o) return; if (v.estado_version === "creada") return; // v0 borrador: ya contado en Pendiente if (periodoDe(o.fecha_imputacion) !== periodo) return; if (inicioInfo && v.fecha_version <= inicioInfo) return; // límite inferior (exclusivo) if (v.fecha_version > fechaCierre) return; // límite superior (inclusivo) // documento (si la versión está imputada a un documento con fecha ≤ cierre) const pf = pfByVer[v.id] || null; const doc = pf && pf.id_documento_tributario ? docById[pf.id_documento_tributario] : null; const facturadaAsOf = doc && doc.fecha_emision && doc.fecha_emision <= fechaCierre; let grupo; if (facturadaAsOf) grupo = "Facturado"; else if (o.estado === "por_refacturar") grupo = "Diferencias"; else grupo = "Provision"; grupos[grupo].push({ id_orden: o.id, numero_version: v.numero_version, zona: zonaDeOrden(o, eb, pb), monto: v.valor_neto_incremental, grupo }); }); const sum = (arr) => arr.reduce((s, r) => s + Number(r.monto || 0), 0); const total_pendiente = sum(grupos.Pendiente), total_provision = sum(grupos.Provision), total_diferencias = sum(grupos.Diferencias), total_facturado = sum(grupos.Facturado); const ingreso_reconocido = total_provision + total_diferencias + total_facturado; return { periodo, fechaCierre, inicioInfo, finInfo: fechaCierre, grupos, total_pendiente, total_provision, total_diferencias, total_facturado, ingreso_reconocido }; }, // Cuadratura (§7.5): ingreso reconocido vs lo contabilizado. // // El lado contabilizado son los DOCUMENTOS TRIBUTARIOS. La provisión a SAP ya // no suma acá: sus tablas (sap_feed_provision / _linea) se retiraron en la // migración 56 y nada las escribía desde que la provisión dejó de ser del // circuito de la orden de venta. Cuando se arme la de la orden de producción, // vuelve a entrar en esta cuenta. auditarCuadratura(prev) { const par = parametroCierre(); const docs = read(K.DOC).filter(d => d.fecha_emision && d.fecha_emision <= prev.fechaCierre); const docNeto = docs.reduce((s, d) => s + (d.id_clase_de_documento === CLASE_DOC.NOTA_CREDITO ? -1 : 1) * Number(d.monto_total_neto || 0), 0); // Guard de rollout: si aún no hay lado contabilizado, no bloquear. if (docNeto === 0) return { cuadra: true, diferencia: 0, contabilizado: 0, rollout: true }; const contabilizado = docNeto; const diferencia = prev.ingreso_reconocido - contabilizado; return { cuadra: Math.abs(diferencia) <= (par.tolerancia_redondeo_clp || 1), diferencia, contabilizado, rollout: false }; }, ejecutar(periodo, fechaCierre) { const prev = this.previsualizar(periodo, fechaCierre); const audit = this.auditarCuadratura(prev); if (!audit.cuadra) return { ok: false, error: "No cuadra: diferencia " + audit.diferencia + " CLP.", prev, audit }; const cm = read(K.CM); // Encadenado: se permite re-cerrar un período en una fecha POSTERIOR (nuevo // incremento de información); sólo se bloquea si ya hay cierre hasta esa fecha. const corte = this.ultimoCorte(periodo); if (corte && prev.fechaCierre <= corte) return { ok: false, error: "Este período ya tiene cierre hasta " + corte + "." }; const finMes = new Date(Date.UTC(Number(periodo.slice(0, 4)), Number(periodo.slice(5, 7)), 0)).toISOString().slice(0, 10); const row = { id: nextId(cm, "CM"), periodo, fecha_inicio_servicio: periodo + "-01", fecha_fin_servicio: finMes, fecha_inicio_info: prev.inicioInfo || "", fecha_fin_info: prev.fechaCierre, fecha_cierre_calculada: prev.fechaCierre, fecha_cierre_efectiva: today(), dia_habil_usado: parametroCierre().dia_habil_cierre, estado_cierre: "cerrado", id_usuario: "maqueta", total_pendiente: prev.total_pendiente, total_provision: prev.total_provision, total_diferencias: prev.total_diferencias, total_facturado: prev.total_facturado, ingreso_reconocido: prev.ingreso_reconocido, total_arrastre_saliente: prev.total_pendiente, cuadra: true, diferencia_cuadratura: audit.diferencia, hash_auditoria: "H" + Math.abs(Math.round(prev.ingreso_reconocido)) + "-" + periodo, }; cm.push(row); write(K.CM, cm); // snapshot detalle const cd = read(K.CD); ["Pendiente", "Provision", "Diferencias", "Facturado"].forEach(g => prev.grupos[g].forEach(r => cd.push({ id: nextId(cd, "CD"), id_cierre_mensual: row.id, id_orden_de_venta: r.id_orden, numero_version: r.numero_version, id_zona_monetizacion: r.zona, grupo: g, monto: r.monto, periodo_origen: periodo, es_arrastre: false }))); write(K.CD, cd); const ca = read(K.CA); ca.push({ id: nextId(ca, "CA"), id_cierre_mensual: row.id, timestamp: nowISO(), id_usuario: "maqueta", accion: "cerrar", ingreso_reconocido: prev.ingreso_reconocido, diferencia: audit.diferencia, cuadra: true, nota: "" }); write(K.CA, ca); return { ok: true, cierre: row, prev, audit }; }, reabrir(idCM, motivo) { const cm = read(K.CM); const c = cm.find(x => x.id === idCM); if (!c) return { ok: false, error: "Cierre inexistente." }; c.estado_cierre = "reabierto"; write(K.CM, cm); const ca = read(K.CA); ca.push({ id: nextId(ca, "CA"), id_cierre_mensual: idCM, timestamp: nowISO(), id_usuario: "maqueta", accion: "reabrir", nota: motivo || "" }); write(K.CA, ca); return { ok: true }; }, }; // PropuestaValidacion (§8.3, R1) se eliminó junto con las columnas que // comparaba: la propuesta ya no declara un valor_total_neto propio contra el // cual cuadrar la suma de sus zonas, ni una tolerancia para esa diferencia // (migración 37). El valor de la propuesta ES la suma de sus elementos // (window.SCPropuesta.valor), así que no queda nada que cuadrar y el gate de // «Ganada» que dependía de eso desapareció con ella. // ── Publicar ───────────────────────────────────────────────────── window.OrdenService = OrdenService; window.CierreService = CierreService; window.SAPConnector = SAPConnector; window.FacturadorSII = FacturadorSII; window.SCMotor = { CLASE_DOC, claseDocById, claseIncluyeIva, pepCecoDeOrden: (o) => window.OrdenService.pepCecoDeOrden(o), proyectoDeOrden: (o) => window.OrdenService.proyectoDeOrden(o), K, ESTADOS, EST_BY, read, write, nextId, fmtCLP: (n) => "$" + (Math.round(Number(n) || 0)).toLocaleString("es-CL"), zonaDeElemento, zonaDeOrden, razonSocialDeOrden, versionesDeOrden, ultimaVersion, valorOriginalDeOrden, montoDeOrden, monedaDeOrden, tipoCambioDeOrden, elById, prodById, areaById, rsById, periodoDe, today, }; })();