diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index c274e10..06fbaa8 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -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, diff --git a/backend/src/modules/document-numbering/document-numbering-admin.controller.ts b/backend/src/modules/document-numbering/document-numbering-admin.controller.ts index de0de78..cd57dec 100644 --- a/backend/src/modules/document-numbering/document-numbering-admin.controller.ts +++ b/backend/src/modules/document-numbering/document-numbering-admin.controller.ts @@ -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); } } diff --git a/backend/src/modules/document-numbering/document-numbering.controller.ts b/backend/src/modules/document-numbering/document-numbering.controller.ts index 1dab115..a54f587 100644 --- a/backend/src/modules/document-numbering/document-numbering.controller.ts +++ b/backend/src/modules/document-numbering/document-numbering.controller.ts @@ -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); } } diff --git a/backend/src/modules/document-numbering/document-numbering.service.ts b/backend/src/modules/document-numbering/document-numbering.service.ts index dd60baa..011eb6f 100644 --- a/backend/src/modules/document-numbering/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/document-numbering.service.ts @@ -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 { + 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 { + return this.formatRepo.find({ + where: { projectId }, + relations: ['correspondenceType'], + order: { correspondenceTypeId: 'ASC' }, + }); + } + + /** + * Save (create or update) a template + */ + async saveTemplate( + dto: Partial + ): Promise { + 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 { + await this.formatRepo.delete(id); + } + + // ============================================================ + // Audit & Error Log Methods + // ============================================================ + + /** + * Get audit logs for document number generation + */ + async getAuditLogs(limit = 100): Promise { + return this.auditRepo.find({ + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + /** + * Get error logs for document numbering + */ + async getErrorLogs(limit = 100): Promise { + 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 { + await this.counterRepo.update(counterId, { lastNumber: newSequence }); + } + private async logAudit(data: any): Promise { const audit = this.auditRepo.create({ ...data, diff --git a/backend/src/modules/document-numbering/dto/preview-number.dto.ts b/backend/src/modules/document-numbering/dto/preview-number.dto.ts new file mode 100644 index 0000000..2fd8c7c --- /dev/null +++ b/backend/src/modules/document-numbering/dto/preview-number.dto.ts @@ -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; +} diff --git a/frontend/app/(admin)/admin/numbering/page.tsx b/frontend/app/(admin)/admin/numbering/page.tsx index 3db9ccb..7300cfb 100644 --- a/frontend/app/(admin)/admin/numbering/page.tsx +++ b/frontend/app/(admin)/admin/numbering/page.tsx @@ -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) => { 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) => ( - +

- {template.documentTypeName} + {template.correspondenceType?.typeName || 'Default Format'}

- {projects.find((p: any) => p.id.toString() === template.projectId?.toString())?.projectName || selectedProjectName} - - {template.disciplineCode && {template.disciplineCode}} - - {template.isActive ? 'Active' : 'Inactive'} + {template.project?.projectCode || selectedProjectName} + {template.description && {template.description}}
- {template.templateFormat} + {template.formatTemplate}
- Example: + Type Code: - {template.exampleNumber} + {template.correspondenceType?.typeCode || 'DEFAULT'}
Reset: - {template.resetAnnually ? 'Annually' : 'Never'} + {template.resetSequenceYearly ? 'Annually' : 'Continuous'}
diff --git a/frontend/components/numbering/sequence-viewer.tsx b/frontend/components/numbering/sequence-viewer.tsx index b284e0e..cd53eaa 100644 --- a/frontend/components/numbering/sequence-viewer.tsx +++ b/frontend/components/numbering/sequence-viewer.tsx @@ -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 (
-

Number Sequences

- @@ -45,7 +56,7 @@ export function SequenceViewer() {
setSearch(e.target.value)} /> @@ -53,31 +64,32 @@ export function SequenceViewer() {
{filteredSequences.length === 0 && ( -
No sequences found
+
+ No sequences found +
)} - {filteredSequences.map((seq) => ( + {filteredSequences.map((seq, index) => (
Year {seq.year} - {seq.organizationCode && ( - {seq.organizationCode} - )} - {seq.disciplineCode && ( - {seq.disciplineCode} + Project: {seq.projectId} + Type: {seq.typeId} + {seq.disciplineId > 0 && ( + Disc: {seq.disciplineId} )}
- Current: {seq.currentNumber} | Last Generated:{' '} - {seq.lastGeneratedNumber} + + Counter: {seq.lastNumber} + {' '} + | Originator: {seq.originatorId} | Recipient:{' '} + {seq.recipientOrganizationId === -1 ? 'All' : seq.recipientOrganizationId}
-
- Updated {new Date(seq.updatedAt).toLocaleDateString()} -
))}
diff --git a/frontend/components/numbering/template-editor.tsx b/frontend/components/numbering/template-editor.tsx index b6c5b17..511ce11 100644 --- a/frontend/components/numbering/template-editor.tsx +++ b/frontend/components/numbering/template-editor.tsx @@ -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 ( @@ -136,12 +136,13 @@ export function TemplateEditor({ {/* Configuration Column */}
- + +

+ Leave empty to create a default template for this project. +

diff --git a/frontend/components/numbering/template-tester.tsx b/frontend/components/numbering/template-tester.tsx index 227331c..618fe3b 100644 --- a/frontend/components/numbering/template-tester.tsx +++ b/frontend/components/numbering/template-tester.tsx @@ -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
- Template: {template?.templateFormat} + Template: {template?.formatTemplate}
@@ -80,7 +80,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP

- Format: {template?.templateFormat} + Format: {template?.formatTemplate}

diff --git a/frontend/lib/api/numbering.ts b/frontend/lib/api/numbering.ts index 0b0d65f..e6d00be 100644 --- a/frontend/lib/api/numbering.ts +++ b/frontend/lib/api/numbering.ts @@ -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; + templateUsed: string; + operation: 'RESERVE' | 'CONFIRM' | 'MANUAL_OVERRIDE' | 'VOID_REPLACE' | 'CANCEL'; + metadata?: Record; + 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; + 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 => { const res = await apiClient.get('/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 => { + const res = await apiClient.get( + `/admin/document-numbering/templates?projectId=${projectId}` + ); + return res.data; + }, + + /** + * Get single template by ID + */ getTemplate: async (id: number): Promise => { - // 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): Promise => { - // 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('/admin/document-numbering/templates', payload); - return res.data; + /** + * Save (create or update) a template + */ + saveTemplate: async (dto: Partial): Promise => { + const res = await apiClient.post( + '/admin/document-numbering/templates', + dto + ); + return res.data; }, - getSequences: async (): Promise => { - // TODO: Implement backend endpoint for sequences list - return new Promise((resolve) => { - setTimeout(() => resolve([]), 500); - }); + /** + * Delete a template + */ + deleteTemplate: async (id: number): Promise => { + 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 => { + const res = await apiClient.get( + `/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 => { + const res = await apiClient.get( + `/document-numbering/logs/errors?limit=${limit}` + ); + return res.data; }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - manualOverride: async (data: any): Promise => { - 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 => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res = await apiClient.post('/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 => { - 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 => { + await apiClient.patch(`/document-numbering/counters/${counterId}`, { sequence }); + }, + + // ---------------------------------------------------------- + // Placeholder Methods (Backend not yet implemented) + // ---------------------------------------------------------- + + /** + * Get all counter sequences + */ + getSequences: async (projectId?: number): Promise => { + const url = projectId + ? `/document-numbering/sequences?projectId=${projectId}` + : '/document-numbering/sequences'; + const res = await apiClient.get(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; +} diff --git a/specs/03-implementation/document-numbering.md b/specs/03-implementation/document-numbering.md index 36ea717..24bfdec 100644 --- a/specs/03-implementation/document-numbering.md +++ b/specs/03-implementation/document-numbering.md @@ -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 diff --git a/specs/09-history/20251216-document-numbering-backend-methods.md b/specs/09-history/20251216-document-numbering-backend-methods.md new file mode 100644 index 0000000..175e0b6 --- /dev/null +++ b/specs/09-history/20251216-document-numbering-backend-methods.md @@ -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 +```