Files
lcbp3/backend/src/modules/document-numbering/services/document-numbering.service.ts
T
admin ba642e7e42
Build and Deploy / deploy (push) Successful in 11m17s
260318:1237 Fix UUID #4
2026-03-18 12:37:29 +07:00

491 lines
16 KiB
TypeScript

import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DocumentNumberFormat } from '../entities/document-number-format.entity';
import { DocumentNumberAudit } from '../entities/document-number-audit.entity';
import { DocumentNumberError } from '../entities/document-number-error.entity';
// Services
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';
import { GenerateNumberContext } from '../interfaces/document-numbering.interface';
import { ReserveNumberDto } from '../dto/reserve-number.dto';
import { ConfirmReservationDto } from '../dto/confirm-reservation.dto';
import { Project } from '../../project/entities/project.entity';
import { Organization } from '../../organization/entities/organization.entity';
@Injectable()
export class DocumentNumberingService {
private readonly logger = new Logger(DocumentNumberingService.name);
constructor(
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(DocumentNumberAudit)
private auditRepo: Repository<DocumentNumberAudit>,
@InjectRepository(DocumentNumberError)
private errorRepo: Repository<DocumentNumberError>,
private counterService: CounterService,
private reservationService: ReservationService,
private formatService: FormatService,
private lockService: DocumentNumberingLockService,
private configService: ConfigService,
private manualOverrideService: ManualOverrideService,
private metricsService: MetricsService,
@InjectEntityManager()
private entityManager: EntityManager
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Public facade for controllers to resolve project/organization IDs
*/
async resolveIdForPreview(
type: 'project' | 'organization',
id: number | string
): Promise<number> {
if (type === 'project') return this.resolveProjectId(id);
return this.resolveOrganizationId(id);
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.entityManager.findOne(Organization, {
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
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();
const resetScope = `YEAR_${currentYear}`;
// 1. Prepare Counter Key
const key: CounterKeyDto = {
projectId: ctx.projectId,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId || 0,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId || 0,
rfaTypeId: ctx.rfaTypeId || 0,
disciplineId: ctx.disciplineId || 0,
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);
// 4. Format Number
const generatedNumber = await this.formatService.format({
projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId,
rfaTypeId: ctx.rfaTypeId,
disciplineId: ctx.disciplineId,
sequence: sequence,
resetScope: resetScope,
year: currentYear,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId,
});
// 5. Audit Log
const audit = await this.logAudit({
generatedNumber,
counterKey: JSON.stringify(key),
templateUsed: 'DELEGATED_TO_FORMAT_SERVICE',
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);
}
}
}
async reserveNumber(
dto: ReserveNumberDto,
userId: number,
ipAddress?: string
): Promise<any> {
try {
// Delegate completely to ReservationService
return await this.reservationService.reserve(
dto,
userId,
ipAddress || '0.0.0.0',
'Unknown' // userAgent not passed in legacy call
);
} catch (error: any) {
this.logger.error('Reservation failed', error);
throw error;
}
}
async confirmReservation(
dto: ConfirmReservationDto,
userId: number
): Promise<any> {
return this.reservationService.confirm(dto, userId);
}
async cancelReservation(token: string, userId: number): Promise<void> {
return this.reservationService.cancel(token, userId);
}
async previewNumber(
ctx: GenerateNumberContext
): Promise<{ previewNumber: string; nextSequence: number }> {
const currentYear = new Date().getFullYear();
const resetScope = `YEAR_${currentYear}`;
const key: CounterKeyDto = {
projectId: ctx.projectId,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId || 0,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId || 0,
rfaTypeId: ctx.rfaTypeId || 0,
disciplineId: ctx.disciplineId || 0,
resetScope: resetScope,
};
const currentSeq = await this.counterService.getCurrentCounter(key);
const nextSequence = currentSeq + 1;
const previewNumber = await this.formatService.format({
projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId,
rfaTypeId: ctx.rfaTypeId,
disciplineId: ctx.disciplineId,
sequence: nextSequence,
resetScope: resetScope,
year: currentYear,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId,
});
return { previewNumber, nextSequence };
}
/**
* Generates a new number for a draft when its context changes.
*/
async updateNumberForDraft(
currentNumber: string,
oldCtx: GenerateNumberContext,
newCtx: GenerateNumberContext
): Promise<string> {
const result = await this.generateNextNumber(newCtx);
return result.number;
}
// --- Admin / Legacy ---
async getTemplates() {
return this.formatRepo.find();
}
async getTemplatesByProject(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.formatRepo.find({ where: { projectId: internalId } });
}
async saveTemplate(dto: any) {
if (dto.projectId) {
dto.projectId = await this.resolveProjectId(dto.projectId);
}
return this.formatRepo.save(dto);
}
async deleteTemplate(id: number) {
return this.formatRepo.delete(id);
}
async getAuditLogs(limit: number) {
return this.auditRepo.find({ take: limit, order: { createdAt: 'DESC' } });
}
async getErrorLogs(limit: number) {
return this.errorRepo.find({ take: limit, order: { createdAt: 'DESC' } });
}
async getSequences(projectId?: number) {
await Promise.resolve(projectId); // satisfy unused
return [];
}
async setCounterValue(id: number, sequence: number) {
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, userId: number) {
return this.manualOverrideService.applyOverride(dto, userId);
}
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: {
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[]) {
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> {
const audit = this.auditRepo.create({
...data,
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) {
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);
}
}
}