feat: expand admin and data sync workflows
This commit is contained in:
@@ -112,6 +112,26 @@ function dec(v: Prisma.Decimal | null | undefined): string {
|
||||
*/
|
||||
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()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -579,7 +599,10 @@ export class BillingService {
|
||||
}
|
||||
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: { customerId },
|
||||
where: {
|
||||
customerId,
|
||||
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -44,7 +44,7 @@ export class OpsController {
|
||||
|
||||
@Post("ingest/:name")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }),
|
||||
FileInterceptor("file", { limits: { fileSize: 2 * 1024 * 1024 * 1024 } }),
|
||||
)
|
||||
async uploadIngest(
|
||||
@Param("name") name: string,
|
||||
|
||||
@@ -43,8 +43,11 @@ interface MysqlConn {
|
||||
export class OpsService implements OnModuleInit {
|
||||
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 =
|
||||
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration");
|
||||
process.env.MIGRATION_DIR ??
|
||||
path.resolve(__dirname, "..", "..", "..", "..", "migration");
|
||||
private readonly ingestDir =
|
||||
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
|
||||
private readonly backupDir =
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -69,4 +71,12 @@ export class UsersController {
|
||||
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
|
||||
|
||||
@@ -27,6 +27,8 @@ import type {
|
||||
OpsJobKind,
|
||||
} from "@/lib/types";
|
||||
|
||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
export default function OperacionesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -37,6 +39,7 @@ export default function OperacionesPage() {
|
||||
|
||||
type ConfirmState =
|
||||
| { kind: "REIMPORT" }
|
||||
| { kind: "SYNC" }
|
||||
| { kind: "RESTORE"; file: string }
|
||||
| null;
|
||||
|
||||
@@ -176,6 +179,7 @@ function Operaciones() {
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||
else if (c.kind === "SYNC") await start("SYNC");
|
||||
else await start("RESTORE", c.file);
|
||||
}
|
||||
|
||||
@@ -224,7 +228,7 @@ function Operaciones() {
|
||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||
<p className="inline-form-note">
|
||||
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>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
@@ -306,11 +310,11 @@ function Operaciones() {
|
||||
/>
|
||||
<OpTile
|
||||
title="Sincronizar"
|
||||
desc="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B."
|
||||
action="Próximamente"
|
||||
tone="muted"
|
||||
disabled
|
||||
onClick={() => {}}
|
||||
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="Sincronizar"
|
||||
tone="primary"
|
||||
disabled={jobRunning || starting || !ingestReady}
|
||||
onClick={() => askConfirm({ kind: "SYNC" })}
|
||||
/>
|
||||
</div>
|
||||
{!ingestReady && (
|
||||
@@ -446,12 +450,18 @@ function Operaciones() {
|
||||
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
|
||||
<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>
|
||||
<p className="inline-form-note">
|
||||
{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 sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||
: 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.`}
|
||||
</p>
|
||||
<label className="field">
|
||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||
@@ -464,12 +474,16 @@ function Operaciones() {
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
|
||||
type="button"
|
||||
disabled={confirmText !== "CONFIRMAR" || starting}
|
||||
onClick={runConfirmed}
|
||||
>
|
||||
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
|
||||
{confirm.kind === "REIMPORT"
|
||||
? "Reimportar"
|
||||
: confirm.kind === "SYNC"
|
||||
? "Sincronizar"
|
||||
: "Restaurar"}
|
||||
</button>
|
||||
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
|
||||
Cancelar
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth, useCan } from "@/lib/abilities";
|
||||
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
@@ -312,6 +329,19 @@ function UsuariosAdmin() {
|
||||
>
|
||||
Contraseña
|
||||
</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>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -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) */
|
||||
|
||||
export function listIngest(): Promise<IngestFile[]> {
|
||||
|
||||
@@ -331,6 +331,12 @@ def main():
|
||||
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))
|
||||
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}
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user