251223:1649 On going update to 1.7.0: Refoctory drawing Module & document number Module
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-23 16:49:16 +07:00
parent 0d6432ab83
commit 7db6a003db
81 changed files with 4703 additions and 1449 deletions

View File

@@ -140,7 +140,6 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
// 📦 Feature Modules
AuthModule,
UserModule,
UserModule,
ProjectModule,
OrganizationModule,
ContractModule,

View File

@@ -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')

View File

@@ -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,

View File

@@ -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
};
}
}

View File

@@ -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 {}

View File

@@ -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();
});
});
});

View File

@@ -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;
}

View File

@@ -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>;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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' })

View File

@@ -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
}
}
}

View File

@@ -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));
}

View File

@@ -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}`
);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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' },
})
);
});
});

View File

@@ -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,
});
}
}

View File

@@ -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>
) {}
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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, // ✅

View File

@@ -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()

View File

@@ -10,6 +10,13 @@ export class CreateShopDrawingRevisionDto {
@IsString()
revisionLabel!: string; // จำเป็น: ใส่ !
@IsString()
title!: string; // Add title
@IsString()
@IsOptional()
legacyDrawingNumber?: string;
@IsDateString()
@IsOptional()
revisionDate?: string; // Optional: ใส่ ?

View File

@@ -14,8 +14,8 @@ export class SearchContractDrawingDto {
@IsOptional()
@IsInt()
@Type(() => Number)
subCategoryId?: number; // Optional: ใส่ ?
@IsOptional()
mapCatId?: number; // Optional: ใส่ ?
@IsOptional()
@IsString()

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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; // เติม !

View File

@@ -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 ใช้ ?

View File

@@ -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; // เติม !
}

View File

@@ -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; // เติม !

View File

@@ -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');

View File

@@ -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;
}