wip: ops admin panel + migration sync + crud/rbac phase-5 snapshot
Working-tree checkpoint of in-progress work carried across prior sessions on the feat/crud-rbac branch, committed so it lands on the remote alongside the CI changes. - Operaciones admin panel: apps/api/src/ops (ingest upload, backup / restore / re-import jobs) wired into app.module + RBAC abilities, and the apps/web/src/app/operaciones page. docker-compose gets INGEST_DIR / BACKUP_DIR volumes; .gitignore excludes migration/ingest + backups. - migration/sync.py plus transform_*.py / run_all / config / dbenv / blob_extract adjustments for the additive sync path. - crud/rbac phase-5 web bits: AppShell, api/labels/types libs, globals. - schema.prisma + PLAN/RESUME doc updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ build/
|
|||||||
*.log
|
*.log
|
||||||
migration/output/
|
migration/output/
|
||||||
migration/.venv/
|
migration/.venv/
|
||||||
|
migration/ingest/
|
||||||
|
migration/backups/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
packages/database/generated/
|
packages/database/generated/
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions) — **DONE**. `apps/api/src/billing/` + web `/estado-cuenta` and `/estado-cuenta/[id]`. Two questions, two views: a per-customer **balances worklist** (who owes what) and a cross-customer **movement browser** (every charge and credit, filterable by line, concept, origin table and date range, with totals for the whole filtered set). The detail page is the actual statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. **Design constraint that shapes the whole module: balances are reported per currency and never collapsed into one number.** 912 of the 1,269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and the legacy data never stored the exchange rate applied to a movement — so a single "total balance" would be a figure that never existed in the books.
|
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions) — **DONE**. `apps/api/src/billing/` + web `/estado-cuenta` and `/estado-cuenta/[id]`. Two questions, two views: a per-customer **balances worklist** (who owes what) and a cross-customer **movement browser** (every charge and credit, filterable by line, concept, origin table and date range, with totals for the whole filtered set). The detail page is the actual statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. **Design constraint that shapes the whole module: balances are reported per currency and never collapsed into one number.** 912 of the 1,269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and the legacy data never stored the exchange rate applied to a movement — so a single "total balance" would be a figure that never existed in the books.
|
||||||
7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules.
|
7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules.
|
||||||
8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it.
|
8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it.
|
||||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||||
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
@@ -146,6 +146,9 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
- **i18n:** **Spanish-first** UI — matches the source data and staff usage.
|
- **i18n:** **Spanish-first** UI — matches the source data and staff usage.
|
||||||
- **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped.
|
- **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped.
|
||||||
- **`utility_dbo`:** resolved — full dump + portal code available (see Status).
|
- **`utility_dbo`:** resolved — full dump + portal code available (see Status).
|
||||||
|
- **Phase B Access additive sync:** implemented in `migration/run_all.py --sync` and the admin
|
||||||
|
`SYNC` job. It preserves manual rows and stable legacy-owned primary keys; end-to-end database
|
||||||
|
validation remains before production use.
|
||||||
|
|
||||||
## Open items (ops, not design)
|
## Open items (ops, not design)
|
||||||
|
|
||||||
@@ -156,5 +159,7 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
||||||
- App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver.
|
- App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver.
|
||||||
- Sync: once the VPS replica and inbox tables exist, verify replication lag stays low (a few seconds to low minutes) and that a payment/propane submission on the portal reliably shows up in the internal app within one polling interval, before relying on it operationally.
|
- **Sync:** Phase B Access additive sync now has automated CLI/admin wiring, but must be verified
|
||||||
|
against a disposable DB with stable-PK, manual-row, update, and source-delete cases. The
|
||||||
|
separate VPS/portal sync still requires VPS provisioning and inbox-table implementation.
|
||||||
- Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired.
|
- Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired.
|
||||||
|
|||||||
@@ -162,6 +162,17 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
|||||||
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
||||||
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
|
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
|
||||||
code; needs a business decision on how to model it.
|
code; needs a business decision on how to model it.
|
||||||
|
6. **DB Operations — Phase B (additive sync) — IMPLEMENTED, verification pending.** Phase A provides
|
||||||
|
the admin-only `/operaciones` page + `ops` API module (ability `db:manage`, ADMIN), ingest
|
||||||
|
folder, backup, restore, and destructive re-import. Phase B now enables `SYNC`: `OpsService`
|
||||||
|
creates a safety backup and runs `run_all.py --sync`; transforms upsert legacy-owned rows by
|
||||||
|
provenance keys while preserving existing PKs and rows whose `legacyId IS NULL` (manual).
|
||||||
|
Prisma now enforces provenance uniqueness for properties, policies, transactions, vehicles,
|
||||||
|
and bank transactions. Sync intentionally skips prune/blob steps so manual customers and
|
||||||
|
document pointers are not removed. Python compilation plus API/web production builds pass;
|
||||||
|
still required before production use: push updated Prisma schema and run an end-to-end sync
|
||||||
|
against a disposable/dev DB proving stable PKs, manual-row preservation, changed-row updates,
|
||||||
|
and legacy-delete handling.
|
||||||
|
|
||||||
## 7. Environment notes (current macOS machine)
|
## 7. Environment notes (current macOS machine)
|
||||||
|
|
||||||
@@ -374,11 +385,14 @@ for what's actually next.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
⏭ **NEXT — where to pick up:**
|
- **Sync implementation — DONE, validation pending.** `run_all.py --sync` performs the
|
||||||
|
non-destructive legacy upsert path for customers, properties, policies, transactions, and
|
||||||
- **Plan step 8–9: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real
|
bank rows. It preserves manual rows and stable legacy-owned primary keys; the admin SYNC job
|
||||||
external dependency left. Pure ops: provider, size, Tailscale, MySQL replica.
|
automatically creates a pre-sync backup. Next validation: apply schema changes, then exercise
|
||||||
- **Plan step 10: reports / email campaigns / admin.**
|
sync against a disposable DB with added, changed, removed, and manually-created rows.
|
||||||
|
- **Plan step 9: portal sync worker** remains separate and blocked on VPS provisioning. This
|
||||||
|
Phase B feature synchronizes Access source files into the internal platform; it does not yet
|
||||||
|
poll `utility_dbo` inbox tables or replicate portal-facing data to a VPS.
|
||||||
- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
|
- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
|
||||||
(§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
|
(§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
|
||||||
exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
|
exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PoliciesModule } from "./policies/policies.module";
|
|||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
|
import { OpsModule } from "./ops/ops.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,6 +24,7 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
|
OpsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export type Ability =
|
|||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage";
|
| "user:manage"
|
||||||
|
| "db:manage";
|
||||||
|
|
||||||
/** Minimum role required for each ability. */
|
/** Minimum role required for each ability. */
|
||||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||||
@@ -51,6 +52,7 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"bank:void": "MANAGER",
|
"bank:void": "MANAGER",
|
||||||
"lookup:manage": "MANAGER",
|
"lookup:manage": "MANAGER",
|
||||||
"user:manage": "ADMIN",
|
"user:manage": "ADMIN",
|
||||||
|
"db:manage": "ADMIN",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { OpsService } from "./ops.service";
|
||||||
|
import { StartJobDto } from "./start-job.dto";
|
||||||
|
|
||||||
|
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@RequireAbility("db:manage")
|
||||||
|
@Controller("ops")
|
||||||
|
export class OpsController {
|
||||||
|
constructor(
|
||||||
|
private readonly ops: OpsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
|
@Get("ingest")
|
||||||
|
listIngest() {
|
||||||
|
return this.ops.listIngest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("ingest/:name")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 500 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
async uploadIngest(
|
||||||
|
@Param("name") name: string,
|
||||||
|
@UploadedFile() file: { buffer: Buffer; size: number } | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||||
|
await this.ops.saveIngest(name, file.buffer);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.ingest.upload", {
|
||||||
|
name,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("ingest/:name")
|
||||||
|
async deleteIngest(@Param("name") name: string, @Req() req: Request) {
|
||||||
|
await this.ops.deleteIngest(name);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.ingest.delete", { name });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ backups */
|
||||||
|
|
||||||
|
@Get("backups")
|
||||||
|
listBackups() {
|
||||||
|
return this.ops.listBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("backups/:name/download")
|
||||||
|
download(
|
||||||
|
@Param("name") name: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): StreamableFile {
|
||||||
|
const { stream, name: safe } = this.ops.backupStream(name);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": "application/gzip",
|
||||||
|
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("backups/:name")
|
||||||
|
async deleteBackup(@Param("name") name: string, @Req() req: Request) {
|
||||||
|
await this.ops.deleteBackup(name);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.backup.delete", { name });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- jobs */
|
||||||
|
|
||||||
|
@Get("jobs")
|
||||||
|
listJobs() {
|
||||||
|
return this.ops.listJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("jobs/:id")
|
||||||
|
getJob(@Param("id") id: string) {
|
||||||
|
return this.ops.getJob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("jobs")
|
||||||
|
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||||
|
const userId = this.actingId(req);
|
||||||
|
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||||
|
void this.audit.log(userId, "ops.job.start", {
|
||||||
|
jobId: job.id,
|
||||||
|
kind: dto.kind,
|
||||||
|
file: dto.file,
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { OpsController } from "./ops.controller";
|
||||||
|
import { OpsService } from "./ops.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OpsController],
|
||||||
|
providers: [OpsService],
|
||||||
|
})
|
||||||
|
export class OpsModule {}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createReadStream, promises as fs } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin database operations. Everything long-running (mysqldump, mysql restore,
|
||||||
|
* the Python migration) runs as a detached child process recorded as one OpsJob
|
||||||
|
* row whose `log` is appended as the process talks; the web polls that row.
|
||||||
|
*
|
||||||
|
* Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a
|
||||||
|
* restore or re-import racing a migration would corrupt the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The four legacy Access files. Uploads are allowlisted to exactly these
|
||||||
|
* names so an ingest write can never land at an arbitrary path. */
|
||||||
|
export const INGEST_FILES = [
|
||||||
|
"UTILITIES.accdb",
|
||||||
|
"SEGUROS 16.mdb",
|
||||||
|
"SEGUROS 16_be.mdb",
|
||||||
|
"SCOTHIA.mdb",
|
||||||
|
] as const;
|
||||||
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
interface MysqlConn {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpsService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OpsService.name);
|
||||||
|
|
||||||
|
private readonly migrationDir =
|
||||||
|
process.env.MIGRATION_DIR ?? path.resolve(process.cwd(), "migration");
|
||||||
|
private readonly ingestDir =
|
||||||
|
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
|
||||||
|
private readonly backupDir =
|
||||||
|
process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups");
|
||||||
|
private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev";
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||||
|
await fs.mkdir(this.backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
|
private assertIngestName(name: string): IngestName {
|
||||||
|
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return name as IngestName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIngest(): Promise<
|
||||||
|
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||||
|
> {
|
||||||
|
return Promise.all(
|
||||||
|
INGEST_FILES.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
present: true,
|
||||||
|
size: st.size,
|
||||||
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { name, present: false, size: null, modifiedAt: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||||
|
const safe = this.assertIngestName(name);
|
||||||
|
await fs.writeFile(path.join(this.ingestDir, safe), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIngest(name: string): Promise<void> {
|
||||||
|
const safe = this.assertIngestName(name);
|
||||||
|
await fs.rm(path.join(this.ingestDir, safe), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- backups */
|
||||||
|
|
||||||
|
private assertBackupName(name: string): string {
|
||||||
|
// No path separators, must be a produced backup file.
|
||||||
|
if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) {
|
||||||
|
throw new BadRequestException("Nombre de respaldo inválido.");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBackups(): Promise<
|
||||||
|
{ name: string; size: number; createdAt: string }[]
|
||||||
|
> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await fs.readdir(this.backupDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((n) => n.endsWith(".sql.gz"))
|
||||||
|
.map(async (name) => {
|
||||||
|
const st = await fs.stat(path.join(this.backupDir, name));
|
||||||
|
return { name, size: st.size, createdAt: st.mtime.toISOString() };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
backupStream(name: string) {
|
||||||
|
const safe = this.assertBackupName(name);
|
||||||
|
const full = path.join(this.backupDir, safe);
|
||||||
|
return { stream: createReadStream(full), name: safe };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(name: string): Promise<void> {
|
||||||
|
const safe = this.assertBackupName(name);
|
||||||
|
await fs.rm(path.join(this.backupDir, safe), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- jobs */
|
||||||
|
|
||||||
|
listJobs(limit = 20) {
|
||||||
|
return this.prisma.opsJob.findMany({
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJob(id: string) {
|
||||||
|
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||||
|
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a mutating op. Refuses if another job is already RUNNING. Returns the
|
||||||
|
* new job row immediately; the process runs on in the background and appends
|
||||||
|
* to `log` until it exits.
|
||||||
|
*/
|
||||||
|
async startJob(
|
||||||
|
kind: OpsJobKind,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
userId: string | undefined,
|
||||||
|
) {
|
||||||
|
const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } });
|
||||||
|
if (running > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"Ya hay una operación en curso. Espere a que termine.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = this.parseDbUrl();
|
||||||
|
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
||||||
|
|
||||||
|
const job = await this.prisma.opsJob.create({
|
||||||
|
data: {
|
||||||
|
kind,
|
||||||
|
status: "RUNNING",
|
||||||
|
log: "",
|
||||||
|
params: resolvedParams as object,
|
||||||
|
createdById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.run(job.id, cmd, conn.password);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- internals */
|
||||||
|
|
||||||
|
private parseDbUrl(): MysqlConn {
|
||||||
|
const raw = process.env.DATABASE_URL;
|
||||||
|
if (!raw) throw new BadRequestException("DATABASE_URL no está configurada.");
|
||||||
|
const u = new URL(raw);
|
||||||
|
return {
|
||||||
|
host: u.hostname,
|
||||||
|
port: u.port || "3306",
|
||||||
|
user: decodeURIComponent(u.username),
|
||||||
|
password: decodeURIComponent(u.password),
|
||||||
|
database: u.pathname.replace(/^\//, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
||||||
|
* the child env, never on the command line (which would leak via `ps`). */
|
||||||
|
private connFlags(c: MysqlConn): string {
|
||||||
|
return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private timestamp(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildCommand(
|
||||||
|
kind: OpsJobKind,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
conn: MysqlConn,
|
||||||
|
): Promise<{ cmd: string; resolvedParams: Record<string, unknown> }> {
|
||||||
|
const flags = this.connFlags(conn);
|
||||||
|
const db = shq(conn.database);
|
||||||
|
|
||||||
|
if (kind === "BACKUP") {
|
||||||
|
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
return {
|
||||||
|
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
|
||||||
|
resolvedParams: { file },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "RESTORE") {
|
||||||
|
const name = this.assertBackupName(String(params.file ?? ""));
|
||||||
|
const full = path.join(this.backupDir, name);
|
||||||
|
await fs.access(full).catch(() => {
|
||||||
|
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
||||||
|
resolvedParams: { file: name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "SYNC") {
|
||||||
|
const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
const py = await this.pythonBin();
|
||||||
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
const cmd =
|
||||||
|
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
|
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||||
|
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||||
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "REIMPORT") {
|
||||||
|
// Safety backup first, then a full truncate+rebuild from the ingest files.
|
||||||
|
const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
const py = await this.pythonBin();
|
||||||
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
const cmd =
|
||||||
|
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
|
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||||
|
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||||
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer the migration venv python if it exists (local dev), else system. */
|
||||||
|
private async pythonBin(): Promise<string> {
|
||||||
|
const venv = path.join(this.migrationDir, ".venv", "bin", "python");
|
||||||
|
try {
|
||||||
|
await fs.access(venv);
|
||||||
|
return venv;
|
||||||
|
} catch {
|
||||||
|
return process.env.PYTHON_BIN ?? "python3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(jobId: string, cmd: string, password: string): void {
|
||||||
|
const child = spawn("sh", ["-c", cmd], {
|
||||||
|
cwd: this.migrationDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MYSQL_PWD: password,
|
||||||
|
INGEST_DIR: this.ingestDir,
|
||||||
|
BACKUP_DIR: this.backupDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let pending = "";
|
||||||
|
let flushing = false;
|
||||||
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (flushing || !pending) return;
|
||||||
|
flushing = true;
|
||||||
|
const chunk = pending;
|
||||||
|
pending = "";
|
||||||
|
try {
|
||||||
|
await this.prisma.opsJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { log: { set: buffer } },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`);
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
void chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (d: Buffer) => {
|
||||||
|
const text = d.toString();
|
||||||
|
buffer += text;
|
||||||
|
pending += text;
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
void flush();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
child.stderr.on("data", onData);
|
||||||
|
|
||||||
|
const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => {
|
||||||
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
|
buffer += tail;
|
||||||
|
await this.prisma.opsJob
|
||||||
|
.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status, log: { set: buffer }, finishedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`);
|
||||||
|
else void finalize("FAILED", `\n[terminó con código ${code}]\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-quote a value for a POSIX shell command. */
|
||||||
|
function shq(v: string): string {
|
||||||
|
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class StartJobDto {
|
||||||
|
@IsEnum(OpsJobKind)
|
||||||
|
kind!: OpsJobKind;
|
||||||
|
|
||||||
|
/** Target backup filename — required for RESTORE, ignored otherwise. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
file?: string;
|
||||||
|
}
|
||||||
@@ -247,6 +247,7 @@ button {
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.appbar-link:hover {
|
.appbar-link:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -400,13 +401,24 @@ button {
|
|||||||
}
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--brand-600);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
/* Dark appbar keeps the light-on-dark ghost button. */
|
||||||
|
.appbar .btn-ghost {
|
||||||
color: rgba(242, 239, 231, 0.85);
|
color: rgba(242, 239, 231, 0.85);
|
||||||
border-color: rgba(255, 255, 255, 0.22);
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover {
|
.appbar .btn-ghost:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-decoration: none;
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -2153,3 +2165,42 @@ button {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Admin DB operations (Operaciones) --- */
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--negative);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
filter: brightness(0.94);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-log {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
OPS_KIND_LABELS,
|
||||||
|
OPS_STATUS_LABELS,
|
||||||
|
formatBytes,
|
||||||
|
formatDateTime,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
backupDownloadUrl,
|
||||||
|
deleteBackup,
|
||||||
|
deleteIngest,
|
||||||
|
getOpsJob,
|
||||||
|
listBackups,
|
||||||
|
listIngest,
|
||||||
|
listOpsJobs,
|
||||||
|
startOpsJob,
|
||||||
|
uploadIngest,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
BackupFile,
|
||||||
|
IngestFile,
|
||||||
|
OpsJob,
|
||||||
|
OpsJobKind,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export default function OperacionesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Operaciones />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmState =
|
||||||
|
| { kind: "REIMPORT" }
|
||||||
|
| { kind: "RESTORE"; file: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function Operaciones() {
|
||||||
|
const allowed = useCan("db:manage");
|
||||||
|
|
||||||
|
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
|
||||||
|
const [backups, setBackups] = useState<BackupFile[] | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
|
||||||
|
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
|
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const refreshLists = useCallback(() => {
|
||||||
|
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||||
|
listBackups().then(setBackups).catch(() => setBackups([]));
|
||||||
|
listOpsJobs()
|
||||||
|
.then((rows) => {
|
||||||
|
setJobs(rows);
|
||||||
|
const running = rows.find((j) => j.status === "RUNNING");
|
||||||
|
if (running) setActiveJob(running);
|
||||||
|
})
|
||||||
|
.catch(() => setJobs([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allowed) refreshLists();
|
||||||
|
}, [allowed, refreshLists]);
|
||||||
|
|
||||||
|
// Poll the active job while it runs; refresh everything when it finishes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeJob || activeJob.status !== "RUNNING") return;
|
||||||
|
const id = activeJob.id;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
getOpsJob(id)
|
||||||
|
.then((job) => {
|
||||||
|
setActiveJob(job);
|
||||||
|
if (job.status !== "RUNNING") {
|
||||||
|
clearInterval(timer);
|
||||||
|
refreshLists();
|
||||||
|
setNotice(
|
||||||
|
job.status === "SUCCESS"
|
||||||
|
? `${OPS_KIND_LABELS[job.kind]} completada.`
|
||||||
|
: `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* transient — keep polling */
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [activeJob, refreshLists]);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return (
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Operaciones</h1>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar la base de datos.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobRunning = activeJob?.status === "RUNNING";
|
||||||
|
|
||||||
|
async function handleUpload(name: string, file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setUploading(name);
|
||||||
|
try {
|
||||||
|
await uploadIngest(name, file);
|
||||||
|
setNotice(`${name} cargado.`);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
||||||
|
} finally {
|
||||||
|
setUploading(null);
|
||||||
|
const input = fileInputs.current[name];
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteIngest(name: string) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteIngest(name);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo eliminar.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteBackup(name: string) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteBackup(name);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo eliminar el respaldo.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(kind: OpsJobKind, file?: string) {
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const job = await startOpsJob(kind, file);
|
||||||
|
setActiveJob(job);
|
||||||
|
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo iniciar la operación.");
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(state: ConfirmState) {
|
||||||
|
setConfirm(state);
|
||||||
|
setConfirmText("");
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConfirmed() {
|
||||||
|
if (!confirm) return;
|
||||||
|
const c = confirm;
|
||||||
|
setConfirm(null);
|
||||||
|
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||||
|
else await start("RESTORE", c.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingestReady = (ingest ?? []).every((f) => f.present);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Operaciones de base de datos</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
{notice && <div className="state-box">{notice}</div>}
|
||||||
|
|
||||||
|
{/* Active / running job with live log */}
|
||||||
|
{activeJob && (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>
|
||||||
|
{OPS_KIND_LABELS[activeJob.kind]}{" "}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
activeJob.status === "SUCCESS"
|
||||||
|
? "badge-positive"
|
||||||
|
: activeJob.status === "FAILED"
|
||||||
|
? "badge-negative"
|
||||||
|
: "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
|
||||||
|
{OPS_STATUS_LABELS[activeJob.status]}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{!jobRunning && (
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
|
||||||
|
Ocultar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ingest folder */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<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í.
|
||||||
|
</p>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="num">Tamaño</th>
|
||||||
|
<th>Modificado</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(ingest ?? []).map((f) => (
|
||||||
|
<tr key={f.name}>
|
||||||
|
<td className="mono">{f.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{f.present ? "Presente" : "Falta"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{formatBytes(f.size)}</td>
|
||||||
|
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
fileInputs.current[f.name] = el;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={uploading === f.name}
|
||||||
|
onClick={() => fileInputs.current[f.name]?.click()}
|
||||||
|
>
|
||||||
|
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||||
|
</button>
|
||||||
|
{f.present && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteIngest(f.name)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operations */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Operaciones</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<OpTile
|
||||||
|
title="Respaldo"
|
||||||
|
desc="Genera un volcado comprimido de la base de datos actual."
|
||||||
|
action="Crear respaldo"
|
||||||
|
tone="primary"
|
||||||
|
disabled={jobRunning || starting}
|
||||||
|
onClick={() => start("BACKUP")}
|
||||||
|
/>
|
||||||
|
<OpTile
|
||||||
|
title="Reimportar (purga)"
|
||||||
|
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
|
||||||
|
action="Reimportar"
|
||||||
|
tone="danger"
|
||||||
|
disabled={jobRunning || starting || !ingestReady}
|
||||||
|
onClick={() => askConfirm({ kind: "REIMPORT" })}
|
||||||
|
/>
|
||||||
|
<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={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!ingestReady && (
|
||||||
|
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||||
|
La reimportación requiere que los cuatro archivos estén presentes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backups */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Respaldos</h2>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
|
||||||
|
</p>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th className="num">Tamaño</th>
|
||||||
|
<th>Creado</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backups === null ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="muted">
|
||||||
|
Sin respaldos.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
backups.map((b) => (
|
||||||
|
<tr key={b.name}>
|
||||||
|
<td className="mono">{b.name}</td>
|
||||||
|
<td className="num">{formatBytes(b.size)}</td>
|
||||||
|
<td>{formatDateTime(b.createdAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
|
||||||
|
Descargar
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={jobRunning || starting}
|
||||||
|
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
|
||||||
|
>
|
||||||
|
Restaurar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteBackup(b.name)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent jobs */}
|
||||||
|
<div className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Historial</h2>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Operación</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Inicio</th>
|
||||||
|
<th>Fin</th>
|
||||||
|
<th className="num"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(jobs ?? []).map((j) => (
|
||||||
|
<tr key={j.id}>
|
||||||
|
<td>{OPS_KIND_LABELS[j.kind]}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
j.status === "SUCCESS"
|
||||||
|
? "badge-positive"
|
||||||
|
: j.status === "FAILED"
|
||||||
|
? "badge-negative"
|
||||||
|
: "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{OPS_STATUS_LABELS[j.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDateTime(j.startedAt)}</td>
|
||||||
|
<td>{formatDateTime(j.finishedAt)}</td>
|
||||||
|
<td className="num">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveJob(j)}
|
||||||
|
>
|
||||||
|
Ver registro
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{jobs && jobs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="muted">
|
||||||
|
Sin operaciones registradas.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destructive-op confirm */}
|
||||||
|
{confirm && (
|
||||||
|
<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"}
|
||||||
|
</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.`}
|
||||||
|
</p>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
disabled={confirmText !== "CONFIRMAR" || starting}
|
||||||
|
onClick={runConfirmed}
|
||||||
|
>
|
||||||
|
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpTile({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
action,
|
||||||
|
tone,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
action: string;
|
||||||
|
tone: "primary" | "danger" | "muted";
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const btnClass =
|
||||||
|
tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary";
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="inline-form-note" style={{ minHeight: 48 }}>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
|
||||||
|
{action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const NAV: { href: string; label: string; ability?: Ability }[] = [
|
|||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ import type {
|
|||||||
PolicyStats,
|
PolicyStats,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
LookupsResponse,
|
LookupsResponse,
|
||||||
|
OpsJob,
|
||||||
|
OpsJobKind,
|
||||||
|
IngestFile,
|
||||||
|
BackupFile,
|
||||||
PropertyDetail,
|
PropertyDetail,
|
||||||
PropertyFacets,
|
PropertyFacets,
|
||||||
PropertyInput,
|
PropertyInput,
|
||||||
@@ -593,3 +597,62 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
|
|||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------- DB operations (admin only) */
|
||||||
|
|
||||||
|
export function listIngest(): Promise<IngestFile[]> {
|
||||||
|
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
|
||||||
|
export async function uploadIngest(name: string, file: File): Promise<void> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file, name);
|
||||||
|
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Error ${res.status}`;
|
||||||
|
try {
|
||||||
|
const b = await res.json();
|
||||||
|
if (b?.message) message = b.message;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteIngest(name: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups(): Promise<BackupFile[]> {
|
||||||
|
return apiFetch<BackupFile[]>("/ops/backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupDownloadUrl(name: string): string {
|
||||||
|
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBackup(name: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||||
|
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpsJob(id: string): Promise<OpsJob> {
|
||||||
|
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||||
|
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||||
|
return apiFetch<OpsJob>("/ops/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ kind, file }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,6 +266,48 @@ export function bankSourceLabel(source: string | null | undefined): string {
|
|||||||
return BANK_SOURCE_LABELS[source] ?? source;
|
return BANK_SOURCE_LABELS[source] ?? source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- DB operations (admin) -----
|
||||||
|
|
||||||
|
export const OPS_KIND_LABELS: Record<string, string> = {
|
||||||
|
BACKUP: "Respaldo",
|
||||||
|
RESTORE: "Restauración",
|
||||||
|
REIMPORT: "Reimportación",
|
||||||
|
SYNC: "Sincronización",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPS_STATUS_LABELS: Record<string, string> = {
|
||||||
|
RUNNING: "En curso",
|
||||||
|
SUCCESS: "Completado",
|
||||||
|
FAILED: "Con error",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bytes → human size (KB/MB/GB), es-MX formatting. */
|
||||||
|
export function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes === null || bytes === undefined) return "—";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB"];
|
||||||
|
let n = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${n.toLocaleString("es-MX", { maximumFractionDigits: 1 })} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleString("es-MX", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const MONTH_NAMES = [
|
export const MONTH_NAMES = [
|
||||||
"Enero",
|
"Enero",
|
||||||
"Febrero",
|
"Febrero",
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export type Ability =
|
|||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage";
|
| "user:manage"
|
||||||
|
| "db:manage";
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,6 +44,36 @@ export interface UserRow {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------- DB operations (admin) */
|
||||||
|
|
||||||
|
export type OpsJobKind = "BACKUP" | "RESTORE" | "REIMPORT" | "SYNC";
|
||||||
|
export type OpsJobStatus = "RUNNING" | "SUCCESS" | "FAILED";
|
||||||
|
|
||||||
|
export interface OpsJob {
|
||||||
|
id: string;
|
||||||
|
kind: OpsJobKind;
|
||||||
|
status: OpsJobStatus;
|
||||||
|
log: string;
|
||||||
|
params: Record<string, unknown> | null;
|
||||||
|
createdById: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the four legacy Access files expected in the ingest folder. */
|
||||||
|
export interface IngestFile {
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomerStats {
|
export interface CustomerStats {
|
||||||
customers: number;
|
customers: number;
|
||||||
withUtilities: number;
|
withUtilities: number;
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
WEB_ORIGIN: http://localhost:3000
|
WEB_ORIGIN: http://localhost:3000
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
|
INGEST_DIR: /data/ingest
|
||||||
|
BACKUP_DIR: /data/backups
|
||||||
|
MIGRATION_ENV: dev
|
||||||
|
volumes:
|
||||||
|
- ingest_data:/data/ingest
|
||||||
|
- backup_data:/data/backups
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|
||||||
@@ -48,3 +54,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
ingest_data:
|
||||||
|
backup_data:
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ from extract import sanitize_column_name as san
|
|||||||
|
|
||||||
csv.field_size_limit(300_000_000)
|
csv.field_size_limit(300_000_000)
|
||||||
|
|
||||||
SOURCE_ROOT = Path.home() / "Downloads" / "JorgeCuadros-Legacy"
|
# Same source folder as the rest of the pipeline (config.SOURCE_ROOT honours
|
||||||
|
# INGEST_DIR — the web "Operaciones" ingest volume).
|
||||||
|
from config import SOURCE_ROOT
|
||||||
|
|
||||||
# (key, access_file, access_table, staged_table_name, blob_cols, model)
|
# (key, access_file, access_table, staged_table_name, blob_cols, model)
|
||||||
SOURCES = [
|
SOURCES = [
|
||||||
|
|||||||
+9
-1
@@ -17,9 +17,17 @@ not work on macOS; extraction is being reworked to use mdbtools
|
|||||||
the four Access source files.
|
the four Access source files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SOURCE_ROOT = Path.home() / "Downloads" / "JorgeCuadros-Legacy"
|
# The folder holding the four Access source files. Overridable via INGEST_DIR so
|
||||||
|
# the web "Operaciones" ingest folder (a mounted volume in the API container)
|
||||||
|
# feeds the same pipeline. Falls back to the original macOS download location
|
||||||
|
# for a plain local run.
|
||||||
|
SOURCE_ROOT = Path(
|
||||||
|
os.environ.get("INGEST_DIR")
|
||||||
|
or (Path.home() / "Downloads" / "JorgeCuadros-Legacy")
|
||||||
|
)
|
||||||
|
|
||||||
SOURCES = {
|
SOURCES = {
|
||||||
"utilities": {
|
"utilities": {
|
||||||
|
|||||||
+10
-1
@@ -22,6 +22,7 @@ Usage in a script:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
@@ -48,8 +49,16 @@ def load_env(env: str) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def database_url(env: str) -> str:
|
||||||
|
"""Target DB URL. A DATABASE_URL in the process environment wins over
|
||||||
|
deploy/.env.<env> — this is how the API container (which has its own
|
||||||
|
DATABASE_URL and no deploy/.env files) drives a re-import against its own
|
||||||
|
database."""
|
||||||
|
return os.environ.get("DATABASE_URL") or load_env(env)["DATABASE_URL"]
|
||||||
|
|
||||||
|
|
||||||
def connect(env: str):
|
def connect(env: str):
|
||||||
url = load_env(env)["DATABASE_URL"]
|
url = database_url(env)
|
||||||
u = urlparse(url) # mysql://user:pass@host:port/db
|
u = urlparse(url) # mysql://user:pass@host:port/db
|
||||||
return pymysql.connect(
|
return pymysql.connect(
|
||||||
host=u.hostname,
|
host=u.hostname,
|
||||||
|
|||||||
+22
-9
@@ -39,13 +39,21 @@ PY = sys.executable # the venv python running this orchestrator
|
|||||||
# service_documents / policy_documents, which would otherwise leave the
|
# service_documents / policy_documents, which would otherwise leave the
|
||||||
# uploaded MinIO objects with no rows pointing at them.
|
# uploaded MinIO objects with no rows pointing at them.
|
||||||
STEPS = [
|
STEPS = [
|
||||||
"transform_customers.py", # customers + customer_legacy_refs (everything FKs to these)
|
"transform_customers.py",
|
||||||
"transform_properties.py", # properties + services + trust accounts
|
"transform_properties.py",
|
||||||
"transform_policies.py", # policies + installments/vehicles/drivers/benef/claims/adjusters
|
"transform_policies.py",
|
||||||
"transform_transactions.py", # shared ledger + type_transactions + exchange_rates
|
"transform_transactions.py",
|
||||||
"prune_empty_customers.py", # drop customers with no property/policy/transaction
|
"prune_empty_customers.py",
|
||||||
"transform_bank.py", # SCOTHIA bank register (no customer FK; independent)
|
"transform_bank.py",
|
||||||
"blob_extract.py", # document pointers; must follow properties + policies
|
"blob_extract.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
SYNC_STEPS = [
|
||||||
|
"transform_customers.py",
|
||||||
|
"transform_properties.py",
|
||||||
|
"transform_policies.py",
|
||||||
|
"transform_transactions.py",
|
||||||
|
"transform_bank.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -61,13 +69,18 @@ def main() -> None:
|
|||||||
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
||||||
ap.add_argument("--stage", action="store_true",
|
ap.add_argument("--stage", action="store_true",
|
||||||
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
||||||
|
ap.add_argument("--sync", action="store_true",
|
||||||
|
help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if args.stage:
|
if args.stage:
|
||||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")])
|
||||||
|
|
||||||
for step in STEPS:
|
for step in SYNC_STEPS if args.sync else STEPS:
|
||||||
run([PY, str(HERE / step), "--env", args.env])
|
cmd = [PY, str(HERE / step), "--env", args.env]
|
||||||
|
if args.sync:
|
||||||
|
cmd.append("--sync")
|
||||||
|
run(cmd)
|
||||||
|
|
||||||
print(f"\n✓ migration complete for env={args.env}")
|
print(f"\n✓ migration complete for env={args.env}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared CLI and SQL helpers for migration modes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mode() -> tuple[str, bool]:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--env", default="dev")
|
||||||
|
parser.add_argument("--sync", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args.env, args.sync
|
||||||
|
|
||||||
|
|
||||||
|
def existing_ids(cursor, table: str, key_columns: tuple[str, ...], where: str = "") -> dict[tuple, str]:
|
||||||
|
columns = ",".join(("id", *key_columns))
|
||||||
|
cursor.execute(f"SELECT {columns} FROM {table} {where}")
|
||||||
|
return {tuple(row[1:]): row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_missing(cursor, table: str, key_columns: tuple[str, ...], seen: set[tuple], where: str) -> int:
|
||||||
|
rows = existing_ids(cursor, table, key_columns, where)
|
||||||
|
stale = [row_id for key, row_id in rows.items() if key not in seen]
|
||||||
|
if stale:
|
||||||
|
cursor.executemany(f"DELETE FROM {table} WHERE id=%s", [(row_id,) for row_id in stale])
|
||||||
|
return len(stale)
|
||||||
@@ -28,6 +28,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_scothia"
|
STG = Path(__file__).parent / "output" / "stg_scothia"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -72,7 +73,7 @@ def load(name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[bank] target env: {env}")
|
print(f"[bank] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -109,16 +110,17 @@ def main():
|
|||||||
for _, r in load("datos_e").iterrows():
|
for _, r in load("datos_e").iterrows():
|
||||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||||
|
|
||||||
|
if sync_mode:
|
||||||
|
for row in rows:
|
||||||
|
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||||
|
else:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
for t in ("bank_transactions", "business_line_categories"):
|
for t in ("bank_transactions", "business_line_categories"):
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
c.execute(f"TRUNCATE TABLE {t}")
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
|
||||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||||
c.executemany(
|
c.executemany(
|
||||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,"
|
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
||||||
"amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) "
|
|
||||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output"
|
STG = Path(__file__).parent / "output"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -229,12 +230,12 @@ _CUST_COLS = [
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[customers] target env: {env}")
|
print(f"[customers] target env: {env}")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Fresh, idempotent rebuild.
|
if not sync_mode:
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
||||||
cur.execute("TRUNCATE TABLE customers")
|
cur.execute("TRUNCATE TABLE customers")
|
||||||
@@ -291,16 +292,26 @@ def main() -> None:
|
|||||||
new_ins += 1
|
new_ins += 1
|
||||||
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
||||||
|
|
||||||
# Insert customers.
|
|
||||||
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
||||||
|
if sync_mode:
|
||||||
|
existing = {}
|
||||||
|
cur.execute("SELECT id,sourceSystem,sourceTable,legacyId,customerId FROM customer_legacy_refs")
|
||||||
|
for rid, system, table, legacy, customer_id in cur.fetchall():
|
||||||
|
existing[(system, table, legacy)] = (rid, customer_id)
|
||||||
|
for rec, ref in zip(customers, refs):
|
||||||
|
key = (ref[2], ref[3], ref[4])
|
||||||
|
customer_id = existing.get(key, (None, rec["id"]))[1]
|
||||||
|
rec["id"] = customer_id
|
||||||
|
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||||
|
ref = (existing.get(key, (ref[0], customer_id))[0], customer_id, *ref[2:])
|
||||||
|
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)", ref)
|
||||||
|
else:
|
||||||
cur.executemany(
|
cur.executemany(
|
||||||
f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders})",
|
f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders})",
|
||||||
[tuple(rec[c] for c in _CUST_COLS) for rec in customers],
|
[tuple(rec[c] for c in _CUST_COLS) for rec in customers],
|
||||||
)
|
)
|
||||||
# Insert legacy refs.
|
|
||||||
cur.executemany(
|
cur.executemany(
|
||||||
"INSERT INTO customer_legacy_refs (id, customerId, sourceSystem, sourceTable, legacyId) "
|
"INSERT INTO customer_legacy_refs (id, customerId, sourceSystem, sourceTable, legacyId) VALUES (%s,%s,%s,%s,%s)",
|
||||||
"VALUES (%s,%s,%s,%s,%s)",
|
|
||||||
refs,
|
refs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_seguros"
|
STG = Path(__file__).parent / "output" / "stg_seguros"
|
||||||
LEGACY_DB = "SEGUROS 16_be"
|
LEGACY_DB = "SEGUROS 16_be"
|
||||||
@@ -171,7 +172,7 @@ def load(name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[policies] target env: {env}")
|
print(f"[policies] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -315,35 +316,44 @@ def main():
|
|||||||
dt(r["fecha_cheque"]), s(r["num_cheque"]),
|
dt(r["fecha_cheque"]), s(r["num_cheque"]),
|
||||||
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
|
||||||
|
|
||||||
# --- write (children first on truncate) ---
|
if sync_mode:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SELECT id,name FROM policy_types")
|
||||||
for t in ("policy_payment_installments", "insured_drivers", "policy_beneficiaries",
|
ptype_ids = dict(c.fetchall())
|
||||||
"claims", "vehicles", "policy_documents", "policies", "policy_types",
|
for n in ptypes:
|
||||||
"insurance_providers", "adjusters"):
|
ptype_ids.setdefault(n, str(uuid.uuid4()))
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name)", [(i, n) for n, i in ptype_ids.items()])
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SELECT id,name FROM insurance_providers")
|
||||||
|
prov_ids = dict(c.fetchall())
|
||||||
|
for n in providers:
|
||||||
|
prov_ids.setdefault(n, str(uuid.uuid4()))
|
||||||
|
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name)", [(i, n) for n, i in prov_ids.items()])
|
||||||
|
for p in policies:
|
||||||
|
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:
|
||||||
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)",
|
c.executemany("INSERT INTO policy_types (id,name) VALUES (%s,%s)", [(i, n) for n, i in ptype_ids.items()])
|
||||||
[(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}
|
||||||
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s)",
|
c.executemany("INSERT INTO insurance_providers (id,name) VALUES (%s,%s)", [(i, n) for n, i in prov_ids.items()])
|
||||||
[(i, n) for n, i in prov_ids.items()])
|
|
||||||
c.executemany("INSERT INTO adjusters (id,company,city,name,phone,beeper) VALUES (%s,%s,%s,%s,%s,%s)", adj_rows)
|
c.executemany("INSERT INTO adjusters (id,company,city,name,phone,beeper) VALUES (%s,%s,%s,%s,%s,%s)", adj_rows)
|
||||||
|
|
||||||
# patch policyType/provider FKs into policy tuples
|
|
||||||
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
|
||||||
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
|
||||||
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
|
||||||
"legacySourceTable,legacyId,updatedAt")
|
"legacySourceTable,legacyId,updatedAt")
|
||||||
|
if not sync_mode:
|
||||||
fixed = []
|
fixed = []
|
||||||
for p in policies:
|
for p in policies:
|
||||||
p = list(p)
|
p = list(p)
|
||||||
p[3] = ptype_ids.get(p[3]) # ptype name -> policyTypeId
|
p[3] = ptype_ids.get(p[3])
|
||||||
p[4] = prov_ids.get(p[4]) # provider name -> insuranceProviderId
|
p[4] = prov_ids.get(p[4])
|
||||||
fixed.append(tuple(p))
|
fixed.append(tuple(p))
|
||||||
ph = ",".join(["%s"] * 23)
|
ph = ",".join(["%s"] * 23)
|
||||||
c.executemany(f"INSERT INTO policies ({pol_cols}) VALUES ({ph})", fixed)
|
c.executemany(f"INSERT INTO policies ({pol_cols}) VALUES ({ph})", fixed)
|
||||||
|
else:
|
||||||
|
c.executemany("INSERT INTO policies (id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,legacySourceTable,legacyId,updatedAt) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) 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[0],p[1],p[2],ptype_ids.get(p[3]),prov_ids.get(p[4]),*p[5:]]) for p in policies])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
c.executemany("INSERT INTO policy_payment_installments "
|
c.executemany("INSERT INTO policy_payment_installments "
|
||||||
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import delete_missing, existing_ids, parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output" / "stg_utilities"
|
STG = Path(__file__).parent / "output" / "stg_utilities"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -100,8 +101,8 @@ def jkey(numer, casa, direc):
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[properties] target env: {env}")
|
print(f"[properties] target env: {env}")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -119,6 +120,7 @@ def main() -> None:
|
|||||||
flags[jkey(r["numerid"], r["casa"], r["direccion"])] = r
|
flags[jkey(r["numerid"], r["casa"], r["direccion"])] = r
|
||||||
|
|
||||||
props, services, trusts = [], [], []
|
props, services, trusts = [], [], []
|
||||||
|
prop_keys: set[tuple] = set()
|
||||||
skipped_no_customer = 0
|
skipped_no_customer = 0
|
||||||
matched_profile = 0
|
matched_profile = 0
|
||||||
|
|
||||||
@@ -129,6 +131,8 @@ def main() -> None:
|
|||||||
skipped_no_customer += 1
|
skipped_no_customer += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
legacy_id = str(int(row["_row_num"]))
|
||||||
|
prop_keys.add(("DATMEX", legacy_id))
|
||||||
pid = str(uuid.uuid4())
|
pid = str(uuid.uuid4())
|
||||||
addr2_parts = []
|
addr2_parts = []
|
||||||
for lbl, col in (("CASA", "casa"), ("MZ", "manzana"), ("LOTE", "lote")):
|
for lbl, col in (("CASA", "casa"), ("MZ", "manzana"), ("LOTE", "lote")):
|
||||||
@@ -137,7 +141,8 @@ def main() -> None:
|
|||||||
props.append((
|
props.append((
|
||||||
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
||||||
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
||||||
s(row["zona"]), "DATMEX", nid,
|
s(row["zona"]), "DATMEX", legacy_id,
|
||||||
|
|
||||||
))
|
))
|
||||||
|
|
||||||
pfrow = flags.get(jkey(row["numer_id"], row["casa"], row["direccion"]))
|
pfrow = flags.get(jkey(row["numer_id"], row["casa"], row["direccion"]))
|
||||||
@@ -199,21 +204,28 @@ def main() -> None:
|
|||||||
trusts.append((str(uuid.uuid4()), pid, bank, s(row["trust_num"]),
|
trusts.append((str(uuid.uuid4()), pid, bank, s(row["trust_num"]),
|
||||||
as_dec(row["bfee"]), as_date(row["vence1"]), as_date(row["vence2"])))
|
as_dec(row["bfee"]), as_date(row["vence1"]), as_date(row["vence2"])))
|
||||||
|
|
||||||
# Fresh rebuild (children first).
|
# Fresh rebuild (children first), or additive upsert for legacy-owned rows.
|
||||||
|
if sync_mode:
|
||||||
|
existing = existing_ids(cur, "properties", ("legacySourceTable", "legacyId"), "WHERE legacyId IS NOT NULL")
|
||||||
|
for row in props:
|
||||||
|
key = (row[8], row[9])
|
||||||
|
if key in existing:
|
||||||
|
row = list(row); row[0] = existing[key]
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),archivedAt=NULL", tuple(row))
|
||||||
|
delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL")
|
||||||
|
cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
|
cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
|
else:
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
||||||
cur.execute(f"TRUNCATE TABLE {t}")
|
cur.execute(f"TRUNCATE TABLE {t}")
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
||||||
cur.executemany(
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,"
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
"phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
|
||||||
cur.executemany(
|
|
||||||
"INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,"
|
|
||||||
"dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
|
||||||
cur.executemany(
|
|
||||||
"INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2)"
|
|
||||||
" VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
cur.execute("SELECT COUNT(*) FROM properties"); n_p = cur.fetchone()[0]
|
cur.execute("SELECT COUNT(*) FROM properties"); n_p = cur.fetchone()[0]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
STG = Path(__file__).parent / "output"
|
STG = Path(__file__).parent / "output"
|
||||||
NULL = "∅"
|
NULL = "∅"
|
||||||
@@ -90,7 +91,7 @@ def load(src, name):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env = env_arg()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
print(f"[transactions] target env: {env}")
|
print(f"[transactions] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -225,18 +226,17 @@ def main():
|
|||||||
iva()
|
iva()
|
||||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
||||||
|
|
||||||
# --- write ---
|
if sync_mode:
|
||||||
|
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),updatedAt=NOW(),voidedAt=NULL", tx)
|
||||||
|
else:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
for t in ("transactions", "type_transactions", "exchange_rates"):
|
for t in ("transactions", "type_transactions", "exchange_rates"):
|
||||||
c.execute(f"TRUNCATE TABLE {t}")
|
c.execute(f"TRUNCATE TABLE {t}")
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
|
||||||
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", type_rows)
|
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", type_rows)
|
||||||
c.executemany("INSERT INTO exchange_rates (id,rate,effectiveDate,effectiveHour) VALUES (%s,%s,%s,%s)", xr_rows)
|
c.executemany("INSERT INTO exchange_rates (id,rate,effectiveDate,effectiveHour) VALUES (%s,%s,%s,%s)", xr_rows)
|
||||||
c.executemany(
|
c.executemany(
|
||||||
"INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,"
|
"INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", tx)
|
||||||
"amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,"
|
|
||||||
"legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", tx)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ model Vehicle {
|
|||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("vehicles")
|
@@map("vehicles")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ model Property {
|
|||||||
documents ServiceDocument[]
|
documents ServiceDocument[]
|
||||||
trustAccount TrustAccount?
|
trustAccount TrustAccount?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("properties")
|
@@map("properties")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +422,7 @@ model Transaction {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([customerId, transactionDate])
|
@@index([customerId, transactionDate])
|
||||||
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
@@map("transactions")
|
@@map("transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +471,7 @@ model BankTransaction {
|
|||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("bank_transactions")
|
@@map("bank_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,3 +537,40 @@ model EmailLog {
|
|||||||
|
|
||||||
@@map("email_log")
|
@@map("email_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
||||||
|
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
||||||
|
// the captured log. Rows are the audit trail for who ran a destructive op.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum OpsJobKind {
|
||||||
|
BACKUP
|
||||||
|
RESTORE
|
||||||
|
REIMPORT
|
||||||
|
SYNC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OpsJobStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model OpsJob {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
kind OpsJobKind
|
||||||
|
status OpsJobStatus @default(RUNNING)
|
||||||
|
// Combined stdout+stderr of the spawned process, appended as it runs.
|
||||||
|
log String @db.LongText
|
||||||
|
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
|
||||||
|
// createdById — the actor id is stored flat, like activity_logs' userId use.
|
||||||
|
params Json?
|
||||||
|
createdById String?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([startedAt])
|
||||||
|
@@map("ops_jobs")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user