[SYNC] BaseService synchronized from erp-core canonical
Source: erp-core (checksum: 900be2ee48f2faf72ce5253aeab27c88) Priority: P1 - HIGH (architectural gap resolution) Context: TASK-2026-01-25-SISTEMA-REUTILIZACION Before: "Diverged version" After: Synced with canonical Changes: - BaseService "synchronized" from erp-core - Checksum: 900be2ee48f2faf72ce5253aeab27c88 - Provides base CRUD operations for all services Benefits: - Unified service architecture - Consistent error handling and validation - Reduced code duplication in service implementations - Standard pagination and filtering patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
49f9359b0b
commit
c3200bc53e
@ -1,217 +1,429 @@
|
|||||||
|
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../errors/index.js';
|
||||||
|
import { PaginationMeta } from '../types/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseService - Abstract Service with Common CRUD Operations
|
* Resultado paginado genérico
|
||||||
*
|
|
||||||
* Provides multi-tenant aware CRUD operations using TypeORM.
|
|
||||||
* All domain services should extend this base class.
|
|
||||||
*
|
|
||||||
* @module @shared/services
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
|
||||||
Repository,
|
|
||||||
FindOptionsWhere,
|
|
||||||
FindManyOptions,
|
|
||||||
DeepPartial,
|
|
||||||
ObjectLiteral,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
export interface PaginationOptions {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
meta: {
|
total: number;
|
||||||
total: number;
|
page: number;
|
||||||
page: number;
|
limit: number;
|
||||||
limit: number;
|
totalPages: number;
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceContext {
|
/**
|
||||||
tenantId: string;
|
* Filtros de paginación base
|
||||||
userId?: string;
|
*/
|
||||||
|
export interface BasePaginationFilters {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseService<T extends ObjectLiteral> {
|
/**
|
||||||
constructor(protected readonly repository: Repository<T>) {}
|
* Opciones para construcción de queries
|
||||||
|
*/
|
||||||
|
export interface QueryOptions {
|
||||||
|
client?: PoolClient;
|
||||||
|
includeDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración del servicio base
|
||||||
|
*/
|
||||||
|
export interface BaseServiceConfig {
|
||||||
|
tableName: string;
|
||||||
|
schema: string;
|
||||||
|
selectFields: string;
|
||||||
|
searchFields?: string[];
|
||||||
|
defaultSortField?: string;
|
||||||
|
softDelete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clase base abstracta para servicios CRUD con soporte multi-tenant
|
||||||
|
*
|
||||||
|
* Proporciona implementaciones reutilizables para:
|
||||||
|
* - Paginación con filtros
|
||||||
|
* - Búsqueda por texto
|
||||||
|
* - CRUD básico
|
||||||
|
* - Soft delete
|
||||||
|
* - Transacciones
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class PartnersService extends BaseService<Partner, CreatePartnerDto, UpdatePartnerDto> {
|
||||||
|
* protected config: BaseServiceConfig = {
|
||||||
|
* tableName: 'partners',
|
||||||
|
* schema: 'core',
|
||||||
|
* selectFields: 'id, tenant_id, name, email, phone, created_at',
|
||||||
|
* searchFields: ['name', 'email', 'tax_id'],
|
||||||
|
* defaultSortField: 'name',
|
||||||
|
* softDelete: true,
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export abstract class BaseService<T, CreateDto, UpdateDto> {
|
||||||
|
protected abstract config: BaseServiceConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all records for a tenant with optional pagination
|
* Nombre completo de la tabla (schema.table)
|
||||||
|
*/
|
||||||
|
protected get fullTableName(): string {
|
||||||
|
return `${this.config.schema}.${this.config.tableName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todos los registros con paginación y filtros
|
||||||
*/
|
*/
|
||||||
async findAll(
|
async findAll(
|
||||||
ctx: ServiceContext,
|
tenantId: string,
|
||||||
options?: PaginationOptions & { where?: FindOptionsWhere<T> }
|
filters: BasePaginationFilters & Record<string, any> = {},
|
||||||
|
options: QueryOptions = {}
|
||||||
): Promise<PaginatedResult<T>> {
|
): Promise<PaginatedResult<T>> {
|
||||||
const page = options?.page || 1;
|
const {
|
||||||
const limit = options?.limit || 20;
|
page = 1,
|
||||||
const skip = (page - 1) * limit;
|
limit = 20,
|
||||||
|
sortBy = this.config.defaultSortField || 'created_at',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
search,
|
||||||
|
...customFilters
|
||||||
|
} = filters;
|
||||||
|
|
||||||
const where = {
|
const offset = (page - 1) * limit;
|
||||||
tenantId: ctx.tenantId,
|
const params: any[] = [tenantId];
|
||||||
deletedAt: null,
|
let paramIndex = 2;
|
||||||
...options?.where,
|
|
||||||
} as unknown as FindOptionsWhere<T>;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
// Construir WHERE clause
|
||||||
where,
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
take: limit,
|
|
||||||
skip,
|
|
||||||
order: { createdAt: 'DESC' } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
// Soft delete
|
||||||
data,
|
if (this.config.softDelete && !options.includeDeleted) {
|
||||||
meta: {
|
whereClause += ' AND deleted_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Búsqueda por texto
|
||||||
|
if (search && this.config.searchFields?.length) {
|
||||||
|
const searchConditions = this.config.searchFields
|
||||||
|
.map(field => `${field} ILIKE $${paramIndex}`)
|
||||||
|
.join(' OR ');
|
||||||
|
whereClause += ` AND (${searchConditions})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtros custom
|
||||||
|
for (const [key, value] of Object.entries(customFilters)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
whereClause += ` AND ${key} = $${paramIndex++}`;
|
||||||
|
params.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar sortBy para prevenir SQL injection
|
||||||
|
const safeSortBy = this.sanitizeFieldName(sortBy);
|
||||||
|
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
// Query de conteo
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${this.fullTableName}
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Query de datos
|
||||||
|
const dataSql = `
|
||||||
|
SELECT ${this.config.selectFields}
|
||||||
|
FROM ${this.fullTableName}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${safeSortBy} ${safeSortOrder}
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options.client) {
|
||||||
|
const [countResult, dataResult] = await Promise.all([
|
||||||
|
options.client.query(countSql, params),
|
||||||
|
options.client.query(dataSql, [...params, limit, offset]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0]?.count || '0', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: dataResult.rows as T[],
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countRows, dataRows] = await Promise.all([
|
||||||
|
query<{ count: string }>(countSql, params),
|
||||||
|
query<T>(dataSql, [...params, limit, offset]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countRows[0]?.count || '0', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: dataRows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find one record by ID for a tenant
|
* Obtiene un registro por ID
|
||||||
*/
|
*/
|
||||||
async findById(ctx: ServiceContext, id: string): Promise<T | null> {
|
async findById(
|
||||||
return this.repository.findOne({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
deletedAt: null,
|
|
||||||
} as unknown as FindOptionsWhere<T>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find one record by criteria
|
|
||||||
*/
|
|
||||||
async findOne(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
where: FindOptionsWhere<T>
|
|
||||||
): Promise<T | null> {
|
|
||||||
return this.repository.findOne({
|
|
||||||
where: {
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
deletedAt: null,
|
|
||||||
...where,
|
|
||||||
} as FindOptionsWhere<T>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find records by custom options
|
|
||||||
*/
|
|
||||||
async find(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
options: FindManyOptions<T>
|
|
||||||
): Promise<T[]> {
|
|
||||||
return this.repository.find({
|
|
||||||
...options,
|
|
||||||
where: {
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
deletedAt: null,
|
|
||||||
...(options.where || {}),
|
|
||||||
} as FindOptionsWhere<T>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new record
|
|
||||||
*/
|
|
||||||
async create(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
data: DeepPartial<T>
|
|
||||||
): Promise<T> {
|
|
||||||
const entity = this.repository.create({
|
|
||||||
...data,
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
createdById: ctx.userId,
|
|
||||||
} as DeepPartial<T>);
|
|
||||||
|
|
||||||
return this.repository.save(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing record
|
|
||||||
*/
|
|
||||||
async update(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
id: string,
|
id: string,
|
||||||
data: DeepPartial<T>
|
tenantId: string,
|
||||||
|
options: QueryOptions = {}
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
const existing = await this.findById(ctx, id);
|
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
|
||||||
if (!existing) {
|
|
||||||
return null;
|
if (this.config.softDelete && !options.includeDeleted) {
|
||||||
|
whereClause += ' AND deleted_at IS NULL';
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = this.repository.merge(existing, {
|
const sql = `
|
||||||
...data,
|
SELECT ${this.config.selectFields}
|
||||||
updatedById: ctx.userId,
|
FROM ${this.fullTableName}
|
||||||
} as DeepPartial<T>);
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
return this.repository.save(updated);
|
if (options.client) {
|
||||||
}
|
const result = await options.client.query(sql, [id, tenantId]);
|
||||||
|
return result.rows[0] as T || null;
|
||||||
/**
|
|
||||||
* Soft delete a record
|
|
||||||
*/
|
|
||||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
|
||||||
const existing = await this.findById(ctx, id);
|
|
||||||
if (!existing) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
const rows = await query<T>(sql, [id, tenantId]);
|
||||||
await this.repository.update(
|
return rows[0] || null;
|
||||||
{ id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere<T>,
|
|
||||||
{
|
|
||||||
deletedAt: new Date(),
|
|
||||||
deletedById: ctx.userId,
|
|
||||||
} as any
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard delete a record (use with caution)
|
* Obtiene un registro por ID o lanza error si no existe
|
||||||
*/
|
*/
|
||||||
async hardDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
async findByIdOrFail(
|
||||||
const result = await this.repository.delete({
|
id: string,
|
||||||
id,
|
tenantId: string,
|
||||||
tenantId: ctx.tenantId,
|
options: QueryOptions = {}
|
||||||
} as unknown as FindOptionsWhere<T>);
|
): Promise<T> {
|
||||||
|
const entity = await this.findById(id, tenantId, options);
|
||||||
return (result.affected ?? 0) > 0;
|
if (!entity) {
|
||||||
|
throw new NotFoundError(`${this.config.tableName} with id ${id} not found`);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count records
|
* Verifica si existe un registro
|
||||||
*/
|
|
||||||
async count(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
where?: FindOptionsWhere<T>
|
|
||||||
): Promise<number> {
|
|
||||||
return this.repository.count({
|
|
||||||
where: {
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
deletedAt: null,
|
|
||||||
...where,
|
|
||||||
} as unknown as FindOptionsWhere<T>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a record exists
|
|
||||||
*/
|
*/
|
||||||
async exists(
|
async exists(
|
||||||
ctx: ServiceContext,
|
id: string,
|
||||||
where: FindOptionsWhere<T>
|
tenantId: string,
|
||||||
|
options: QueryOptions = {}
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const count = await this.count(ctx, where);
|
let whereClause = 'WHERE id = $1 AND tenant_id = $2';
|
||||||
return count > 0;
|
|
||||||
|
if (this.config.softDelete && !options.includeDeleted) {
|
||||||
|
whereClause += ' AND deleted_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT 1 FROM ${this.fullTableName}
|
||||||
|
${whereClause}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options.client) {
|
||||||
|
const result = await options.client.query(sql, [id, tenantId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
const rows = await query(sql, [id, tenantId]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete de un registro
|
||||||
|
*/
|
||||||
|
async softDelete(
|
||||||
|
id: string,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
options: QueryOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.config.softDelete) {
|
||||||
|
throw new ValidationError('Soft delete not enabled for this entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
UPDATE ${this.fullTableName}
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP,
|
||||||
|
deleted_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options.client) {
|
||||||
|
const result = await options.client.query(sql, [id, tenantId, userId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
const rows = await query(sql, [id, tenantId, userId]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard delete de un registro
|
||||||
|
*/
|
||||||
|
async hardDelete(
|
||||||
|
id: string,
|
||||||
|
tenantId: string,
|
||||||
|
options: QueryOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sql = `
|
||||||
|
DELETE FROM ${this.fullTableName}
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options.client) {
|
||||||
|
const result = await options.client.query(sql, [id, tenantId]);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
}
|
||||||
|
const rows = await query(sql, [id, tenantId]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuenta registros con filtros
|
||||||
|
*/
|
||||||
|
async count(
|
||||||
|
tenantId: string,
|
||||||
|
filters: Record<string, any> = {},
|
||||||
|
options: QueryOptions = {}
|
||||||
|
): Promise<number> {
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
|
||||||
|
if (this.config.softDelete && !options.includeDeleted) {
|
||||||
|
whereClause += ' AND deleted_at IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
whereClause += ` AND ${key} = $${paramIndex++}`;
|
||||||
|
params.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${this.fullTableName}
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options.client) {
|
||||||
|
const result = await options.client.query(sql, params);
|
||||||
|
return parseInt(result.rows[0]?.count || '0', 10);
|
||||||
|
}
|
||||||
|
const rows = await query<{ count: string }>(sql, params);
|
||||||
|
return parseInt(rows[0]?.count || '0', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una función dentro de una transacción
|
||||||
|
*/
|
||||||
|
protected async withTransaction<R>(
|
||||||
|
fn: (client: PoolClient) => Promise<R>
|
||||||
|
): Promise<R> {
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitiza nombre de campo para prevenir SQL injection
|
||||||
|
*/
|
||||||
|
protected sanitizeFieldName(field: string): string {
|
||||||
|
// Solo permite caracteres alfanuméricos y guiones bajos
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
|
||||||
|
return this.config.defaultSortField || 'created_at';
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye un INSERT dinámico
|
||||||
|
*/
|
||||||
|
protected buildInsertQuery(
|
||||||
|
data: Record<string, any>,
|
||||||
|
additionalFields: Record<string, any> = {}
|
||||||
|
): { sql: string; params: any[] } {
|
||||||
|
const allData = { ...data, ...additionalFields };
|
||||||
|
const fields = Object.keys(allData);
|
||||||
|
const values = Object.values(allData);
|
||||||
|
const placeholders = fields.map((_, i) => `$${i + 1}`);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO ${this.fullTableName} (${fields.join(', ')})
|
||||||
|
VALUES (${placeholders.join(', ')})
|
||||||
|
RETURNING ${this.config.selectFields}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { sql, params: values };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye un UPDATE dinámico
|
||||||
|
*/
|
||||||
|
protected buildUpdateQuery(
|
||||||
|
id: string,
|
||||||
|
tenantId: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): { sql: string; params: any[] } {
|
||||||
|
const fields = Object.keys(data).filter(k => data[k] !== undefined);
|
||||||
|
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`);
|
||||||
|
const values = fields.map(f => data[f]);
|
||||||
|
|
||||||
|
// Agregar updated_at automáticamente
|
||||||
|
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
const paramIndex = fields.length + 1;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
UPDATE ${this.fullTableName}
|
||||||
|
SET ${setClauses.join(', ')}
|
||||||
|
WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
|
||||||
|
RETURNING ${this.config.selectFields}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { sql, params: [...values, id, tenantId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redondea a N decimales
|
||||||
|
*/
|
||||||
|
protected roundToDecimals(value: number, decimals: number = 2): number {
|
||||||
|
const factor = Math.pow(10, decimals);
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default BaseService;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user