diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index b4381a5..43868bf 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -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, diff --git a/apps/api/src/ops/ops.controller.ts b/apps/api/src/ops/ops.controller.ts index 86b8078..0d37b39 100644 --- a/apps/api/src/ops/ops.controller.ts +++ b/apps/api/src/ops/ops.controller.ts @@ -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, diff --git a/apps/api/src/ops/ops.service.ts b/apps/api/src/ops/ops.service.ts index 891b98a..e4fe3c8 100644 --- a/apps/api/src/ops/ops.service.ts +++ b/apps/api/src/ops/ops.service.ts @@ -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 = diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 9f641cf..5046428 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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 }); + } } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 59f8465..c8907a7 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { + 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 { const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } }); if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`); diff --git a/apps/web/src/app/operaciones/page.tsx b/apps/web/src/app/operaciones/page.tsx index 6e27dc6..ede68e1 100644 --- a/apps/web/src/app/operaciones/page.tsx +++ b/apps/web/src/app/operaciones/page.tsx @@ -27,6 +27,8 @@ import type { OpsJobKind, } from "@/lib/types"; +const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024; + export default function OperacionesPage() { return ( @@ -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() {

Carpeta de ingesta

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)}.

@@ -306,11 +310,11 @@ function Operaciones() { /> {}} + 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" })} /> {!ingestReady && ( @@ -446,12 +450,18 @@ function Operaciones() {

- {confirm.kind === "REIMPORT" ? "Confirmar reimportación" : "Confirmar restauración"} + {confirm.kind === "REIMPORT" + ? "Confirmar reimportación" + : confirm.kind === "SYNC" + ? "Confirmar sincronización" + : "Confirmar restauración"}

{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.`}

+
)} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ee18155..e529c68 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -655,6 +655,10 @@ export function resetUserPassword(id: string, password: string): Promise { + return apiFetch(`/users/${id}`, { method: "DELETE" }); +} + /* ------------------------------------------- DB operations (admin only) */ export function listIngest(): Promise { diff --git a/migration/transform_policies.py b/migration/transform_policies.py index 8ed9f1b..7a98cde 100644 --- a/migration/transform_policies.py +++ b/migration/transform_policies.py @@ -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}