Merge dev to main #1
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",
|
"name": "fiberops-api",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { AccountingModule } from './accounting/accounting.module';
|
|||||||
import { PayrollModule } from './payroll/payroll.module';
|
import { PayrollModule } from './payroll/payroll.module';
|
||||||
import { RoleModule } from './role/role.module';
|
import { RoleModule } from './role/role.module';
|
||||||
import { CommentModule } from './comment/comment.module';
|
import { CommentModule } from './comment/comment.module';
|
||||||
|
import { ImportModule } from './import/import.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
@@ -71,6 +72,7 @@ import { AccessGuard } from './common/guards/access.guard';
|
|||||||
PayrollModule,
|
PayrollModule,
|
||||||
RoleModule,
|
RoleModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
ImportModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
|
|||||||
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,9 +41,10 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('unremitted')
|
@Get('unremitted')
|
||||||
@Roles('technician', 'collector')
|
@Roles('technician', 'collector', 'manager')
|
||||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
|
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.paymentService.getUnremittedPayments(user.tenantId, user.sub);
|
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
|
||||||
|
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('remittances')
|
@Get('remittances')
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export class PaymentService {
|
|||||||
// Attach full payment details to each remittance's join records
|
// Attach full payment details to each remittance's join records
|
||||||
return remittances.map((r) => ({
|
return remittances.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
createdAt: r.submittedAt.toISOString(),
|
||||||
payments: r.payments.map((rp) => ({
|
payments: r.payments.map((rp) => ({
|
||||||
...rp,
|
...rp,
|
||||||
payment: paymentMap.get(rp.paymentId) ?? null,
|
payment: paymentMap.get(rp.paymentId) ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user