251223:1649 On going update to 1.7.0: Refoctory drawing Module & document number Module
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<DocumentNumberAudit>
|
||||
) {}
|
||||
|
||||
async log(entry: Partial<DocumentNumberAudit>): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -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<Lock> {
|
||||
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<void> {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentNumberAudit> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(ManualOverrideService);
|
||||
counterService = module.get<CounterService>(CounterService);
|
||||
auditService = module.get<AuditService>(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' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string>,
|
||||
@InjectMetric('numbering_sequence_utilization')
|
||||
public sequenceUtilization: Gauge<string>,
|
||||
@InjectMetric('numbering_lock_wait_seconds')
|
||||
public lockWaitTime: Histogram<string>,
|
||||
@InjectMetric('numbering_lock_failures_total')
|
||||
public lockFailures: Counter<string>
|
||||
) {}
|
||||
}
|
||||
@@ -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<DocumentNumberFormat>
|
||||
// private readonly validator: TemplateValidator,
|
||||
) {}
|
||||
|
||||
async findTemplate(
|
||||
projectId: number,
|
||||
correspondenceTypeId?: number
|
||||
): Promise<DocumentNumberFormat | null> {
|
||||
// 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, // ✅
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,6 +10,13 @@ export class CreateShopDrawingRevisionDto {
|
||||
@IsString()
|
||||
revisionLabel!: string; // จำเป็น: ใส่ !
|
||||
|
||||
@IsString()
|
||||
title!: string; // Add title
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
legacyDrawingNumber?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
revisionDate?: string; // Optional: ใส่ ?
|
||||
|
||||
@@ -14,8 +14,8 @@ export class SearchContractDrawingDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
subCategoryId?: number; // Optional: ใส่ ?
|
||||
@IsOptional()
|
||||
mapCatId?: number; // Optional: ใส่ ?
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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; // เติม !
|
||||
|
||||
|
||||
@@ -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 ใช้ ?
|
||||
|
||||
|
||||
@@ -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; // เติม !
|
||||
}
|
||||
|
||||
@@ -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; // เติม !
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user