From 7db6a003db69599218bbc2c1cd3ca53b05f8f9a0 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 23 Dec 2025 16:49:16 +0700 Subject: [PATCH] 251223:1649 On going update to 1.7.0: Refoctory drawing Module & document number Module --- backend/src/app.module.ts | 1 - .../document-numbering-admin.controller.ts | 5 +- .../document-numbering.controller.ts | 4 +- .../numbering-metrics.controller.ts | 25 + .../document-numbering.module.ts | 46 +- .../document-numbering.service.spec.ts | 84 +- .../dto/manual-override.dto.ts | 33 + .../dto/preview-number.dto.ts | 29 +- .../entities/document-number-audit.entity.ts | 51 +- .../entities/document-number-error.entity.ts | 19 +- .../entities/document-number-format.entity.ts | 4 +- .../services/audit.service.ts | 26 + .../services/counter.service.ts | 59 +- .../document-numbering-lock.service.ts | 61 + .../services/document-numbering.service.ts | 256 +- .../services/manual-override.service.spec.ts | 67 + .../services/manual-override.service.ts | 37 + .../services/metrics.service.ts | 17 + .../services/template.service.ts | 40 + .../drawing/contract-drawing.service.ts | 20 +- backend/src/modules/drawing/drawing.module.ts | 4 + .../dto/create-contract-drawing.dto.ts | 6 +- .../dto/create-shop-drawing-revision.dto.ts | 7 + .../dto/search-contract-drawing.dto.ts | 4 +- .../asbuilt-drawing-revision.entity.ts | 74 + .../entities/asbuilt-drawing.entity.ts | 56 + .../contract-drawing-category.entity.ts | 41 + .../contract-drawing-subcat-cat-map.entity.ts | 37 + .../entities/contract-drawing.entity.ts | 15 +- .../shop-drawing-main-category.entity.ts | 3 + .../entities/shop-drawing-revision.entity.ts | 6 + .../shop-drawing-sub-category.entity.ts | 14 +- .../drawing/entities/shop-drawing.entity.ts | 3 - .../modules/drawing/shop-drawing.service.ts | 16 +- .../dto/search-organization.dto.ts | 10 +- backend/test_output.txt | 56 +- .../admin/numbering/[id]/edit/page.tsx | 4 +- frontend/app/(admin)/admin/numbering/page.tsx | 180 +- frontend/app/(dashboard)/drawings/page.tsx | 7 +- frontend/components/drawings/card.tsx | 22 +- frontend/components/drawings/list.tsx | 2 +- frontend/components/drawings/upload-form.tsx | 404 +-- .../components/numbering/audit-logs-table.tsx | 69 + .../components/numbering/bulk-import-form.tsx | 54 + .../numbering/cancel-number-form.tsx | 86 + .../numbering/manual-override-form.tsx | 133 + .../numbering/metrics-dashboard.tsx | 81 + .../components/numbering/template-editor.tsx | 65 +- .../components/numbering/template-tester.tsx | 162 +- .../numbering/void-replace-form.tsx | 113 + .../__tests__/use-correspondence.test.ts | 38 +- frontend/hooks/__tests__/use-rfa.test.ts | 12 +- frontend/hooks/use-drawing.ts | 20 +- frontend/hooks/use-master-data.ts | 25 + .../__tests__/correspondence.service.test.ts | 21 +- .../lib/services/asbuilt-drawing.service.ts | 41 + .../lib/services/contract-drawing.service.ts | 2 +- .../services/document-numbering.service.ts | 46 + frontend/lib/services/master-data.service.ts | 19 + frontend/lib/services/shop-drawing.service.ts | 2 +- frontend/tsconfig.json | 5 +- frontend/types/drawing.ts | 41 +- .../types/dto/drawing/asbuilt-drawing.dto.ts | 38 + .../types/dto/drawing/contract-drawing.dto.ts | 11 +- .../types/dto/drawing/shop-drawing.dto.ts | 5 +- frontend/types/dto/numbering.dto.ts | 35 + .../dto/organization/organization.dto.ts | 1 + .../01-03.11-document-numbering.md | 32 +- specs/02-architecture/02-03-data-model.md | 42 +- .../03-04-document-numbering.md | 10 +- .../ADR-002-document-numbering-strategy.md | 35 +- ...TASK-BE-017-document-numbering-refactor.md | 173 +- specs/06-tasks/TASK-BE-018-v170-refactor.md | 37 + ...TASK-FE-017-document-numbering-refactor.md | 20 +- specs/06-tasks/TASK-FE-019-v170-refactor.md | 53 + specs/07-database/data-dictionary-v1.7.0.md | 274 +- specs/07-database/lcbp3-v1.7.0-schema.sql | 149 +- .../lcbp3-v1.7.0-seed-shopdrawing.sql | 2265 ++++++++++++----- specs/09-history/2025-12-20-Revise-Schema.md | 3 + ...-23-document-numbering-form-refactoring.md | 41 + .../2025-12-23-frontend-refactor-v170.md | 38 + 81 files changed, 4703 insertions(+), 1449 deletions(-) create mode 100644 backend/src/modules/document-numbering/controllers/numbering-metrics.controller.ts create mode 100644 backend/src/modules/document-numbering/dto/manual-override.dto.ts create mode 100644 backend/src/modules/document-numbering/services/audit.service.ts create mode 100644 backend/src/modules/document-numbering/services/document-numbering-lock.service.ts create mode 100644 backend/src/modules/document-numbering/services/manual-override.service.spec.ts create mode 100644 backend/src/modules/document-numbering/services/manual-override.service.ts create mode 100644 backend/src/modules/document-numbering/services/metrics.service.ts create mode 100644 backend/src/modules/document-numbering/services/template.service.ts create mode 100644 backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts create mode 100644 backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts create mode 100644 backend/src/modules/drawing/entities/contract-drawing-category.entity.ts create mode 100644 backend/src/modules/drawing/entities/contract-drawing-subcat-cat-map.entity.ts create mode 100644 frontend/components/numbering/audit-logs-table.tsx create mode 100644 frontend/components/numbering/bulk-import-form.tsx create mode 100644 frontend/components/numbering/cancel-number-form.tsx create mode 100644 frontend/components/numbering/manual-override-form.tsx create mode 100644 frontend/components/numbering/metrics-dashboard.tsx create mode 100644 frontend/components/numbering/void-replace-form.tsx create mode 100644 frontend/lib/services/asbuilt-drawing.service.ts create mode 100644 frontend/lib/services/document-numbering.service.ts create mode 100644 frontend/types/dto/drawing/asbuilt-drawing.dto.ts create mode 100644 frontend/types/dto/numbering.dto.ts create mode 100644 specs/06-tasks/TASK-BE-018-v170-refactor.md create mode 100644 specs/06-tasks/TASK-FE-019-v170-refactor.md create mode 100644 specs/09-history/2025-12-23-document-numbering-form-refactoring.md create mode 100644 specs/09-history/2025-12-23-frontend-refactor-v170.md diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c9456f4..5d0f97e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -140,7 +140,6 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module'; // ðŸ“Ķ Feature Modules AuthModule, UserModule, - UserModule, ProjectModule, OrganizationModule, ContractModule, diff --git a/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts b/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts index af64030..8e525b1 100644 --- a/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts +++ b/backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts @@ -14,6 +14,7 @@ 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'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; @ApiTags('Admin / Document Numbering') @ApiBearerAuth() @@ -73,8 +74,8 @@ export class DocumentNumberingAdminController { summary: 'Manually override or set a document number counter', }) @RequirePermission('system.manage_settings') - async manualOverride(@Body() dto: any) { - return this.service.manualOverride(dto); + async manualOverride(@Body() dto: any, @CurrentUser() user: any) { + return this.service.manualOverride(dto, user.userId); } @Post('void-and-replace') diff --git a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts index eb7a51a..133a91f 100644 --- a/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts +++ b/backend/src/modules/document-numbering/controllers/document-numbering.controller.ts @@ -90,8 +90,8 @@ export class DocumentNumberingController { async previewNumber(@Body() dto: PreviewNumberDto) { return this.numberingService.previewNumber({ projectId: dto.projectId, - originatorOrganizationId: dto.originatorOrganizationId, - typeId: dto.correspondenceTypeId, + originatorOrganizationId: dto.originatorId, + typeId: dto.typeId, subTypeId: dto.subTypeId, rfaTypeId: dto.rfaTypeId, disciplineId: dto.disciplineId, diff --git a/backend/src/modules/document-numbering/controllers/numbering-metrics.controller.ts b/backend/src/modules/document-numbering/controllers/numbering-metrics.controller.ts new file mode 100644 index 0000000..d844e3a --- /dev/null +++ b/backend/src/modules/document-numbering/controllers/numbering-metrics.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { MetricsService } from '../services/metrics.service'; +// import { PermissionGuard } from '../../auth/guards/permission.guard'; +// import { Permissions } from '../../auth/decorators/permissions.decorator'; + +@Controller('admin/document-numbering/metrics') +// @UseGuards(PermissionGuard) +export class NumberingMetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get() + // @Permissions('system.view_logs') + async getMetrics() { + // Determine how to return metrics. + // Standard Prometheus metrics are usually exposed via a separate /metrics endpoint processing all metrics. + // If the frontend needs JSON data, we might need to query the current values from the registry or metrics service. + + // For now, returning a simple status or aggregated view if supported by MetricsService, + // otherwise this might be a placeholder for a custom dashboard API. + return { + status: 'Metrics are being collected', + // TODO: Implement custom JSON export of metric values if needed for custom dashboard + }; + } +} diff --git a/backend/src/modules/document-numbering/document-numbering.module.ts b/backend/src/modules/document-numbering/document-numbering.module.ts index 12c2d12..4f674b8 100644 --- a/backend/src/modules/document-numbering/document-numbering.module.ts +++ b/backend/src/modules/document-numbering/document-numbering.module.ts @@ -1,19 +1,31 @@ -// File: src/modules/document-numbering/document-numbering.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; +import { + makeCounterProvider, + makeGaugeProvider, + makeHistogramProvider, +} from '@willsoto/nestjs-prometheus'; import { DocumentNumberingService } from './services/document-numbering.service'; import { DocumentNumberingController } from './controllers/document-numbering.controller'; import { DocumentNumberingAdminController } from './controllers/document-numbering-admin.controller'; +import { NumberingMetricsController } from './controllers/numbering-metrics.controller'; + import { DocumentNumberFormat } from './entities/document-number-format.entity'; import { DocumentNumberCounter } from './entities/document-number-counter.entity'; import { DocumentNumberReservation } from './entities/document-number-reservation.entity'; import { DocumentNumberAudit } from './entities/document-number-audit.entity'; import { DocumentNumberError } from './entities/document-number-error.entity'; + import { CounterService } from './services/counter.service'; import { ReservationService } from './services/reservation.service'; import { FormatService } from './services/format.service'; +import { DocumentNumberingLockService } from './services/document-numbering-lock.service'; +import { TemplateService } from './services/template.service'; +import { AuditService } from './services/audit.service'; +import { MetricsService } from './services/metrics.service'; +import { ManualOverrideService } from './services/manual-override.service'; // Master Entities āļ—āļĩāđˆāļ•āđ‰āļ­āļ‡āđƒāļŠāđ‰ Lookup import { Project } from '../project/entities/project.entity'; @@ -40,18 +52,48 @@ import { UserModule } from '../user/user.module'; CorrespondenceSubType, ]), ], - controllers: [DocumentNumberingController, DocumentNumberingAdminController], + controllers: [ + DocumentNumberingController, + DocumentNumberingAdminController, + NumberingMetricsController, + ], providers: [ DocumentNumberingService, CounterService, ReservationService, FormatService, + DocumentNumberingLockService, + TemplateService, + AuditService, + MetricsService, + ManualOverrideService, + // Prometheus Providers + makeCounterProvider({ + name: 'numbering_sequences_total', + help: 'Total number of sequences generated', + }), + makeGaugeProvider({ + name: 'numbering_sequence_utilization', + help: 'Current utilization of sequence space', + }), + makeHistogramProvider({ + name: 'numbering_lock_wait_seconds', + help: 'Time spent waiting for locks', + }), + makeCounterProvider({ + name: 'numbering_lock_failures_total', + help: 'Total number of lock acquisition failures', + }), ], exports: [ DocumentNumberingService, CounterService, ReservationService, FormatService, + DocumentNumberingLockService, + TemplateService, + AuditService, + MetricsService, ], }) export class DocumentNumberingModule {} diff --git a/backend/src/modules/document-numbering/document-numbering.service.spec.ts b/backend/src/modules/document-numbering/document-numbering.service.spec.ts index a3860dd..68da5c9 100644 --- a/backend/src/modules/document-numbering/document-numbering.service.spec.ts +++ b/backend/src/modules/document-numbering/document-numbering.service.spec.ts @@ -9,26 +9,9 @@ import { DocumentNumberFormat } from './entities/document-number-format.entity'; import { DocumentNumberAudit } from './entities/document-number-audit.entity'; import { DocumentNumberError } from './entities/document-number-error.entity'; -// Mock Redis and Redlock (legacy mocks, kept just in case) -const mockRedis = { - disconnect: jest.fn(), - on: jest.fn(), -}; -const mockRedlock = { - acquire: jest.fn(), -}; -const mockLock = { - release: jest.fn().mockResolvedValue(undefined), -}; - -jest.mock('ioredis', () => { - return jest.fn().mockImplementation(() => mockRedis); -}); -jest.mock('redlock', () => { - return jest.fn().mockImplementation(() => { - return mockRedlock; - }); -}); +import { DocumentNumberingLockService } from './services/document-numbering-lock.service'; +import { ManualOverrideService } from './services/manual-override.service'; +import { MetricsService } from './services/metrics.service'; describe('DocumentNumberingService', () => { let service: DocumentNumberingService; @@ -39,15 +22,16 @@ describe('DocumentNumberingService', () => { const mockContext = { projectId: 1, originatorOrganizationId: 1, + recipientOrganizationId: 1, typeId: 1, + subTypeId: 1, + rfaTypeId: 1, disciplineId: 1, year: 2025, customTokens: { TYPE_CODE: 'COR', ORG_CODE: 'GGL' }, }; beforeEach(async () => { - mockRedlock.acquire.mockResolvedValue(mockLock); - module = await Test.createTestingModule({ providers: [ DocumentNumberingService, @@ -76,6 +60,24 @@ describe('DocumentNumberingService', () => { format: jest.fn().mockResolvedValue('0001'), }, }, + { + provide: DocumentNumberingLockService, + useValue: { + acquireLock: jest.fn().mockResolvedValue({ release: jest.fn() }), + releaseLock: jest.fn(), + }, + }, + { + provide: ManualOverrideService, + useValue: { applyOverride: jest.fn() }, + }, + { + provide: MetricsService, + useValue: { + numbersGenerated: { inc: jest.fn() }, + lockFailures: { inc: jest.fn() }, + }, + }, { provide: getRepositoryToken(DocumentNumberFormat), useValue: { findOne: jest.fn() }, @@ -85,6 +87,7 @@ describe('DocumentNumberingService', () => { useValue: { create: jest.fn().mockReturnValue({ id: 1 }), save: jest.fn().mockResolvedValue({ id: 1 }), + findOne: jest.fn(), }, }, { @@ -136,4 +139,41 @@ describe('DocumentNumberingService', () => { ); }); }); + + describe('Admin Operations', () => { + it('voidAndReplace should verify audit log exists', async () => { + const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit)); + (auditRepo.findOne as jest.Mock).mockResolvedValue({ + generatedNumber: 'DOC-001', + counterKey: JSON.stringify({ projectId: 1, correspondenceTypeId: 1 }), + templateUsed: 'test', + }); + (auditRepo.save as jest.Mock).mockResolvedValue({ id: 2 }); + + const result = await service.voidAndReplace({ + documentNumber: 'DOC-001', + reason: 'test', + replace: false, + }); + expect(result.status).toBe('VOIDED'); + expect(auditRepo.save).toHaveBeenCalled(); + }); + + it('cancelNumber should log cancellation', async () => { + const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit)); + (auditRepo.findOne as jest.Mock).mockResolvedValue({ + generatedNumber: 'DOC-002', + counterKey: {}, + }); + (auditRepo.save as jest.Mock).mockResolvedValue({ id: 3 }); + + const result = await service.cancelNumber({ + documentNumber: 'DOC-002', + reason: 'bad', + projectId: 1, + }); + expect(result.status).toBe('CANCELLED'); + expect(auditRepo.save).toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/modules/document-numbering/dto/manual-override.dto.ts b/backend/src/modules/document-numbering/dto/manual-override.dto.ts new file mode 100644 index 0000000..770cc33 --- /dev/null +++ b/backend/src/modules/document-numbering/dto/manual-override.dto.ts @@ -0,0 +1,33 @@ +import { + IsNumber, + IsString, + IsNotEmpty, + Min, + IsOptional, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { CounterKeyDto } from './counter-key.dto'; + +export class ManualOverrideDto extends CounterKeyDto { + @ApiProperty({ example: 10, description: 'New last number value' }) + @IsNumber() + @Min(0) + newLastNumber!: number; + + @ApiProperty({ + example: 'Correction due to system error', + description: 'Reason for override', + }) + @IsString() + @IsNotEmpty() + reason!: string; + + @ApiProperty({ + example: 'ADMIN-001', + description: 'Reference ticket or user', + required: false, + }) + @IsString() + @IsOptional() + reference?: string; +} diff --git a/backend/src/modules/document-numbering/dto/preview-number.dto.ts b/backend/src/modules/document-numbering/dto/preview-number.dto.ts index 2cac80b..4ee080b 100644 --- a/backend/src/modules/document-numbering/dto/preview-number.dto.ts +++ b/backend/src/modules/document-numbering/dto/preview-number.dto.ts @@ -1,31 +1,56 @@ // File: src/modules/document-numbering/dto/preview-number.dto.ts import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsObject } from 'class-validator'; +import { Type } from 'class-transformer'; export class PreviewNumberDto { @ApiProperty({ description: 'Project ID' }) + @IsInt() + @Type(() => Number) projectId!: number; @ApiProperty({ description: 'Originator organization ID' }) - originatorOrganizationId!: number; + @IsInt() + @Type(() => Number) + originatorId!: number; @ApiProperty({ description: 'Correspondence type ID' }) - correspondenceTypeId!: number; + @IsInt() + @Type(() => Number) + typeId!: number; @ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' }) + @IsOptional() + @IsInt() + @Type(() => Number) subTypeId?: number; @ApiPropertyOptional({ description: 'RFA type ID (for RFA)' }) + @IsOptional() + @IsInt() + @Type(() => Number) rfaTypeId?: number; @ApiPropertyOptional({ description: 'Discipline ID' }) + @IsOptional() + @IsInt() + @Type(() => Number) disciplineId?: number; @ApiPropertyOptional({ description: 'Year (defaults to current)' }) + @IsOptional() + @IsInt() + @Type(() => Number) year?: number; @ApiPropertyOptional({ description: 'Recipient organization ID' }) + @IsOptional() + @IsInt() + @Type(() => Number) recipientOrganizationId?: number; @ApiPropertyOptional({ description: 'Custom tokens' }) + @IsOptional() + @IsObject() customTokens?: Record; } diff --git a/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts b/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts index 27a3f02..648dbb6 100644 --- a/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-audit.entity.ts @@ -9,12 +9,17 @@ import { @Entity('document_number_audit') @Index(['createdAt']) @Index(['userId']) +@Index(['documentId']) +@Index(['status']) +@Index(['operation']) +@Index(['generatedNumber']) +@Index(['reservationToken']) export class DocumentNumberAudit { @PrimaryGeneratedColumn() id!: number; - @Column({ name: 'document_id' }) - documentId!: number; + @Column({ name: 'document_id', nullable: true }) + documentId?: number; @Column({ name: 'generated_number', length: 100 }) generatedNumber!: string; @@ -28,16 +33,49 @@ export class DocumentNumberAudit { @Column({ name: 'operation', type: 'enum', - enum: ['RESERVE', 'CONFIRM', 'MANUAL_OVERRIDE', 'VOID_REPLACE', 'CANCEL'], + enum: [ + 'RESERVE', + 'CONFIRM', + 'CANCEL', + 'MANUAL_OVERRIDE', + 'VOID', + 'GENERATE', + ], default: 'CONFIRM', }) operation!: string; + @Column({ + name: 'status', + type: 'enum', + enum: ['RESERVED', 'CONFIRMED', 'CANCELLED', 'VOID', 'MANUAL'], + nullable: true, + }) + status?: string; + + @Column({ name: 'reservation_token', length: 36, nullable: true }) + reservationToken?: string; + + @Column({ name: 'idempotency_key', length: 36, nullable: true }) + idempotencyKey?: string; + + @Column({ name: 'originator_organization_id', nullable: true }) + originatorOrganizationId?: number; + + @Column({ name: 'recipient_organization_id', nullable: true }) + recipientOrganizationId?: number; + + @Column({ name: 'old_value', type: 'text', nullable: true }) + oldValue?: string; + + @Column({ name: 'new_value', type: 'text', nullable: true }) + newValue?: string; + @Column({ name: 'metadata', type: 'json', nullable: true }) metadata?: any; - @Column({ name: 'user_id' }) - userId!: number; + @Column({ name: 'user_id', nullable: true }) + userId?: number; @Column({ name: 'ip_address', length: 45, nullable: true }) ipAddress?: string; @@ -45,6 +83,9 @@ export class DocumentNumberAudit { @Column({ name: 'user_agent', type: 'text', nullable: true }) userAgent?: string; + @Column({ name: 'is_success', default: true }) + isSuccess!: boolean; + @Column({ name: 'retry_count', default: 0 }) retryCount!: number; diff --git a/backend/src/modules/document-numbering/entities/document-number-error.entity.ts b/backend/src/modules/document-numbering/entities/document-number-error.entity.ts index dac9481..a6b0f5a 100644 --- a/backend/src/modules/document-numbering/entities/document-number-error.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-error.entity.ts @@ -7,12 +7,29 @@ import { } from 'typeorm'; @Entity('document_number_errors') +@Index(['errorType']) @Index(['createdAt']) @Index(['userId']) export class DocumentNumberError { @PrimaryGeneratedColumn() id!: number; + @Column({ + name: 'error_type', + type: 'enum', + enum: [ + 'LOCK_TIMEOUT', + 'VERSION_CONFLICT', + 'DB_ERROR', + 'REDIS_ERROR', + 'VALIDATION_ERROR', + 'SEQUENCE_EXHAUSTED', + 'RESERVATION_EXPIRED', + 'DUPLICATE_NUMBER', + ], + }) + errorType!: string; + @Column({ name: 'error_message', type: 'text' }) errorMessage!: string; @@ -20,7 +37,7 @@ export class DocumentNumberError { stackTrace?: string; @Column({ name: 'context_data', type: 'json', nullable: true }) - context?: any; + contextData?: any; @Column({ name: 'user_id', nullable: true }) userId?: number; diff --git a/backend/src/modules/document-numbering/entities/document-number-format.entity.ts b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts index 89a7214..28f4846 100644 --- a/backend/src/modules/document-numbering/entities/document-number-format.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts @@ -25,14 +25,14 @@ export class DocumentNumberFormat { @Column({ name: 'correspondence_type_id', nullable: true }) correspondenceTypeId?: number; - @Column({ name: 'format_template', length: 100 }) + @Column({ name: 'format_string', length: 100 }) formatTemplate!: string; @Column({ name: 'description', nullable: true }) description?: string; // [NEW] Control yearly reset behavior - @Column({ name: 'reset_sequence_yearly', default: true }) + @Column({ name: 'reset_annually', default: true }) resetSequenceYearly!: boolean; @CreateDateColumn({ name: 'created_at' }) diff --git a/backend/src/modules/document-numbering/services/audit.service.ts b/backend/src/modules/document-numbering/services/audit.service.ts new file mode 100644 index 0000000..876d461 --- /dev/null +++ b/backend/src/modules/document-numbering/services/audit.service.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentNumberAudit } from '../entities/document-number-audit.entity'; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor( + @InjectRepository(DocumentNumberAudit) + private readonly auditRepo: Repository + ) {} + + async log(entry: Partial): Promise { + try { + // Async save - do not await strictly if we want fire-and-forget, but for data integrity await is safer in critical flows + // For performance, we might offload to a queue, but direct save is safer for now. + const logEntry = this.auditRepo.create(entry); + await this.auditRepo.save(logEntry); + } catch (error) { + this.logger.error('Failed to write audit log', error); + // Fail silent or throw? -> Fail silent to not crash the main flow, assuming log is secondary + } + } +} diff --git a/backend/src/modules/document-numbering/services/counter.service.ts b/backend/src/modules/document-numbering/services/counter.service.ts index 4a7a7db..8d8b6c0 100644 --- a/backend/src/modules/document-numbering/services/counter.service.ts +++ b/backend/src/modules/document-numbering/services/counter.service.ts @@ -31,7 +31,14 @@ export class CounterService { if (!counter) { counter = manager.create(DocumentNumberCounter, { - ...counterKey, + projectId: counterKey.projectId, + originatorId: counterKey.originatorOrganizationId, + recipientOrganizationId: counterKey.recipientOrganizationId, + correspondenceTypeId: counterKey.correspondenceTypeId, + subTypeId: counterKey.subTypeId, + rfaTypeId: counterKey.rfaTypeId, + disciplineId: counterKey.disciplineId, + resetScope: counterKey.resetScope, lastNumber: 1, version: 0, }); @@ -89,7 +96,7 @@ export class CounterService { private buildWhereClause(key: CounterKeyDto) { return { projectId: key.projectId, - originatorOrganizationId: key.originatorOrganizationId, + originatorId: key.originatorOrganizationId, recipientOrganizationId: key.recipientOrganizationId, correspondenceTypeId: key.correspondenceTypeId, subTypeId: key.subTypeId, @@ -99,6 +106,54 @@ export class CounterService { }; } + /** + * Force update counter value (Admin Override) + * WARNING: This bypasses optimistic locking checks slightly, but still increments version + */ + async forceUpdateCounter( + counterKey: CounterKeyDto, + newValue: number + ): Promise { + await this.dataSource.transaction(async (manager) => { + let counter = await manager.findOne(DocumentNumberCounter, { + where: this.buildWhereClause(counterKey), + }); + + if (!counter) { + counter = manager.create(DocumentNumberCounter, { + projectId: counterKey.projectId, + originatorId: counterKey.originatorOrganizationId, + recipientOrganizationId: counterKey.recipientOrganizationId, + correspondenceTypeId: counterKey.correspondenceTypeId, + subTypeId: counterKey.subTypeId, + rfaTypeId: counterKey.rfaTypeId, + disciplineId: counterKey.disciplineId, + resetScope: counterKey.resetScope, + lastNumber: newValue, + version: 1, + }); + await manager.save(counter); + } else { + // Force update regardless of version, but increment version + await manager + .createQueryBuilder() + .update(DocumentNumberCounter) + .set({ + lastNumber: newValue, + version: () => 'version + 1', + }) + .where(this.buildWhereClause(counterKey)) + .execute(); + } + }); + + this.logger.log( + `Counter force updated to ${newValue} for key: ${JSON.stringify( + counterKey + )}` + ); + } + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/backend/src/modules/document-numbering/services/document-numbering-lock.service.ts b/backend/src/modules/document-numbering/services/document-numbering-lock.service.ts new file mode 100644 index 0000000..c028a69 --- /dev/null +++ b/backend/src/modules/document-numbering/services/document-numbering-lock.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import Redlock, { Lock } from 'redlock'; + +interface LockCounterKey { + projectId: number; + originatorOrgId: number; + recipientOrgId: number; + correspondenceTypeId: number; + subTypeId: number; + rfaTypeId: number; + disciplineId: number; + resetScope: string; +} + +@Injectable() +export class DocumentNumberingLockService { + private readonly logger = new Logger(DocumentNumberingLockService.name); + private redlock: Redlock; + + constructor(@InjectRedis() private readonly redis: Redis) { + this.redlock = new Redlock([redis], { + driftFactor: 0.01, + retryCount: 5, + retryDelay: 100, + retryJitter: 50, + }); + } + + async acquireLock(key: LockCounterKey): Promise { + const lockKey = this.buildLockKey(key); + const ttl = 5000; // 5 seconds + + try { + const lock = await this.redlock.acquire([lockKey], ttl); + this.logger.debug(`Acquired lock: ${lockKey}`); + return lock; + } catch (error) { + this.logger.error(`Failed to acquire lock: ${lockKey}`, error); + throw error; + } + } + + async releaseLock(lock: Lock): Promise { + try { + await lock.release(); + this.logger.debug(`Released lock`); + } catch (error) { + this.logger.warn('Failed to release lock (may have expired)', error); + } + } + + private buildLockKey(key: LockCounterKey): string { + return ( + `lock:docnum:${key.projectId}:${key.originatorOrgId}:` + + `${key.recipientOrgId ?? 0}:${key.correspondenceTypeId}:` + + `${key.subTypeId}:${key.rfaTypeId}:${key.disciplineId}:${key.resetScope}` + ); + } +} diff --git a/backend/src/modules/document-numbering/services/document-numbering.service.ts b/backend/src/modules/document-numbering/services/document-numbering.service.ts index 73dac6b..851806f 100644 --- a/backend/src/modules/document-numbering/services/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/services/document-numbering.service.ts @@ -11,6 +11,9 @@ import { DocumentNumberError } from '../entities/document-number-error.entity'; import { CounterService } from './counter.service'; import { ReservationService } from './reservation.service'; import { FormatService } from './format.service'; +import { DocumentNumberingLockService } from './document-numbering-lock.service'; +import { ManualOverrideService } from './manual-override.service'; +import { MetricsService } from './metrics.service'; // DTOs import { CounterKeyDto } from '../dto/counter-key.dto'; @@ -33,34 +36,25 @@ export class DocumentNumberingService { private counterService: CounterService, private reservationService: ReservationService, private formatService: FormatService, - private configService: ConfigService + private lockService: DocumentNumberingLockService, + private configService: ConfigService, + private manualOverrideService: ManualOverrideService, + private metricsService: MetricsService ) {} async generateNextNumber( ctx: GenerateNumberContext ): Promise<{ number: string; auditId: number }> { + let lock = null; try { + // 0. Check Idempotency (Ideally done in Guard/Middleware, but double check here if passed) + // Note: If idempotencyKey exists in ctx, check audit log for existing SUCCESS entry? + // Omitted for brevity as per spec usually handled by middleware or separate check. + const currentYear = new Date().getFullYear(); - - // Determine reset scope (logic was previously in resolveFormatAndScope but now simplified or we need to query format to know if year-based) - // Since FormatService now encapsulates format resolution, we might need a way to just get the scope if we want to build the key correctly? - // Actually, standard behavior is YEAR reset. - // If we want to strictly follow the config, we might need to expose helper or just assume YEAR for now as Refactor step. - // However, FormatService.format internally resolves the template. - // BUT we need the SEQUENCE to pass to FormatService. - // And to get the SEQUENCE, we need the KEY, which needs the RESET SCOPE. - // Chicken and egg? - // Not really. Key depends on Scope. Scope depends on Format Config. - // So we DO need to look up the format config to know the scope. - // I should expose `resolveScope` from FormatService or Query it here. - // For now, I'll rely on a default assumption or duplicate the lightweight query. - // Let's assume YEAR_YYYY for now to proceed, or better, make FormatService expose `getResetScope(projectId, typeId)`. - - // Wait, FormatService.format takes `sequence`. - // I will implement a quick lookup here similar to what it was, or just assume YEAR reset for safety as per default. const resetScope = `YEAR_${currentYear}`; - // 2. Prepare Counter Key + // 1. Prepare Counter Key const key: CounterKeyDto = { projectId: ctx.projectId, originatorOrganizationId: ctx.originatorOrganizationId, @@ -72,6 +66,30 @@ export class DocumentNumberingService { resetScope: resetScope, }; + // 2. Acquire Redis Lock + try { + // Map CounterKeyDto to LockCounterKey (names slightly different or cast if same) + lock = await this.lockService.acquireLock({ + projectId: key.projectId, + originatorOrgId: key.originatorOrganizationId, + recipientOrgId: key.recipientOrganizationId, + correspondenceTypeId: key.correspondenceTypeId, + subTypeId: key.subTypeId, + rfaTypeId: key.rfaTypeId, + disciplineId: key.disciplineId, + resetScope: key.resetScope, + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.logger.warn( + `Failed to acquire Redis lock, falling back to DB lock only: ${errorMessage}` + ); + this.metricsService.lockFailures.inc({ + project_id: String(key.projectId), + }); + // Fallback: Proceed without Redlock, relying on CounterService DB optimistic lock + } + // 3. Increment Counter const sequence = await this.counterService.incrementCounter(key); @@ -97,12 +115,22 @@ export class DocumentNumberingService { context: ctx, isSuccess: true, operation: 'GENERATE', + // metadata: { idempotencyKey: ctx.idempotencyKey } // If available + }); + + this.metricsService.numbersGenerated.inc({ + project_id: String(ctx.projectId), + type_id: String(ctx.typeId), }); return { number: generatedNumber, auditId: audit.id }; } catch (error: any) { await this.logError(error, ctx, 'GENERATE'); throw error; + } finally { + if (lock) { + await this.lockService.releaseLock(lock); + } } } @@ -211,32 +239,168 @@ export class DocumentNumberingService { } async getSequences(projectId?: number) { - await Promise.resolve(); // satisfy await + await Promise.resolve(projectId); // satisfy unused return []; } async setCounterValue(id: number, sequence: number) { - await Promise.resolve(); // satisfy await + await Promise.resolve(id); // satisfy unused + await Promise.resolve(sequence); throw new BadRequestException( 'Updating counter by single ID is not supported with composite keys. Use manualOverride.' ); } - async manualOverride(dto: any) { - await Promise.resolve(); - return { success: true }; + async manualOverride(dto: any, userId: number) { + return this.manualOverrideService.applyOverride(dto, userId); } - async voidAndReplace(dto: any) { - await Promise.resolve(); - return {}; + async voidAndReplace(dto: { + documentNumber: string; + reason: string; + replace: boolean; + }) { + // 1. Find the audit log for this number to get context + const lastAudit = await this.auditRepo.findOne({ + where: { generatedNumber: dto.documentNumber }, + order: { createdAt: 'DESC' }, + }); + + if (!lastAudit) { + // If not found in audit, we can't easily regenerate with same context unless passed in dto. + // For now, log a warning and return error or just log the void decision. + this.logger.warn( + `Void request for unknown number: ${dto.documentNumber}` + ); + // Create a void audit anyway if possible? + await this.logAudit({ + generatedNumber: dto.documentNumber, + counterKey: {}, // Unknown + templateUsed: 'VOID_UNKNOWN', + context: { userId: 0, ipAddress: '0.0.0.0' }, // System + isSuccess: true, + operation: 'VOID', + status: 'VOID', + newValue: 'VOIDED', + metadata: { reason: dto.reason }, + }); + return { status: 'VOIDED_UNKNOWN_CONTEXT' }; + } + + // 2. Log VOID + await this.logAudit({ + generatedNumber: dto.documentNumber, + counterKey: lastAudit.counterKey, + templateUsed: lastAudit.templateUsed, + context: { userId: 0, ipAddress: '0.0.0.0' }, // TODO: Pass userId from controller + isSuccess: true, + operation: 'VOID', + status: 'VOID', + oldValue: dto.documentNumber, + newValue: 'VOIDED', + metadata: { reason: dto.reason, replace: dto.replace }, + }); + + if (dto.replace) { + // 3. Generate Replacement + // Parse context from lastAudit.counterKey? + // GenerateNumberContext needs more than counterKey. + // But we can reconstruct it. + let context: GenerateNumberContext; + try { + const key = + typeof lastAudit.counterKey === 'string' + ? JSON.parse(lastAudit.counterKey) + : lastAudit.counterKey; + + context = { + projectId: key.projectId, + typeId: key.correspondenceTypeId, + subTypeId: key.subTypeId, + rfaTypeId: key.rfaTypeId, + disciplineId: key.disciplineId, + originatorOrganizationId: key.originatorOrganizationId || 0, + recipientOrganizationId: key.recipientOrganizationId || 0, + userId: 0, // System replacement + }; + + const next = await this.generateNextNumber(context); + return { + status: 'REPLACED', + oldNumber: dto.documentNumber, + newNumber: next.number, + }; + } catch (e) { + this.logger.error(`Failed to replace number ${dto.documentNumber}`, e); + return { + status: 'VOIDED_REPLACE_FAILED', + error: e instanceof Error ? e.message : String(e), + }; + } + } + + return { status: 'VOIDED' }; } - async cancelNumber(dto: any) { - await Promise.resolve(); - return {}; + + async cancelNumber(dto: { + documentNumber: string; + reason: string; + projectId?: number; + }) { + // Similar to VOID but status CANCELLED + const lastAudit = await this.auditRepo.findOne({ + where: { generatedNumber: dto.documentNumber }, + order: { createdAt: 'DESC' }, + }); + + const contextKey = lastAudit?.counterKey; + + await this.logAudit({ + generatedNumber: dto.documentNumber, + counterKey: contextKey || {}, + templateUsed: lastAudit?.templateUsed || 'CANCEL', + context: { + userId: 0, + ipAddress: '0.0.0.0', + projectId: dto.projectId || 0, + }, + isSuccess: true, + operation: 'CANCEL', + status: 'CANCELLED', + metadata: { reason: dto.reason }, + }); + + return { status: 'CANCELLED' }; } + async bulkImport(items: any[]) { - await Promise.resolve(); - return {}; + const results = { success: 0, failed: 0, errors: [] as string[] }; + + // items expected to be ManualOverrideDto[] or similar + // Actually bulk import usually means "Here is a list of EXISTING numbers used in legacy system" + // So we should parse them and update counters if they are higher. + + // Implementation: For each item, likely delegate to ManualOverrideService if it fits schema. + // Or if items is just a number of CSV rows? + // Assuming items is parsed CSV rows. + + for (const item of items) { + try { + // Adapt item to ManualOverrideDto + /* + CSV columns: ProjectID, TypeID, OriginatorID, RecipientID, LastNumber + */ + if (item.newLastNumber && item.correspondenceTypeId) { + await this.manualOverrideService.applyOverride(item, 0); // 0 = System + results.success++; + } + } catch (e) { + results.failed++; + results.errors.push( + `Failed item ${JSON.stringify(item)}: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + return results; } private async logAudit(data: any): Promise { @@ -245,22 +409,26 @@ export class DocumentNumberingService { projectId: data.context.projectId, createdBy: data.context.userId, ipAddress: data.context.ipAddress, + // map other fields }); return (await this.auditRepo.save(audit)) as unknown as DocumentNumberAudit; } private async logError(error: any, ctx: any, operation: string) { - this.errorRepo - .save( - this.errorRepo.create({ - errorMessage: error.message, - context: { - ...ctx, - errorType: 'GENERATE_ERROR', - inputPayload: JSON.stringify(ctx), - }, - }) - ) - .catch((e) => this.logger.error(e)); + try { + const errEntity = this.errorRepo.create({ + errorMessage: error.message || 'Unknown Error', + errorType: error.name || 'GENERATE_ERROR', // Simple mapping + contextData: { + // Mapped from context + ...ctx, + operation, + inputPayload: JSON.stringify(ctx), + }, + }); + await this.errorRepo.save(errEntity); + } catch (e) { + this.logger.error('Failed to log error to DB', e); + } } } diff --git a/backend/src/modules/document-numbering/services/manual-override.service.spec.ts b/backend/src/modules/document-numbering/services/manual-override.service.spec.ts new file mode 100644 index 0000000..14163b4 --- /dev/null +++ b/backend/src/modules/document-numbering/services/manual-override.service.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ManualOverrideService } from './manual-override.service'; +import { CounterService } from './counter.service'; +import { AuditService } from './audit.service'; +import { ManualOverrideDto } from '../dto/manual-override.dto'; + +describe('ManualOverrideService', () => { + let service: ManualOverrideService; + let counterService: CounterService; + let auditService: AuditService; + + const mockCounterService = { + forceUpdateCounter: jest.fn(), + }; + + const mockAuditService = { + log: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ManualOverrideService, + { provide: CounterService, useValue: mockCounterService }, + { provide: AuditService, useValue: mockAuditService }, + ], + }).compile(); + + service = module.get(ManualOverrideService); + counterService = module.get(CounterService); + auditService = module.get(AuditService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should apply override and log audit', async () => { + const dto: ManualOverrideDto = { + projectId: 1, + originatorOrganizationId: 2, + recipientOrganizationId: 3, + correspondenceTypeId: 4, + subTypeId: null, + rfaTypeId: null, + disciplineId: null, + resetScope: 'YEAR_2024', + newLastNumber: 999, + reason: 'System sync', + reference: 'TICKET-123', + }; + const userId = 101; + + await service.applyOverride(dto, userId); + + expect(counterService.forceUpdateCounter).toHaveBeenCalledWith(dto, 999); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ + generatedNumber: 'OVERRIDE-TO-999', + operation: 'MANUAL_OVERRIDE', + status: 'MANUAL', + userId: userId, + metadata: { reason: 'System sync', reference: 'TICKET-123' }, + }) + ); + }); +}); diff --git a/backend/src/modules/document-numbering/services/manual-override.service.ts b/backend/src/modules/document-numbering/services/manual-override.service.ts new file mode 100644 index 0000000..8c3ef33 --- /dev/null +++ b/backend/src/modules/document-numbering/services/manual-override.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CounterService } from './counter.service'; +import { AuditService } from './audit.service'; +import { ManualOverrideDto } from '../dto/manual-override.dto'; + +@Injectable() +export class ManualOverrideService { + private readonly logger = new Logger(ManualOverrideService.name); + + constructor( + private readonly counterService: CounterService, + private readonly auditService: AuditService + ) {} + + async applyOverride(dto: ManualOverrideDto, userId: number): Promise { + this.logger.log( + `Applying manual override by user ${userId}: ${JSON.stringify(dto)}` + ); + + // 1. Force update the counter + await this.counterService.forceUpdateCounter(dto, dto.newLastNumber); + + // 2. Log Audit + await this.auditService.log({ + documentId: undefined, // No specific document + generatedNumber: `OVERRIDE-TO-${dto.newLastNumber}`, + operation: 'MANUAL_OVERRIDE', + status: 'MANUAL', + counterKey: dto, // CounterKeyDto part of ManualOverrideDto + templateUsed: 'MANUAL_OVERRIDE', + userId: userId, + isSuccess: true, + metadata: { reason: dto.reason, reference: dto.reference }, + totalDurationMs: 0, + }); + } +} diff --git a/backend/src/modules/document-numbering/services/metrics.service.ts b/backend/src/modules/document-numbering/services/metrics.service.ts new file mode 100644 index 0000000..ab63795 --- /dev/null +++ b/backend/src/modules/document-numbering/services/metrics.service.ts @@ -0,0 +1,17 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Counter, Gauge, Histogram } from 'prom-client'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; + +@Injectable() +export class MetricsService { + constructor( + @InjectMetric('numbering_sequences_total') + public numbersGenerated: Counter, + @InjectMetric('numbering_sequence_utilization') + public sequenceUtilization: Gauge, + @InjectMetric('numbering_lock_wait_seconds') + public lockWaitTime: Histogram, + @InjectMetric('numbering_lock_failures_total') + public lockFailures: Counter + ) {} +} diff --git a/backend/src/modules/document-numbering/services/template.service.ts b/backend/src/modules/document-numbering/services/template.service.ts new file mode 100644 index 0000000..7516989 --- /dev/null +++ b/backend/src/modules/document-numbering/services/template.service.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentNumberFormat } from '../entities/document-number-format.entity'; +// import { TemplateValidator } from '../validators/template.validator'; // TODO: Create validator + +@Injectable() +export class TemplateService { + private readonly logger = new Logger(TemplateService.name); + + constructor( + @InjectRepository(DocumentNumberFormat) + private readonly formatRepo: Repository + // private readonly validator: TemplateValidator, + ) {} + + async findTemplate( + projectId: number, + correspondenceTypeId?: number + ): Promise { + // 1. Try specific Project + Type + if (correspondenceTypeId) { + const specific = await this.formatRepo.findOne({ + where: { projectId, correspondenceTypeId }, + }); + if (specific) return specific; + } + + // 2. Fallback to Project default (type IS NULL) + // Note: Assuming specific requirements for fallback, usually project-wide default is stored with null type + // If not, we might need a Global default logic. + const defaultProj = await this.formatRepo.findOne({ + where: { projectId, correspondenceTypeId: undefined }, // IS NULL + }); + + return defaultProj; + } + + // Placeholder for Create/Update with validation logic +} diff --git a/backend/src/modules/drawing/contract-drawing.service.ts b/backend/src/modules/drawing/contract-drawing.service.ts index 7d48ea3..0b80e66 100644 --- a/backend/src/modules/drawing/contract-drawing.service.ts +++ b/backend/src/modules/drawing/contract-drawing.service.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException, ConflictException, - InternalServerErrorException, Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -73,8 +72,9 @@ export class ContractDrawingService { projectId: createDto.projectId, contractDrawingNo: createDto.contractDrawingNo, title: createDto.title, - subCategoryId: createDto.subCategoryId, + mapCatId: createDto.mapCatId, // Updated volumeId: createDto.volumeId, + volumePage: createDto.volumePage, // Updated updatedBy: user.user_id, attachments: attachments, }); @@ -111,7 +111,7 @@ export class ContractDrawingService { const { projectId, volumeId, - subCategoryId, + mapCatId, search, page = 1, limit = 20, @@ -129,11 +129,9 @@ export class ContractDrawingService { query.andWhere('drawing.volumeId = :volumeId', { volumeId }); } - // Filter by SubCategory - if (subCategoryId) { - query.andWhere('drawing.subCategoryId = :subCategoryId', { - subCategoryId, - }); + // Filter by Map Category (Updated) + if (mapCatId) { + query.andWhere('drawing.mapCatId = :mapCatId', { mapCatId }); } // Search Text (No. or Title) @@ -198,8 +196,10 @@ export class ContractDrawingService { if (updateDto.title) drawing.title = updateDto.title; if (updateDto.volumeId !== undefined) drawing.volumeId = updateDto.volumeId; - if (updateDto.subCategoryId !== undefined) - drawing.subCategoryId = updateDto.subCategoryId; + if (updateDto.volumePage !== undefined) + drawing.volumePage = updateDto.volumePage; + if (updateDto.mapCatId !== undefined) + drawing.mapCatId = updateDto.mapCatId; drawing.updatedBy = user.user_id; diff --git a/backend/src/modules/drawing/drawing.module.ts b/backend/src/modules/drawing/drawing.module.ts index 8e16120..4567a48 100644 --- a/backend/src/modules/drawing/drawing.module.ts +++ b/backend/src/modules/drawing/drawing.module.ts @@ -11,6 +11,8 @@ import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity'; // Entities (Master Data - Shop Drawing) - ✅ āđ€āļžāļīāđˆāļĄāđƒāļŦāļĄāđˆ +import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity'; +import { ContractDrawingCategory } from './entities/contract-drawing-category.entity'; import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity'; import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity'; @@ -40,6 +42,8 @@ import { UserModule } from '../user/user.module'; // Master Data ContractDrawingVolume, ContractDrawingSubCategory, + ContractDrawingSubcatCatMap, + ContractDrawingCategory, ShopDrawingMainCategory, // ✅ ShopDrawingSubCategory, // ✅ diff --git a/backend/src/modules/drawing/dto/create-contract-drawing.dto.ts b/backend/src/modules/drawing/dto/create-contract-drawing.dto.ts index e524493..922e1e9 100644 --- a/backend/src/modules/drawing/dto/create-contract-drawing.dto.ts +++ b/backend/src/modules/drawing/dto/create-contract-drawing.dto.ts @@ -21,12 +21,16 @@ export class CreateContractDrawingDto { @IsInt() @IsOptional() - subCategoryId?: number; // ✅ āđƒāļŠāđˆ ? + mapCatId?: number; // ✅ āđƒāļŠāđˆ ? @IsInt() @IsOptional() volumeId?: number; // ✅ āđƒāļŠāđˆ ? + @IsInt() + @IsOptional() + volumePage?: number; + @IsArray() @IsInt({ each: true }) @IsOptional() diff --git a/backend/src/modules/drawing/dto/create-shop-drawing-revision.dto.ts b/backend/src/modules/drawing/dto/create-shop-drawing-revision.dto.ts index 2f2e0d6..f971f7f 100644 --- a/backend/src/modules/drawing/dto/create-shop-drawing-revision.dto.ts +++ b/backend/src/modules/drawing/dto/create-shop-drawing-revision.dto.ts @@ -10,6 +10,13 @@ export class CreateShopDrawingRevisionDto { @IsString() revisionLabel!: string; // āļˆāļģāđ€āļ›āđ‡āļ™: āđƒāļŠāđˆ ! + @IsString() + title!: string; // Add title + + @IsString() + @IsOptional() + legacyDrawingNumber?: string; + @IsDateString() @IsOptional() revisionDate?: string; // Optional: āđƒāļŠāđˆ ? diff --git a/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts b/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts index b4e96bb..cf49240 100644 --- a/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts +++ b/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts @@ -14,8 +14,8 @@ export class SearchContractDrawingDto { @IsOptional() @IsInt() - @Type(() => Number) - subCategoryId?: number; // Optional: āđƒāļŠāđˆ ? + @IsOptional() + mapCatId?: number; // Optional: āđƒāļŠāđˆ ? @IsOptional() @IsString() diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts new file mode 100644 index 0000000..2f824b6 --- /dev/null +++ b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { AsBuiltDrawing } from './asbuilt-drawing.entity'; +import { ShopDrawingRevision } from './shop-drawing-revision.entity'; +import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; + +@Entity('asbuilt_drawing_revisions') +export class AsBuiltDrawingRevision { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'asbuilt_drawing_id' }) + asBuiltDrawingId!: number; + + @Column({ name: 'revision_number' }) + revisionNumber!: number; + + @Column({ name: 'title', length: 255 }) + title!: string; + + @Column({ name: 'revision_label', length: 10, nullable: true }) + revisionLabel?: string; + + @Column({ name: 'revision_date', type: 'date', nullable: true }) + revisionDate?: Date; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => AsBuiltDrawing, (drawing) => drawing.revisions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'asbuilt_drawing_id' }) + asBuiltDrawing!: AsBuiltDrawing; + + // Relation to Shop Drawing Revisions (M:N) + @ManyToMany(() => ShopDrawingRevision) + @JoinTable({ + name: 'asbuilt_revision_shop_revisions_refs', + joinColumn: { + name: 'asbuilt_drawing_revision_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'shop_drawing_revision_id', + referencedColumnName: 'id', + }, + }) + shopDrawingRevisions!: ShopDrawingRevision[]; + + // Attachments (M:N) + @ManyToMany(() => Attachment) + @JoinTable({ + name: 'asbuilt_drawing_revision_attachments', + joinColumn: { + name: 'asbuilt_drawing_revision_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { name: 'attachment_id', referencedColumnName: 'id' }, + }) + attachments!: Attachment[]; +} diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts new file mode 100644 index 0000000..63f4985 --- /dev/null +++ b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity'; +import { User } from '../../user/entities/user.entity'; + +@Entity('asbuilt_drawings') +export class AsBuiltDrawing { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_id' }) + projectId!: number; + + @Column({ name: 'drawing_number', length: 100, unique: true }) + drawingNumber!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; + + @Column({ name: 'updated_by', nullable: true }) + updatedBy?: number; + + // Relations + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project!: Project; + + @ManyToOne(() => User) + @JoinColumn({ name: 'updated_by' }) + updater?: User; + + @OneToMany( + () => AsBuiltDrawingRevision, + (revision) => revision.asBuiltDrawing, + { + cascade: true, + } + ) + revisions!: AsBuiltDrawingRevision[]; +} diff --git a/backend/src/modules/drawing/entities/contract-drawing-category.entity.ts b/backend/src/modules/drawing/entities/contract-drawing-category.entity.ts new file mode 100644 index 0000000..54155b2 --- /dev/null +++ b/backend/src/modules/drawing/entities/contract-drawing-category.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from '../../project/entities/project.entity'; + +@Entity('contract_drawing_cats') +export class ContractDrawingCategory { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_id' }) + projectId!: number; + + @Column({ name: 'cat_code', length: 50 }) + catCode!: string; + + @Column({ name: 'cat_name', length: 255 }) + catName!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'sort_order', default: 0 }) + sortOrder!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project!: Project; +} diff --git a/backend/src/modules/drawing/entities/contract-drawing-subcat-cat-map.entity.ts b/backend/src/modules/drawing/entities/contract-drawing-subcat-cat-map.entity.ts new file mode 100644 index 0000000..7dddb48 --- /dev/null +++ b/backend/src/modules/drawing/entities/contract-drawing-subcat-cat-map.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { ContractDrawingCategory } from './contract-drawing-category.entity'; +import { ContractDrawingSubCategory } from './contract-drawing-sub-category.entity'; + +@Entity('contract_drawing_subcat_cat_maps') +export class ContractDrawingSubcatCatMap { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_id', nullable: true }) + projectId?: number; + + @Column({ name: 'sub_cat_id', nullable: true }) + subCategoryId?: number; + + @Column({ name: 'cat_id', nullable: true }) + categoryId?: number; + + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @ManyToOne(() => ContractDrawingSubCategory) + @JoinColumn({ name: 'sub_cat_id' }) + subCategory?: ContractDrawingSubCategory; + + @ManyToOne(() => ContractDrawingCategory) + @JoinColumn({ name: 'cat_id' }) + category?: ContractDrawingCategory; +} diff --git a/backend/src/modules/drawing/entities/contract-drawing.entity.ts b/backend/src/modules/drawing/entities/contract-drawing.entity.ts index c5093a0..a1ebec2 100644 --- a/backend/src/modules/drawing/entities/contract-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/contract-drawing.entity.ts @@ -13,7 +13,7 @@ import { import { Project } from '../../project/entities/project.entity'; import { User } from '../../user/entities/user.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; -import { ContractDrawingSubCategory } from './contract-drawing-sub-category.entity'; +import { ContractDrawingSubcatCatMap } from './contract-drawing-subcat-cat-map.entity'; import { ContractDrawingVolume } from './contract-drawing-volume.entity'; @Entity('contract_drawings') @@ -30,12 +30,15 @@ export class ContractDrawing { @Column({ length: 255 }) title!: string; // ! āļŦāđ‰āļēāļĄāļ§āđˆāļēāļ‡ - @Column({ name: 'sub_cat_id', nullable: true }) - subCategoryId?: number; // ? āļ§āđˆāļēāļ‡āđ„āļ”āđ‰ (Nullable) + @Column({ name: 'map_cat_id', nullable: true }) + mapCatId?: number; // ? āļ§āđˆāļēāļ‡āđ„āļ”āđ‰ (Nullable) @Column({ name: 'volume_id', nullable: true }) volumeId?: number; // ? āļ§āđˆāļēāļ‡āđ„āļ”āđ‰ (Nullable) + @Column({ name: 'volume_page', nullable: true }) + volumePage?: number; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; // ! āļŦāđ‰āļēāļĄāļ§āđˆāļēāļ‡ @@ -58,9 +61,9 @@ export class ContractDrawing { @JoinColumn({ name: 'updated_by' }) updater?: User; // ? āļ§āđˆāļēāļ‡āđ„āļ”āđ‰ - @ManyToOne(() => ContractDrawingSubCategory) - @JoinColumn({ name: 'sub_cat_id' }) - subCategory?: ContractDrawingSubCategory; // ? āļ§āđˆāļēāļ‡āđ„āļ”āđ‰ (āļŠāļąāļĄāļžāļąāļ™āļ˜āđŒāļāļąāļš subCategoryId) + @ManyToOne(() => ContractDrawingSubcatCatMap) + @JoinColumn({ name: 'map_cat_id' }) + mapCategory?: ContractDrawingSubcatCatMap; @ManyToOne(() => ContractDrawingVolume) @JoinColumn({ name: 'volume_id' }) diff --git a/backend/src/modules/drawing/entities/shop-drawing-main-category.entity.ts b/backend/src/modules/drawing/entities/shop-drawing-main-category.entity.ts index 3db9a2e..b35cc16 100644 --- a/backend/src/modules/drawing/entities/shop-drawing-main-category.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing-main-category.entity.ts @@ -11,6 +11,9 @@ export class ShopDrawingMainCategory { @PrimaryGeneratedColumn() id!: number; // āđ€āļ•āļīāļĄ ! + @Column({ name: 'project_id' }) + projectId!: number; // āđ€āļ•āļīāļĄ ! + @Column({ name: 'main_category_code', length: 50, unique: true }) mainCategoryCode!: string; // āđ€āļ•āļīāļĄ ! diff --git a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts index 6948eda..b673973 100644 --- a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts @@ -23,6 +23,12 @@ export class ShopDrawingRevision { @Column({ name: 'revision_number' }) revisionNumber!: number; // āđ€āļ•āļīāļĄ ! + @Column({ name: 'title', length: 255 }) + title!: string; // āđ€āļ•āļīāļĄ ! + + @Column({ name: 'legacy_drawing_number', length: 100, nullable: true }) + legacyDrawingNumber?: string; + @Column({ name: 'revision_label', length: 10, nullable: true }) revisionLabel?: string; // nullable āđƒāļŠāđ‰ ? diff --git a/backend/src/modules/drawing/entities/shop-drawing-sub-category.entity.ts b/backend/src/modules/drawing/entities/shop-drawing-sub-category.entity.ts index 83c100d..d8dd319 100644 --- a/backend/src/modules/drawing/entities/shop-drawing-sub-category.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing-sub-category.entity.ts @@ -4,25 +4,22 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, } from 'typeorm'; -import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity'; @Entity('shop_drawing_sub_categories') export class ShopDrawingSubCategory { @PrimaryGeneratedColumn() id!: number; // āđ€āļ•āļīāļĄ ! (āļ•āļąāļ§āļ—āļĩāđˆ error) + @Column({ name: 'project_id' }) + projectId!: number; // āđ€āļ•āļīāļĄ ! + @Column({ name: 'sub_category_code', length: 50, unique: true }) subCategoryCode!: string; // āđ€āļ•āļīāļĄ ! @Column({ name: 'sub_category_name', length: 255 }) subCategoryName!: string; // āđ€āļ•āļīāļĄ ! - @Column({ name: 'main_category_id' }) - mainCategoryId!: number; // āđ€āļ•āļīāļĄ ! - @Column({ type: 'text', nullable: true }) description?: string; // nullable āđƒāļŠāđ‰ ? @@ -37,9 +34,4 @@ export class ShopDrawingSubCategory { @UpdateDateColumn({ name: 'updated_at' }) updatedAt!: Date; // āđ€āļ•āļīāļĄ ! - - // Relation to Main Category - @ManyToOne(() => ShopDrawingMainCategory) - @JoinColumn({ name: 'main_category_id' }) - mainCategory!: ShopDrawingMainCategory; // āđ€āļ•āļīāļĄ ! } diff --git a/backend/src/modules/drawing/entities/shop-drawing.entity.ts b/backend/src/modules/drawing/entities/shop-drawing.entity.ts index 3640cf2..4e3b503 100644 --- a/backend/src/modules/drawing/entities/shop-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing.entity.ts @@ -25,9 +25,6 @@ export class ShopDrawing { @Column({ name: 'drawing_number', length: 100, unique: true }) drawingNumber!: string; // āđ€āļ•āļīāļĄ ! - @Column({ length: 500 }) - title!: string; // āđ€āļ•āļīāļĄ ! - @Column({ name: 'main_category_id' }) mainCategoryId!: number; // āđ€āļ•āļīāļĄ ! diff --git a/backend/src/modules/drawing/shop-drawing.service.ts b/backend/src/modules/drawing/shop-drawing.service.ts index 7e8cdec..7ed3d57 100644 --- a/backend/src/modules/drawing/shop-drawing.service.ts +++ b/backend/src/modules/drawing/shop-drawing.service.ts @@ -5,7 +5,7 @@ import { Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, In, Brackets } from 'typeorm'; +import { Repository, DataSource, In } from 'typeorm'; // Entities import { ShopDrawing } from './entities/shop-drawing.entity'; @@ -77,7 +77,6 @@ export class ShopDrawingService { const shopDrawing = queryRunner.manager.create(ShopDrawing, { projectId: createDto.projectId, drawingNumber: createDto.drawingNumber, - title: createDto.title, mainCategoryId: createDto.mainCategoryId, subCategoryId: createDto.subCategoryId, updatedBy: user.user_id, @@ -89,6 +88,7 @@ export class ShopDrawingService { shopDrawingId: savedShopDrawing.id, revisionNumber: 0, revisionLabel: createDto.revisionLabel || '0', + title: createDto.title, // Add title to revision revisionDate: createDto.revisionDate ? new Date(createDto.revisionDate) : new Date(), @@ -175,6 +175,8 @@ export class ShopDrawingService { shopDrawingId, revisionNumber: nextRevNum, revisionLabel: createDto.revisionLabel, + title: createDto.title, // Add title from DTO + legacyDrawingNumber: createDto.legacyDrawingNumber, // Add legacy number revisionDate: createDto.revisionDate ? new Date(createDto.revisionDate) : new Date(), @@ -226,13 +228,9 @@ export class ShopDrawingService { } if (search) { - query.andWhere( - new Brackets((qb) => { - qb.where('sd.drawingNumber LIKE :search', { - search: `%${search}%`, - }).orWhere('sd.title LIKE :search', { search: `%${search}%` }); - }) - ); + query.andWhere('sd.drawingNumber LIKE :search', { + search: `%${search}%`, + }); } query.orderBy('sd.updatedAt', 'DESC'); diff --git a/backend/src/modules/organization/dto/search-organization.dto.ts b/backend/src/modules/organization/dto/search-organization.dto.ts index 4f0a88c..29208ea 100644 --- a/backend/src/modules/organization/dto/search-organization.dto.ts +++ b/backend/src/modules/organization/dto/search-organization.dto.ts @@ -1,5 +1,5 @@ -import { IsOptional, IsString, IsInt, Min } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsInt, Min, IsBoolean } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class SearchOrganizationDto { @@ -33,4 +33,10 @@ export class SearchOrganizationDto { @Min(1) @Type(() => Number) limit?: number = 100; + + @ApiPropertyOptional({ description: 'Filter by Active status' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isActive?: boolean; } diff --git a/backend/test_output.txt b/backend/test_output.txt index 2513d7f..08d05e6 100644 --- a/backend/test_output.txt +++ b/backend/test_output.txt @@ -1,35 +1,43 @@ - -> backend@1.5.1 test -> jest --forceExit modules/document-numbering/document-numbering.service.spec.ts - FAIL src/modules/document-numbering/document-numbering.service.spec.ts DocumentNumberingService - ΓÊÜ should be defined (19 ms) + ΓÊÜ should be defined (15 ms) generateNextNumber - ΓÊÜ should generate a new number successfully (17 ms) - ├Ãđ should throw error when transaction fails (7 ms) + ΓÊÜ should generate a new number successfully (4 ms) + ΓÊÜ should throw error when increment fails (9 ms) + Admin Operations + ├Ãđ voidAndReplace should verify audit log exists (2 ms) + ├Ãđ cancelNumber should log cancellation (3 ms) - ΓÃđÅ DocumentNumberingService › generateNextNumber › should throw error when transaction fails + ΓÃđÅ DocumentNumberingService › Admin Operations › voidAndReplace should verify audit log exists - expect(received).rejects.toThrow() + TypeError: Cannot read properties of undefined (reading 'mockResolvedValue') - Received promise resolved instead of rejected - Resolved to value: {"auditId": 1, "number": "0001"} + 143 | it('voidAndReplace should verify audit log exists', async () => { + 144 | const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit)); + > 145 | (auditRepo.findOne as jest.Mock).mockResolvedValue({ + | ^ + 146 | generatedNumber: 'DOC-001', + 147 | counterKey: JSON.stringify({ projectId: 1, correspondenceTypeId: 1 }), + 148 | templateUsed: 'test', - 201 | ); - 202 | - > 203 | await expect(service.generateNextNumber(mockContext)).rejects.toThrow( - | ^ - 204 | Error - 205 | ); - 206 | }); + at Object. (modules/document-numbering/document-numbering.service.spec.ts:145:40) - at expect (../node_modules/expect/build/index.js:2116:15) - at Object. (modules/document-numbering/document-numbering.service.spec.ts:203:13) + ΓÃđÅ DocumentNumberingService › Admin Operations › cancelNumber should log cancellation + + TypeError: Cannot read properties of undefined (reading 'mockResolvedValue') + + 161 | it('cancelNumber should log cancellation', async () => { + 162 | const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit)); + > 163 | (auditRepo.findOne as jest.Mock).mockResolvedValue({ + | ^ + 164 | generatedNumber: 'DOC-002', + 165 | counterKey: {}, + 166 | }); + + at Object. (modules/document-numbering/document-numbering.service.spec.ts:163:40) Test Suites: 1 failed, 1 total -Tests: 1 failed, 2 passed, 3 total +Tests: 2 failed, 3 passed, 5 total Snapshots: 0 total -Time: 1.506 s, estimated 2 s -Ran all test suites matching modules/document-numbering/document-numbering.service.spec.ts. -Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished? +Time: 1.381 s, estimated 2 s +Ran all test suites matching src/modules/document-numbering/document-numbering.service.spec.ts. diff --git a/frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx b/frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx index 46baa2c..129284b 100644 --- a/frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx +++ b/frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx @@ -24,7 +24,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } }) const contractId = contracts[0]?.id; const { data: disciplines = [] } = useDisciplines(contractId); - const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3'; + const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3'; useEffect(() => { const fetchTemplate = async () => { @@ -46,7 +46,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } }) const handleSave = async (data: Partial) => { try { - await numberingApi.saveTemplate({ ...data, templateId: parseInt(params.id) }); + await numberingApi.saveTemplate({ ...data, id: parseInt(params.id) }); router.push("/admin/numbering"); } catch (error) { console.error("Failed to update template", error); diff --git a/frontend/app/(admin)/admin/numbering/page.tsx b/frontend/app/(admin)/admin/numbering/page.tsx index 7300cfb..05a75af 100644 --- a/frontend/app/(admin)/admin/numbering/page.tsx +++ b/frontend/app/(admin)/admin/numbering/page.tsx @@ -2,13 +2,10 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Plus, Edit, Play, AlertTriangle, ShieldAlert, CheckCircle2 } from 'lucide-react'; +import { Plus, Edit, Play } from 'lucide-react'; import { numberingApi, NumberingTemplate } from '@/lib/api/numbering'; import { TemplateEditor } from '@/components/numbering/template-editor'; import { SequenceViewer } from '@/components/numbering/sequence-viewer'; @@ -22,136 +19,15 @@ import { SelectValue, } from "@/components/ui/select"; import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data'; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -// --- Sub-components for Tools --- -function ManualOverrideForm({ onSuccess, projectId }: { onSuccess: () => void, projectId: number }) { - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - typeId: '', - disciplineId: '', - year: new Date().getFullYear().toString(), - newSequence: '', - reason: '' - }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - try { - await numberingApi.manualOverride({ - projectId, - correspondenceTypeId: parseInt(formData.typeId) || null, - year: parseInt(formData.year), - newValue: parseInt(formData.newSequence), - }); - toast.success("Manual override applied successfully"); - onSuccess(); - } catch (error) { - toast.error("Failed to apply override"); - } finally { - setLoading(false); - } - }; +import { ManualOverrideForm } from '@/components/numbering/manual-override-form'; +import { MetricsDashboard } from '@/components/numbering/metrics-dashboard'; +import { AuditLogsTable } from '@/components/numbering/audit-logs-table'; +import { VoidReplaceForm } from '@/components/numbering/void-replace-form'; +import { CancelNumberForm } from '@/components/numbering/cancel-number-form'; +import { BulkImportForm } from '@/components/numbering/bulk-import-form'; - return ( - - - Manual Override - Force set a counter sequence. Use with caution. - - - - - Warning - Changing counters manually can cause duplication errors. - -
-
-
- - setFormData({...formData, typeId: e.target.value})} - required - /> -
-
- - setFormData({...formData, disciplineId: e.target.value})} - /> -
-
-
-
- - setFormData({...formData, year: e.target.value})} - required - /> -
-
- - setFormData({...formData, newSequence: e.target.value})} - required - /> -
-
-
- -