panels-origin/api/companies.ts
2026-08-21 16:55:01 -05:00

334 lines
12 KiB
TypeScript

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