feat: expand admin and data sync workflows
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m40s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m13s

This commit is contained in:
2026-07-23 22:00:08 -07:00
parent 70911e7e62
commit 27b3bd9efc
9 changed files with 127 additions and 13 deletions
+24 -1
View File
@@ -112,6 +112,26 @@ function dec(v: Prisma.Decimal | null | undefined): string {
*/ */
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null }; const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
/**
* Source tables excluded from the customer-facing statement.
*
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
* (`objects.json:1358`), so the customer's "current balance" never saw
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
* the statement must drop them to match the legacy number the customer has
* been quoted for years. The staff-facing balances worklist and movement
* browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
];
@Injectable() @Injectable()
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -579,7 +599,10 @@ export class BillingService {
} }
const rows = await this.prisma.transaction.findMany({ const rows = await this.prisma.transaction.findMany({
where: { customerId }, where: {
customerId,
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
id: true, id: true,
+1 -1
View File
@@ -44,7 +44,7 @@ export class OpsController {
@Post("ingest/:name") @Post("ingest/:name")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }), FileInterceptor("file", { limits: { fileSize: 2 * 1024 * 1024 * 1024 } }),
) )
async uploadIngest( async uploadIngest(
@Param("name") name: string, @Param("name") name: string,
+4 -1
View File
@@ -43,8 +43,11 @@ interface MysqlConn {
export class OpsService implements OnModuleInit { export class OpsService implements OnModuleInit {
private readonly logger = new Logger(OpsService.name); private readonly logger = new Logger(OpsService.name);
// Resolve from this source file so it works regardless of process.cwd()
// (the API runs from apps/api/, but the Python ETL lives at repo-root migration/).
private readonly migrationDir = private readonly migrationDir =
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration"); process.env.MIGRATION_DIR ??
path.resolve(__dirname, "..", "..", "..", "..", "migration");
private readonly ingestDir = private readonly ingestDir =
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest"); process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
private readonly backupDir = private readonly backupDir =
+10
View File
@@ -1,7 +1,9 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpCode,
Param, Param,
Patch, Patch,
Post, Post,
@@ -69,4 +71,12 @@ export class UsersController {
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id }); void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
return user; return user;
} }
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id: string, @Req() req: Request) {
const actingId = this.actingId(req);
await this.users.remove(id, actingId);
void this.audit.log(actingId, "user.delete", { userId: id });
}
} }
+24
View File
@@ -111,6 +111,30 @@ export class UsersService {
}); });
} }
/**
* Hard-delete a user. The schema's ActivityLog.userId FK would otherwise
* block the row (default `Restrict`), so null it out in the same
* transaction. Rows + the actor id captured in the `message` JSON stay
* intact for the audit trail.
*/
async remove(id: string, actingUserId: string): Promise<void> {
if (id === actingUserId) {
throw new BadRequestException("No puede eliminar su propia cuenta");
}
await this.ensureExists(id);
try {
await this.prisma.$transaction([
this.prisma.activityLog.updateMany({
where: { userId: id },
data: { userId: null },
}),
this.prisma.user.delete({ where: { id } }),
]);
} catch (e) {
throw this.mapError(e);
}
}
private async ensureExists(id: string): Promise<void> { private async ensureExists(id: string): Promise<void> {
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } }); const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`); if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
+23 -9
View File
@@ -27,6 +27,8 @@ import type {
OpsJobKind, OpsJobKind,
} from "@/lib/types"; } from "@/lib/types";
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export default function OperacionesPage() { export default function OperacionesPage() {
return ( return (
<AppShell> <AppShell>
@@ -37,6 +39,7 @@ export default function OperacionesPage() {
type ConfirmState = type ConfirmState =
| { kind: "REIMPORT" } | { kind: "REIMPORT" }
| { kind: "SYNC" }
| { kind: "RESTORE"; file: string } | { kind: "RESTORE"; file: string }
| null; | null;
@@ -176,6 +179,7 @@ function Operaciones() {
const c = confirm; const c = confirm;
setConfirm(null); setConfirm(null);
if (c.kind === "REIMPORT") await start("REIMPORT"); if (c.kind === "REIMPORT") await start("REIMPORT");
else if (c.kind === "SYNC") await start("SYNC");
else await start("RESTORE", c.file); else await start("RESTORE", c.file);
} }
@@ -224,7 +228,7 @@ function Operaciones() {
<h2 className="section-title">Carpeta de ingesta</h2> <h2 className="section-title">Carpeta de ingesta</h2>
<p className="inline-form-note"> <p className="inline-form-note">
Los cuatro archivos originales de Access. La reimportación y la Los cuatro archivos originales de Access. La reimportación y la
sincronización leen de aquí. sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
</p> </p>
<div className="tx-scroll"> <div className="tx-scroll">
<table className="tx-table"> <table className="tx-table">
@@ -306,11 +310,11 @@ function Operaciones() {
/> />
<OpTile <OpTile
title="Sincronizar" title="Sincronizar"
desc="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B." desc="Respalda, luego importa lo nuevo del legado. Borra del sistema los registros del legado que ya no aparecen en los archivos de ingesta. Se conservan los datos capturados a mano."
action="Próximamente" action="Sincronizar"
tone="muted" tone="primary"
disabled disabled={jobRunning || starting || !ingestReady}
onClick={() => {}} onClick={() => askConfirm({ kind: "SYNC" })}
/> />
</div> </div>
{!ingestReady && ( {!ingestReady && (
@@ -446,11 +450,17 @@ function Operaciones() {
<div className="modal-backdrop" role="dialog" aria-modal="true"> <div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="card" style={{ padding: 24, maxWidth: 480 }}> <div className="card" style={{ padding: 24, maxWidth: 480 }}>
<h2 className="section-title" style={{ marginTop: 0 }}> <h2 className="section-title" style={{ marginTop: 0 }}>
{confirm.kind === "REIMPORT" ? "Confirmar reimportación" : "Confirmar restauración"} {confirm.kind === "REIMPORT"
? "Confirmar reimportación"
: confirm.kind === "SYNC"
? "Confirmar sincronización"
: "Confirmar restauración"}
</h2> </h2>
<p className="inline-form-note"> <p className="inline-form-note">
{confirm.kind === "REIMPORT" {confirm.kind === "REIMPORT"
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático." ? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
: confirm.kind === "SYNC"
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`} : `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
</p> </p>
<label className="field"> <label className="field">
@@ -464,12 +474,16 @@ function Operaciones() {
</label> </label>
<div className="form-actions"> <div className="form-actions">
<button <button
className="btn btn-danger" className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
type="button" type="button"
disabled={confirmText !== "CONFIRMAR" || starting} disabled={confirmText !== "CONFIRMAR" || starting}
onClick={runConfirmed} onClick={runConfirmed}
> >
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"} {confirm.kind === "REIMPORT"
? "Reimportar"
: confirm.kind === "SYNC"
? "Sincronizar"
: "Restaurar"}
</button> </button>
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}> <button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
Cancelar Cancelar
+30
View File
@@ -6,6 +6,7 @@ import { useAuth, useCan } from "@/lib/abilities";
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels"; import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
import { import {
createUser, createUser,
deleteUser,
listUsers, listUsers,
resetUserPassword, resetUserPassword,
updateUser, updateUser,
@@ -133,6 +134,22 @@ function UsuariosAdmin() {
} }
} }
async function submitDelete(u: UserRow) {
if (!window.confirm(`¿Eliminar al usuario "${u.name}"? Esta acción no se puede deshacer.`)) {
return;
}
setError(null);
setNotice(null);
try {
await deleteUser(u.id);
if (editingId === u.id) startCreate();
setNotice("Usuario eliminado.");
refresh();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar el usuario.");
}
}
return ( return (
<> <>
<div className="page-head"> <div className="page-head">
@@ -312,6 +329,19 @@ function UsuariosAdmin() {
> >
Contraseña Contraseña
</button> </button>
<button
className="btn btn-ghost btn-danger"
type="button"
disabled={u.id === me?.id}
title={
u.id === me?.id
? "No puede eliminar su propia cuenta"
: "Eliminar usuario"
}
onClick={() => submitDelete(u)}
>
Eliminar
</button>
</div> </div>
)} )}
</td> </td>
+4
View File
@@ -655,6 +655,10 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
}); });
} }
export function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
}
/* ------------------------------------------- DB operations (admin only) */ /* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> { export function listIngest(): Promise<IngestFile[]> {
+6
View File
@@ -331,6 +331,12 @@ def main():
p = list(p); p[3] = ptype_ids[p[3]]; p[4] = prov_ids.get(p[4]) p = list(p); p[3] = ptype_ids[p[3]]; p[4] = prov_ids.get(p[4])
c.execute(f"INSERT INTO policies ({pol_cols}) VALUES ({','.join(['%s'] * 23)}) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL", tuple(p)) c.execute(f"INSERT INTO policies ({pol_cols}) VALUES ({','.join(['%s'] * 23)}) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL", tuple(p))
else: else:
c.execute("SET FOREIGN_KEY_CHECKS=0")
for t in ("policy_payment_installments", "vehicles", "insured_drivers",
"policy_beneficiaries", "claims", "adjusters",
"policies", "policy_types", "insurance_providers"):
c.execute(f"TRUNCATE TABLE {t}")
c.execute("SET FOREIGN_KEY_CHECKS=1")
ptype_ids = {n: str(uuid.uuid4()) for n in ptypes} ptype_ids = {n: str(uuid.uuid4()) for n in ptypes}
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s)", [(i, n) for n, i in ptype_ids.items()]) c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s)", [(i, n) for n, i in ptype_ids.items()])
prov_ids = {n: str(uuid.uuid4()) for n in providers} prov_ids = {n: str(uuid.uuid4()) for n in providers}