mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:43:18 +00:00
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
import * as XLSX from "xlsx";
|
|
import type { Database } from "@db/sqlite";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { encryptBytes } from "./docs_crypto.ts";
|
|
import { sha256Hex } from "./crypto.ts";
|
|
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
|
|
import { refreshPipeline, workerDir, lastInsertId, projectDir } from "./db.ts";
|
|
import { resolveCompany } from "./companies.ts";
|
|
|
|
export const IMPORT_COLUMNS = [
|
|
"NOMBRE",
|
|
"2 NOMBRE",
|
|
"APELLIDO PATERNO",
|
|
"APELLIDO MATERNO",
|
|
"CURP",
|
|
"RFC",
|
|
"NSS",
|
|
"TELEFONO",
|
|
"CORREO",
|
|
"DIRECCION",
|
|
"TIPO SANGRE",
|
|
"ALTA",
|
|
"CARGO",
|
|
"RIESGO",
|
|
"TIPO TRABAJO",
|
|
"JORNAL",
|
|
"GAFETE",
|
|
"ESTATUS",
|
|
"URL FOTO",
|
|
] as const;
|
|
|
|
const FIELD_LABEL: Record<string, string> = {
|
|
first_name: "Nombre",
|
|
last_name_p: "Apellido paterno",
|
|
last_name_m: "Apellido materno",
|
|
curp: "CURP",
|
|
rfc: "RFC",
|
|
nss: "NSS",
|
|
phone: "Teléfono",
|
|
email: "Correo",
|
|
address: "Dirección",
|
|
hire_type: "Empresa",
|
|
position: "Cargo",
|
|
risk_code: "Riesgo",
|
|
work_type: "Tipo de trabajo",
|
|
daily_wage: "Jornal",
|
|
};
|
|
|
|
const KEY_ALIAS: Record<string, string> = {
|
|
"SEGUNDO NOMBRE": "2 NOMBRE",
|
|
"2NOMBRE": "2 NOMBRE",
|
|
TELEFONO: "TELEFONO",
|
|
TEL: "TELEFONO",
|
|
EMAIL: "CORREO",
|
|
MAIL: "CORREO",
|
|
"TIPO DE SANGRE": "TIPO SANGRE",
|
|
"TIPO TRABAJO": "TIPO TRABAJO",
|
|
"TIPO DE TRABAJO": "TIPO TRABAJO",
|
|
"JORNAL DIARIO": "JORNAL",
|
|
SALARIO: "JORNAL",
|
|
STATUS: "ESTATUS",
|
|
ESTADO: "ESTATUS",
|
|
FOTO: "URL FOTO",
|
|
};
|
|
|
|
function normHeader(raw: string) {
|
|
const k = raw
|
|
.normalize("NFD")
|
|
.replace(/\p{M}/gu, "")
|
|
.toUpperCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return KEY_ALIAS[k] || k;
|
|
}
|
|
|
|
function normalizeRow(r: Record<string, unknown>): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(r)) out[normHeader(k)] = String(v ?? "").trim();
|
|
return out;
|
|
}
|
|
|
|
function sheetRows(wb: XLSX.WorkBook, name: string): { row: number; data: Record<string, string> }[] {
|
|
const sheet = wb.Sheets[name];
|
|
if (!sheet) return [];
|
|
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "" });
|
|
return rows.map((r, i) => ({ row: i + 2, data: normalizeRow(r) }));
|
|
}
|
|
|
|
function parseWage(r: Record<string, string>): number | undefined {
|
|
if (!("JORNAL" in r)) return undefined;
|
|
const raw = r["JORNAL"].replace(/[$\s]/g, "").replace(",", ".");
|
|
if (!raw) return NaN;
|
|
return Number(raw);
|
|
}
|
|
|
|
function yesNo(v: string) {
|
|
return ["si", "sí", "yes", "1", "true"].includes(v.toLowerCase());
|
|
}
|
|
|
|
function rowToInput(r: Record<string, string>, fallbackStatus: "activo" | "baja"): WorkerInput {
|
|
const est = (r["ESTATUS"] || fallbackStatus).toLowerCase();
|
|
const wage = parseWage(r);
|
|
return {
|
|
first_name: r["NOMBRE"],
|
|
middle_name: r["2 NOMBRE"] || null,
|
|
last_name_p: r["APELLIDO PATERNO"],
|
|
last_name_m: r["APELLIDO MATERNO"],
|
|
curp: r["CURP"],
|
|
rfc: r["RFC"],
|
|
nss: r["NSS"],
|
|
phone: r["TELEFONO"],
|
|
email: r["CORREO"],
|
|
address: r["DIRECCION"],
|
|
blood_type: r["TIPO SANGRE"] || null,
|
|
hire_type: (r["ALTA"] || "ARCT2608").toUpperCase(),
|
|
position: r["CARGO"],
|
|
risk_code: canonicalRiskCode(r["RIESGO"] || ""),
|
|
work_type: (r["TIPO TRABAJO"] || "N").toUpperCase(),
|
|
daily_wage: wage === undefined ? 0 : wage,
|
|
needs_badge: r["GAFETE"] ? yesNo(r["GAFETE"]) : true,
|
|
status: est.startsWith("baja") ? "baja" : "activo",
|
|
};
|
|
}
|
|
|
|
function isEmptyRow(r: Record<string, string>) {
|
|
return IMPORT_COLUMNS.every((c) => !r[c]);
|
|
}
|
|
|
|
function errorMessages(errors: Record<string, string>): string[] {
|
|
return Object.entries(errors).map(([k, v]) => `${FIELD_LABEL[k] || k}: ${v}`);
|
|
}
|
|
|
|
export function buildImportTemplate(companies: { code: string; name: string }[] = []): Uint8Array {
|
|
const wb = XLSX.utils.book_new();
|
|
const companyRows = companies.length
|
|
? companies.map((company) => ["ALTA", company.code, company.name])
|
|
: [
|
|
["ALTA", "ARCT2608", "Empresa principal"],
|
|
["ALTA", "FISICA", "Persona física"],
|
|
["ALTA", "ARCOTEC", "Subempresa"],
|
|
];
|
|
|
|
const instructions = [
|
|
["Plantilla de carga masiva — Panel de proyectos Arctec"],
|
|
[""],
|
|
["1. Llene la hoja PERSONAL. No cambie los nombres de las columnas."],
|
|
["2. CURP, RFC y NSS deben ser únicos. Si ya existen en el sistema, esa fila no se inserta (queda como 'ya existía')."],
|
|
["3. Obligatorio: nombre, apellidos, CURP, RFC, NSS, teléfono, correo, dirección, alta, cargo, riesgo, tipo trabajo, jornal."],
|
|
["4. Opcional: segundo nombre, tipo de sangre, URL foto."],
|
|
["5. Valores permitidos: vea la hoja CATALOGOS."],
|
|
["6. ESTATUS: activo o baja. Si deja vacío, se toma activo."],
|
|
["7. GAFETE: SI o NO."],
|
|
["8. Suba este archivo en Padrón → Importar Excel."],
|
|
["9. También se aceptan las hojas ACTUALES e HISTORICO del Excel anterior."],
|
|
];
|
|
const wsI = XLSX.utils.aoa_to_sheet(instructions);
|
|
wsI["!cols"] = [{ wch: 110 }];
|
|
XLSX.utils.book_append_sheet(wb, wsI, "INSTRUCCIONES");
|
|
|
|
const personal = [IMPORT_COLUMNS.slice() as string[]];
|
|
const wsP = XLSX.utils.aoa_to_sheet(personal);
|
|
wsP["!cols"] = IMPORT_COLUMNS.map((c) => ({ wch: Math.max(14, c.length + 2) }));
|
|
XLSX.utils.book_append_sheet(wb, wsP, "PERSONAL");
|
|
|
|
const catalogs = [
|
|
["Campo", "Valor", "Notas"],
|
|
...companyRows,
|
|
["RIESGO", "alto", "Alto — barra gafete roja"],
|
|
["RIESGO", "medio", "Medio — barra amarilla"],
|
|
["RIESGO", "bajo", "Bajo — barra verde"],
|
|
["RIESGO", "rojo", "También se acepta el color de barra"],
|
|
["RIESGO", "amarillo", "Color de barra"],
|
|
["RIESGO", "azul", "Color de barra (se trata como bajo)"],
|
|
["RIESGO", "verde", "Color de barra"],
|
|
["RIESGO", "negro", "Color de barra (se trata como alto)"],
|
|
["RIESGO", "naranja", "Color de barra (se trata como alto)"],
|
|
["TIPO TRABAJO", "N", "Normal"],
|
|
["TIPO TRABAJO", "D", "Destajo"],
|
|
["GAFETE", "SI", "Se imprime gafete"],
|
|
["GAFETE", "NO", "No lleva gafete"],
|
|
["ESTATUS", "activo", "Alta en padrón"],
|
|
["ESTATUS", "baja", "Histórico"],
|
|
];
|
|
const wsC = XLSX.utils.aoa_to_sheet(catalogs);
|
|
wsC["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 36 }];
|
|
XLSX.utils.book_append_sheet(wb, wsC, "CATALOGOS");
|
|
|
|
const out = XLSX.write(wb, { type: "array", bookType: "xlsx" });
|
|
return out instanceof Uint8Array ? out : new Uint8Array(out);
|
|
}
|
|
|
|
function findExisting(db: Database, curp: string, rfc: string, nss: string) {
|
|
return db.prepare(
|
|
`SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers
|
|
WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`,
|
|
).get(curp, rfc, nss) as
|
|
| { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string }
|
|
| undefined;
|
|
}
|
|
|
|
export async function storeDocument(
|
|
db: Database,
|
|
workerId: number,
|
|
type: string,
|
|
filename: string,
|
|
mime: string,
|
|
bytes: Uint8Array,
|
|
userId: number | null,
|
|
meta: {
|
|
issued_at?: string | null;
|
|
expires_at?: string | null;
|
|
imss_company_id?: number | null;
|
|
imss_alta_at?: string | null;
|
|
imss_baja_at?: string | null;
|
|
} = {},
|
|
) {
|
|
const allowed = db.prepare("SELECT code FROM document_types WHERE code = ?").get(type);
|
|
if (!allowed) throw new Error("Tipo de documento no válido");
|
|
|
|
const { iv, cipher } = await encryptBytes(bytes);
|
|
const hash = await sha256Hex(bytes);
|
|
const storage = `${crypto.randomUUID()}.enc`;
|
|
const dir = workerDir(workerId);
|
|
await mkdir(dir, { recursive: true });
|
|
await Deno.writeFile(join(dir, storage), cipher);
|
|
db.prepare("UPDATE documents SET is_current = 0 WHERE worker_id = ? AND type_code = ?").run(
|
|
workerId,
|
|
type,
|
|
);
|
|
const issuedAt = meta.issued_at || null;
|
|
const expiresAt = meta.expires_at || null;
|
|
const imssCompanyId = meta.imss_company_id || null;
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null);
|
|
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
|
|
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
|
|
|
|
db.prepare(
|
|
`INSERT INTO documents
|
|
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current,
|
|
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
workerId,
|
|
type,
|
|
filename,
|
|
mime,
|
|
bytes.byteLength,
|
|
hash,
|
|
iv,
|
|
storage,
|
|
issuedAt || movementDate,
|
|
expiresAt,
|
|
imssCompanyId,
|
|
movementDate,
|
|
userId,
|
|
);
|
|
|
|
if (type === "alta_imss") {
|
|
const companyId = imssCompanyId ||
|
|
(db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
|
|
?.company_id ||
|
|
null;
|
|
const company = companyId
|
|
? db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as
|
|
| { id: number; code: string }
|
|
| undefined
|
|
: undefined;
|
|
if (!company) throw new Error("Empresa patrón no válida");
|
|
db.prepare(
|
|
`UPDATE workers SET
|
|
imss_status = 'alta',
|
|
imss_company_id = ?,
|
|
imss_alta_at = ?,
|
|
imss_baja_at = NULL,
|
|
company_id = ?,
|
|
hire_type = ?,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
).run(company.id, imssAltaAt, company.id, company.code, workerId);
|
|
}
|
|
|
|
if (type === "baja_imss") {
|
|
db.prepare(
|
|
`UPDATE workers SET
|
|
imss_status = 'baja_imss',
|
|
imss_baja_at = ?,
|
|
imss_company_id = NULL,
|
|
company_id = NULL,
|
|
hire_type = '',
|
|
updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
).run(imssBajaAt, workerId);
|
|
}
|
|
|
|
refreshPipeline(db, workerId);
|
|
}
|
|
|
|
export async function storeProjectDocument(
|
|
db: Database,
|
|
projectId: number,
|
|
type: string,
|
|
filename: string,
|
|
mime: string,
|
|
bytes: Uint8Array,
|
|
userId: number | null,
|
|
) {
|
|
const allowed = db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type);
|
|
if (!allowed) throw new Error("Tipo de documento no válido");
|
|
const { iv, cipher } = await encryptBytes(bytes);
|
|
const hash = await sha256Hex(bytes);
|
|
const storage = `${crypto.randomUUID()}.enc`;
|
|
const dir = projectDir(projectId);
|
|
await mkdir(dir, { recursive: true });
|
|
await Deno.writeFile(join(dir, storage), cipher);
|
|
db.prepare("UPDATE project_documents SET is_current = 0 WHERE project_id = ? AND type_code = ?").run(
|
|
projectId,
|
|
type,
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO project_documents
|
|
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`,
|
|
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
|
}
|
|
|
|
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
|
if (!url.startsWith("http")) return null;
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) return null;
|
|
return new Uint8Array(await res.arrayBuffer());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function upsertWorker(
|
|
db: Database,
|
|
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
|
|
projectId: number | null,
|
|
) {
|
|
const existing = findExisting(db, input.curp, input.rfc, input.nss);
|
|
if (existing) {
|
|
db.prepare(
|
|
`UPDATE workers SET
|
|
first_name=?, middle_name=?, last_name_p=?, last_name_m=?,
|
|
curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?,
|
|
hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?,
|
|
needs_badge=?, status=?, updated_at=datetime('now')
|
|
WHERE id=?`,
|
|
).run(
|
|
input.first_name,
|
|
input.middle_name,
|
|
input.last_name_p,
|
|
input.last_name_m,
|
|
input.curp,
|
|
input.rfc,
|
|
input.nss,
|
|
input.phone,
|
|
input.email,
|
|
input.address,
|
|
input.blood_type,
|
|
input.hire_type,
|
|
input.company_id,
|
|
input.tenant_id ?? null,
|
|
input.position,
|
|
input.risk_code,
|
|
input.work_type,
|
|
input.daily_wage,
|
|
input.needs_badge,
|
|
input.status,
|
|
existing.id,
|
|
);
|
|
if (projectId) assign(db, existing.id, projectId);
|
|
refreshPipeline(db, existing.id);
|
|
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
|
|
return { id: existing.id, action: "existed" as const, matched };
|
|
}
|
|
db.prepare(
|
|
`INSERT INTO workers
|
|
(first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
|
|
blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
).run(
|
|
input.first_name,
|
|
input.middle_name,
|
|
input.last_name_p,
|
|
input.last_name_m,
|
|
input.curp,
|
|
input.rfc,
|
|
input.nss,
|
|
input.phone,
|
|
input.email,
|
|
input.address,
|
|
input.blood_type,
|
|
input.hire_type,
|
|
input.company_id,
|
|
input.position,
|
|
input.risk_code,
|
|
input.work_type,
|
|
input.daily_wage,
|
|
input.needs_badge,
|
|
input.status,
|
|
input.tenant_id ?? null,
|
|
);
|
|
const id = lastInsertId(db);
|
|
if (projectId) assign(db, id, projectId);
|
|
refreshPipeline(db, id);
|
|
return { id, action: "inserted" as const, matched: null as string | null };
|
|
}
|
|
|
|
function assign(db: Database, workerId: number, projectId: number) {
|
|
db.prepare(
|
|
`INSERT INTO assignments (worker_id, project_id, active, start_date)
|
|
VALUES (?, ?, 1, date('now'))
|
|
ON CONFLICT(worker_id, project_id) DO UPDATE SET
|
|
active=1,
|
|
start_date=CASE WHEN assignments.active=0 THEN excluded.start_date ELSE assignments.start_date END,
|
|
end_date=NULL`,
|
|
).run(workerId, projectId);
|
|
}
|
|
|
|
export type ImportReport = {
|
|
inserted: number;
|
|
existed: number;
|
|
errors: number;
|
|
photos: number;
|
|
existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[];
|
|
error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[];
|
|
};
|
|
|
|
export async function importExcel(
|
|
db: Database,
|
|
bytes: Uint8Array,
|
|
projectId: number | null,
|
|
userId: number | null,
|
|
): Promise<ImportReport> {
|
|
const wb = XLSX.read(bytes, { type: "array" });
|
|
const risks = new Set(
|
|
(db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
|
|
);
|
|
const report: ImportReport = {
|
|
inserted: 0,
|
|
existed: 0,
|
|
errors: 0,
|
|
photos: 0,
|
|
existed_rows: [],
|
|
error_rows: [],
|
|
};
|
|
const seenCurp = new Map<string, number>();
|
|
const seenRfc = new Map<string, number>();
|
|
const seenNss = new Map<string, number>();
|
|
|
|
const jobs: { sheet: string; fallback: "activo" | "baja"; rows: { row: number; data: Record<string, string> }[] }[] = [];
|
|
if (wb.SheetNames.includes("PERSONAL")) {
|
|
jobs.push({ sheet: "PERSONAL", fallback: "activo", rows: sheetRows(wb, "PERSONAL") });
|
|
}
|
|
if (wb.SheetNames.includes("ACTUALES")) {
|
|
jobs.push({ sheet: "ACTUALES", fallback: "activo", rows: sheetRows(wb, "ACTUALES") });
|
|
}
|
|
if (wb.SheetNames.includes("HISTORICO")) {
|
|
jobs.push({ sheet: "HISTORICO", fallback: "baja", rows: sheetRows(wb, "HISTORICO") });
|
|
}
|
|
if (!jobs.length) {
|
|
report.error_rows.push({
|
|
sheet: "-",
|
|
row: 0,
|
|
nombre: "",
|
|
curp: "",
|
|
messages: ["El archivo no tiene hoja PERSONAL, ACTUALES ni HISTORICO. Descargue la plantilla."],
|
|
});
|
|
report.errors = 1;
|
|
return report;
|
|
}
|
|
|
|
for (const job of jobs) {
|
|
for (const { row, data } of job.rows) {
|
|
if (isEmptyRow(data)) continue;
|
|
const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" ");
|
|
const input = rowToInput(data, job.fallback);
|
|
const errors = validateWorkerFields(input);
|
|
const company = resolveCompany(db, input);
|
|
if (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`;
|
|
if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
|
|
errors.email = "Correo inválido";
|
|
}
|
|
if (input.risk_code && !risks.has(input.risk_code)) {
|
|
errors.risk_code = `Riesgo inválido (${data["RIESGO"]}). Use alto, medio o bajo`;
|
|
}
|
|
const curp = normUpper(input.curp);
|
|
const rfc = normUpper(input.rfc);
|
|
const nss = formatNss(input.nss ?? "");
|
|
if (!errors.curp && seenCurp.has(curp)) errors.curp = `CURP duplicada en el archivo (fila ${seenCurp.get(curp)})`;
|
|
if (!errors.rfc && seenRfc.has(rfc)) errors.rfc = `RFC duplicado en el archivo (fila ${seenRfc.get(rfc)})`;
|
|
if (!errors.nss && seenNss.has(nss)) errors.nss = `NSS duplicado en el archivo (fila ${seenNss.get(nss)})`;
|
|
|
|
if (Object.keys(errors).length) {
|
|
report.error_rows.push({
|
|
sheet: job.sheet,
|
|
row,
|
|
nombre,
|
|
curp: data["CURP"] || "",
|
|
messages: errorMessages(errors),
|
|
});
|
|
report.errors++;
|
|
continue;
|
|
}
|
|
|
|
seenCurp.set(curp, row);
|
|
seenRfc.set(rfc, row);
|
|
seenNss.set(nss, row);
|
|
|
|
const n = {
|
|
...normalizeWorker(input),
|
|
hire_type: company!.code,
|
|
company_id: company!.id,
|
|
tenant_id: company!.tenant_id ?? 1,
|
|
};
|
|
const res = upsertWorker(db, n, n.status === "activo" ? projectId : null);
|
|
if (res.action === "inserted") report.inserted++;
|
|
else {
|
|
report.existed++;
|
|
report.existed_rows.push({
|
|
sheet: job.sheet,
|
|
row,
|
|
nombre,
|
|
curp: n.curp,
|
|
matched: res.matched || "CURP",
|
|
});
|
|
}
|
|
const url = data["URL FOTO"];
|
|
if (url) {
|
|
const photo = await fetchPhoto(url);
|
|
if (photo) {
|
|
await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId);
|
|
report.photos++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return report;
|
|
}
|
|
|
|
export { findExisting, upsertWorker, assign };
|