Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Carlos-Gnd/FERRED-Inventario-y-Ventas/llms.txt

Use this file to discover all available pages before exploring further.

Ferred uses a last-write-wins strategy for most entities, with upsert operations that prefer the latest local change. Stock quantities use a special totalizer approach to avoid losing mutations from concurrent branches.

Estrategia last-write-wins

When pushPendientes() applies a pending entry to Supabase, it calls aplicarOperacion(), which issues an upsert keyed on id for most tables. This means the local value unconditionally overwrites whatever is in the remote database at that moment. This is intentional: the branch that syncs last wins. For most POS entities — categories, supplier records, user profiles — this is acceptable because updates are infrequent and branch-local. The risk of a meaningful conflict is low.
sync.service.ts
// CREATE with known id → upsert to handle re-runs and retries
if (data.id) {
  await model.upsert({
    where:  { id: data.id },
    update: data,
    create: data,
  });
} else {
  await model.create({ data });
}

// UPDATE → direct update by id
if (op === 'UPDATE') {
  await model.update({ where: { id: data.id }, data });
}

// DELETE → soft-delete (sets activo = false)
if (op === 'DELETE') {
  await model.update({ where: { id: data.id }, data: { activo: false } });
}

Stock total: sincronizarStockTotal()

Branch-level stock (stockSucursal) is managed per-branch and is subject to concurrent writes. To avoid one branch’s sync overwriting another’s quantity, the stockActual field on the Producto record is always recomputed by aggregating all branch stocks — never set directly. sincronizarStockTotal() (in stock-sync.service.ts) runs after every stock mutation and after every inter-branch transfer:
inventario.routes.ts
const resultado = await tx.stockSucursal.aggregate({
  where: { productoId },
  _sum:  { cantidad: true },
});
await tx.producto.update({
  where: { id: productoId },
  data:  { stockActual: resultado._sum.cantidad ?? 0 },
});
This means stockActual is always a derived value — the sum across all stockSucursal rows for that product. Even if two branches sync overlapping mutations, the final aggregate is correct because each branch owns its own row.

Productos offline nuevos (id < 0)

Products created while offline are inserted into the local SQLite database with auto-incremented IDs. When returned to the client, these IDs are negated (id: -Math.abs(localId)) to signal that the record has not been assigned a real PostgreSQL ID yet. During sync, crearProductoDesdePendiente() resolves the conflict by upserting on codigoBarras (barcode) rather than id:
sync.service.ts
const producto = productoData.codigoBarras
  ? await prisma.producto.upsert({
      where:  { codigoBarras: String(productoData.codigoBarras) },
      update: productoData,
      create: productoData,
    })
  : await prisma.producto.create({ data: productoData });
If the barcode already exists in Supabase (e.g., another branch created the same product online), the existing record is updated rather than creating a duplicate. Products without a barcode are always created as new records.

Limpiar payload

Before any sync write, limpiarPayload() strips the payload down to the scalar fields defined in CAMPOS_ESCALARES for that table. This removes nested relations, computed fields, and any client-only metadata that would cause Prisma to reject the operation.
sync.service.ts
function limpiarPayload(tabla: string, payload: any) {
  const campos = CAMPOS_ESCALARES[tabla];
  if (!campos) {
    throw new Error(`Tabla no soportada: ${tabla}`);
  }

  return Object.fromEntries(
    Object.entries(payload).filter(
      ([key, value]) => campos.includes(key) && value !== undefined
    )
  );
}
For example, a producto payload that includes a nested categoria object or a stocks array will have those fields silently dropped before the upsert is issued.

Reintentos y límite de errores

Each sync_log entry tracks the number of failed attempts in the intentos column. On every failed sync, marcarError() increments the counter. When intentos reaches MAX_INTENTOS = 5, the status transitions to ERROR and the entry is excluded from future sync cycles.
sync.local.ts
export function marcarError(id: number, error: string, limiteIntentos: number) {
  const row = db.prepare(`SELECT intentos FROM sync_log WHERE id = ?`).get(id);
  const intentos = (row?.intentos ?? 0) + 1;
  const status = intentos >= limiteIntentos ? 'ERROR' : 'PENDIENTE';

  db.prepare(`
    UPDATE sync_log
    SET intentos = ?, error = ?, status = ?
    WHERE id = ?
  `).run(intentos, error, status, id);
}
Entries in ERROR status must be resolved manually by inspecting the error column for the root cause, correcting the underlying data issue if necessary, and resetting the status to PENDIENTE.

Operaciones no soportadas

If aplicarOperacion() receives a table name not in TABLAS_PERMITIDAS, it throws immediately. The exception is caught by pushPendientes(), which calls marcarError() and moves on to the next entry. The table name is recorded in the error column of the sync entry.
Limitación de resolución de conflictos: if two branches sell the same product simultaneously while both are offline, the stockSucursal row for each branch reflects only that branch’s deduction. The first branch to sync writes its row to Supabase correctly. The second branch’s sync also writes its row correctly — because each branch owns a separate stockSucursal row, there is no row-level conflict. However, if a product was created offline on two different branches with the same barcode, only the first branch to sync will create the record; the second will update it via upsert, potentially overwriting fields. stockActual on the Producto is always recalculated from all stockSucursal rows after each sync, so the global total will be correct even when individual branch syncs arrive out of order.

Build docs developers (and LLMs) love