feat(papeleria): aprobación independiente por cliente

- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
This commit is contained in:
Horux Dev
2026-05-29 00:36:33 +00:00
parent e01422e443
commit 9b535354fb
8 changed files with 479 additions and 68 deletions

View File

@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(),
requiereAprobacionCliente: z.boolean(),
archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100),
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
export async function upload(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
}
const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64');
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
anio: data.anio,
mes: data.mes,
requiereAprobacion: data.requiereAprobacion,
requiereAprobacionCliente: data.requiereAprobacionCliente,
archivo,
archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime,
subidoPor: req.user!.userId,
});
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
);
}
if (item.requiereAprobacionCliente) {
notifyClienteAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
);
}
res.status(201).json(item);
} catch (error: any) {
@@ -74,13 +77,20 @@ const listSchema = z.object({
export async function list(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const q = listSchema.parse(req.query);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(q.contribuyenteId)) {
return res.json([]);
}
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado,
entidadIds,
userRole: req.user!.role,
});
res.json(items);
} catch (error) {
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function download(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.getById(req.tenantPool!, id);
if (!item) return next(new AppError(404, 'Documento no encontrado'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(item.contribuyenteId)) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime);
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
export async function aprobar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar(
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
export async function rechazar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
}
}
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error) {
next(error);
}
}
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.rechazarCliente(
req.tenantPool!, id, req.user!.userId, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
// ─── Notificaciones ───
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
return rows[0] ?? null;
}
/**
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/
async function notifyAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } },
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
if (m.user.active && m.user.email) recipients.add(m.user.email);
}
// No notificarse a sí mismo
recipients.delete(req.user!.email);
if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
}
/**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
*/
async function notifyDecisionAuxiliar(
req: Request,
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email,
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
link,
});
}
/**
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
* requiere su aprobación.
*/
async function notifyClienteAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Obtener user_ids de clientes con acceso a este contribuyente
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const userIds = rows.map(r => r.user_id);
const users = await prisma.user.findMany({
where: { id: { in: userIds }, active: true },
select: { email: true },
});
const recipients = users.map(u => u.email).filter(Boolean) as string[];
if (recipients.length === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
}
}
}

View File

@@ -0,0 +1,21 @@
-- Papelería de trabajo: aprobación independiente por cliente
ALTER TABLE papeleria_trabajo
ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS estado_cliente varchar(20)
CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')),
ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid,
ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz,
ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text;
CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente
ON papeleria_trabajo(estado_cliente)
WHERE estado_cliente IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente
ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente)
WHERE requiere_aprobacion_cliente = true;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
router.get('/:id/download', ctrl.download);
router.post('/:id/aprobar', ctrl.aprobar);
router.post('/:id/rechazar', ctrl.rechazar);
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
router.delete('/:id', ctrl.eliminar);
export { router as papeleriaRoutes };

View File

@@ -193,6 +193,19 @@ export const emailService = {
);
},
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
sendPapeleriaAprobacionClienteRequerida: async (
to: string,
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
) => {
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
await sendEmail(
to,
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
papeleriaAprobacionClienteRequeridaEmail(data),
);
},
/**
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
* correo por destinatario con el batch completo. Caller debe deduplicar

View File

@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
`;
return baseTemplate(body);
}
export interface PapeleriaAprobacionClienteRequeridaData {
contribuyenteRfc: string;
contribuyenteNombre: string;
despachoNombre?: string;
nombreDocumento: string;
descripcion: string | null;
periodo: string;
subidoPor: string;
link: string;
}
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
const body = `
${heading('Documento pendiente de tu aprobación')}
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
<ul>
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
<li><strong>Periodo:</strong> ${d.periodo}</li>
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
</ul>
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
<div style="margin-top: 24px;">
${primaryButton('Ver documento', d.link)}
</div>
`;
return baseTemplate(body);
}

View File

@@ -27,6 +27,11 @@ export interface PapeleriaItem {
aprobadoPor: string | null;
aprobadoAt: Date | null;
comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string;
createdAt: Date;
}
@@ -36,6 +41,7 @@ const SELECT = `
archivo_filename, archivo_mime, archivo_size,
anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
subido_por, created_at
`;
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo,
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
estadoCliente: r.estado_cliente,
aprobadoPorCliente: r.aprobado_por_cliente,
aprobadoAtCliente: r.aprobado_at_cliente,
comentarioRechazoCliente: r.comentario_rechazo_cliente,
subidoPor: r.subido_por,
createdAt: r.created_at,
});
@@ -69,6 +80,7 @@ export interface UploadInput {
anio: number;
mes: number;
requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
}
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`,
[
sanitizeUuid(input.contribuyenteId),
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
input.mes,
input.requiereAprobacion,
estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor,
],
);
@@ -117,6 +132,8 @@ export interface ListFilters {
anio?: number;
mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
}
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false');
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(f.estado);
}
if (f.entidadIds && f.entidadIds.length > 0) {
conds.push(`contribuyente_id = ANY($${i++})`);
vals.push(f.entidadIds);
}
if (f.userRole === 'cliente') {
conds.push('requiere_aprobacion_cliente = true');
}
const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')}
@@ -202,6 +226,39 @@ export async function rechazar(
return r ? ROW(r) : null;
}
export async function aprobarCliente(
pool: Pool,
id: number,
userId: string,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = NULL
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazarCliente(
pool: Pool,
id: number,
userId: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = $3
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`,
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
);
return (rowCount ?? 0) > 0;
}
/**
* Calcula el estado visual combinado considerando ambas aprobaciones.
*/
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return null;
// Si cualquiera está rechazado, el documento está rechazado
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
// Si ambos requieren aprobación
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
// Solo owner
if (reqOwner) return estOwner;
// Solo cliente
return estCliente;
}