Compare commits
6 Commits
59ee1fbe33
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc89ed124f | ||
|
|
d4d9249933 | ||
|
|
6ea1f412a1 | ||
|
|
7f9beaac2b | ||
|
|
85aea30730 | ||
|
|
da9707f3fb |
11
alter-tenant-id.sh
Normal file
11
alter-tenant-id.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
cd packages/db
|
||||
node -e "
|
||||
const { Client } = require('pg');
|
||||
const url = process.env.DATABASE_URL.replace(/%21/g, '!');
|
||||
const c = new Client(url);
|
||||
c.connect().then(() => c.query('ALTER TABLE users ALTER COLUMN tenantId DROP NOT NULL'))
|
||||
.then(() => { console.log('tenantId nullable OK'); return c.end(); })
|
||||
.catch(e => { console.log('Error:', e.message.substring(0,100)); return c.end(); });
|
||||
"
|
||||
npx tsx prisma/seed.ts
|
||||
7602
package-lock.json
generated
Normal file
7602
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"name": "fiberops-api",
|
||||
"private": true,
|
||||
"workspaces": ["packages/*"],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
@@ -32,6 +34,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AccountingModule } from './accounting/accounting.module';
|
||||
import { PayrollModule } from './payroll/payroll.module';
|
||||
import { RoleModule } from './role/role.module';
|
||||
import { CommentModule } from './comment/comment.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||
@@ -71,6 +72,7 @@ import { AccessGuard } from './common/guards/access.guard';
|
||||
PayrollModule,
|
||||
RoleModule,
|
||||
CommentModule,
|
||||
ImportModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
|
||||
@@ -54,7 +54,7 @@ export class ClientController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('manager')
|
||||
@Roles('manager', 'technician', 'collector')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
@@ -38,14 +38,20 @@ export class CommentController {
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateCommentDto,
|
||||
@Body() body: any,
|
||||
@UploadedFiles() files?: { files?: Express.Multer.File[] },
|
||||
) {
|
||||
let content = body?.content;
|
||||
if (Array.isArray(content)) content = content[0];
|
||||
if (typeof content !== 'string') content = String(content ?? '');
|
||||
if (!content || content.length > 50000) {
|
||||
throw new BadRequestException('Content must be between 1 and 50000 characters');
|
||||
}
|
||||
return this.commentService.create(
|
||||
user.tenantId,
|
||||
ticketId,
|
||||
user.sub,
|
||||
dto.content,
|
||||
content,
|
||||
files?.files,
|
||||
);
|
||||
}
|
||||
|
||||
7
src/comment/dto/create-comment.dto.bak
Normal file
7
src/comment/dto/create-comment.dto.bak
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content!: string;
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content!: string;
|
||||
@MaxLength(50000)
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { extname, resolve } from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { Request } from 'express';
|
||||
|
||||
const uploadDir = resolve('./uploads');
|
||||
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
export const multerOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
||||
cb(null, './uploads');
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
@@ -22,7 +26,6 @@ export const multerOptions: MulterOptions = {
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
|
||||
@@ -14,28 +14,24 @@ export class DashboardController {
|
||||
@Get('kpis')
|
||||
@Roles('manager')
|
||||
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
||||
const data = await this.dashboardService.getKpis(user.tenantId);
|
||||
return { data };
|
||||
return this.dashboardService.getKpis(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('revenue-chart')
|
||||
@Roles('manager')
|
||||
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
||||
const data = await this.dashboardService.getRevenueChart(user.tenantId);
|
||||
return { data };
|
||||
return this.dashboardService.getRevenueChart(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@Roles('manager')
|
||||
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
||||
const data = await this.dashboardService.getRecentActivity(user.tenantId);
|
||||
return { data };
|
||||
return this.dashboardService.getRecentActivity(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('financial-summary')
|
||||
@Roles('manager')
|
||||
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||
const data = await this.dashboardService.getFinancialSummary(user.tenantId);
|
||||
return { data };
|
||||
return this.dashboardService.getFinancialSummary(user.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
91
src/import/import.controller.ts
Normal file
91
src/import/import.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { ImportService } from './import.service';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('import')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class ImportController {
|
||||
constructor(private readonly importService: ImportService) {}
|
||||
|
||||
@Get('template/:type')
|
||||
@Roles('tenant_admin')
|
||||
async downloadTemplate(
|
||||
@Param('type') type: string,
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { buffer, filename, contentType } =
|
||||
await this.importService.generateTemplate(user.tenantId, type);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
@Roles('tenant_admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = file.originalname.split('.').pop()?.toLowerCase();
|
||||
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only CSV and Excel files are allowed'), false);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
async importClients(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.importService.importClients(
|
||||
user.tenantId,
|
||||
user.sub,
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('invoices')
|
||||
@Roles('tenant_admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = file.originalname.split('.').pop()?.toLowerCase();
|
||||
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only CSV and Excel files are allowed'), false);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
async importInvoices(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.importService.importInvoices(
|
||||
user.tenantId,
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/import/import.module.ts
Normal file
11
src/import/import.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ImportController],
|
||||
providers: [ImportService],
|
||||
})
|
||||
export class ImportModule {}
|
||||
427
src/import/import.service.ts
Normal file
427
src/import/import.service.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
interface RowResult {
|
||||
row: number;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
errors: number;
|
||||
details: RowResult[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Template generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async generateTemplate(
|
||||
tenantId: string,
|
||||
type: string,
|
||||
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
|
||||
if (type === 'clients') {
|
||||
return this.generateClientsTemplate(tenantId);
|
||||
}
|
||||
if (type === 'invoices') {
|
||||
return this.generateInvoicesTemplate(tenantId);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Unknown template type "${type}". Use "clients" or "invoices".`,
|
||||
);
|
||||
}
|
||||
|
||||
private async generateClientsTemplate(tenantId: string) {
|
||||
const [plans, areas] = await Promise.all([
|
||||
this.prisma.plan.findMany({
|
||||
where: { tenantId, isActive: true, deletedAt: null },
|
||||
select: { name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
this.prisma.area.findMany({
|
||||
where: { tenantId, deletedAt: null },
|
||||
select: { name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const headers = [
|
||||
'firstName*',
|
||||
'lastName*',
|
||||
'email',
|
||||
'phone',
|
||||
'address*',
|
||||
'accountNumber',
|
||||
'planName*',
|
||||
'subscriptionType*',
|
||||
'areaName',
|
||||
];
|
||||
|
||||
const exampleRow = [
|
||||
'Juan',
|
||||
'Dela Cruz',
|
||||
'juan@example.com',
|
||||
'+639171234567',
|
||||
'123 Main Street, Barangay 1',
|
||||
'C-000001',
|
||||
plans[0]?.name || 'Plan 1',
|
||||
'postpaid',
|
||||
areas[0]?.name || '',
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||
|
||||
// Add data-validation notes in a second sheet
|
||||
const notesData = [
|
||||
['Field', 'Required', 'Description', 'Valid Values'],
|
||||
['firstName', 'Yes', 'Client first name', ''],
|
||||
['lastName', 'Yes', 'Client last name', ''],
|
||||
['email', 'No', 'Email address', ''],
|
||||
['phone', 'No', 'Phone number', ''],
|
||||
['address', 'Yes', 'Full address', ''],
|
||||
['accountNumber', 'No', 'Existing account number (auto-generated if empty)', ''],
|
||||
['planName', 'Yes', 'Must match an existing plan name', plans.map((p) => p.name).join(', ') || '(no plans created yet)'],
|
||||
['subscriptionType', 'Yes', 'Subscription type', 'prepaid, postpaid'],
|
||||
['areaName', 'No', 'Must match an existing area name', areas.map((a) => a.name).join(', ') || '(no areas created yet)'],
|
||||
];
|
||||
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
|
||||
|
||||
// Set column widths
|
||||
ws['!cols'] = headers.map(() => ({ wch: 25 }));
|
||||
notesWs['!cols'] = [
|
||||
{ wch: 20 },
|
||||
{ wch: 10 },
|
||||
{ wch: 45 },
|
||||
{ wch: 50 },
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Clients');
|
||||
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
|
||||
|
||||
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
return {
|
||||
buffer,
|
||||
filename: 'fiberops_clients_template.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
}
|
||||
|
||||
private async generateInvoicesTemplate(tenantId: string) {
|
||||
const headers = [
|
||||
'accountNumber*',
|
||||
'invoiceNumber',
|
||||
'amount*',
|
||||
'balance*',
|
||||
'dueDate*',
|
||||
'status*',
|
||||
];
|
||||
|
||||
const exampleRow = [
|
||||
'C-000001',
|
||||
'',
|
||||
'1500.00',
|
||||
'1500.00',
|
||||
'2026-06-01',
|
||||
'unpaid',
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||
|
||||
const notesData = [
|
||||
['Field', 'Required', 'Description', 'Valid Values'],
|
||||
['accountNumber', 'Yes', 'Must match an existing client account number', ''],
|
||||
['invoiceNumber', 'No', 'Invoice number (auto-generated if empty)', ''],
|
||||
['amount', 'Yes', 'Invoice total amount', ''],
|
||||
['balance', 'Yes', 'Outstanding balance', ''],
|
||||
['dueDate', 'Yes', 'Due date in YYYY-MM-DD format', ''],
|
||||
['status', 'Yes', 'Invoice status', 'unpaid, partial'],
|
||||
];
|
||||
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
|
||||
|
||||
ws['!cols'] = headers.map(() => ({ wch: 25 }));
|
||||
notesWs['!cols'] = [
|
||||
{ wch: 20 },
|
||||
{ wch: 10 },
|
||||
{ wch: 50 },
|
||||
{ wch: 30 },
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Invoices');
|
||||
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
|
||||
|
||||
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
return {
|
||||
buffer,
|
||||
filename: 'fiberops_invoices_template.xlsx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client + Subscription import */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async importClients(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<ImportResult> {
|
||||
const rows = this.parseFile(fileBuffer, filename);
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException('File is empty');
|
||||
}
|
||||
|
||||
// Prefetch plans and areas for lookup
|
||||
const [plans, areas] = await Promise.all([
|
||||
this.prisma.plan.findMany({
|
||||
where: { tenantId, isActive: true, deletedAt: null },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
this.prisma.area.findMany({
|
||||
where: { tenantId, deletedAt: null },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const planMap = new Map(plans.map((p) => [p.name.toLowerCase(), p.id]));
|
||||
const areaMap = new Map(areas.map((a) => [a.name.toLowerCase(), a.id]));
|
||||
|
||||
const details: RowResult[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rowNumber = i + 2; // Excel is 1-indexed, +1 for header
|
||||
|
||||
try {
|
||||
const firstName = this.req(row, 'firstName', rowNumber);
|
||||
const lastName = this.req(row, 'lastName', rowNumber);
|
||||
const address = this.req(row, 'address', rowNumber);
|
||||
const planName = this.req(row, 'planName', rowNumber);
|
||||
const subscriptionType = this.req(row, 'subscriptionType', rowNumber);
|
||||
|
||||
if (!['prepaid', 'postpaid'].includes(subscriptionType.toLowerCase())) {
|
||||
throw new Error(`Invalid subscriptionType "${subscriptionType}". Must be "prepaid" or "postpaid".`);
|
||||
}
|
||||
|
||||
const planId = planMap.get(planName.toLowerCase());
|
||||
if (!planId) {
|
||||
throw new Error(`Plan "${planName}" not found. Available plans: ${plans.map((p) => p.name).join(', ') || '(none)'}`);
|
||||
}
|
||||
|
||||
const areaName = this.opt(row, 'areaName');
|
||||
const areaId = areaName ? areaMap.get(areaName.toLowerCase()) : undefined;
|
||||
if (areaName && !areaId) {
|
||||
throw new Error(`Area "${areaName}" not found. Available areas: ${areas.map((a) => a.name).join(', ') || '(none)'}`);
|
||||
}
|
||||
|
||||
const accountNumber = this.opt(row, 'accountNumber') || undefined;
|
||||
|
||||
// Check account number uniqueness if provided
|
||||
if (accountNumber) {
|
||||
const existing = await this.prisma.client.findFirst({
|
||||
where: { tenantId, accountNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`Account number "${accountNumber}" already exists for client ${existing.firstName} ${existing.lastName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate account number if not provided
|
||||
const finalAccountNumber =
|
||||
accountNumber ||
|
||||
(await this.generateAccountNumber(tenantId));
|
||||
|
||||
const client = await this.prisma.client.create({
|
||||
data: {
|
||||
tenantId,
|
||||
accountNumber: finalAccountNumber,
|
||||
firstName,
|
||||
lastName,
|
||||
email: this.opt(row, 'email'),
|
||||
phone: this.opt(row, 'phone'),
|
||||
address,
|
||||
areaId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create subscription
|
||||
await this.prisma.subscription.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId: client.id,
|
||||
planId,
|
||||
type: subscriptionType.toLowerCase(),
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
details.push({ row: rowNumber, status: 'ok', message: `Created ${firstName} ${lastName} (${finalAccountNumber})` });
|
||||
} catch (err: any) {
|
||||
details.push({ row: rowNumber, status: 'error', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const imported = details.filter((d) => d.status === 'ok').length;
|
||||
const errors = details.filter((d) => d.status === 'error').length;
|
||||
|
||||
// Log audit
|
||||
if (imported > 0) {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId,
|
||||
action: 'IMPORT_CLIENTS',
|
||||
entity: 'client',
|
||||
entityId: 'bulk',
|
||||
details: { imported, errors, total: rows.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { total: rows.length, imported, errors, details };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Invoice import */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async importInvoices(
|
||||
tenantId: string,
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<ImportResult> {
|
||||
const rows = this.parseFile(fileBuffer, filename);
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException('File is empty');
|
||||
}
|
||||
|
||||
const details: RowResult[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rowNumber = i + 2;
|
||||
|
||||
try {
|
||||
const accountNumber = this.req(row, 'accountNumber', rowNumber);
|
||||
const amountStr = this.req(row, 'amount', rowNumber);
|
||||
const balanceStr = this.req(row, 'balance', rowNumber);
|
||||
const dueDateStr = this.req(row, 'dueDate', rowNumber);
|
||||
const status = this.req(row, 'status', rowNumber);
|
||||
|
||||
// Find client by account number
|
||||
const client = await this.prisma.client.findFirst({
|
||||
where: { tenantId, accountNumber },
|
||||
});
|
||||
if (!client) {
|
||||
throw new Error(`Client with account number "${accountNumber}" not found`);
|
||||
}
|
||||
|
||||
const amount = parseFloat(amountStr);
|
||||
const balance = parseFloat(balanceStr);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
throw new Error(`Invalid amount "${amountStr}"`);
|
||||
}
|
||||
if (isNaN(balance) || balance < 0) {
|
||||
throw new Error(`Invalid balance "${balanceStr}"`);
|
||||
}
|
||||
|
||||
const dueDate = new Date(dueDateStr);
|
||||
if (isNaN(dueDate.getTime())) {
|
||||
throw new Error(`Invalid dueDate "${dueDateStr}". Use YYYY-MM-DD format.`);
|
||||
}
|
||||
|
||||
if (!['unpaid', 'partial'].includes(status.toLowerCase())) {
|
||||
throw new Error(`Invalid status "${status}". Must be "unpaid" or "partial".`);
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
const invoiceNumber =
|
||||
this.opt(row, 'invoiceNumber') ||
|
||||
(await this.generateInvoiceNumber(tenantId));
|
||||
|
||||
await this.prisma.invoice.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId: client.id,
|
||||
number: invoiceNumber,
|
||||
amount,
|
||||
balance,
|
||||
status: status.toLowerCase(),
|
||||
dueDate,
|
||||
},
|
||||
});
|
||||
|
||||
details.push({
|
||||
row: rowNumber,
|
||||
status: 'ok',
|
||||
message: `Invoice ${invoiceNumber} for ${client.firstName} ${client.lastName} (${accountNumber})`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
details.push({ row: rowNumber, status: 'error', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const imported = details.filter((d) => d.status === 'ok').length;
|
||||
const errors = details.filter((d) => d.status === 'error').length;
|
||||
|
||||
return { total: rows.length, imported, errors, details };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private parseFile(buffer: Buffer, filename: string): Record<string, string>[] {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows: Record<string, string>[] = XLSX.utils.sheet_to_json(ws, {
|
||||
defval: '',
|
||||
});
|
||||
|
||||
// Normalize headers: strip asterisks and whitespace
|
||||
return rows.map((row) => {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
normalized[key.replace(/\*/g, '').trim()] = String(value).trim();
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
private req(row: Record<string, string>, field: string, rowNumber: number): string {
|
||||
const value = row[field];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required field "${field}"`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private opt(row: Record<string, string>, field: string): string | undefined {
|
||||
const value = row[field];
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
private async generateAccountNumber(tenantId: string): Promise<string> {
|
||||
const count = await this.prisma.client.count({ where: { tenantId } });
|
||||
return `C-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private async generateInvoiceNumber(tenantId: string): Promise<string> {
|
||||
const count = await this.prisma.invoice.count({ where: { tenantId } });
|
||||
return `INV-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,9 @@ export class PaymentController {
|
||||
}
|
||||
|
||||
@Get('unremitted')
|
||||
@Roles('technician', 'collector')
|
||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) {
|
||||
@Roles('technician', 'collector', 'manager')
|
||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
|
||||
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
|
||||
// Managers see all unremitted; collectors see only their own
|
||||
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
|
||||
}
|
||||
|
||||
@@ -80,4 +79,10 @@ export class PaymentController {
|
||||
) {
|
||||
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Get('unremitted-breakdown')
|
||||
@Roles('manager')
|
||||
async getUnremittedBreakdown(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.paymentService.getUnremittedBreakdown(user.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export class PaymentService {
|
||||
// Attach full payment details to each remittance's join records
|
||||
return remittances.map((r) => ({
|
||||
...r,
|
||||
createdAt: r.submittedAt.toISOString(),
|
||||
payments: r.payments.map((rp) => ({
|
||||
...rp,
|
||||
payment: paymentMap.get(rp.paymentId) ?? null,
|
||||
@@ -301,4 +302,36 @@ export class PaymentService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getUnremittedBreakdown(tenantId: string) {
|
||||
const remittedIds = (await this.prisma.remittancePayment.findMany({
|
||||
where: { remittance: { tenantId } },
|
||||
select: { paymentId: true },
|
||||
})).map((r) => r.paymentId);
|
||||
|
||||
const grouped = await this.prisma.payment.groupBy({
|
||||
by: ['collectedById'],
|
||||
where: {
|
||||
tenantId,
|
||||
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
|
||||
collectedById: { not: null },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const collectorIds = grouped.map((g) => g.collectedById!).filter(Boolean);
|
||||
const collectors = await this.prisma.user.findMany({
|
||||
where: { id: { in: collectorIds } },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
const nameMap = new Map(collectors.map((c) => [c.id, `${c.firstName} ${c.lastName}`]));
|
||||
|
||||
return grouped.map((g) => ({
|
||||
collectorId: g.collectedById,
|
||||
collectorName: nameMap.get(g.collectedById!) ?? 'Unknown',
|
||||
totalAmount: Number(g._sum.amount ?? 0),
|
||||
count: g._count,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
86
src/scheduler/payment.scheduler.ts
Normal file
86
src/scheduler/payment.scheduler.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentScheduler {
|
||||
private readonly logger = new Logger(PaymentScheduler.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
@Cron('0 17 * * *')
|
||||
async sendUnremittedReminders() {
|
||||
this.logger.log('Sending unremitted payment reminders...');
|
||||
|
||||
const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } });
|
||||
|
||||
for (const tenant of tenants) {
|
||||
try {
|
||||
await this._remindForTenant(tenant.id);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send reminders for tenant ${tenant.slug}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Unremitted payment reminders complete.');
|
||||
}
|
||||
|
||||
private async _remindForTenant(tenantId: string) {
|
||||
// Find remitted payment IDs
|
||||
const remittedIds = (await this.prisma.remittancePayment.findMany({
|
||||
where: { remittance: { tenantId } },
|
||||
select: { paymentId: true },
|
||||
})).map((r) => r.paymentId);
|
||||
|
||||
// Find unremitted payments grouped by collector
|
||||
const unremitted = await this.prisma.payment.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
|
||||
collectedById: { not: null },
|
||||
},
|
||||
select: { collectedById: true, amount: true },
|
||||
});
|
||||
|
||||
if (unremitted.length === 0) return;
|
||||
|
||||
// Group by collector
|
||||
const byCollector = new Map<string, number>();
|
||||
for (const p of unremitted) {
|
||||
const id = p.collectedById!;
|
||||
byCollector.set(id, (byCollector.get(id) ?? 0) + Number(p.amount));
|
||||
}
|
||||
|
||||
// Check which collectors already got a reminder today
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const alreadyReminded = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
channel: 'unremitted_reminder',
|
||||
createdAt: { gte: todayStart },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
const remindedSet = new Set(alreadyReminded.map((n) => n.userId).filter(Boolean));
|
||||
|
||||
for (const [collectorId, total] of byCollector) {
|
||||
if (remindedSet.has(collectorId)) continue;
|
||||
|
||||
this.notificationService.create(tenantId, {
|
||||
userId: collectorId,
|
||||
type: 'in_app',
|
||||
channel: 'unremitted_reminder',
|
||||
title: 'End-of-Day Reminder',
|
||||
message: `You have ₱${total.toLocaleString()} in unremitted payments. Please submit your remittance.`,
|
||||
}).catch((err) =>
|
||||
this.logger.error(`Failed to notify ${collectorId}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { InvoiceScheduler } from './invoice.scheduler';
|
||||
import { PaymentScheduler } from './payment.scheduler';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), BillingModule],
|
||||
providers: [InvoiceScheduler],
|
||||
imports: [ScheduleModule.forRoot(), BillingModule, NotificationModule],
|
||||
providers: [InvoiceScheduler, PaymentScheduler],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
|
||||
Reference in New Issue
Block a user