mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 19:43:18 +00:00
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { Database } from "@db/sqlite";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { DATA_DIR, DB_PATH, EXPEDIENTES_DIR, PDFS_DIR, LOGOS_DIR, PROJECTS_DIR } from "./config.ts";
|
|
import { config } from "./config.ts";
|
|
import { hashPassword } from "./crypto.ts";
|
|
import { DOCUMENT_TYPE_SEED, evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
|
|
import { runLiquibase } from "./liquibase.ts";
|
|
|
|
export type Db = Database;
|
|
|
|
let db: Database | null = null;
|
|
|
|
export async function getDb(): Promise<Database> {
|
|
if (db) return db;
|
|
await mkdir(DATA_DIR, { recursive: true });
|
|
await mkdir(EXPEDIENTES_DIR, { recursive: true });
|
|
await mkdir(PDFS_DIR, { recursive: true });
|
|
await mkdir(LOGOS_DIR, { recursive: true });
|
|
await mkdir(PROJECTS_DIR, { recursive: true });
|
|
await runLiquibase();
|
|
db = new Database(DB_PATH);
|
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
await seed(db);
|
|
return db;
|
|
}
|
|
|
|
export function seedDocumentTypes(database: Database) {
|
|
const upsert = database.prepare(
|
|
`INSERT INTO document_types
|
|
(code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(code) DO UPDATE SET
|
|
label = excluded.label,
|
|
required = excluded.required,
|
|
validity_mode = excluded.validity_mode,
|
|
freshness_days = excluded.freshness_days,
|
|
requires_issued_at = excluded.requires_issued_at,
|
|
requires_expires_at = excluded.requires_expires_at`,
|
|
);
|
|
for (const d of DOCUMENT_TYPE_SEED) {
|
|
upsert.run(
|
|
d.code,
|
|
d.label,
|
|
d.required,
|
|
d.validity_mode,
|
|
d.freshness_days,
|
|
d.requires_issued_at,
|
|
d.requires_expires_at,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function nextProjectCode(database: Database): string {
|
|
const row = database.prepare(
|
|
`SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n
|
|
FROM projects WHERE code GLOB 'PRY-[0-9][0-9][0-9][0-9]*' OR code GLOB 'OBR-[0-9][0-9][0-9][0-9]*'`,
|
|
).get() as { n: number };
|
|
return `PRY-${String(row.n).padStart(4, "0")}`;
|
|
}
|
|
|
|
export const PROJECT_STATUSES = ["activo", "pausado", "concluido", "cancelado"] as const;
|
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
|
|
export const PROJECT_STATUS_CATALOG = [
|
|
{ code: "activo", label: "Activo" },
|
|
{ code: "pausado", label: "Pausado" },
|
|
{ code: "concluido", label: "Concluido" },
|
|
{ code: "cancelado", label: "Cancelado" },
|
|
] as const;
|
|
|
|
export function isProjectStatus(value: string): value is ProjectStatus {
|
|
return (PROJECT_STATUSES as readonly string[]).includes(value);
|
|
}
|
|
|
|
export function projectById(database: Database, id: number) {
|
|
return database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
|
|
| { id: number; code: string; name: string; status: string }
|
|
| undefined;
|
|
}
|
|
|
|
export function projectMustBe(
|
|
project: { status: string } | undefined,
|
|
allowed: readonly ProjectStatus[],
|
|
closedMessage: string,
|
|
): { error: string; status: 400 | 404 } | null {
|
|
if (!project) return { error: "Proyecto no encontrado", status: 404 };
|
|
if (!allowed.includes(project.status as ProjectStatus)) {
|
|
return { error: closedMessage, status: 400 };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function seed(database: Database) {
|
|
const risks = [
|
|
["rojo", "Rojo", "#A20000", "#FFFFFF"],
|
|
["amarillo", "Amarillo", "#EEEE3C", "#000000"],
|
|
["azul", "Azul", "#001485", "#FFFFFF"],
|
|
["verde", "Verde", "#008514", "#FFFFFF"],
|
|
["negro", "Negro", "#000000", "#FFFFFF"],
|
|
["naranja", "Naranja", "#FF5733", "#FFFFFF"],
|
|
];
|
|
const insRisk = database.prepare(
|
|
"INSERT OR IGNORE INTO risk_levels (code, label, color, text_color) VALUES (?, ?, ?, ?)",
|
|
);
|
|
for (const r of risks) insRisk.run(...r);
|
|
|
|
database.prepare(
|
|
"INSERT OR IGNORE INTO badge_themes (id, name, layout) VALUES (?, ?, ?)",
|
|
).run(
|
|
"arctec-dos-logos-fold",
|
|
"Arctec dos logos (doblez carta)",
|
|
"letter-landscape-4-fold",
|
|
);
|
|
|
|
seedDocumentTypes(database);
|
|
|
|
const projectDocs = [
|
|
["contrato", "Contrato", 1],
|
|
["presupuesto", "Presupuesto", 1],
|
|
["planos", "Planos", 0],
|
|
["otro", "Otro", 0],
|
|
];
|
|
const insPDoc = database.prepare(
|
|
"INSERT OR IGNORE INTO project_document_types (code, label, required) VALUES (?, ?, ?)",
|
|
);
|
|
for (const d of projectDocs) insPDoc.run(...d);
|
|
|
|
const defaultTenantId = 1;
|
|
let principal = database.prepare(
|
|
"SELECT id FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
|
|
).get(defaultTenantId) as { id: number } | undefined;
|
|
if (!principal) {
|
|
database.prepare(
|
|
`INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
|
|
VALUES ('ARCT2608', 'ARCTEC', NULL, 'principal', ?, 'ARCTEC', 'ARCTEC')`,
|
|
).run(defaultTenantId);
|
|
principal = database.prepare(
|
|
"SELECT id FROM companies WHERE code = 'ARCT2608'",
|
|
).get() as { id: number };
|
|
}
|
|
for (const [code, name] of [["FISICA", "FISICA"], ["ARCOTEC", "ARCOTEC"]] as const) {
|
|
const exists = database.prepare("SELECT id FROM companies WHERE code = ?").get(code);
|
|
if (!exists) {
|
|
database.prepare(
|
|
`INSERT INTO companies (code, name, parent_id, kind, tenant_id, nombre_comercial, razon_social)
|
|
VALUES (?, ?, ?, 'sub', ?, ?, ?)`,
|
|
).run(code, name, principal.id, defaultTenantId, name, name);
|
|
}
|
|
}
|
|
|
|
const users = [
|
|
["arct2608", "Administrador"],
|
|
];
|
|
// Migración suave de usuarios legacy → código puro
|
|
database.prepare(
|
|
"UPDATE users SET username = 'arct2608' WHERE username IN ('papa', 'adm.arctec') AND NOT EXISTS (SELECT 1 FROM users WHERE username = 'arct2608')",
|
|
).run();
|
|
for (const [username, display] of users) {
|
|
const exists = database.prepare("SELECT id FROM users WHERE username = ?").get(username);
|
|
if (!exists) {
|
|
const hash = await hashPassword(config.seedPassword);
|
|
database.prepare(
|
|
`INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role)
|
|
VALUES (?, ?, ?, ?, ?, 'tenant_admin')`,
|
|
).run(username, hash, display, principal.id, defaultTenantId);
|
|
}
|
|
}
|
|
database.prepare(
|
|
"UPDATE users SET company_id = ?, tenant_id = COALESCE(tenant_id, ?), role = COALESCE(NULLIF(role, ''), 'tenant_admin') WHERE company_id IS NULL OR tenant_id IS NULL",
|
|
).run(principal.id, defaultTenantId);
|
|
|
|
const proj = database.prepare("SELECT id FROM projects LIMIT 1").get();
|
|
if (!proj) {
|
|
database.prepare(
|
|
`INSERT INTO projects (code, name, address, theme_id, company_id, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
nextProjectCode(database),
|
|
"ZENDALA CANCUN",
|
|
"",
|
|
"arctec-dos-logos-fold",
|
|
principal.id,
|
|
defaultTenantId,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function workerImssContext(database: Database, workerId: number): WorkerImssContext {
|
|
const w = database.prepare(
|
|
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
|
|
).get(workerId) as {
|
|
imss_status: ImssStatus;
|
|
imss_alta_at: string | null;
|
|
last_rehire_at: string | null;
|
|
} | undefined;
|
|
const altaDoc = database.prepare(
|
|
`SELECT imss_alta_at, uploaded_at FROM documents
|
|
WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = 1
|
|
LIMIT 1`,
|
|
).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
|
|
return {
|
|
imss_status: (w?.imss_status as ImssStatus) || "sin_alta",
|
|
last_rehire_at: w?.last_rehire_at ?? null,
|
|
current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null,
|
|
};
|
|
}
|
|
|
|
/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. */
|
|
export function checklistItemsFor(database: Database, workerId: number) {
|
|
const types = database.prepare(
|
|
`SELECT code, label, required, validity_mode, freshness_days,
|
|
requires_issued_at, requires_expires_at
|
|
FROM document_types`,
|
|
).all() as {
|
|
code: string;
|
|
label: string;
|
|
required: number;
|
|
validity_mode: string;
|
|
freshness_days: number | null;
|
|
requires_issued_at: number;
|
|
requires_expires_at: number;
|
|
}[];
|
|
const currentDocs = database.prepare(
|
|
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
|
|
FROM documents WHERE worker_id = ? AND is_current = 1`,
|
|
).all(workerId) as {
|
|
type_code: string;
|
|
issued_at: string | null;
|
|
expires_at: string | null;
|
|
uploaded_at: string;
|
|
imss_alta_at: string | null;
|
|
}[];
|
|
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
|
|
const ctx = workerImssContext(database, workerId);
|
|
|
|
return {
|
|
ctx,
|
|
freshness_required: freshnessRequired(ctx),
|
|
items: types.map((t) => {
|
|
const policy = {
|
|
code: t.code,
|
|
label: t.label,
|
|
required: !!t.required,
|
|
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry",
|
|
freshness_days: t.freshness_days,
|
|
requires_issued_at: !!t.requires_issued_at,
|
|
requires_expires_at: !!t.requires_expires_at,
|
|
};
|
|
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx);
|
|
return {
|
|
code: t.code,
|
|
label: t.label,
|
|
required: !!t.required,
|
|
validity_mode: policy.validity_mode,
|
|
freshness_days: t.freshness_days,
|
|
requires_issued_at: policy.requires_issued_at,
|
|
requires_expires_at: policy.requires_expires_at,
|
|
present: evaled.present,
|
|
valid: evaled.valid,
|
|
validity_status: evaled.validity_status,
|
|
issued_at: evaled.issued_at,
|
|
expires_at: evaled.expires_at,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function imssFlagsFor(database: Database, workerId: number) {
|
|
const { ctx, freshness_required: needFresh, items } = checklistItemsFor(database, workerId);
|
|
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
|
const inProject = !!database.prepare(
|
|
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
|
|
).get(workerId);
|
|
const hasImss = ctx.imss_status === "alta";
|
|
return {
|
|
imss_status: ctx.imss_status,
|
|
freshness_required: needFresh,
|
|
imss_ready: requiredOk && !hasImss,
|
|
in_project_without_imss: inProject && !hasImss,
|
|
expediente_ok: requiredOk,
|
|
};
|
|
}
|
|
|
|
export function checklistFor(database: Database, workerId: number) {
|
|
return checklistItemsFor(database, workerId).items;
|
|
}
|
|
|
|
export function refreshPipeline(database: Database, workerId: number) {
|
|
const w = database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
|
|
| { status: string }
|
|
| undefined;
|
|
if (!w) return;
|
|
if (w.status === "baja") {
|
|
database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
|
|
return;
|
|
}
|
|
const items = checklistFor(database, workerId);
|
|
const photo = items.find((i) => i.code === "foto");
|
|
const photoOk = photo?.present && photo?.valid;
|
|
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
|
const printed = database.prepare(
|
|
`SELECT 1 FROM badge_job_people p
|
|
JOIN badge_jobs j ON j.id = p.job_id
|
|
WHERE p.worker_id = ? LIMIT 1`,
|
|
).get(workerId);
|
|
let pipeline = "incompleto";
|
|
if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
|
|
const assigned = database.prepare(
|
|
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = 1 LIMIT 1",
|
|
).get(workerId);
|
|
if (pipeline !== "incompleto" && assigned) {
|
|
pipeline = printed ? "activo" : "listo_gafete";
|
|
}
|
|
database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
|
|
}
|
|
|
|
export function lastInsertId(database: Database): number {
|
|
const row = database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number };
|
|
return Number(row.id);
|
|
}
|
|
|
|
export function workerDir(workerId: number): string {
|
|
return join(EXPEDIENTES_DIR, String(workerId));
|
|
}
|
|
|
|
export function projectDir(projectId: number): string {
|
|
return join(PROJECTS_DIR, String(projectId));
|
|
}
|
|
|
|
export function projectChecklistFor(database: Database, projectId: number) {
|
|
const types = database.prepare(
|
|
"SELECT code, label, required FROM project_document_types",
|
|
).all() as { code: string; label: string; required: number }[];
|
|
const current = database.prepare(
|
|
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`,
|
|
).all(projectId) as { type_code: string }[];
|
|
const have = new Set(current.map((c) => c.type_code));
|
|
return types.map((t) => ({
|
|
code: t.code,
|
|
label: t.label,
|
|
required: !!t.required,
|
|
present: have.has(t.code),
|
|
}));
|
|
}
|