feat(expenses): upsert function for expenses
This commit is contained in:
parent
46deae63bc
commit
52114deb33
|
|
@ -225,9 +225,10 @@ model Expenses {
|
|||
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
|
||||
bank_code_id Int
|
||||
date DateTime @db.Date
|
||||
amount Decimal @db.Money
|
||||
attachement String?
|
||||
comment String?
|
||||
amount Decimal @db.Money
|
||||
mileage Decimal?
|
||||
attachment String?
|
||||
comment String
|
||||
is_approved Boolean @default(false)
|
||||
supervisor_comment String?
|
||||
|
||||
|
|
@ -244,8 +245,9 @@ model ExpensesArchive {
|
|||
archived_at DateTime @default(now())
|
||||
bank_code_id Int
|
||||
date DateTime @db.Date
|
||||
amount Decimal @db.Money
|
||||
attachement String?
|
||||
amount Decimal? @db.Money
|
||||
mileage Decimal?
|
||||
attachment String?
|
||||
comment String?
|
||||
is_approved Boolean
|
||||
supervisor_comment String?
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ArchivalModule } from './modules/archival/archival.module';
|
||||
import { AuthenticationModule } from './modules/authentication/auth.module';
|
||||
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
||||
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
||||
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
|
||||
// import { CsvExportModule } from './modules/exports/csv-exports.module';
|
||||
import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { EmployeesModule } from './modules/employees/employees.module';
|
||||
import { ExpensesModule } from './modules/expenses/expenses.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { EmployeesModule } from './modules/employees/employees.module';
|
||||
import { ExpensesModule } from './modules/expenses/expenses.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
|
||||
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
||||
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
||||
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ShiftsModule } from './modules/shifts/shifts.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ShiftsModule } from './modules/shifts/shifts.module';
|
||||
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
||||
import { UsersModule } from './modules/users-management/users.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { UsersModule } from './modules/users-management/users.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ValidationError } from 'class-validator';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { ExpensesQueryService } from "../services/expenses-query.service";
|
||||
import { CreateExpenseDto } from "../dtos/create-expense.dto";
|
||||
import { Expenses } from "@prisma/client";
|
||||
|
|
@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg
|
|||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||
import { ExpensesCommandService } from "../services/expenses-command.service";
|
||||
import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
||||
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
|
||||
|
||||
@ApiTags('Expenses')
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -15,17 +17,26 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
|
|||
@Controller('Expenses')
|
||||
export class ExpensesController {
|
||||
constructor(
|
||||
private readonly expensesService: ExpensesQueryService,
|
||||
private readonly expensesApprovalService: ExpensesCommandService,
|
||||
private readonly query: ExpensesQueryService,
|
||||
private readonly command: ExpensesCommandService,
|
||||
) {}
|
||||
|
||||
@Put('upsert/:email/:date')
|
||||
async upsert_by_date(
|
||||
@Param('email') email: string,
|
||||
@Param('date') date: string,
|
||||
@Body() dto: UpsertExpenseDto,
|
||||
): Promise<UpsertExpenseResult> {
|
||||
return this.command.upsertExpensesByDate(email, date, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Create expense' })
|
||||
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
||||
return this.expensesService.create(dto);
|
||||
return this.query.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
|
@ -35,7 +46,7 @@ export class ExpensesController {
|
|||
@ApiResponse({ status: 400, description: 'List of expenses not found' })
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||
return this.expensesService.findAll(filters);
|
||||
return this.query.findAll(filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
|
@ -44,7 +55,7 @@ export class ExpensesController {
|
|||
@ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
|
||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
|
||||
return this.expensesService.findOne(id);
|
||||
return this.query.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
|
@ -53,7 +64,7 @@ export class ExpensesController {
|
|||
@ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
|
||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
|
||||
return this.expensesService.update(id,dto);
|
||||
return this.query.update(id,dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
|
@ -62,13 +73,13 @@ export class ExpensesController {
|
|||
@ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
|
||||
@ApiResponse({ status: 400, description: 'Expense not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
|
||||
return this.expensesService.remove(id);
|
||||
return this.query.remove(id);
|
||||
}
|
||||
|
||||
@Patch('approval/:id')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||
return this.expensesApprovalService.updateApproval(id, isApproved);
|
||||
return this.command.updateApproval(id, isApproved);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ export class CreateExpenseDto {
|
|||
description:'explain`s why the expense was made'
|
||||
})
|
||||
@IsString()
|
||||
comment?: string;
|
||||
comment: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'DENIED, APPROUVED, PENDING, etc...',
|
||||
|
|
|
|||
42
src/modules/expenses/dtos/upsert-expense.dto.ts
Normal file
42
src/modules/expenses/dtos/upsert-expense.dto.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Transform, Type } from "class-transformer";
|
||||
import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator";
|
||||
|
||||
export class ExpensePayloadDto {
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
|
||||
@IsDefined()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount!: number;
|
||||
|
||||
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
|
||||
@IsDefined()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
mileage!: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(280)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
comment!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
attachment?: string;
|
||||
}
|
||||
|
||||
|
||||
export class UpsertExpenseDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(()=> ExpensePayloadDto)
|
||||
old_expense?: ExpensePayloadDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(()=> ExpensePayloadDto)
|
||||
new_expense?: ExpensePayloadDto;
|
||||
}
|
||||
|
|
@ -3,11 +3,20 @@ import { Module } from "@nestjs/common";
|
|||
import { ExpensesQueryService } from "./services/expenses-query.service";
|
||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||
import { ExpensesCommandService } from "./services/expenses-command.service";
|
||||
import { BankCodesRepo } from "./repos/bank-codes.repo";
|
||||
import { TimesheetsRepo } from "./repos/timesheets.repo";
|
||||
import { EmployeesRepo } from "./repos/employee.repo";
|
||||
|
||||
@Module({
|
||||
imports: [BusinessLogicsModule],
|
||||
controllers: [ExpensesController],
|
||||
providers: [ExpensesQueryService, ExpensesCommandService],
|
||||
providers: [
|
||||
ExpensesQueryService,
|
||||
ExpensesCommandService,
|
||||
BankCodesRepo,
|
||||
TimesheetsRepo,
|
||||
EmployeesRepo,
|
||||
],
|
||||
exports: [ ExpensesQueryService ],
|
||||
})
|
||||
|
||||
|
|
|
|||
34
src/modules/expenses/repos/bank-codes.repo.ts
Normal file
34
src/modules/expenses/repos/bank-codes.repo.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
@Injectable()
|
||||
export class BankCodesRepo {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
//find id and modifier by type
|
||||
readonly findByType = async ( type: string, client?: Tx
|
||||
): Promise<{id:number; modifier: number }> => {
|
||||
const db = client ?? this.prisma;
|
||||
const bank = await db.bankCodes.findFirst({
|
||||
where: {
|
||||
type,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
modifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(!bank) {
|
||||
throw new NotFoundException(`Unknown bank code type: ${type}`);
|
||||
}
|
||||
return {
|
||||
id: bank.id,
|
||||
modifier: bank.modifier,
|
||||
};
|
||||
};
|
||||
}
|
||||
32
src/modules/expenses/repos/employee.repo.ts
Normal file
32
src/modules/expenses/repos/employee.repo.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesRepo {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// find employee id by email
|
||||
readonly findIdByEmail = async ( email: string, client?: Tx
|
||||
): Promise<number> => {
|
||||
const db = client ?? this.prisma;
|
||||
const employee = await db.employees.findFirst({
|
||||
where: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(!employee) {
|
||||
throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||
}
|
||||
return employee.id;
|
||||
}
|
||||
}
|
||||
42
src/modules/expenses/repos/timesheets.repo.ts
Normal file
42
src/modules/expenses/repos/timesheets.repo.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
@Injectable()
|
||||
export class TimesheetsRepo {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
//find an existing timesheet linked to the employee
|
||||
readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx,
|
||||
): Promise<{id: number; start_date: Date }> => {
|
||||
const db = client ?? this.prisma;
|
||||
const startOfWeek = weekStartMondayUTC(date);
|
||||
const existing = await db.timesheets.findFirst({
|
||||
where: {
|
||||
employee_id: employee_id,
|
||||
start_date: startOfWeek,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
start_date: true,
|
||||
},
|
||||
});
|
||||
if(existing) return existing;
|
||||
|
||||
const created = await db.timesheets.create({
|
||||
data: {
|
||||
employee_id: employee_id,
|
||||
start_date: startOfWeek,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
start_date: true,
|
||||
},
|
||||
});
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,23 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Expenses, Prisma } from "@prisma/client";
|
||||
import { Decimal } from "@prisma/client/runtime/library";
|
||||
import { transcode } from "buffer";
|
||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||
import { BankCodesRepo } from "../repos/bank-codes.repo";
|
||||
import { TimesheetsRepo } from "../repos/timesheets.repo";
|
||||
import { EmployeesRepo } from "../repos/employee.repo";
|
||||
import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils";
|
||||
import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
|
||||
import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
||||
constructor(prisma: PrismaService) { super(prisma); }
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
private readonly bankCodesRepo: BankCodesRepo,
|
||||
private readonly timesheetsRepo: TimesheetsRepo,
|
||||
private readonly employeesRepo: EmployeesRepo,
|
||||
) { super(prisma); }
|
||||
|
||||
protected get delegate() {
|
||||
return this.prisma.expenses;
|
||||
|
|
@ -22,4 +32,202 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
|||
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||
);
|
||||
}
|
||||
|
||||
//-------------------- Master CRUD function --------------------
|
||||
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
|
||||
): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => {
|
||||
|
||||
//validates if there is an existing expense, at least 1 old or new
|
||||
const { old_expense, new_expense } = dto ?? {};
|
||||
if(!old_expense && !new_expense) {
|
||||
throw new BadRequestException('At least one expense must be provided');
|
||||
}
|
||||
|
||||
//validate date format
|
||||
const dateOnly = toDateOnlyUTC(date);
|
||||
if(Number.isNaN(dateOnly.getTime())) {
|
||||
throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
|
||||
}
|
||||
|
||||
//resolve employee_id by email
|
||||
const employee_id = await this.resolveEmployeeIdByEmail(email);
|
||||
|
||||
//make sure a timesheet existes
|
||||
const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const loadDay = async (): Promise<DayExpenseResponse[]> => {
|
||||
const rows = await tx.expenses.findMany({
|
||||
where: {
|
||||
timesheet_id: timesheet_id,
|
||||
date: dateOnly,
|
||||
},
|
||||
include: {
|
||||
bank_code: {
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
return rows.map(this.mapDbToDayResponse);
|
||||
};
|
||||
|
||||
const normalizePayload = async (payload: {
|
||||
type: string;
|
||||
amount?: number;
|
||||
mileage?: number;
|
||||
comment: string;
|
||||
attachment?: string;
|
||||
}): Promise<{
|
||||
type: string;
|
||||
bank_code_id: number;
|
||||
amount: Prisma.Decimal;
|
||||
comment: string;
|
||||
attachment: string | null;
|
||||
}> => {
|
||||
const type = this.normalizeType(payload.type);
|
||||
const comment = this.assertAndTrimComment(payload.comment);
|
||||
const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null;
|
||||
const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type);
|
||||
const amount = this.computeAmountDecimal(type, payload, modifier);
|
||||
|
||||
return {
|
||||
type,
|
||||
bank_code_id,
|
||||
amount,
|
||||
comment,
|
||||
attachment
|
||||
};
|
||||
};
|
||||
|
||||
const findExactOld = async (norm: {
|
||||
bank_code_id: number;
|
||||
amount: Prisma.Decimal;
|
||||
comment: string;
|
||||
attachment: string | null;
|
||||
}) => {
|
||||
return tx.expenses.findFirst({
|
||||
where: {
|
||||
timesheet_id: timesheet_id,
|
||||
date: dateOnly,
|
||||
bank_code_id: norm.bank_code_id,
|
||||
amount: norm.amount,
|
||||
comment: norm.comment,
|
||||
attachment: norm.attachment,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
};
|
||||
|
||||
let action : UpsertAction;
|
||||
//-------------------- DELETE --------------------
|
||||
if(old_expense && !new_expense) {
|
||||
const oldNorm = await normalizePayload(old_expense);
|
||||
const existing = await findExactOld(oldNorm);
|
||||
if(!existing) {
|
||||
throw new NotFoundException({
|
||||
error_code: 'EXPENSE_STALE',
|
||||
message: 'The expense was modified or deleted by someone else',
|
||||
});
|
||||
}
|
||||
await tx.expenses.delete({where: { id: existing.id } });
|
||||
action = 'deleted';
|
||||
}
|
||||
//-------------------- CREATE --------------------
|
||||
else if (!old_expense && new_expense) {
|
||||
const new_exp = await normalizePayload(new_expense);
|
||||
await tx.expenses.create({
|
||||
data: {
|
||||
timesheet_id: timesheet_id,
|
||||
date: dateOnly,
|
||||
bank_code_id: new_exp.bank_code_id,
|
||||
amount: new_exp.amount,
|
||||
mileage: null,
|
||||
comment: new_exp.comment,
|
||||
attachment: new_exp.attachment,
|
||||
is_approved: false,
|
||||
},
|
||||
});
|
||||
action = 'created';
|
||||
}
|
||||
|
||||
else if(old_expense && new_expense) {
|
||||
const oldNorm = await normalizePayload(old_expense);
|
||||
const existing = await findExactOld(oldNorm);
|
||||
if(!existing) {
|
||||
throw new NotFoundException({
|
||||
error_code: 'EXPENSE_STALE',
|
||||
message: 'The expense was modified or deleted by someone else',
|
||||
});
|
||||
}
|
||||
|
||||
const new_exp = await normalizePayload(new_expense);
|
||||
await tx.expenses.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
bank_code_id: new_exp.bank_code_id,
|
||||
amount: new_exp.amount,
|
||||
mileage: null,
|
||||
comment: new_exp.comment,
|
||||
attachment: new_exp.attachment,
|
||||
},
|
||||
});
|
||||
action = 'updated';
|
||||
}
|
||||
else {
|
||||
throw new BadRequestException('Invalid upsert combination');
|
||||
}
|
||||
|
||||
const day = await loadDay();
|
||||
|
||||
return { action, day };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
//helpers imported from utils and repos.
|
||||
private readonly normalizeType = (type: string): string =>
|
||||
normalizeTypeUtil(type);
|
||||
|
||||
private readonly assertAndTrimComment = (comment: string): string =>
|
||||
assertAndTrimComment(comment);
|
||||
|
||||
private readonly resolveEmployeeIdByEmail = async (email: string): Promise<number> =>
|
||||
this.employeesRepo.findIdByEmail(email);
|
||||
|
||||
private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date
|
||||
): Promise<number> => {
|
||||
const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date);
|
||||
return id;
|
||||
};
|
||||
|
||||
private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string
|
||||
): Promise<{id: number; modifier: number}> =>
|
||||
this.bankCodesRepo.findByType(type, transaction);
|
||||
|
||||
private readonly computeAmountDecimal = (
|
||||
type: string,
|
||||
payload: { amount?: number; mileage?: number;},
|
||||
modifier: number,
|
||||
): Prisma.Decimal => {
|
||||
if(type === 'MILEAGE') {
|
||||
const km = payload.mileage ?? 0;
|
||||
const amountNumber = computeMileageAmount(km, modifier);
|
||||
return new Prisma.Decimal(amountNumber);
|
||||
}
|
||||
return new Prisma.Decimal(payload.amount!);
|
||||
};
|
||||
|
||||
private readonly mapDbToDayResponse = (row: {
|
||||
date: Date;
|
||||
amount: Prisma.Decimal | number | string;
|
||||
comment: string;
|
||||
is_approved: boolean;
|
||||
bank_code: { type: string } | null;
|
||||
}): DayExpenseResponse => mapDbExpenseToDayResponse(row);
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ export class ExpensesQueryService {
|
|||
bank_code_id: exp.bank_code_id,
|
||||
date: exp.date,
|
||||
amount: exp.amount,
|
||||
attachement: exp.attachement,
|
||||
attachment: exp.attachment,
|
||||
comment: exp.comment,
|
||||
is_approved: exp.is_approved,
|
||||
supervisor_comment: exp.supervisor_comment,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
|
||||
export interface DayExpenseResponse {
|
||||
date: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
comment: string;
|
||||
is_approved: boolean;
|
||||
};
|
||||
|
||||
export type UpsertExpenseResult = {
|
||||
action: UpsertAction;
|
||||
day: DayExpenseResponse[]
|
||||
};
|
||||
65
src/modules/expenses/utils/expenses.utils.ts
Normal file
65
src/modules/expenses/utils/expenses.utils.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { BadRequestException } from "@nestjs/common";
|
||||
import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
|
||||
|
||||
//uppercase and trim for validation
|
||||
export function normalizeType(type: string): string {
|
||||
return (type ?? '').trim().toUpperCase();
|
||||
};
|
||||
|
||||
//required comment after trim
|
||||
export function assertAndTrimComment(comment: string): string {
|
||||
const cmt = (comment ?? '').trim();
|
||||
if(cmt.length === 0) {
|
||||
throw new BadRequestException('A comment is required');
|
||||
}
|
||||
return cmt;
|
||||
};
|
||||
|
||||
//rounding $ to 2 decimals
|
||||
export function roundMoney2(num: number): number {
|
||||
return Math.round((num + Number.EPSILON) * 100)/ 100;
|
||||
};
|
||||
|
||||
export function computeMileageAmount(km: number, modifier: number): number {
|
||||
if(km < 0) throw new BadRequestException('mileage must be positive');
|
||||
if(modifier < 0) throw new BadRequestException('modifier must be positive');
|
||||
return roundMoney2(km * modifier);
|
||||
};
|
||||
|
||||
//compat. types with Prisma.Decimal. work around Prisma import in utils.
|
||||
export type DecimalLike =
|
||||
| number
|
||||
| string
|
||||
| { toNumber?: () => number }
|
||||
| { toString?: () => string };
|
||||
|
||||
|
||||
//safe conversion to number
|
||||
export function toNumberSafe(value: DecimalLike): number {
|
||||
if(typeof value === 'number') return value;
|
||||
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
|
||||
return Number(
|
||||
typeof (value as any)?.toString === 'function'
|
||||
? (value as any).toString()
|
||||
: value,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//map of a row for DayExpenseResponse
|
||||
export function mapDbExpenseToDayResponse(row: {
|
||||
date: Date;
|
||||
amount: DecimalLike;
|
||||
comment: string;
|
||||
is_approved: boolean;
|
||||
bank_code?: { type?: string | null } | null;
|
||||
}): DayExpenseResponse {
|
||||
const yyyyMmDd = row.date.toISOString().slice(0,10);
|
||||
return {
|
||||
date: yyyyMmDd,
|
||||
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
|
||||
amount: toNumberSafe(row.amount),
|
||||
comment: row.comment,
|
||||
is_approved: row.is_approved,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export class ShiftsController {
|
|||
@Param('date') date_param: string,
|
||||
@Body() payload: UpsertShiftDto,
|
||||
) {
|
||||
return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload);
|
||||
return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||
|
||||
export const COMMENT_MAX_LENGTH = 512;
|
||||
export const COMMENT_MAX_LENGTH = 280;
|
||||
|
||||
export class ShiftPayloadDto {
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
|||
constructor(prisma: PrismaService) { super(prisma); }
|
||||
|
||||
//create/update/delete master method
|
||||
async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto):
|
||||
async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto):
|
||||
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
|
||||
const { old_shift, new_shift } = dto;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Timesheets, TimesheetsArchive } from '@prisma/client';
|
||||
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
|
||||
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
|
||||
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
|
||||
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
|
||||
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -161,14 +161,14 @@ export class TimesheetsQueryService {
|
|||
const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16);
|
||||
|
||||
//maps all shifts of selected timesheet
|
||||
const shifts = timesheet.shift.map((sft) => ({
|
||||
bank_type: sft.bank_code?.type ?? '',
|
||||
date: formatDateISO(sft.date),
|
||||
start_time: to_HH_mm(sft.start_time),
|
||||
end_time: to_HH_mm(sft.end_time),
|
||||
comment: sft.comment ?? '',
|
||||
is_approved: sft.is_approved ?? false,
|
||||
is_remote: sft.is_remote ?? false,
|
||||
const shifts = timesheet.shift.map((shift_row) => ({
|
||||
bank_type: shift_row.bank_code?.type ?? '',
|
||||
date: formatDateISO(shift_row.date),
|
||||
start_time: to_HH_mm(shift_row.start_time),
|
||||
end_time: to_HH_mm(shift_row.end_time),
|
||||
comment: shift_row.comment ?? '',
|
||||
is_approved: shift_row.is_approved ?? false,
|
||||
is_remote: shift_row.is_remote ?? false,
|
||||
}));
|
||||
|
||||
//maps all expenses of selected timsheet
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user