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 };
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user