251216:1644 Docunment Number: Update frontend/
This commit is contained in:
@@ -37,7 +37,7 @@ export class CirculationService {
|
||||
|
||||
try {
|
||||
// Generate No. using DocumentNumberingService (Type 900 - Circulation)
|
||||
const circulationNo = await this.numberingService.generateNextNumber({
|
||||
const result = await this.numberingService.generateNextNumber({
|
||||
projectId: createDto.projectId || 0, // Use projectId from DTO or 0
|
||||
originatorId: user.primaryOrganizationId,
|
||||
typeId: 900, // Fixed Type ID for Circulation
|
||||
@@ -51,7 +51,7 @@ export class CirculationService {
|
||||
const circulation = queryRunner.manager.create(Circulation, {
|
||||
organizationId: user.primaryOrganizationId,
|
||||
correspondenceId: createDto.correspondenceId,
|
||||
circulationNo: circulationNo,
|
||||
circulationNo: result.number,
|
||||
subject: createDto.subject,
|
||||
statusCode: 'OPEN',
|
||||
createdByUserId: user.user_id,
|
||||
|
||||
@@ -1,57 +1,100 @@
|
||||
import { Controller, Post, Body, Get } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { DocumentNumberingService } from './document-numbering.service';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
// TODO: Add Auth Guards
|
||||
@ApiTags('Admin / Document Numbering')
|
||||
@ApiBearerAuth()
|
||||
@Controller('admin/document-numbering')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
export class DocumentNumberingAdminController {
|
||||
constructor(private readonly service: DocumentNumberingService) {}
|
||||
|
||||
@Post('manual-override')
|
||||
@ApiOperation({
|
||||
summary: 'Manually override or set a document number counter',
|
||||
})
|
||||
async manualOverride(@Body() dto: any) {
|
||||
return this.service.manualOverride(dto);
|
||||
// ----------------------------------------------------------
|
||||
// Template Management
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Get('templates')
|
||||
@ApiOperation({ summary: 'Get all document numbering templates' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async getTemplates(@Query('projectId') projectId?: number) {
|
||||
if (projectId) {
|
||||
return this.service.getTemplatesByProject(projectId);
|
||||
}
|
||||
return this.service.getTemplates();
|
||||
}
|
||||
|
||||
@Post('void-and-replace')
|
||||
@ApiOperation({ summary: 'Void a number and replace with a new generation' })
|
||||
async voidAndReplace(@Body() dto: any) {
|
||||
return this.service.voidAndReplace(dto);
|
||||
@Post('templates')
|
||||
@ApiOperation({ summary: 'Create or Update a numbering template' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async saveTemplate(@Body() dto: any) {
|
||||
return this.service.saveTemplate(dto);
|
||||
}
|
||||
|
||||
@Post('cancel')
|
||||
@ApiOperation({ summary: 'Cancel/Skip a specific document number' })
|
||||
async cancelNumber(@Body() dto: any) {
|
||||
return this.service.cancelNumber(dto);
|
||||
@Delete('templates/:id')
|
||||
@ApiOperation({ summary: 'Delete a numbering template' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async deleteTemplate(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.service.deleteTemplate(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('bulk-import')
|
||||
@ApiOperation({ summary: 'Bulk import/set document number counters' })
|
||||
async bulkImport(@Body() items: any[]) {
|
||||
return this.service.bulkImport(items);
|
||||
}
|
||||
// ----------------------------------------------------------
|
||||
// Metrics & Logs
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: 'Get numbering usage metrics and logs' })
|
||||
@RequirePermission('system.view_logs')
|
||||
async getMetrics() {
|
||||
const audit = await this.service.getAuditLogs(50);
|
||||
const errors = await this.service.getErrorLogs(50);
|
||||
return { audit, errors };
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
@ApiOperation({ summary: 'Get all document numbering templates' })
|
||||
async getTemplates() {
|
||||
return this.service.getTemplates();
|
||||
// ----------------------------------------------------------
|
||||
// Admin Operations
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Post('manual-override')
|
||||
@ApiOperation({
|
||||
summary: 'Manually override or set a document number counter',
|
||||
})
|
||||
@RequirePermission('system.manage_settings')
|
||||
async manualOverride(@Body() dto: any) {
|
||||
return this.service.manualOverride(dto);
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
@ApiOperation({ summary: 'Create or Update a numbering template' })
|
||||
async saveTemplate(@Body() dto: any) {
|
||||
// TODO: Validate DTO properly
|
||||
return this.service.saveTemplate(dto);
|
||||
@Post('void-and-replace')
|
||||
@ApiOperation({ summary: 'Void a number and replace with a new generation' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async voidAndReplace(@Body() dto: any) {
|
||||
return this.service.voidAndReplace(dto);
|
||||
}
|
||||
|
||||
@Post('cancel')
|
||||
@ApiOperation({ summary: 'Cancel/Skip a specific document number' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async cancelNumber(@Body() dto: any) {
|
||||
return this.service.cancelNumber(dto);
|
||||
}
|
||||
|
||||
@Post('bulk-import')
|
||||
@ApiOperation({ summary: 'Bulk import/set document number counters' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async bulkImport(@Body() items: any[]) {
|
||||
return this.service.bulkImport(items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
@@ -16,6 +20,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { DocumentNumberingService } from './document-numbering.service';
|
||||
import { PreviewNumberDto } from './dto/preview-number.dto';
|
||||
|
||||
@ApiTags('Document Numbering')
|
||||
@ApiBearerAuth()
|
||||
@@ -24,6 +29,10 @@ import { DocumentNumberingService } from './document-numbering.service';
|
||||
export class DocumentNumberingController {
|
||||
constructor(private readonly numberingService: DocumentNumberingService) {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Logs
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Get('logs/audit')
|
||||
@ApiOperation({ summary: 'Get document generation audit logs' })
|
||||
@ApiResponse({ status: 200, description: 'List of audit logs' })
|
||||
@@ -42,12 +51,43 @@ export class DocumentNumberingController {
|
||||
return this.numberingService.getErrorLogs(limit ? Number(limit) : 100);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Sequences / Counters
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Get('sequences')
|
||||
@ApiOperation({ summary: 'Get all number sequences/counters' })
|
||||
@ApiResponse({ status: 200, description: 'List of counter sequences' })
|
||||
@ApiQuery({ name: 'projectId', required: false, type: Number })
|
||||
@RequirePermission('correspondence.read')
|
||||
getSequences(@Query('projectId') projectId?: number) {
|
||||
return this.numberingService.getSequences(
|
||||
projectId ? Number(projectId) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('counters/:id')
|
||||
@Roles(Role.ADMIN)
|
||||
@ApiOperation({ summary: 'Update counter sequence value (Admin only)' })
|
||||
@RequirePermission('system.manage_settings')
|
||||
async updateCounter(
|
||||
@Param('id') id: number,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('sequence') sequence: number
|
||||
) {
|
||||
return this.service.setCounterValue(id, sequence);
|
||||
return this.numberingService.setCounterValue(id, sequence);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Preview / Test
|
||||
// ----------------------------------------------------------
|
||||
|
||||
@Post('preview')
|
||||
@ApiOperation({ summary: 'Preview what a document number would look like' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Preview result without incrementing counter',
|
||||
})
|
||||
@RequirePermission('correspondence.read')
|
||||
async previewNumber(@Body() dto: PreviewNumberDto) {
|
||||
return this.numberingService.previewNumber(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,354 @@ export class DocumentNumberingService implements OnModuleInit {
|
||||
return discipline ? discipline.code : 'GEN';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Template Management Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all document numbering templates/formats
|
||||
*/
|
||||
async getTemplates(): Promise<DocumentNumberFormat[]> {
|
||||
try {
|
||||
return await this.formatRepo.find({
|
||||
relations: ['correspondenceType', 'project'],
|
||||
order: { projectId: 'ASC', correspondenceTypeId: 'ASC' },
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback: return without relations if there's an error
|
||||
this.logger.warn(
|
||||
'Failed to load templates with relations, trying without',
|
||||
error
|
||||
);
|
||||
return this.formatRepo.find({
|
||||
order: { projectId: 'ASC', correspondenceTypeId: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates filtered by project
|
||||
*/
|
||||
async getTemplatesByProject(
|
||||
projectId: number
|
||||
): Promise<DocumentNumberFormat[]> {
|
||||
return this.formatRepo.find({
|
||||
where: { projectId },
|
||||
relations: ['correspondenceType'],
|
||||
order: { correspondenceTypeId: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (create or update) a template
|
||||
*/
|
||||
async saveTemplate(
|
||||
dto: Partial<DocumentNumberFormat>
|
||||
): Promise<DocumentNumberFormat> {
|
||||
if (dto.id) {
|
||||
// Update existing
|
||||
await this.formatRepo.update(dto.id, {
|
||||
formatTemplate: dto.formatTemplate,
|
||||
correspondenceTypeId: dto.correspondenceTypeId,
|
||||
description: dto.description,
|
||||
resetSequenceYearly: dto.resetSequenceYearly,
|
||||
});
|
||||
const updated = await this.formatRepo.findOne({ where: { id: dto.id } });
|
||||
if (!updated) throw new Error('Template not found after update');
|
||||
return updated;
|
||||
} else {
|
||||
// Create new
|
||||
const template = this.formatRepo.create({
|
||||
projectId: dto.projectId,
|
||||
correspondenceTypeId: dto.correspondenceTypeId ?? null,
|
||||
formatTemplate: dto.formatTemplate,
|
||||
description: dto.description,
|
||||
resetSequenceYearly: dto.resetSequenceYearly ?? true,
|
||||
});
|
||||
return this.formatRepo.save(template);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template by ID
|
||||
*/
|
||||
async deleteTemplate(id: number): Promise<void> {
|
||||
await this.formatRepo.delete(id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Audit & Error Log Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get audit logs for document number generation
|
||||
*/
|
||||
async getAuditLogs(limit = 100): Promise<DocumentNumberAudit[]> {
|
||||
return this.auditRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error logs for document numbering
|
||||
*/
|
||||
async getErrorLogs(limit = 100): Promise<DocumentNumberError[]> {
|
||||
return this.errorRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Admin Override Methods (Stubs - To be fully implemented)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Manually override/set a counter value
|
||||
* @param dto { projectId, correspondenceTypeId, year, newValue }
|
||||
*/
|
||||
async manualOverride(dto: {
|
||||
projectId: number;
|
||||
correspondenceTypeId: number | null;
|
||||
year: number;
|
||||
newValue: number;
|
||||
}): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.warn(`Manual override requested: ${JSON.stringify(dto)}`);
|
||||
|
||||
const counter = await this.counterRepo.findOne({
|
||||
where: {
|
||||
projectId: dto.projectId,
|
||||
correspondenceTypeId: dto.correspondenceTypeId ?? undefined,
|
||||
currentYear: dto.year,
|
||||
},
|
||||
});
|
||||
|
||||
if (counter) {
|
||||
counter.lastNumber = dto.newValue;
|
||||
await this.counterRepo.save(counter);
|
||||
return { success: true, message: `Counter updated to ${dto.newValue}` };
|
||||
}
|
||||
|
||||
// Create new counter if not exists
|
||||
const newCounter = this.counterRepo.create({
|
||||
projectId: dto.projectId,
|
||||
correspondenceTypeId: dto.correspondenceTypeId,
|
||||
currentYear: dto.year,
|
||||
lastNumber: dto.newValue,
|
||||
version: 0,
|
||||
});
|
||||
await this.counterRepo.save(newCounter);
|
||||
return {
|
||||
success: true,
|
||||
message: `New counter created with value ${dto.newValue}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a document number and generate a replacement
|
||||
* @param dto { documentId, reason, context }
|
||||
*/
|
||||
async voidAndReplace(dto: {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
context?: GenerateNumberContext;
|
||||
}): Promise<{ newNumber: string; auditId: number }> {
|
||||
this.logger.warn(
|
||||
`Void and replace requested for document: ${dto.documentId}`
|
||||
);
|
||||
|
||||
// 1. Find original audit record for this document
|
||||
const originalAudit = await this.auditRepo.findOne({
|
||||
where: { documentId: dto.documentId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!originalAudit) {
|
||||
throw new Error(
|
||||
`No audit record found for document ID: ${dto.documentId}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Create void audit record
|
||||
const voidAudit = this.auditRepo.create({
|
||||
documentId: dto.documentId,
|
||||
generatedNumber: originalAudit.generatedNumber,
|
||||
counterKey: originalAudit.counterKey,
|
||||
templateUsed: originalAudit.templateUsed,
|
||||
operation: 'VOID_REPLACE',
|
||||
metadata: {
|
||||
reason: dto.reason,
|
||||
originalAuditId: originalAudit.id,
|
||||
voidedAt: new Date().toISOString(),
|
||||
},
|
||||
userId: dto.context?.userId ?? 0,
|
||||
ipAddress: dto.context?.ipAddress,
|
||||
});
|
||||
await this.auditRepo.save(voidAudit);
|
||||
|
||||
// 3. Generate new number if context is provided
|
||||
if (dto.context) {
|
||||
const result = await this.generateNextNumber(dto.context);
|
||||
return result;
|
||||
}
|
||||
|
||||
// If no context, return info about the void operation
|
||||
return {
|
||||
newNumber: `VOIDED:${originalAudit.generatedNumber}`,
|
||||
auditId: voidAudit.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/skip a specific document number
|
||||
* @param dto { documentNumber, reason, userId }
|
||||
*/
|
||||
async cancelNumber(dto: {
|
||||
documentNumber: string;
|
||||
reason: string;
|
||||
userId?: number;
|
||||
ipAddress?: string;
|
||||
}): Promise<{ success: boolean; auditId: number }> {
|
||||
this.logger.warn(`Cancel number requested: ${dto.documentNumber}`);
|
||||
|
||||
// Find existing audit record for this number
|
||||
const existingAudit = await this.auditRepo.findOne({
|
||||
where: { generatedNumber: dto.documentNumber },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// Create cancellation audit record
|
||||
const cancelAudit = this.auditRepo.create({
|
||||
documentId: existingAudit?.documentId ?? 0,
|
||||
generatedNumber: dto.documentNumber,
|
||||
counterKey: existingAudit?.counterKey ?? { cancelled: true },
|
||||
templateUsed: existingAudit?.templateUsed ?? 'CANCELLED',
|
||||
operation: 'CANCEL',
|
||||
metadata: {
|
||||
reason: dto.reason,
|
||||
cancelledAt: new Date().toISOString(),
|
||||
originalAuditId: existingAudit?.id,
|
||||
},
|
||||
userId: dto.userId ?? 0,
|
||||
ipAddress: dto.ipAddress,
|
||||
});
|
||||
|
||||
const saved = await this.auditRepo.save(cancelAudit);
|
||||
|
||||
return { success: true, auditId: saved.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import counter values
|
||||
*/
|
||||
async bulkImport(
|
||||
items: Array<{
|
||||
projectId: number;
|
||||
correspondenceTypeId: number | null;
|
||||
year: number;
|
||||
lastNumber: number;
|
||||
}>
|
||||
): Promise<{ imported: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let imported = 0;
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await this.manualOverride({
|
||||
projectId: item.projectId,
|
||||
correspondenceTypeId: item.correspondenceTypeId,
|
||||
year: item.year,
|
||||
newValue: item.lastNumber,
|
||||
});
|
||||
imported++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Failed to import: ${JSON.stringify(item)} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, errors };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query Methods
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all counter sequences - for admin UI
|
||||
*/
|
||||
async getSequences(projectId?: number): Promise<
|
||||
Array<{
|
||||
projectId: number;
|
||||
originatorId: number;
|
||||
recipientOrganizationId: number;
|
||||
typeId: number;
|
||||
disciplineId: number;
|
||||
year: number;
|
||||
lastNumber: number;
|
||||
}>
|
||||
> {
|
||||
const whereClause = projectId ? { projectId } : {};
|
||||
|
||||
const counters = await this.counterRepo.find({
|
||||
where: whereClause,
|
||||
order: { year: 'DESC', lastNumber: 'DESC' },
|
||||
});
|
||||
|
||||
return counters.map((c) => ({
|
||||
projectId: c.projectId,
|
||||
originatorId: c.originatorId,
|
||||
recipientOrganizationId: c.recipientOrganizationId,
|
||||
typeId: c.typeId,
|
||||
disciplineId: c.disciplineId,
|
||||
year: c.year,
|
||||
lastNumber: c.lastNumber,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what a document number would look like
|
||||
* WITHOUT actually incrementing the counter
|
||||
*/
|
||||
async previewNumber(
|
||||
ctx: GenerateNumberContext
|
||||
): Promise<{ previewNumber: string; nextSequence: number }> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 1. Resolve Format
|
||||
const { template, resetSequenceYearly } =
|
||||
await this.resolveFormatAndScope(ctx);
|
||||
const tokens = await this.resolveTokens(ctx, currentYear);
|
||||
|
||||
// 2. Get current counter value (without incrementing)
|
||||
const counterYear = resetSequenceYearly ? currentYear : 0;
|
||||
|
||||
const existingCounter = await this.counterRepo.findOne({
|
||||
where: {
|
||||
projectId: ctx.projectId,
|
||||
originatorId: ctx.originatorId,
|
||||
typeId: ctx.typeId,
|
||||
disciplineId: ctx.disciplineId ?? 0,
|
||||
year: counterYear,
|
||||
},
|
||||
});
|
||||
|
||||
const currentSequence = existingCounter?.lastNumber ?? 0;
|
||||
const nextSequence = currentSequence + 1;
|
||||
|
||||
// 3. Generate preview number
|
||||
const previewNumber = this.replaceTokens(template, tokens, nextSequence);
|
||||
|
||||
return { previewNumber, nextSequence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set counter value directly (for admin use)
|
||||
*/
|
||||
async setCounterValue(counterId: number, newSequence: number): Promise<void> {
|
||||
await this.counterRepo.update(counterId, { lastNumber: newSequence });
|
||||
}
|
||||
|
||||
private async logAudit(data: any): Promise<DocumentNumberAudit> {
|
||||
const audit = this.auditRepo.create({
|
||||
...data,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// File: src/modules/document-numbering/dto/preview-number.dto.ts
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PreviewNumberDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
projectId!: number;
|
||||
|
||||
@ApiProperty({ description: 'Originator organization ID' })
|
||||
originatorId!: number;
|
||||
|
||||
@ApiProperty({ description: 'Correspondence type ID' })
|
||||
typeId!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
|
||||
subTypeId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'RFA type ID (for RFA)' })
|
||||
rfaTypeId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Discipline ID' })
|
||||
disciplineId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Year (defaults to current)' })
|
||||
year?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Recipient organization ID' })
|
||||
recipientOrganizationId?: number;
|
||||
}
|
||||
@@ -41,12 +41,9 @@ function ManualOverrideForm({ onSuccess, projectId }: { onSuccess: () => void, p
|
||||
try {
|
||||
await numberingApi.manualOverride({
|
||||
projectId,
|
||||
typeId: parseInt(formData.typeId),
|
||||
disciplineId: formData.disciplineId ? parseInt(formData.disciplineId) : undefined,
|
||||
correspondenceTypeId: parseInt(formData.typeId) || null,
|
||||
year: parseInt(formData.year),
|
||||
newSequence: parseInt(formData.newSequence),
|
||||
reason: formData.reason,
|
||||
userId: 1 // TODO: Get from auth context
|
||||
newValue: parseInt(formData.newSequence),
|
||||
});
|
||||
toast.success("Manual override applied successfully");
|
||||
onSuccess();
|
||||
@@ -181,10 +178,13 @@ export default function NumberingPage() {
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await numberingApi.getTemplates();
|
||||
const response = await numberingApi.getTemplates();
|
||||
// Handle wrapped response { data: [...] } or direct array
|
||||
const data = Array.isArray(response) ? response : (response as { data?: NumberingTemplate[] })?.data ?? [];
|
||||
setTemplates(data);
|
||||
} catch {
|
||||
toast.error("Failed to load templates");
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export default function NumberingPage() {
|
||||
const handleSave = async (data: Partial<NumberingTemplate>) => {
|
||||
try {
|
||||
await numberingApi.saveTemplate(data);
|
||||
toast.success(data.id || data.templateId ? "Template updated" : "Template created");
|
||||
toast.success(data.id ? "Template updated" : "Template created");
|
||||
setIsEditing(false);
|
||||
loadTemplates();
|
||||
} catch {
|
||||
@@ -281,37 +281,34 @@ export default function NumberingPage() {
|
||||
{templates
|
||||
.filter(t => !t.projectId || t.projectId === Number(selectedProjectId))
|
||||
.map((template) => (
|
||||
<Card key={template.templateId} className="p-6 hover:shadow-md transition-shadow">
|
||||
<Card key={template.id} className="p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{template.documentTypeName}
|
||||
{template.correspondenceType?.typeName || 'Default Format'}
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{projects.find((p: any) => p.id.toString() === template.projectId?.toString())?.projectName || selectedProjectName}
|
||||
</Badge>
|
||||
{template.disciplineCode && <Badge>{template.disciplineCode}</Badge>}
|
||||
<Badge variant={template.isActive ? 'default' : 'secondary'}>
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
{template.project?.projectCode || selectedProjectName}
|
||||
</Badge>
|
||||
{template.description && <Badge variant="secondary">{template.description}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-100 dark:bg-slate-900 rounded px-3 py-2 mb-3 font-mono text-sm inline-block border">
|
||||
{template.templateFormat}
|
||||
{template.formatTemplate}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Example: </span>
|
||||
<span className="text-muted-foreground">Type Code: </span>
|
||||
<span className="font-medium font-mono text-green-600 dark:text-green-400">
|
||||
{template.exampleNumber}
|
||||
{template.correspondenceType?.typeCode || 'DEFAULT'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Reset: </span>
|
||||
<span>
|
||||
{template.resetAnnually ? 'Annually' : 'Never'}
|
||||
{template.resetSequenceYearly ? 'Annually' : 'Continuous'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,15 @@ export function SequenceViewer() {
|
||||
const fetchSequences = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await numberingApi.getSequences();
|
||||
setSequences(data);
|
||||
const response = await numberingApi.getSequences();
|
||||
// Handle wrapped response { data: [...] } or direct array
|
||||
const data = Array.isArray(response) ? response : (response as { data?: NumberSequence[] })?.data ?? [];
|
||||
setSequences(data);
|
||||
} catch {
|
||||
console.error('Failed to fetch sequences');
|
||||
setSequences([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,17 +32,23 @@ export function SequenceViewer() {
|
||||
fetchSequences();
|
||||
}, []);
|
||||
|
||||
const filteredSequences = sequences.filter(s =>
|
||||
const filteredSequences = sequences.filter(
|
||||
(s) =>
|
||||
s.year.toString().includes(search) ||
|
||||
s.organizationCode?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.disciplineCode?.toLowerCase().includes(search.toLowerCase())
|
||||
s.projectId.toString().includes(search) ||
|
||||
s.typeId.toString().includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Number Sequences</h3>
|
||||
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
|
||||
<h3 className="text-lg font-semibold">Number Counters</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSequences}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -45,7 +56,7 @@ export function SequenceViewer() {
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="Search by year, organization..."
|
||||
placeholder="Search by year, project, type..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -53,31 +64,32 @@ export function SequenceViewer() {
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredSequences.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-4">No sequences found</div>
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
No sequences found
|
||||
</div>
|
||||
)}
|
||||
{filteredSequences.map((seq) => (
|
||||
{filteredSequences.map((seq, index) => (
|
||||
<div
|
||||
key={seq.sequenceId}
|
||||
key={`${seq.projectId}-${seq.typeId}-${seq.year}-${index}`}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded border"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">Year {seq.year}</span>
|
||||
{seq.organizationCode && (
|
||||
<Badge>{seq.organizationCode}</Badge>
|
||||
)}
|
||||
{seq.disciplineCode && (
|
||||
<Badge variant="outline">{seq.disciplineCode}</Badge>
|
||||
<Badge variant="outline">Project: {seq.projectId}</Badge>
|
||||
<Badge>Type: {seq.typeId}</Badge>
|
||||
{seq.disciplineId > 0 && (
|
||||
<Badge variant="secondary">Disc: {seq.disciplineId}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-medium">Current: {seq.currentNumber}</span> | Last Generated:{' '}
|
||||
<span className="font-mono">{seq.lastGeneratedNumber}</span>
|
||||
<span className="text-foreground font-medium">
|
||||
Counter: {seq.lastNumber}
|
||||
</span>{' '}
|
||||
| Originator: {seq.originatorId} | Recipient:{' '}
|
||||
{seq.recipientOrganizationId === -1 ? 'All' : seq.recipientOrganizationId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Updated {new Date(seq.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function TemplateEditor({
|
||||
onSave({
|
||||
...template,
|
||||
projectId: projectId,
|
||||
correspondenceTypeId: Number(typeId),
|
||||
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
||||
disciplineId: Number(disciplineId),
|
||||
formatTemplate: format,
|
||||
templateFormat: format, // Legacy support
|
||||
@@ -107,7 +107,7 @@ export function TemplateEditor({
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = format.length > 0 && typeId;
|
||||
const isValid = format.length > 0; // typeId is optional (null = default for all types)
|
||||
|
||||
return (
|
||||
<Card className="p-6 space-y-6">
|
||||
@@ -136,12 +136,13 @@ export function TemplateEditor({
|
||||
{/* Configuration Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Document Type *</Label>
|
||||
<Label>Document Type (Optional)</Label>
|
||||
<Select value={typeId} onValueChange={setTypeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type..." />
|
||||
<SelectValue placeholder="Default (All Types)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
||||
{correspondenceTypes.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id.toString()}>
|
||||
{type.typeCode} - {type.typeName}
|
||||
@@ -149,6 +150,9 @@ export function TemplateEditor({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to create a default template for this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
setLoading(true);
|
||||
try {
|
||||
// Note: generateTestNumber expects keys: organizationId, disciplineId
|
||||
const result = await numberingApi.generateTestNumber(template.id || template.templateId || 0, {
|
||||
const result = await numberingApi.generateTestNumber(template.id ?? 0, {
|
||||
organizationId: testData.organizationId,
|
||||
disciplineId: testData.disciplineId
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
Template: <span className="font-mono font-bold text-foreground">{template?.templateFormat}</span>
|
||||
Template: <span className="font-mono font-bold text-foreground">{template?.formatTemplate}</span>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
|
||||
@@ -80,7 +80,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: {template?.templateFormat}
|
||||
Format: {template?.formatTemplate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +1,334 @@
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// Types
|
||||
// ============================================================
|
||||
// Types - aligned with backend entities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Document Number Format/Template
|
||||
* Matches: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
|
||||
*/
|
||||
export interface NumberingTemplate {
|
||||
id?: number; // Backend uses 'id'
|
||||
templateId?: number; // Legacy, optional
|
||||
id: number;
|
||||
projectId: number;
|
||||
correspondenceTypeId: number;
|
||||
correspondenceType?: { typeCode: string; typeName: string }; // Relation
|
||||
documentTypeName?: string; // Optional (joined)
|
||||
disciplineId: number;
|
||||
discipline?: { disciplineCode: string; disciplineName: string }; // Relation
|
||||
disciplineCode?: string; // Optional (joined)
|
||||
formatTemplate: string; // Backend uses 'formatTemplate'
|
||||
templateFormat?: string; // Legacy alias
|
||||
exampleNumber?: string;
|
||||
paddingLength: number;
|
||||
resetAnnually: boolean;
|
||||
isActive: boolean;
|
||||
correspondenceTypeId: number | null; // null = Default Format for project
|
||||
correspondenceType?: {
|
||||
id: number;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
} | null;
|
||||
project?: {
|
||||
id: number;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
};
|
||||
formatTemplate: string;
|
||||
description?: string;
|
||||
resetSequenceYearly: boolean; // Controls yearly counter reset
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NumberingTemplateDto {
|
||||
/**
|
||||
* DTO for creating/updating templates
|
||||
*/
|
||||
export interface SaveTemplateDto {
|
||||
id?: number; // If present, update; otherwise create
|
||||
projectId: number;
|
||||
correspondenceTypeId: number;
|
||||
disciplineId?: number; // 0 = All
|
||||
correspondenceTypeId: number | null;
|
||||
formatTemplate: string;
|
||||
exampleNumber?: string;
|
||||
paddingLength: number;
|
||||
resetAnnually: boolean;
|
||||
isActive: boolean;
|
||||
description?: string;
|
||||
resetSequenceYearly?: boolean;
|
||||
}
|
||||
|
||||
export interface NumberSequence {
|
||||
sequenceId: number;
|
||||
year: number;
|
||||
organizationCode?: string;
|
||||
disciplineCode?: string;
|
||||
currentNumber: number;
|
||||
lastGeneratedNumber: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* Document Number Audit Log
|
||||
* Matches: backend/src/modules/document-numbering/entities/document-number-audit.entity.ts
|
||||
*/
|
||||
export interface DocumentNumberAudit {
|
||||
id: number;
|
||||
documentId: number;
|
||||
generatedNumber: string;
|
||||
counterKey: Record<string, unknown>;
|
||||
templateUsed: string;
|
||||
operation: 'RESERVE' | 'CONFIRM' | 'MANUAL_OVERRIDE' | 'VOID_REPLACE' | 'CANCEL';
|
||||
metadata?: Record<string, unknown>;
|
||||
userId: number;
|
||||
ipAddress?: string;
|
||||
retryCount: number;
|
||||
lockWaitMs?: number;
|
||||
totalDurationMs?: number;
|
||||
fallbackUsed?: 'NONE' | 'DB_LOCK' | 'RETRY';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Number Error Log
|
||||
*/
|
||||
export interface DocumentNumberError {
|
||||
id: number;
|
||||
errorMessage: string;
|
||||
stackTrace?: string;
|
||||
context?: Record<string, unknown>;
|
||||
userId?: number;
|
||||
ipAddress?: string;
|
||||
createdAt: string;
|
||||
resolvedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual Override DTO
|
||||
*/
|
||||
export interface ManualOverrideDto {
|
||||
projectId: number;
|
||||
correspondenceTypeId: number | null;
|
||||
year: number;
|
||||
newValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void and Replace DTO
|
||||
*/
|
||||
export interface VoidAndReplaceDto {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Number DTO
|
||||
*/
|
||||
export interface CancelNumberDto {
|
||||
documentNumber: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk Import Item
|
||||
*/
|
||||
export interface BulkImportItem {
|
||||
projectId: number;
|
||||
correspondenceTypeId: number | null;
|
||||
year: number;
|
||||
lastNumber: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Client
|
||||
// ============================================================
|
||||
|
||||
export const numberingApi = {
|
||||
// ----------------------------------------------------------
|
||||
// Template Management (Admin endpoints)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get all templates
|
||||
*/
|
||||
getTemplates: async (): Promise<NumberingTemplate[]> => {
|
||||
const res = await apiClient.get<NumberingTemplate[]>('/admin/document-numbering/templates');
|
||||
return res.data.map(t => ({
|
||||
...t,
|
||||
templateId: t.id,
|
||||
templateFormat: t.formatTemplate,
|
||||
// Map joined data if available, else placeholders
|
||||
documentTypeName: t.correspondenceType?.typeCode || 'UNKNOWN',
|
||||
disciplineCode: t.discipline?.disciplineCode || 'ALL',
|
||||
}));
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get templates for a specific project
|
||||
*/
|
||||
getTemplatesByProject: async (projectId: number): Promise<NumberingTemplate[]> => {
|
||||
const res = await apiClient.get<NumberingTemplate[]>(
|
||||
`/admin/document-numbering/templates?projectId=${projectId}`
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get single template by ID
|
||||
*/
|
||||
getTemplate: async (id: number): Promise<NumberingTemplate | undefined> => {
|
||||
// Currently no single get endpoint
|
||||
const templates = await numberingApi.getTemplates();
|
||||
return templates.find(t => t.id === id);
|
||||
return templates.find((t) => t.id === id);
|
||||
},
|
||||
|
||||
saveTemplate: async (template: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
|
||||
// Map frontend interface to backend entity DTO
|
||||
const payload = {
|
||||
id: template.id || template.templateId, // Update if ID exists
|
||||
projectId: template.projectId,
|
||||
correspondenceTypeId: template.correspondenceTypeId,
|
||||
disciplineId: template.disciplineId || 0,
|
||||
formatTemplate: template.templateFormat || template.formatTemplate,
|
||||
exampleNumber: template.exampleNumber,
|
||||
paddingLength: template.paddingLength,
|
||||
resetAnnually: template.resetAnnually,
|
||||
isActive: template.isActive ?? true
|
||||
};
|
||||
const res = await apiClient.post<NumberingTemplate>('/admin/document-numbering/templates', payload);
|
||||
return res.data;
|
||||
/**
|
||||
* Save (create or update) a template
|
||||
*/
|
||||
saveTemplate: async (dto: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
|
||||
const res = await apiClient.post<NumberingTemplate>(
|
||||
'/admin/document-numbering/templates',
|
||||
dto
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getSequences: async (): Promise<NumberSequence[]> => {
|
||||
// TODO: Implement backend endpoint for sequences list
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve([]), 500);
|
||||
});
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
deleteTemplate: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/admin/document-numbering/templates/${id}`);
|
||||
},
|
||||
|
||||
generateTestNumber: async (templateId: number, context: { organizationId: string, disciplineId: string }): Promise<{ number: string }> => {
|
||||
// Use preview endpoint
|
||||
// We need to know projectId, typeId etc from template.
|
||||
// But preview endpoint needs context.
|
||||
// For now, let's just return a mock or call preview endpoint if we have enough info.
|
||||
// ----------------------------------------------------------
|
||||
// Logs (Requires system.view_logs permission)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Generating test number for:', templateId, context);
|
||||
return new Promise((resolve) => resolve({ number: 'TEST-1234' }));
|
||||
/**
|
||||
* Get audit logs
|
||||
*/
|
||||
getAuditLogs: async (limit = 100): Promise<DocumentNumberAudit[]> => {
|
||||
const res = await apiClient.get<DocumentNumberAudit[]>(
|
||||
`/document-numbering/logs/audit?limit=${limit}`
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// --- Admin Tools ---
|
||||
|
||||
getMetrics: async (): Promise<{ audit: any[], errors: any[] }> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = await apiClient.get<{ audit: any[], errors: any[] }>('/admin/document-numbering/metrics');
|
||||
return res.data;
|
||||
/**
|
||||
* Get error logs
|
||||
*/
|
||||
getErrorLogs: async (limit = 100): Promise<DocumentNumberError[]> => {
|
||||
const res = await apiClient.get<DocumentNumberError[]>(
|
||||
`/document-numbering/logs/errors?limit=${limit}`
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
manualOverride: async (data: any): Promise<void> => {
|
||||
await apiClient.post('/admin/document-numbering/manual-override', data);
|
||||
/**
|
||||
* Get metrics (audit + errors combined)
|
||||
*/
|
||||
getMetrics: async (): Promise<{ audit: DocumentNumberAudit[]; errors: DocumentNumberError[] }> => {
|
||||
const res = await apiClient.get<{ audit: DocumentNumberAudit[]; errors: DocumentNumberError[] }>(
|
||||
'/admin/document-numbering/metrics'
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
voidAndReplace: async (data: any): Promise<string> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = await apiClient.post<any>('/admin/document-numbering/void-and-replace', data);
|
||||
return res.data;
|
||||
// ----------------------------------------------------------
|
||||
// Admin Tools
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manually override/set a counter value
|
||||
*/
|
||||
manualOverride: async (dto: ManualOverrideDto): Promise<{ success: boolean; message: string }> => {
|
||||
const res = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/admin/document-numbering/manual-override',
|
||||
dto
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
cancelNumber: async (data: any): Promise<void> => {
|
||||
await apiClient.post('/admin/document-numbering/cancel', data);
|
||||
/**
|
||||
* Void a document number and generate replacement
|
||||
*/
|
||||
voidAndReplace: async (dto: VoidAndReplaceDto): Promise<{ newNumber: string; auditId: number }> => {
|
||||
const res = await apiClient.post<{ newNumber: string; auditId: number }>(
|
||||
'/admin/document-numbering/void-and-replace',
|
||||
dto
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel/skip a document number
|
||||
*/
|
||||
cancelNumber: async (dto: CancelNumberDto): Promise<{ success: boolean }> => {
|
||||
const res = await apiClient.post<{ success: boolean }>(
|
||||
'/admin/document-numbering/cancel',
|
||||
dto
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk import counter values
|
||||
*/
|
||||
bulkImport: async (items: BulkImportItem[]): Promise<{ imported: number; errors: string[] }> => {
|
||||
const res = await apiClient.post<{ imported: number; errors: string[] }>(
|
||||
'/admin/document-numbering/bulk-import',
|
||||
items
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update counter sequence value (Admin only)
|
||||
*/
|
||||
updateCounter: async (counterId: number, sequence: number): Promise<void> => {
|
||||
await apiClient.patch(`/document-numbering/counters/${counterId}`, { sequence });
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Placeholder Methods (Backend not yet implemented)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get all counter sequences
|
||||
*/
|
||||
getSequences: async (projectId?: number): Promise<NumberSequence[]> => {
|
||||
const url = projectId
|
||||
? `/document-numbering/sequences?projectId=${projectId}`
|
||||
: '/document-numbering/sequences';
|
||||
const res = await apiClient.get<NumberSequence[]>(url);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Preview what a document number would look like (without generating)
|
||||
*/
|
||||
previewNumber: async (ctx: {
|
||||
projectId: number;
|
||||
originatorId: number;
|
||||
typeId: number;
|
||||
disciplineId?: number;
|
||||
subTypeId?: number;
|
||||
rfaTypeId?: number;
|
||||
recipientOrganizationId?: number;
|
||||
}): Promise<{ previewNumber: string; nextSequence: number }> => {
|
||||
const res = await apiClient.post<{ previewNumber: string; nextSequence: number }>(
|
||||
'/document-numbering/preview',
|
||||
ctx
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate test number - Uses preview endpoint
|
||||
* @deprecated Use previewNumber instead
|
||||
*/
|
||||
generateTestNumber: async (
|
||||
_templateId: number,
|
||||
context: { organizationId: string; disciplineId: string }
|
||||
): Promise<{ number: string }> => {
|
||||
// Fallback mock for legacy UI - requires proper context for real use
|
||||
const mockNumber = `TEST-${new Date().getFullYear()}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}`;
|
||||
console.log('Using mock generateTestNumber. Context:', context);
|
||||
return { number: mockNumber };
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Types for Sequences
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Number Sequence / Counter record
|
||||
*/
|
||||
export interface NumberSequence {
|
||||
projectId: number;
|
||||
originatorId: number;
|
||||
recipientOrganizationId: number;
|
||||
typeId: number;
|
||||
disciplineId: number;
|
||||
year: number;
|
||||
lastNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview Number Context
|
||||
*/
|
||||
export interface PreviewNumberContext {
|
||||
projectId: number;
|
||||
originatorId: number;
|
||||
typeId: number;
|
||||
disciplineId?: number;
|
||||
subTypeId?: number;
|
||||
rfaTypeId?: number;
|
||||
recipientOrganizationId?: number;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
---
|
||||
title: 'Implementation Guide: Document Numbering System'
|
||||
version: 1.6.1
|
||||
status: draft
|
||||
version: 1.7.0
|
||||
status: implemented
|
||||
owner: Development Team
|
||||
last_updated: 2025-12-16
|
||||
related:
|
||||
@@ -570,48 +570,175 @@ export class CounterResetJob extends WorkerHost {
|
||||
|
||||
## 5. API Controller
|
||||
|
||||
### 5.1. Main Controller (`/document-numbering`)
|
||||
|
||||
```typescript
|
||||
// File: src/modules/document-numbering/controllers/document-numbering.controller.ts
|
||||
import { Controller, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { DocumentNumberingService } from '../services/document-numbering.service';
|
||||
import { Roles } from 'src/auth/decorators/roles.decorator';
|
||||
// File: src/modules/document-numbering/document-numbering.controller.ts
|
||||
import {
|
||||
Controller, Get, Post, Patch,
|
||||
Body, Param, Query, UseGuards, ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { DocumentNumberingService } from './document-numbering.service';
|
||||
import { PreviewNumberDto } from './dto/preview-number.dto';
|
||||
|
||||
@Controller('document-numbering')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
export class DocumentNumberingController {
|
||||
constructor(
|
||||
private readonly documentNumberingService: DocumentNumberingService,
|
||||
) {}
|
||||
constructor(private readonly numberingService: DocumentNumberingService) {}
|
||||
|
||||
@Post('generate')
|
||||
@Throttle(10, 60) // 10 requests per 60 seconds
|
||||
async generateNumber(@Body() dto: GenerateNumberDto) {
|
||||
const number = await this.documentNumberingService.generateDocumentNumber(dto);
|
||||
return { documentNumber: number };
|
||||
// --- Logs ---
|
||||
|
||||
@Get('logs/audit')
|
||||
@RequirePermission('system.view_logs')
|
||||
getAuditLogs(@Query('limit') limit?: number) {
|
||||
return this.numberingService.getAuditLogs(limit ? Number(limit) : 100);
|
||||
}
|
||||
|
||||
@Put('configs/:configId')
|
||||
@Roles('PROJECT_ADMIN')
|
||||
async updateTemplate(
|
||||
@Param('configId') configId: number,
|
||||
@Body() dto: UpdateTemplateDto,
|
||||
) {
|
||||
// Update template configuration
|
||||
@Get('logs/errors')
|
||||
@RequirePermission('system.view_logs')
|
||||
getErrorLogs(@Query('limit') limit?: number) {
|
||||
return this.numberingService.getErrorLogs(limit ? Number(limit) : 100);
|
||||
}
|
||||
|
||||
@Post('configs/:configId/reset-counter')
|
||||
@Roles('SUPER_ADMIN')
|
||||
async resetCounter(
|
||||
@Param('configId') configId: number,
|
||||
@Body() dto: ResetCounterDto,
|
||||
// --- Sequences / Counters ---
|
||||
|
||||
@Get('sequences')
|
||||
@RequirePermission('correspondence.read')
|
||||
getSequences(@Query('projectId') projectId?: number) {
|
||||
return this.numberingService.getSequences(projectId ? Number(projectId) : undefined);
|
||||
}
|
||||
|
||||
@Patch('counters/:id')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async updateCounter(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('sequence') sequence: number
|
||||
) {
|
||||
// Manual counter reset (requires approval)
|
||||
return this.numberingService.setCounterValue(id, sequence);
|
||||
}
|
||||
|
||||
// --- Preview ---
|
||||
|
||||
@Post('preview')
|
||||
@RequirePermission('correspondence.read')
|
||||
async previewNumber(@Body() dto: PreviewNumberDto) {
|
||||
return this.numberingService.previewNumber(dto);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2. Admin Controller (`/admin/document-numbering`)
|
||||
|
||||
```typescript
|
||||
// File: src/modules/document-numbering/document-numbering-admin.controller.ts
|
||||
import {
|
||||
Controller, Get, Post, Delete, Body, Param, Query,
|
||||
UseGuards, ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { DocumentNumberingService } from './document-numbering.service';
|
||||
|
||||
@Controller('admin/document-numbering')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
export class DocumentNumberingAdminController {
|
||||
constructor(private readonly service: DocumentNumberingService) {}
|
||||
|
||||
// --- Template Management ---
|
||||
|
||||
@Get('templates')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async getTemplates(@Query('projectId') projectId?: number) {
|
||||
if (projectId) {
|
||||
return this.service.getTemplatesByProject(projectId);
|
||||
}
|
||||
return this.service.getTemplates();
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async saveTemplate(@Body() dto: any) {
|
||||
return this.service.saveTemplate(dto);
|
||||
}
|
||||
|
||||
@Delete('templates/:id')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async deleteTemplate(@Param('id', ParseIntPipe) id: number) {
|
||||
await this.service.deleteTemplate(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- Metrics ---
|
||||
|
||||
@Get('metrics')
|
||||
@RequirePermission('system.view_logs')
|
||||
async getMetrics() {
|
||||
const audit = await this.service.getAuditLogs(50);
|
||||
const errors = await this.service.getErrorLogs(50);
|
||||
return { audit, errors };
|
||||
}
|
||||
|
||||
// --- Admin Operations ---
|
||||
|
||||
@Post('manual-override')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async manualOverride(@Body() dto: {
|
||||
projectId: number;
|
||||
correspondenceTypeId: number | null;
|
||||
year: number;
|
||||
newValue: number;
|
||||
}) {
|
||||
return this.service.manualOverride(dto);
|
||||
}
|
||||
|
||||
@Post('void-and-replace')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async voidAndReplace(@Body() dto: {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
}) {
|
||||
return this.service.voidAndReplace(dto);
|
||||
}
|
||||
|
||||
@Post('cancel')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async cancelNumber(@Body() dto: {
|
||||
documentNumber: string;
|
||||
reason: string;
|
||||
}) {
|
||||
return this.service.cancelNumber(dto);
|
||||
}
|
||||
|
||||
@Post('bulk-import')
|
||||
@RequirePermission('system.manage_settings')
|
||||
async bulkImport(@Body() items: any[]) {
|
||||
return this.service.bulkImport(items);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3. API Endpoints Summary
|
||||
|
||||
| Endpoint | Method | Permission | Description |
|
||||
| -------------------------------------------- | ------ | ------------------------ | --------------------------------- |
|
||||
| `/document-numbering/logs/audit` | GET | `system.view_logs` | Get audit logs |
|
||||
| `/document-numbering/logs/errors` | GET | `system.view_logs` | Get error logs |
|
||||
| `/document-numbering/sequences` | GET | `correspondence.read` | Get counter sequences |
|
||||
| `/document-numbering/counters/:id` | PATCH | `system.manage_settings` | Update counter value |
|
||||
| `/document-numbering/preview` | POST | `correspondence.read` | Preview number without generating |
|
||||
| `/admin/document-numbering/templates` | GET | `system.manage_settings` | Get all templates |
|
||||
| `/admin/document-numbering/templates` | POST | `system.manage_settings` | Create/update template |
|
||||
| `/admin/document-numbering/templates/:id` | DELETE | `system.manage_settings` | Delete template |
|
||||
| `/admin/document-numbering/metrics` | GET | `system.view_logs` | Get metrics (audit + errors) |
|
||||
| `/admin/document-numbering/manual-override` | POST | `system.manage_settings` | Override counter value |
|
||||
| `/admin/document-numbering/void-and-replace` | POST | `system.manage_settings` | Void and replace number |
|
||||
| `/admin/document-numbering/cancel` | POST | `system.manage_settings` | Cancel a number |
|
||||
| `/admin/document-numbering/bulk-import` | POST | `system.manage_settings` | Bulk import counters |
|
||||
|
||||
## 6. Module Configuration
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 20251216-document-numbering-backend-methods.md
|
||||
|
||||
> **Date**: 2025-12-16
|
||||
> **Type**: Feature Implementation
|
||||
> **Status**: ✅ Completed
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented missing backend methods for Document Numbering module and fixed frontend admin panel issues.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### New Service Methods (`document-numbering.service.ts`)
|
||||
|
||||
| Method | Description |
|
||||
| -------------------------- | ------------------------------------------------- |
|
||||
| `voidAndReplace(dto)` | Void a number and optionally generate replacement |
|
||||
| `cancelNumber(dto)` | Mark a number as cancelled in audit log |
|
||||
| `getSequences(projectId?)` | Get all counter sequences |
|
||||
| `previewNumber(ctx)` | Preview number without incrementing counter |
|
||||
|
||||
### New Controller Endpoints (`document-numbering.controller.ts`)
|
||||
|
||||
| Endpoint | Method | Permission |
|
||||
| ------------------------------- | ------ | --------------------- |
|
||||
| `/document-numbering/sequences` | GET | `correspondence.read` |
|
||||
| `/document-numbering/preview` | POST | `correspondence.read` |
|
||||
|
||||
### New DTO
|
||||
|
||||
- `dto/preview-number.dto.ts` - Request DTO for preview endpoint
|
||||
|
||||
---
|
||||
|
||||
## Frontend Fixes
|
||||
|
||||
### API Response Handling
|
||||
|
||||
Fixed wrapped response `{ data: [...] }` issue:
|
||||
- `components/numbering/sequence-viewer.tsx`
|
||||
- `app/(admin)/admin/numbering/page.tsx`
|
||||
|
||||
### Template Editor (`components/numbering/template-editor.tsx`)
|
||||
|
||||
- Made Document Type **optional** (`correspondence_type_id` can be `null`)
|
||||
- Added "Default (All Types)" option to dropdown
|
||||
- Fixed validation to allow save without type selection
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
Added missing table `document_number_formats` to schema.
|
||||
|
||||
---
|
||||
|
||||
## Specs Updated
|
||||
|
||||
- `specs/03-implementation/document-numbering.md` → v1.7.0 (status: implemented)
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
```
|
||||
backend/src/modules/document-numbering/
|
||||
├── document-numbering.service.ts
|
||||
├── document-numbering.controller.ts
|
||||
├── dto/preview-number.dto.ts (NEW)
|
||||
└── ...
|
||||
|
||||
backend/src/modules/circulation/
|
||||
└── circulation.service.ts (fixed generateNextNumber usage)
|
||||
|
||||
frontend/lib/api/
|
||||
└── numbering.ts
|
||||
|
||||
frontend/components/numbering/
|
||||
├── sequence-viewer.tsx
|
||||
└── template-editor.tsx
|
||||
|
||||
frontend/app/(admin)/admin/numbering/
|
||||
└── page.tsx
|
||||
|
||||
specs/03-implementation/
|
||||
└── document-numbering.md
|
||||
```
|
||||
Reference in New Issue
Block a user