import type { Database } from "@db/sqlite"; function lastId(database: Database): number { return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); } export type Company = { id: number; code: string; name: string; parent_id: number | null; kind: "principal" | "sub"; status: "activo" | "inactivo"; tenant_id?: number | null; registro_patronal?: string; razon_social?: string; nombre_comercial?: string; rfc?: string; regimen_fiscal?: string; clase_riesgo?: string; domicilio_fiscal?: string; codigo_postal?: string; ciudad?: string; estado?: string; telefono?: string; email?: string; representante_legal?: string; giro?: string; created_at?: string; parent_code?: string | null; parent_name?: string | null; worker_count?: number; }; export type CompanyProfileInput = { name?: string; code?: string; status?: string; parent_id?: number | null; registro_patronal?: string; razon_social?: string; nombre_comercial?: string; rfc?: string; regimen_fiscal?: string; clase_riesgo?: string; domicilio_fiscal?: string; codigo_postal?: string; ciudad?: string; estado?: string; telefono?: string; email?: string; representante_legal?: string; giro?: string; }; const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{1,15}$/; const RFC_RE = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/; function trimText(value: unknown): string { return (value ?? "").toString().trim(); } function normRfc(value: unknown): string { return trimText(value).toUpperCase().replace(/\s+/g, ""); } export function listCompanies(database: Database, tenantId?: number | null): Company[] { const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; const params = tenantId != null ? [tenantId] : []; return database.prepare( `SELECT c.*, p.code AS parent_code, p.name AS parent_name, (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count FROM companies c LEFT JOIN companies p ON p.id = c.parent_id ${where} ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, c.name COLLATE NOCASE`, ).all(...params) as Company[]; } export function companyById(database: Database, id: number): Company | undefined { return database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as Company | undefined; } export function companyByCode(database: Database, code: string): Company | undefined { return database.prepare("SELECT * FROM companies WHERE code = ?").get(normCompanyCode(code)) as | Company | undefined; } export function principalCompany(database: Database, tenantId?: number | null): Company | undefined { if (tenantId != null) { return database.prepare( "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", ).get(tenantId) as Company | undefined; } return database.prepare( "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", ).get() as Company | undefined; } export function normCompanyCode(value: string | null | undefined): string { return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, ""); } export function companyCodeFromName(name: string): string { const slug = name .normalize("NFD") .replace(/\p{M}/gu, "") .toUpperCase() .replace(/[^A-Z0-9]+/g, "") .slice(0, 12); return slug || "EMP"; } export function nextCompanyCode(database: Database, name: string): string { const base = companyCodeFromName(name); if (!companyByCode(database, base)) return base; const row = database.prepare( `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n FROM companies WHERE code GLOB 'EMP-[0-9][0-9][0-9][0-9]*'`, ).get() as { n: number }; return `EMP-${String(row.n).padStart(4, "0")}`; } export function resolveCompany( database: Database, body: { company_id?: number | null; hire_type?: string | null }, ): Company | undefined { if (body.company_id) { const byId = companyById(database, Number(body.company_id)); if (byId) return byId; } const code = normCompanyCode(body.hire_type); if (code) return companyByCode(database, code); return undefined; } export function validateCompanyCode(code: string): string | null { if (!CODE_RE.test(code)) { return "Código de 2 a 16 caracteres (letras, números, _ o -)"; } return null; } export function validateCompanyProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { const rfc = normRfc(input.rfc); if (rfc && !RFC_RE.test(rfc)) { return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; } if (opts.requireLegal) { if (!trimText(input.razon_social) && !trimText(input.name) && !trimText(input.nombre_comercial)) { return "Indique razón social o nombre comercial"; } } const clase = trimText(input.clase_riesgo).toUpperCase(); if (clase && !["I", "II", "III", "IV", "V"].includes(clase)) { return "Clase de riesgo IMSS debe ser I, II, III, IV o V"; } return null; } export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName = "") { const nombreComercial = trimText(input.nombre_comercial) || trimText(input.name) || fallbackName; const razonSocial = trimText(input.razon_social) || nombreComercial || fallbackName; const name = trimText(input.name) || nombreComercial || razonSocial || fallbackName; return { name, nombre_comercial: nombreComercial, razon_social: razonSocial, rfc: normRfc(input.rfc), regimen_fiscal: trimText(input.regimen_fiscal), registro_patronal: trimText(input.registro_patronal).toUpperCase(), clase_riesgo: trimText(input.clase_riesgo).toUpperCase(), domicilio_fiscal: trimText(input.domicilio_fiscal), codigo_postal: trimText(input.codigo_postal), ciudad: trimText(input.ciudad), estado: trimText(input.estado), telefono: trimText(input.telefono), email: trimText(input.email).toLowerCase(), representante_legal: trimText(input.representante_legal), giro: trimText(input.giro), }; } function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { return validateCompanyProfile(input, opts); } function profileFromInput(input: CompanyProfileInput, fallbackName = "") { return normalizeCompanyProfile(input, fallbackName); } export function createSubcompany( database: Database, input: CompanyProfileInput, tenantId?: number | null, ): { company?: Company; error?: string } { const profileErr = validateProfile(input, { requireLegal: true }); if (profileErr) return { error: profileErr }; const profile = profileFromInput(input); if (!profile.name) return { error: "Nombre de empresa obligatorio" }; const principal = principalCompany(database, tenantId); if (!principal) return { error: "No hay empresa principal" }; const parentId = input.parent_id ? Number(input.parent_id) : principal.id; const parent = companyById(database, parentId); if (!parent) return { error: "Empresa padre no encontrada" }; if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { return { error: "Empresa padre de otro tenant" }; } let code = normCompanyCode(input.code); if (!code) code = nextCompanyCode(database, profile.name); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr }; if (companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; const tid = tenantId ?? principal.tenant_id ?? null; database.prepare( `INSERT INTO companies ( code, name, parent_id, kind, status, tenant_id, registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( code, profile.name, parent.id, tid, profile.registro_patronal, profile.razon_social, profile.nombre_comercial, profile.rfc, profile.regimen_fiscal, profile.clase_riesgo, profile.domicilio_fiscal, profile.codigo_postal, profile.ciudad, profile.estado, profile.telefono, profile.email, profile.representante_legal, profile.giro, ); return { company: companyById(database, lastId(database)) }; } export function updateCompany( database: Database, id: number, input: CompanyProfileInput, ): { company?: Company; error?: string; status?: 400 | 404 } { const current = companyById(database, id); if (!current) return { error: "Empresa no encontrada", status: 404 }; const profileErr = validateProfile(input); if (profileErr) return { error: profileErr, status: 400 }; const merged: CompanyProfileInput = { name: input.name !== undefined ? input.name : current.name, code: input.code !== undefined ? input.code : current.code, status: input.status !== undefined ? input.status : current.status, registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal, razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social, nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial, rfc: input.rfc !== undefined ? input.rfc : current.rfc, regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal, clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo, domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal, codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal, ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad, estado: input.estado !== undefined ? input.estado : current.estado, telefono: input.telefono !== undefined ? input.telefono : current.telefono, email: input.email !== undefined ? input.email : current.email, representante_legal: input.representante_legal !== undefined ? input.representante_legal : current.representante_legal, giro: input.giro !== undefined ? input.giro : current.giro, }; const profile = profileFromInput(merged, current.name); if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 }; let code = current.code; if (input.code !== undefined) { code = normCompanyCode(input.code); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr, status: 400 }; const clash = companyByCode(database, code); if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; } let status = current.status; if (input.status !== undefined) { if (!["activo", "inactivo"].includes(input.status)) { return { error: "Estatus debe ser activo o inactivo", status: 400 }; } if (current.kind === "principal" && input.status === "inactivo") { return { error: "La empresa principal no se puede inactivar", status: 400 }; } status = input.status as Company["status"]; } database.prepare( `UPDATE companies SET name = ?, code = ?, status = ?, registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, representante_legal = ?, giro = ? WHERE id = ?`, ).run( profile.name, code, status, profile.registro_patronal, profile.razon_social, profile.nombre_comercial, profile.rfc, profile.regimen_fiscal, profile.clase_riesgo, profile.domicilio_fiscal, profile.codigo_postal, profile.ciudad, profile.estado, profile.telefono, profile.email, profile.representante_legal, profile.giro, id, ); if (code !== current.code) { database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); } return { company: companyById(database, id) }; }