UNPKG

@accounter/server

Version:
274 lines (239 loc) 11 kB
import { GraphQLError } from 'graphql'; import xlsx from 'node-xlsx'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { ChargesProvider } from '../../charges/providers/charges.provider.js'; import { EmployeesProvider } from '../providers/employees.provider.js'; import { FundsProvider } from '../providers/funds.provider.js'; import { SalariesProvider } from '../providers/salaries.provider.js'; import type { IInsertSalaryRecordsParams, SalariesModule } from '../types.js'; export class SalaryError extends Error { constructor(message: string) { super(message); } } function getFormattedSalaryMonth(rawDate: string) { const dateParts = rawDate.split('/'); return `${dateParts[1]}-${dateParts[0]}`; } function getEmployeeIdsFromSheet(dataArray: unknown[]): Array<[string, number]> { return dataArray .map((data, index) => [data, index] as [string | null | undefined, number]) .filter(([data, index]) => { if (index < 2) return false; if (!data || data === '') return false; return true; }) as Array<[string, number]>; } function normalizeNationalId(nationalId: number): string { const stringNationalId = nationalId.toString(); return stringNationalId.length === 9 ? stringNationalId : '0'.repeat(9 - stringNationalId.length) + stringNationalId; } function validateGenericSalaryData( salaryData: ReturnType<typeof xlsx.parse<unknown[]>>[number]['data'] | undefined, ): ReturnType<typeof xlsx.parse<unknown[]>>[number]['data'] { if (!salaryData) { throw new SalaryError('Missing salary data sheet'); } return salaryData; } function validateRowByCategory( data: ReturnType<typeof xlsx.parse<unknown[]>>[number]['data'], category: string, ): { row: unknown[]; rowNum: number } { const rowNum = data.findIndex(row => Array.isArray(row) && row[1] === category); if (!data[rowNum]) { throw new SalaryError(`Missing ${category} in row ${rowNum + 1}`); } if (!Array.isArray(data[rowNum])) { throw new SalaryError(`Invalid row for ${category} in row ${rowNum + 1}`); } if (!data[rowNum][1] || data[rowNum][1] !== category) { throw new SalaryError(`Invalid category for ${category} in row ${rowNum + 1}`); } return { row: data[rowNum], rowNum }; } function validateColumn( row: unknown[], columnNum: number, category: string, rowNum: number, nullable = false, ): { cell: unknown; colLetter: string } { const columns = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (!nullable && row[columnNum] == null) { throw new SalaryError( `Missing column ${columns[columnNum]} for ${category} in row ${rowNum + 1}`, ); } return { cell: row[columnNum], colLetter: columns[columnNum] }; } function validateCell( salaryData: ReturnType<typeof xlsx.parse<unknown[]>>[number]['data'], category: string, columnNum: number, nullable = false, ): { cell: unknown; colLetter: string; rowNum: number } { const { row, rowNum } = validateRowByCategory(salaryData, category); const { cell, colLetter } = validateColumn(row, columnNum, category, rowNum, nullable); return { cell, colLetter, rowNum }; } function validateNumericCell( salaryData: ReturnType<typeof xlsx.parse<unknown[]>>[number]['data'], category: string, columnNum: number, nullable = false, ): number { const { cell, colLetter, rowNum } = validateCell(salaryData, category, columnNum, nullable); if (typeof cell !== 'number') { throw new SalaryError(`Invalid ${category} in row ${rowNum + 1}, column ${colLetter}: ${cell}`); } return cell; } export const insertSalaryRecordsFromFile: SalariesModule.MutationResolvers['insertSalaryRecordsFromFile'] = async (_, { file, chargeId }, { injector }) => { try { const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); const buffer = await file.arrayBuffer(); const workSheetsFromFile = xlsx.parse(buffer); if (!workSheetsFromFile) { throw new SalaryError('No salary data file found'); } const salaryRawData = workSheetsFromFile.find( sheet => sheet.name === 'ריכוז נתוני שכר', )?.data; if (!salaryRawData) { throw new SalaryError('No salary data sheet found in file'); } const rawSalaryMonth = salaryRawData[1][3]; const salaryMonth = getFormattedSalaryMonth(rawSalaryMonth); const salaryData = validateGenericSalaryData(salaryRawData); const { row: employeesRow } = validateRowByCategory(salaryData, 'מספר זהות'); const employeesNationalIds = getEmployeeIdsFromSheet(employeesRow); const allEmployeesPromise = injector .get(EmployeesProvider) .getEmployeesByEmployerLoader.load(ownerId) .catch(e => { console.error(e); throw new SalaryError('Failed to get employees for employer'); }); const salaryChargePromise = chargeId ? injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId) : injector .get(ChargesProvider) .generateCharge({ type: 'PAYROLL', ownerId, userDescription: `Salaries ${rawSalaryMonth}`, }) .catch(e => { console.error(e); throw new SalaryError('Failed to generate salary charge'); }); const allFundsPromise = injector.get(FundsProvider).getAllFunds(); const [allEmployees, salaryCharge, allFunds] = await Promise.all([ allEmployeesPromise, salaryChargePromise, allFundsPromise, ]); if (!salaryCharge) { throw new SalaryError('Failed to get or generate salary charge'); } const salaryRecords: Array<IInsertSalaryRecordsParams['salaryRecords'][0]> = []; for (const [nationalId, employeeColumn] of employeesNationalIds) { const employee = allEmployees.find( e => e.national_id === normalizeNationalId(Number(nationalId)), ); if (!employee) { throw new SalaryError(`Employee with national id ${nationalId} not found`); } function validateNumericCellWrapper(category: string, nullable?: boolean) { return validateNumericCell(salaryData, category, employeeColumn, nullable); } function validateFund(category: string, nullable?: boolean) { const { rowNum: prevRowNum } = validateRowByCategory(salaryData, category); const rowNum = prevRowNum + 1; const row = salaryData[rowNum]; if (!Array.isArray(row)) { throw new SalaryError(`Invalid row for ${category} in row ${rowNum + 2}`); } const { cell: fundKey, colLetter } = validateColumn( row, employeeColumn, category, rowNum, nullable, ); if (typeof fundKey !== 'number') { throw new SalaryError( `Invalid ${category} in row ${rowNum + 1}, column ${colLetter}: ${fundKey}`, ); } const fundId = allFunds.find(f => Number(f.otsar_id) === fundKey)?.id; if (!fundId) { throw new SalaryError(`Fund with key ${fundKey} not found`); } return fundId; } const record: IInsertSalaryRecordsParams['salaryRecords'][number] = { ownerId: salaryCharge.owner_id, month: salaryMonth, chargeId: salaryCharge.id, employeeId: employee.business_id, employer: employee.employer, employee: employee.national_id, baseSalary: validateNumericCellWrapper('שכר יסוד'), globalAdditionalHours: validateNumericCellWrapper('שעות נוספות גלובליות', true), hourlyRate: validateNumericCellWrapper('Hourly rate', true), bonus: validateNumericCellWrapper('בונוס', true), gift: validateNumericCellWrapper('מתנות', true), recovery: validateNumericCellWrapper('הבראה', true), vacationTakeout: validateNumericCellWrapper('פדיון חופשה', true), zkufot: validateNumericCellWrapper('שווי קרן השתלמות', true) + validateNumericCellWrapper('שווי קיצבה', true), travelAndSubsistence: validateNumericCellWrapper('החזר הוצאות אש"ל', true), directPaymentAmount: validateNumericCellWrapper('נטו לתשלום'), socialSecurityAmountEmployee: validateNumericCellWrapper('ביטוח לאומי עובד'), socialSecurityAmountEmployer: validateNumericCellWrapper('ביטוח לאומי מעסיק'), healthPaymentAmount: validateNumericCellWrapper('דמי בריאות'), taxAmount: validateNumericCellWrapper('מס הכנסה'), trainingFundId: validateFund('קרן השתלמות'), trainingFundEmployeeAmount: validateNumericCellWrapper('ניכוי קה"ש עובד'), trainingFundEmployeePercentage: validateNumericCellWrapper('אחוז קה"ש עובד') * 100, trainingFundEmployerAmount: validateNumericCellWrapper('סכום קה"ש מעסיק'), trainingFundEmployerPercentage: validateNumericCellWrapper('אחוז קה"ש מעסיק') * 100, pensionFundId: validateFund('קרן פנסיה'), pensionEmployeeAmount: validateNumericCellWrapper('ניכוי פנסיה עובד'), pensionEmployeePercentage: validateNumericCellWrapper('אחוז פנסיה עובד') * 100, pensionEmployerAmount: validateNumericCellWrapper('סכום פנסיה מעסיק'), pensionEmployerPercentage: validateNumericCellWrapper('אחוז פנסיה מעסיק') * 100, compensationsEmployerAmount: validateNumericCellWrapper('סכום פיצויים'), compensationsEmployerPercentage: validateNumericCellWrapper('אחוז פיצויים') * 100, workDays: validateNumericCellWrapper('ימי עבודה'), hours: validateNumericCellWrapper('שעות עבודה'), jobPercentage: validateNumericCellWrapper('Job percentage') * 100, vacationDaysBalance: -1 * validateNumericCellWrapper('ניצול חופשה', true), sicknessDaysBalance: -1 * validateNumericCellWrapper('ניצול מחלה', true), // not currently in use: addedVacationDays: null, }; salaryRecords.push(record); } await injector .get(SalariesProvider) .insertSalaryRecords({ salaryRecords }) .catch(e => { console.error(e); throw new SalaryError('Failed to insert salary records from file'); }); return true; } catch (e) { if (e instanceof SalaryError) { throw new GraphQLError(e.message); } console.error(e); throw new GraphQLError('Failed to insert salary records from file'); } };