251223:1649 On going update to 1.7.0: Refoctory drawing Module & document number Module
This commit is contained in:
@@ -140,7 +140,6 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
|||||||
// 📦 Feature Modules
|
// 📦 Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
UserModule,
|
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
ContractModule,
|
ContractModule,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||||
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
||||||
import { RequirePermission } from '../../../common/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../../common/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
@ApiTags('Admin / Document Numbering')
|
@ApiTags('Admin / Document Numbering')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -73,8 +74,8 @@ export class DocumentNumberingAdminController {
|
|||||||
summary: 'Manually override or set a document number counter',
|
summary: 'Manually override or set a document number counter',
|
||||||
})
|
})
|
||||||
@RequirePermission('system.manage_settings')
|
@RequirePermission('system.manage_settings')
|
||||||
async manualOverride(@Body() dto: any) {
|
async manualOverride(@Body() dto: any, @CurrentUser() user: any) {
|
||||||
return this.service.manualOverride(dto);
|
return this.service.manualOverride(dto, user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('void-and-replace')
|
@Post('void-and-replace')
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ export class DocumentNumberingController {
|
|||||||
async previewNumber(@Body() dto: PreviewNumberDto) {
|
async previewNumber(@Body() dto: PreviewNumberDto) {
|
||||||
return this.numberingService.previewNumber({
|
return this.numberingService.previewNumber({
|
||||||
projectId: dto.projectId,
|
projectId: dto.projectId,
|
||||||
originatorOrganizationId: dto.originatorOrganizationId,
|
originatorOrganizationId: dto.originatorId,
|
||||||
typeId: dto.correspondenceTypeId,
|
typeId: dto.typeId,
|
||||||
subTypeId: dto.subTypeId,
|
subTypeId: dto.subTypeId,
|
||||||
rfaTypeId: dto.rfaTypeId,
|
rfaTypeId: dto.rfaTypeId,
|
||||||
disciplineId: dto.disciplineId,
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
makeCounterProvider,
|
||||||
|
makeGaugeProvider,
|
||||||
|
makeHistogramProvider,
|
||||||
|
} from '@willsoto/nestjs-prometheus';
|
||||||
|
|
||||||
import { DocumentNumberingService } from './services/document-numbering.service';
|
import { DocumentNumberingService } from './services/document-numbering.service';
|
||||||
import { DocumentNumberingController } from './controllers/document-numbering.controller';
|
import { DocumentNumberingController } from './controllers/document-numbering.controller';
|
||||||
import { DocumentNumberingAdminController } from './controllers/document-numbering-admin.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 { DocumentNumberFormat } from './entities/document-number-format.entity';
|
||||||
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
||||||
import { DocumentNumberReservation } from './entities/document-number-reservation.entity';
|
import { DocumentNumberReservation } from './entities/document-number-reservation.entity';
|
||||||
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
|
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
|
||||||
import { DocumentNumberError } from './entities/document-number-error.entity';
|
import { DocumentNumberError } from './entities/document-number-error.entity';
|
||||||
|
|
||||||
import { CounterService } from './services/counter.service';
|
import { CounterService } from './services/counter.service';
|
||||||
import { ReservationService } from './services/reservation.service';
|
import { ReservationService } from './services/reservation.service';
|
||||||
import { FormatService } from './services/format.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
|
// Master Entities ที่ต้องใช้ Lookup
|
||||||
import { Project } from '../project/entities/project.entity';
|
import { Project } from '../project/entities/project.entity';
|
||||||
@@ -40,18 +52,48 @@ import { UserModule } from '../user/user.module';
|
|||||||
CorrespondenceSubType,
|
CorrespondenceSubType,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [DocumentNumberingController, DocumentNumberingAdminController],
|
controllers: [
|
||||||
|
DocumentNumberingController,
|
||||||
|
DocumentNumberingAdminController,
|
||||||
|
NumberingMetricsController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
DocumentNumberingService,
|
DocumentNumberingService,
|
||||||
CounterService,
|
CounterService,
|
||||||
ReservationService,
|
ReservationService,
|
||||||
FormatService,
|
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: [
|
exports: [
|
||||||
DocumentNumberingService,
|
DocumentNumberingService,
|
||||||
CounterService,
|
CounterService,
|
||||||
ReservationService,
|
ReservationService,
|
||||||
FormatService,
|
FormatService,
|
||||||
|
DocumentNumberingLockService,
|
||||||
|
TemplateService,
|
||||||
|
AuditService,
|
||||||
|
MetricsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentNumberingModule {}
|
export class DocumentNumberingModule {}
|
||||||
|
|||||||
@@ -9,26 +9,9 @@ import { DocumentNumberFormat } from './entities/document-number-format.entity';
|
|||||||
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
|
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
|
||||||
import { DocumentNumberError } from './entities/document-number-error.entity';
|
import { DocumentNumberError } from './entities/document-number-error.entity';
|
||||||
|
|
||||||
// Mock Redis and Redlock (legacy mocks, kept just in case)
|
import { DocumentNumberingLockService } from './services/document-numbering-lock.service';
|
||||||
const mockRedis = {
|
import { ManualOverrideService } from './services/manual-override.service';
|
||||||
disconnect: jest.fn(),
|
import { MetricsService } from './services/metrics.service';
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DocumentNumberingService', () => {
|
describe('DocumentNumberingService', () => {
|
||||||
let service: DocumentNumberingService;
|
let service: DocumentNumberingService;
|
||||||
@@ -39,15 +22,16 @@ describe('DocumentNumberingService', () => {
|
|||||||
const mockContext = {
|
const mockContext = {
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
originatorOrganizationId: 1,
|
originatorOrganizationId: 1,
|
||||||
|
recipientOrganizationId: 1,
|
||||||
typeId: 1,
|
typeId: 1,
|
||||||
|
subTypeId: 1,
|
||||||
|
rfaTypeId: 1,
|
||||||
disciplineId: 1,
|
disciplineId: 1,
|
||||||
year: 2025,
|
year: 2025,
|
||||||
customTokens: { TYPE_CODE: 'COR', ORG_CODE: 'GGL' },
|
customTokens: { TYPE_CODE: 'COR', ORG_CODE: 'GGL' },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockRedlock.acquire.mockResolvedValue(mockLock);
|
|
||||||
|
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
DocumentNumberingService,
|
DocumentNumberingService,
|
||||||
@@ -76,6 +60,24 @@ describe('DocumentNumberingService', () => {
|
|||||||
format: jest.fn().mockResolvedValue('0001'),
|
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),
|
provide: getRepositoryToken(DocumentNumberFormat),
|
||||||
useValue: { findOne: jest.fn() },
|
useValue: { findOne: jest.fn() },
|
||||||
@@ -85,6 +87,7 @@ describe('DocumentNumberingService', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
create: jest.fn().mockReturnValue({ id: 1 }),
|
create: jest.fn().mockReturnValue({ id: 1 }),
|
||||||
save: jest.fn().mockResolvedValue({ 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
|
// File: src/modules/document-numbering/dto/preview-number.dto.ts
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsInt, IsOptional, IsObject } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class PreviewNumberDto {
|
export class PreviewNumberDto {
|
||||||
@ApiProperty({ description: 'Project ID' })
|
@ApiProperty({ description: 'Project ID' })
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
projectId!: number;
|
projectId!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Originator organization ID' })
|
@ApiProperty({ description: 'Originator organization ID' })
|
||||||
originatorOrganizationId!: number;
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
originatorId!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Correspondence type ID' })
|
@ApiProperty({ description: 'Correspondence type ID' })
|
||||||
correspondenceTypeId!: number;
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
typeId!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
|
@ApiPropertyOptional({ description: 'Sub type ID (for TRANSMITTAL)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
subTypeId?: number;
|
subTypeId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'RFA type ID (for RFA)' })
|
@ApiPropertyOptional({ description: 'RFA type ID (for RFA)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
rfaTypeId?: number;
|
rfaTypeId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Discipline ID' })
|
@ApiPropertyOptional({ description: 'Discipline ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
disciplineId?: number;
|
disciplineId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Year (defaults to current)' })
|
@ApiPropertyOptional({ description: 'Year (defaults to current)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
year?: number;
|
year?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Recipient organization ID' })
|
@ApiPropertyOptional({ description: 'Recipient organization ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
recipientOrganizationId?: number;
|
recipientOrganizationId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Custom tokens' })
|
@ApiPropertyOptional({ description: 'Custom tokens' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
customTokens?: Record<string, string>;
|
customTokens?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ import {
|
|||||||
@Entity('document_number_audit')
|
@Entity('document_number_audit')
|
||||||
@Index(['createdAt'])
|
@Index(['createdAt'])
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
|
@Index(['documentId'])
|
||||||
|
@Index(['status'])
|
||||||
|
@Index(['operation'])
|
||||||
|
@Index(['generatedNumber'])
|
||||||
|
@Index(['reservationToken'])
|
||||||
export class DocumentNumberAudit {
|
export class DocumentNumberAudit {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@Column({ name: 'document_id' })
|
@Column({ name: 'document_id', nullable: true })
|
||||||
documentId!: number;
|
documentId?: number;
|
||||||
|
|
||||||
@Column({ name: 'generated_number', length: 100 })
|
@Column({ name: 'generated_number', length: 100 })
|
||||||
generatedNumber!: string;
|
generatedNumber!: string;
|
||||||
@@ -28,16 +33,49 @@ export class DocumentNumberAudit {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'operation',
|
name: 'operation',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['RESERVE', 'CONFIRM', 'MANUAL_OVERRIDE', 'VOID_REPLACE', 'CANCEL'],
|
enum: [
|
||||||
|
'RESERVE',
|
||||||
|
'CONFIRM',
|
||||||
|
'CANCEL',
|
||||||
|
'MANUAL_OVERRIDE',
|
||||||
|
'VOID',
|
||||||
|
'GENERATE',
|
||||||
|
],
|
||||||
default: 'CONFIRM',
|
default: 'CONFIRM',
|
||||||
})
|
})
|
||||||
operation!: string;
|
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 })
|
@Column({ name: 'metadata', type: 'json', nullable: true })
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
|
|
||||||
@Column({ name: 'user_id' })
|
@Column({ name: 'user_id', nullable: true })
|
||||||
userId!: number;
|
userId?: number;
|
||||||
|
|
||||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
@@ -45,6 +83,9 @@ export class DocumentNumberAudit {
|
|||||||
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_success', default: true })
|
||||||
|
isSuccess!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'retry_count', default: 0 })
|
@Column({ name: 'retry_count', default: 0 })
|
||||||
retryCount!: number;
|
retryCount!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,29 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity('document_number_errors')
|
@Entity('document_number_errors')
|
||||||
|
@Index(['errorType'])
|
||||||
@Index(['createdAt'])
|
@Index(['createdAt'])
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
export class DocumentNumberError {
|
export class DocumentNumberError {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
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' })
|
@Column({ name: 'error_message', type: 'text' })
|
||||||
errorMessage!: string;
|
errorMessage!: string;
|
||||||
|
|
||||||
@@ -20,7 +37,7 @@ export class DocumentNumberError {
|
|||||||
stackTrace?: string;
|
stackTrace?: string;
|
||||||
|
|
||||||
@Column({ name: 'context_data', type: 'json', nullable: true })
|
@Column({ name: 'context_data', type: 'json', nullable: true })
|
||||||
context?: any;
|
contextData?: any;
|
||||||
|
|
||||||
@Column({ name: 'user_id', nullable: true })
|
@Column({ name: 'user_id', nullable: true })
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export class DocumentNumberFormat {
|
|||||||
@Column({ name: 'correspondence_type_id', nullable: true })
|
@Column({ name: 'correspondence_type_id', nullable: true })
|
||||||
correspondenceTypeId?: number;
|
correspondenceTypeId?: number;
|
||||||
|
|
||||||
@Column({ name: 'format_template', length: 100 })
|
@Column({ name: 'format_string', length: 100 })
|
||||||
formatTemplate!: string;
|
formatTemplate!: string;
|
||||||
|
|
||||||
@Column({ name: 'description', nullable: true })
|
@Column({ name: 'description', nullable: true })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
// [NEW] Control yearly reset behavior
|
// [NEW] Control yearly reset behavior
|
||||||
@Column({ name: 'reset_sequence_yearly', default: true })
|
@Column({ name: 'reset_annually', default: true })
|
||||||
resetSequenceYearly!: boolean;
|
resetSequenceYearly!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@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) {
|
if (!counter) {
|
||||||
counter = manager.create(DocumentNumberCounter, {
|
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,
|
lastNumber: 1,
|
||||||
version: 0,
|
version: 0,
|
||||||
});
|
});
|
||||||
@@ -89,7 +96,7 @@ export class CounterService {
|
|||||||
private buildWhereClause(key: CounterKeyDto) {
|
private buildWhereClause(key: CounterKeyDto) {
|
||||||
return {
|
return {
|
||||||
projectId: key.projectId,
|
projectId: key.projectId,
|
||||||
originatorOrganizationId: key.originatorOrganizationId,
|
originatorId: key.originatorOrganizationId,
|
||||||
recipientOrganizationId: key.recipientOrganizationId,
|
recipientOrganizationId: key.recipientOrganizationId,
|
||||||
correspondenceTypeId: key.correspondenceTypeId,
|
correspondenceTypeId: key.correspondenceTypeId,
|
||||||
subTypeId: key.subTypeId,
|
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> {
|
private sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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 { CounterService } from './counter.service';
|
||||||
import { ReservationService } from './reservation.service';
|
import { ReservationService } from './reservation.service';
|
||||||
import { FormatService } from './format.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
|
// DTOs
|
||||||
import { CounterKeyDto } from '../dto/counter-key.dto';
|
import { CounterKeyDto } from '../dto/counter-key.dto';
|
||||||
@@ -33,34 +36,25 @@ export class DocumentNumberingService {
|
|||||||
private counterService: CounterService,
|
private counterService: CounterService,
|
||||||
private reservationService: ReservationService,
|
private reservationService: ReservationService,
|
||||||
private formatService: FormatService,
|
private formatService: FormatService,
|
||||||
private configService: ConfigService
|
private lockService: DocumentNumberingLockService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private manualOverrideService: ManualOverrideService,
|
||||||
|
private metricsService: MetricsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateNextNumber(
|
async generateNextNumber(
|
||||||
ctx: GenerateNumberContext
|
ctx: GenerateNumberContext
|
||||||
): Promise<{ number: string; auditId: number }> {
|
): Promise<{ number: string; auditId: number }> {
|
||||||
|
let lock = null;
|
||||||
try {
|
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 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}`;
|
const resetScope = `YEAR_${currentYear}`;
|
||||||
|
|
||||||
// 2. Prepare Counter Key
|
// 1. Prepare Counter Key
|
||||||
const key: CounterKeyDto = {
|
const key: CounterKeyDto = {
|
||||||
projectId: ctx.projectId,
|
projectId: ctx.projectId,
|
||||||
originatorOrganizationId: ctx.originatorOrganizationId,
|
originatorOrganizationId: ctx.originatorOrganizationId,
|
||||||
@@ -72,6 +66,30 @@ export class DocumentNumberingService {
|
|||||||
resetScope: resetScope,
|
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
|
// 3. Increment Counter
|
||||||
const sequence = await this.counterService.incrementCounter(key);
|
const sequence = await this.counterService.incrementCounter(key);
|
||||||
|
|
||||||
@@ -97,12 +115,22 @@ export class DocumentNumberingService {
|
|||||||
context: ctx,
|
context: ctx,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
operation: 'GENERATE',
|
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 };
|
return { number: generatedNumber, auditId: audit.id };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await this.logError(error, ctx, 'GENERATE');
|
await this.logError(error, ctx, 'GENERATE');
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (lock) {
|
||||||
|
await this.lockService.releaseLock(lock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,32 +239,168 @@ export class DocumentNumberingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSequences(projectId?: number) {
|
async getSequences(projectId?: number) {
|
||||||
await Promise.resolve(); // satisfy await
|
await Promise.resolve(projectId); // satisfy unused
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async setCounterValue(id: number, sequence: number) {
|
async setCounterValue(id: number, sequence: number) {
|
||||||
await Promise.resolve(); // satisfy await
|
await Promise.resolve(id); // satisfy unused
|
||||||
|
await Promise.resolve(sequence);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Updating counter by single ID is not supported with composite keys. Use manualOverride.'
|
'Updating counter by single ID is not supported with composite keys. Use manualOverride.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async manualOverride(dto: any) {
|
async manualOverride(dto: any, userId: number) {
|
||||||
await Promise.resolve();
|
return this.manualOverrideService.applyOverride(dto, userId);
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
async voidAndReplace(dto: any) {
|
async voidAndReplace(dto: {
|
||||||
await Promise.resolve();
|
documentNumber: string;
|
||||||
return {};
|
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();
|
async cancelNumber(dto: {
|
||||||
return {};
|
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[]) {
|
async bulkImport(items: any[]) {
|
||||||
await Promise.resolve();
|
const results = { success: 0, failed: 0, errors: [] as string[] };
|
||||||
return {};
|
|
||||||
|
// 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> {
|
private async logAudit(data: any): Promise<DocumentNumberAudit> {
|
||||||
@@ -245,22 +409,26 @@ export class DocumentNumberingService {
|
|||||||
projectId: data.context.projectId,
|
projectId: data.context.projectId,
|
||||||
createdBy: data.context.userId,
|
createdBy: data.context.userId,
|
||||||
ipAddress: data.context.ipAddress,
|
ipAddress: data.context.ipAddress,
|
||||||
|
// map other fields
|
||||||
});
|
});
|
||||||
return (await this.auditRepo.save(audit)) as unknown as DocumentNumberAudit;
|
return (await this.auditRepo.save(audit)) as unknown as DocumentNumberAudit;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async logError(error: any, ctx: any, operation: string) {
|
private async logError(error: any, ctx: any, operation: string) {
|
||||||
this.errorRepo
|
try {
|
||||||
.save(
|
const errEntity = this.errorRepo.create({
|
||||||
this.errorRepo.create({
|
errorMessage: error.message || 'Unknown Error',
|
||||||
errorMessage: error.message,
|
errorType: error.name || 'GENERATE_ERROR', // Simple mapping
|
||||||
context: {
|
contextData: {
|
||||||
...ctx,
|
// Mapped from context
|
||||||
errorType: 'GENERATE_ERROR',
|
...ctx,
|
||||||
inputPayload: JSON.stringify(ctx),
|
operation,
|
||||||
},
|
inputPayload: JSON.stringify(ctx),
|
||||||
})
|
},
|
||||||
)
|
});
|
||||||
.catch((e) => this.logger.error(e));
|
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,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
InternalServerErrorException,
|
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -73,8 +72,9 @@ export class ContractDrawingService {
|
|||||||
projectId: createDto.projectId,
|
projectId: createDto.projectId,
|
||||||
contractDrawingNo: createDto.contractDrawingNo,
|
contractDrawingNo: createDto.contractDrawingNo,
|
||||||
title: createDto.title,
|
title: createDto.title,
|
||||||
subCategoryId: createDto.subCategoryId,
|
mapCatId: createDto.mapCatId, // Updated
|
||||||
volumeId: createDto.volumeId,
|
volumeId: createDto.volumeId,
|
||||||
|
volumePage: createDto.volumePage, // Updated
|
||||||
updatedBy: user.user_id,
|
updatedBy: user.user_id,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
});
|
});
|
||||||
@@ -111,7 +111,7 @@ export class ContractDrawingService {
|
|||||||
const {
|
const {
|
||||||
projectId,
|
projectId,
|
||||||
volumeId,
|
volumeId,
|
||||||
subCategoryId,
|
mapCatId,
|
||||||
search,
|
search,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
@@ -129,11 +129,9 @@ export class ContractDrawingService {
|
|||||||
query.andWhere('drawing.volumeId = :volumeId', { volumeId });
|
query.andWhere('drawing.volumeId = :volumeId', { volumeId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by SubCategory
|
// Filter by Map Category (Updated)
|
||||||
if (subCategoryId) {
|
if (mapCatId) {
|
||||||
query.andWhere('drawing.subCategoryId = :subCategoryId', {
|
query.andWhere('drawing.mapCatId = :mapCatId', { mapCatId });
|
||||||
subCategoryId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Text (No. or Title)
|
// Search Text (No. or Title)
|
||||||
@@ -198,8 +196,10 @@ export class ContractDrawingService {
|
|||||||
if (updateDto.title) drawing.title = updateDto.title;
|
if (updateDto.title) drawing.title = updateDto.title;
|
||||||
if (updateDto.volumeId !== undefined)
|
if (updateDto.volumeId !== undefined)
|
||||||
drawing.volumeId = updateDto.volumeId;
|
drawing.volumeId = updateDto.volumeId;
|
||||||
if (updateDto.subCategoryId !== undefined)
|
if (updateDto.volumePage !== undefined)
|
||||||
drawing.subCategoryId = updateDto.subCategoryId;
|
drawing.volumePage = updateDto.volumePage;
|
||||||
|
if (updateDto.mapCatId !== undefined)
|
||||||
|
drawing.mapCatId = updateDto.mapCatId;
|
||||||
|
|
||||||
drawing.updatedBy = user.user_id;
|
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';
|
import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
|
||||||
|
|
||||||
// Entities (Master Data - Shop Drawing) - ✅ เพิ่มใหม่
|
// 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 { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
|
||||||
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
|
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@ import { UserModule } from '../user/user.module';
|
|||||||
// Master Data
|
// Master Data
|
||||||
ContractDrawingVolume,
|
ContractDrawingVolume,
|
||||||
ContractDrawingSubCategory,
|
ContractDrawingSubCategory,
|
||||||
|
ContractDrawingSubcatCatMap,
|
||||||
|
ContractDrawingCategory,
|
||||||
ShopDrawingMainCategory, // ✅
|
ShopDrawingMainCategory, // ✅
|
||||||
ShopDrawingSubCategory, // ✅
|
ShopDrawingSubCategory, // ✅
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export class CreateContractDrawingDto {
|
|||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
subCategoryId?: number; // ✅ ใส่ ?
|
mapCatId?: number; // ✅ ใส่ ?
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
volumeId?: number; // ✅ ใส่ ?
|
volumeId?: number; // ✅ ใส่ ?
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
volumePage?: number;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsInt({ each: true })
|
@IsInt({ each: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export class CreateShopDrawingRevisionDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
revisionLabel!: string; // จำเป็น: ใส่ !
|
revisionLabel!: string; // จำเป็น: ใส่ !
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title!: string; // Add title
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
legacyDrawingNumber?: string;
|
||||||
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
revisionDate?: string; // Optional: ใส่ ?
|
revisionDate?: string; // Optional: ใส่ ?
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export class SearchContractDrawingDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Type(() => Number)
|
@IsOptional()
|
||||||
subCategoryId?: number; // Optional: ใส่ ?
|
mapCatId?: number; // Optional: ใส่ ?
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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 { Project } from '../../project/entities/project.entity';
|
||||||
import { User } from '../../user/entities/user.entity';
|
import { User } from '../../user/entities/user.entity';
|
||||||
import { Attachment } from '../../../common/file-storage/entities/attachment.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';
|
import { ContractDrawingVolume } from './contract-drawing-volume.entity';
|
||||||
|
|
||||||
@Entity('contract_drawings')
|
@Entity('contract_drawings')
|
||||||
@@ -30,12 +30,15 @@ export class ContractDrawing {
|
|||||||
@Column({ length: 255 })
|
@Column({ length: 255 })
|
||||||
title!: string; // ! ห้ามว่าง
|
title!: string; // ! ห้ามว่าง
|
||||||
|
|
||||||
@Column({ name: 'sub_cat_id', nullable: true })
|
@Column({ name: 'map_cat_id', nullable: true })
|
||||||
subCategoryId?: number; // ? ว่างได้ (Nullable)
|
mapCatId?: number; // ? ว่างได้ (Nullable)
|
||||||
|
|
||||||
@Column({ name: 'volume_id', nullable: true })
|
@Column({ name: 'volume_id', nullable: true })
|
||||||
volumeId?: number; // ? ว่างได้ (Nullable)
|
volumeId?: number; // ? ว่างได้ (Nullable)
|
||||||
|
|
||||||
|
@Column({ name: 'volume_page', nullable: true })
|
||||||
|
volumePage?: number;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date; // ! ห้ามว่าง
|
createdAt!: Date; // ! ห้ามว่าง
|
||||||
|
|
||||||
@@ -58,9 +61,9 @@ export class ContractDrawing {
|
|||||||
@JoinColumn({ name: 'updated_by' })
|
@JoinColumn({ name: 'updated_by' })
|
||||||
updater?: User; // ? ว่างได้
|
updater?: User; // ? ว่างได้
|
||||||
|
|
||||||
@ManyToOne(() => ContractDrawingSubCategory)
|
@ManyToOne(() => ContractDrawingSubcatCatMap)
|
||||||
@JoinColumn({ name: 'sub_cat_id' })
|
@JoinColumn({ name: 'map_cat_id' })
|
||||||
subCategory?: ContractDrawingSubCategory; // ? ว่างได้ (สัมพันธ์กับ subCategoryId)
|
mapCategory?: ContractDrawingSubcatCatMap;
|
||||||
|
|
||||||
@ManyToOne(() => ContractDrawingVolume)
|
@ManyToOne(() => ContractDrawingVolume)
|
||||||
@JoinColumn({ name: 'volume_id' })
|
@JoinColumn({ name: 'volume_id' })
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export class ShopDrawingMainCategory {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number; // เติม !
|
id!: number; // เติม !
|
||||||
|
|
||||||
|
@Column({ name: 'project_id' })
|
||||||
|
projectId!: number; // เติม !
|
||||||
|
|
||||||
@Column({ name: 'main_category_code', length: 50, unique: true })
|
@Column({ name: 'main_category_code', length: 50, unique: true })
|
||||||
mainCategoryCode!: string; // เติม !
|
mainCategoryCode!: string; // เติม !
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export class ShopDrawingRevision {
|
|||||||
@Column({ name: 'revision_number' })
|
@Column({ name: 'revision_number' })
|
||||||
revisionNumber!: 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 })
|
@Column({ name: 'revision_label', length: 10, nullable: true })
|
||||||
revisionLabel?: string; // nullable ใช้ ?
|
revisionLabel?: string; // nullable ใช้ ?
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,22 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
|
|
||||||
|
|
||||||
@Entity('shop_drawing_sub_categories')
|
@Entity('shop_drawing_sub_categories')
|
||||||
export class ShopDrawingSubCategory {
|
export class ShopDrawingSubCategory {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number; // เติม ! (ตัวที่ error)
|
id!: number; // เติม ! (ตัวที่ error)
|
||||||
|
|
||||||
|
@Column({ name: 'project_id' })
|
||||||
|
projectId!: number; // เติม !
|
||||||
|
|
||||||
@Column({ name: 'sub_category_code', length: 50, unique: true })
|
@Column({ name: 'sub_category_code', length: 50, unique: true })
|
||||||
subCategoryCode!: string; // เติม !
|
subCategoryCode!: string; // เติม !
|
||||||
|
|
||||||
@Column({ name: 'sub_category_name', length: 255 })
|
@Column({ name: 'sub_category_name', length: 255 })
|
||||||
subCategoryName!: string; // เติม !
|
subCategoryName!: string; // เติม !
|
||||||
|
|
||||||
@Column({ name: 'main_category_id' })
|
|
||||||
mainCategoryId!: number; // เติม !
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description?: string; // nullable ใช้ ?
|
description?: string; // nullable ใช้ ?
|
||||||
|
|
||||||
@@ -37,9 +34,4 @@ export class ShopDrawingSubCategory {
|
|||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt!: Date; // เติม !
|
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 })
|
@Column({ name: 'drawing_number', length: 100, unique: true })
|
||||||
drawingNumber!: string; // เติม !
|
drawingNumber!: string; // เติม !
|
||||||
|
|
||||||
@Column({ length: 500 })
|
|
||||||
title!: string; // เติม !
|
|
||||||
|
|
||||||
@Column({ name: 'main_category_id' })
|
@Column({ name: 'main_category_id' })
|
||||||
mainCategoryId!: number; // เติม !
|
mainCategoryId!: number; // เติม !
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource, In, Brackets } from 'typeorm';
|
import { Repository, DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
// Entities
|
// Entities
|
||||||
import { ShopDrawing } from './entities/shop-drawing.entity';
|
import { ShopDrawing } from './entities/shop-drawing.entity';
|
||||||
@@ -77,7 +77,6 @@ export class ShopDrawingService {
|
|||||||
const shopDrawing = queryRunner.manager.create(ShopDrawing, {
|
const shopDrawing = queryRunner.manager.create(ShopDrawing, {
|
||||||
projectId: createDto.projectId,
|
projectId: createDto.projectId,
|
||||||
drawingNumber: createDto.drawingNumber,
|
drawingNumber: createDto.drawingNumber,
|
||||||
title: createDto.title,
|
|
||||||
mainCategoryId: createDto.mainCategoryId,
|
mainCategoryId: createDto.mainCategoryId,
|
||||||
subCategoryId: createDto.subCategoryId,
|
subCategoryId: createDto.subCategoryId,
|
||||||
updatedBy: user.user_id,
|
updatedBy: user.user_id,
|
||||||
@@ -89,6 +88,7 @@ export class ShopDrawingService {
|
|||||||
shopDrawingId: savedShopDrawing.id,
|
shopDrawingId: savedShopDrawing.id,
|
||||||
revisionNumber: 0,
|
revisionNumber: 0,
|
||||||
revisionLabel: createDto.revisionLabel || '0',
|
revisionLabel: createDto.revisionLabel || '0',
|
||||||
|
title: createDto.title, // Add title to revision
|
||||||
revisionDate: createDto.revisionDate
|
revisionDate: createDto.revisionDate
|
||||||
? new Date(createDto.revisionDate)
|
? new Date(createDto.revisionDate)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
@@ -175,6 +175,8 @@ export class ShopDrawingService {
|
|||||||
shopDrawingId,
|
shopDrawingId,
|
||||||
revisionNumber: nextRevNum,
|
revisionNumber: nextRevNum,
|
||||||
revisionLabel: createDto.revisionLabel,
|
revisionLabel: createDto.revisionLabel,
|
||||||
|
title: createDto.title, // Add title from DTO
|
||||||
|
legacyDrawingNumber: createDto.legacyDrawingNumber, // Add legacy number
|
||||||
revisionDate: createDto.revisionDate
|
revisionDate: createDto.revisionDate
|
||||||
? new Date(createDto.revisionDate)
|
? new Date(createDto.revisionDate)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
@@ -226,13 +228,9 @@ export class ShopDrawingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query.andWhere(
|
query.andWhere('sd.drawingNumber LIKE :search', {
|
||||||
new Brackets((qb) => {
|
search: `%${search}%`,
|
||||||
qb.where('sd.drawingNumber LIKE :search', {
|
});
|
||||||
search: `%${search}%`,
|
|
||||||
}).orWhere('sd.title LIKE :search', { search: `%${search}%` });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query.orderBy('sd.updatedAt', 'DESC');
|
query.orderBy('sd.updatedAt', 'DESC');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
import { IsOptional, IsString, IsInt, Min, IsBoolean } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SearchOrganizationDto {
|
export class SearchOrganizationDto {
|
||||||
@@ -33,4 +33,10 @@ export class SearchOrganizationDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
limit?: number = 100;
|
limit?: number = 100;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by Active status' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
|
|
||||||
> backend@1.5.1 test
|
|
||||||
> jest --forceExit modules/document-numbering/document-numbering.service.spec.ts
|
|
||||||
|
|
||||||
FAIL src/modules/document-numbering/document-numbering.service.spec.ts
|
FAIL src/modules/document-numbering/document-numbering.service.spec.ts
|
||||||
DocumentNumberingService
|
DocumentNumberingService
|
||||||
√ should be defined (19 ms)
|
√ should be defined (15 ms)
|
||||||
generateNextNumber
|
generateNextNumber
|
||||||
√ should generate a new number successfully (17 ms)
|
√ should generate a new number successfully (4 ms)
|
||||||
× should throw error when transaction fails (7 ms)
|
√ should throw error when increment fails (9 ms)
|
||||||
|
Admin Operations
|
||||||
|
× voidAndReplace should verify audit log exists (2 ms)
|
||||||
|
× cancelNumber should log cancellation (3 ms)
|
||||||
|
|
||||||
● DocumentNumberingService › generateNextNumber › should throw error when transaction fails
|
● DocumentNumberingService › Admin Operations › voidAndReplace should verify audit log exists
|
||||||
|
|
||||||
expect(received).rejects.toThrow()
|
TypeError: Cannot read properties of undefined (reading 'mockResolvedValue')
|
||||||
|
|
||||||
Received promise resolved instead of rejected
|
143 | it('voidAndReplace should verify audit log exists', async () => {
|
||||||
Resolved to value: {"auditId": 1, "number": "0001"}
|
144 | const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
|
||||||
|
> 145 | (auditRepo.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
| ^
|
||||||
|
146 | generatedNumber: 'DOC-001',
|
||||||
|
147 | counterKey: JSON.stringify({ projectId: 1, correspondenceTypeId: 1 }),
|
||||||
|
148 | templateUsed: 'test',
|
||||||
|
|
||||||
201 | );
|
at Object.<anonymous> (modules/document-numbering/document-numbering.service.spec.ts:145:40)
|
||||||
202 |
|
|
||||||
> 203 | await expect(service.generateNextNumber(mockContext)).rejects.toThrow(
|
|
||||||
| ^
|
|
||||||
204 | Error
|
|
||||||
205 | );
|
|
||||||
206 | });
|
|
||||||
|
|
||||||
at expect (../node_modules/expect/build/index.js:2116:15)
|
● DocumentNumberingService › Admin Operations › cancelNumber should log cancellation
|
||||||
at Object.<anonymous> (modules/document-numbering/document-numbering.service.spec.ts:203:13)
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'mockResolvedValue')
|
||||||
|
|
||||||
|
161 | it('cancelNumber should log cancellation', async () => {
|
||||||
|
162 | const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
|
||||||
|
> 163 | (auditRepo.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
| ^
|
||||||
|
164 | generatedNumber: 'DOC-002',
|
||||||
|
165 | counterKey: {},
|
||||||
|
166 | });
|
||||||
|
|
||||||
|
at Object.<anonymous> (modules/document-numbering/document-numbering.service.spec.ts:163:40)
|
||||||
|
|
||||||
Test Suites: 1 failed, 1 total
|
Test Suites: 1 failed, 1 total
|
||||||
Tests: 1 failed, 2 passed, 3 total
|
Tests: 2 failed, 3 passed, 5 total
|
||||||
Snapshots: 0 total
|
Snapshots: 0 total
|
||||||
Time: 1.506 s, estimated 2 s
|
Time: 1.381 s, estimated 2 s
|
||||||
Ran all test suites matching modules/document-numbering/document-numbering.service.spec.ts.
|
Ran all test suites matching src/modules/document-numbering/document-numbering.service.spec.ts.
|
||||||
Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished?
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
|
|||||||
const contractId = contracts[0]?.id;
|
const contractId = contracts[0]?.id;
|
||||||
const { data: disciplines = [] } = useDisciplines(contractId);
|
const { data: disciplines = [] } = useDisciplines(contractId);
|
||||||
|
|
||||||
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3';
|
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTemplate = async () => {
|
const fetchTemplate = async () => {
|
||||||
@@ -46,7 +46,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
|
|||||||
|
|
||||||
const handleSave = async (data: Partial<NumberingTemplate>) => {
|
const handleSave = async (data: Partial<NumberingTemplate>) => {
|
||||||
try {
|
try {
|
||||||
await numberingApi.saveTemplate({ ...data, templateId: parseInt(params.id) });
|
await numberingApi.saveTemplate({ ...data, id: parseInt(params.id) });
|
||||||
router.push("/admin/numbering");
|
router.push("/admin/numbering");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update template", error);
|
console.error("Failed to update template", error);
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Plus, Edit, Play } from 'lucide-react';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Plus, Edit, Play, AlertTriangle, ShieldAlert, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
|
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
|
||||||
import { TemplateEditor } from '@/components/numbering/template-editor';
|
import { TemplateEditor } from '@/components/numbering/template-editor';
|
||||||
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
|
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
|
||||||
@@ -22,136 +19,15 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
|
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
// --- Sub-components for Tools ---
|
|
||||||
function ManualOverrideForm({ onSuccess, projectId }: { onSuccess: () => void, projectId: number }) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
typeId: '',
|
|
||||||
disciplineId: '',
|
|
||||||
year: new Date().getFullYear().toString(),
|
|
||||||
newSequence: '',
|
|
||||||
reason: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
import { ManualOverrideForm } from '@/components/numbering/manual-override-form';
|
||||||
e.preventDefault();
|
import { MetricsDashboard } from '@/components/numbering/metrics-dashboard';
|
||||||
setLoading(true);
|
import { AuditLogsTable } from '@/components/numbering/audit-logs-table';
|
||||||
try {
|
import { VoidReplaceForm } from '@/components/numbering/void-replace-form';
|
||||||
await numberingApi.manualOverride({
|
import { CancelNumberForm } from '@/components/numbering/cancel-number-form';
|
||||||
projectId,
|
import { BulkImportForm } from '@/components/numbering/bulk-import-form';
|
||||||
correspondenceTypeId: parseInt(formData.typeId) || null,
|
|
||||||
year: parseInt(formData.year),
|
|
||||||
newValue: parseInt(formData.newSequence),
|
|
||||||
});
|
|
||||||
toast.success("Manual override applied successfully");
|
|
||||||
onSuccess();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to apply override");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Manual Override</CardTitle>
|
|
||||||
<CardDescription>Force set a counter sequence. Use with caution.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert variant="destructive" className="mb-4">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Warning</AlertTitle>
|
|
||||||
<AlertDescription>Changing counters manually can cause duplication errors.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Type ID</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. 1"
|
|
||||||
value={formData.typeId}
|
|
||||||
onChange={e => setFormData({...formData, typeId: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Discipline ID</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="Optional"
|
|
||||||
value={formData.disciplineId}
|
|
||||||
onChange={e => setFormData({...formData, disciplineId: e.target.value})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Year</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.year}
|
|
||||||
onChange={e => setFormData({...formData, year: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>New Sequence</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="e.g. 5"
|
|
||||||
value={formData.newSequence}
|
|
||||||
onChange={e => setFormData({...formData, newSequence: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Reason</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Why is this override needed?"
|
|
||||||
value={formData.reason}
|
|
||||||
onChange={e => setFormData({...formData, reason: e.target.value})}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
|
||||||
{loading && <ShieldAlert className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Apply Override
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminMetrics() {
|
|
||||||
// Fetch metrics from /admin/document-numbering/metrics
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Generation Success Rate</CardTitle>
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">99.9%</div>
|
|
||||||
<p className="text-xs text-muted-foreground">+0.1% from last month</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/* More cards... */}
|
|
||||||
<Card className="col-span-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Audit Logs</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">Log viewer implementation pending.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NumberingPage() {
|
export default function NumberingPage() {
|
||||||
const { data: projects = [] } = useProjects();
|
const { data: projects = [] } = useProjects();
|
||||||
@@ -159,7 +35,6 @@ export default function NumberingPage() {
|
|||||||
const [activeTab, setActiveTab] = useState("templates");
|
const [activeTab, setActiveTab] = useState("templates");
|
||||||
|
|
||||||
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
|
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// View states
|
// View states
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -167,7 +42,7 @@ export default function NumberingPage() {
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
|
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
|
||||||
|
|
||||||
const selectedProjectName = projects.find((p: any) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
|
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
|
||||||
|
|
||||||
// Master Data
|
// Master Data
|
||||||
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
|
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
|
||||||
@@ -176,7 +51,6 @@ export default function NumberingPage() {
|
|||||||
const { data: disciplines = [] } = useDisciplines(contractId);
|
const { data: disciplines = [] } = useDisciplines(contractId);
|
||||||
|
|
||||||
const loadTemplates = async () => {
|
const loadTemplates = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await numberingApi.getTemplates();
|
const response = await numberingApi.getTemplates();
|
||||||
// Handle wrapped response { data: [...] } or direct array
|
// Handle wrapped response { data: [...] } or direct array
|
||||||
@@ -185,8 +59,6 @@ export default function NumberingPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load templates");
|
toast.error("Failed to load templates");
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,7 +122,7 @@ export default function NumberingPage() {
|
|||||||
<SelectValue placeholder="Select Project" />
|
<SelectValue placeholder="Select Project" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projects.map((project: any) => (
|
{projects.map((project: { id: number; projectCode: string; projectName: string }) => (
|
||||||
<SelectItem key={project.id} value={project.id.toString()}>
|
<SelectItem key={project.id} value={project.id.toString()}>
|
||||||
{project.projectCode} - {project.projectName}
|
{project.projectCode} - {project.projectName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -337,31 +209,21 @@ export default function NumberingPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="metrics" className="space-y-4">
|
<TabsContent value="metrics" className="space-y-4">
|
||||||
<AdminMetrics />
|
<MetricsDashboard />
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-medium mb-4">Audit Logs</h3>
|
||||||
|
<AuditLogsTable />
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="tools" className="space-y-4">
|
<TabsContent value="tools" className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ManualOverrideForm onSuccess={() => {}} projectId={Number(selectedProjectId)} />
|
<ManualOverrideForm projectId={Number(selectedProjectId)} />
|
||||||
|
<VoidReplaceForm projectId={Number(selectedProjectId)} />
|
||||||
<Card>
|
<CancelNumberForm />
|
||||||
<CardHeader>
|
<div className="md:col-span-2">
|
||||||
<CardTitle>Void & Replace</CardTitle>
|
<BulkImportForm projectId={Number(selectedProjectId)} />
|
||||||
<CardDescription>Safe voiding of issued numbers.</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded border border-yellow-200 dark:border-yellow-900 text-sm">
|
|
||||||
To void and replace numbers, please use the <strong>Correspondences</strong> list view actions or edit specific documents directly.
|
|
||||||
<br/><br/>
|
|
||||||
This ensures the void action is linked to the correct document record.
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full" disabled>
|
|
||||||
Standalone Void Tool (Coming Soon)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ export default function DrawingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="contract" className="w-full">
|
<Tabs defaultValue="contract" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 max-w-[400px]">
|
<TabsList className="grid w-full grid-cols-3 max-w-[600px]">
|
||||||
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
|
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
|
||||||
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
|
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
|
||||||
|
<TabsTrigger value="asbuilt">As Built Drawings</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="contract" className="mt-6">
|
<TabsContent value="contract" className="mt-6">
|
||||||
@@ -37,6 +38,10 @@ export default function DrawingsPage() {
|
|||||||
<TabsContent value="shop" className="mt-6">
|
<TabsContent value="shop" className="mt-6">
|
||||||
<DrawingList type="SHOP" />
|
<DrawingList type="SHOP" />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="asbuilt" className="mt-6">
|
||||||
|
<DrawingList type="AS_BUILT" />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber}>
|
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber || "No Number"}>
|
||||||
{drawing.drawingNumber}
|
{drawing.drawingNumber || "No Number"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title}>
|
<p className="text-sm text-muted-foreground truncate" title={drawing.title || "No Title"}>
|
||||||
{drawing.title}
|
{drawing.title || "No Title"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</Badge>
|
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</Badge>
|
||||||
@@ -33,11 +33,21 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber}
|
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber || "-"}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision}
|
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || "0"}
|
||||||
</div>
|
</div>
|
||||||
|
{drawing.legacyDrawingNumber && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{drawing.volumePage !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Drawing } from "@/types/drawing";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface DrawingListProps {
|
interface DrawingListProps {
|
||||||
type: "CONTRACT" | "SHOP";
|
type: "CONTRACT" | "SHOP" | "AS_BUILT";
|
||||||
projectId?: number;
|
projectId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,27 +16,73 @@ import {
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCreateDrawing } from "@/hooks/use-drawing";
|
import { useCreateDrawing } from "@/hooks/use-drawing";
|
||||||
import { useDisciplines } from "@/hooks/use-master-data";
|
import { useContractDrawingCategories, useShopMainCategories, useShopSubCategories } from "@/hooks/use-master-data";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
const drawingSchema = z.object({
|
// Base Schema
|
||||||
drawingType: z.enum(["CONTRACT", "SHOP"]),
|
const baseSchema = z.object({
|
||||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
drawingType: z.enum(["CONTRACT", "SHOP", "AS_BUILT"]),
|
||||||
title: z.string().min(5, "Title must be at least 5 characters"),
|
projectId: z.number().default(1), // Hardcoded for now
|
||||||
disciplineId: z.number().min(1, "Discipline is required"),
|
file: z.instanceof(File, { message: "File is required" }),
|
||||||
sheetNumber: z.string().min(1, "Sheet Number is required"),
|
|
||||||
scale: z.string().optional(),
|
|
||||||
file: z.instanceof(File, { message: "File is required" }), // In real app, might validation creation before upload
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type DrawingFormData = z.infer<typeof drawingSchema>;
|
// Contract Schema
|
||||||
|
const contractSchema = baseSchema.extend({
|
||||||
|
drawingType: z.literal("CONTRACT"),
|
||||||
|
contractDrawingNo: z.string().min(1, "Drawing Number is required"),
|
||||||
|
title: z.string().min(3, "Title is required"),
|
||||||
|
volumeId: z.string().optional(), // Select input returns string usually (changed to string for input compatibility)
|
||||||
|
volumePage: z.string().transform(val => parseInt(val, 10)).optional(), // Input type number returns string
|
||||||
|
mapCatId: z.string().min(1, "Category is required"),
|
||||||
|
});
|
||||||
|
|
||||||
export function DrawingUploadForm() {
|
// Shop Schema
|
||||||
|
const shopSchema = baseSchema.extend({
|
||||||
|
drawingType: z.literal("SHOP"),
|
||||||
|
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||||
|
mainCategoryId: z.string().min(1, "Main Category is required"),
|
||||||
|
subCategoryId: z.string().min(1, "Sub Category is required"),
|
||||||
|
// Revision Fields
|
||||||
|
revisionLabel: z.string().default("0"),
|
||||||
|
title: z.string().min(3, "Revision Title is required"),
|
||||||
|
legacyDrawingNumber: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// As Built Schema
|
||||||
|
const asBuiltSchema = baseSchema.extend({
|
||||||
|
drawingType: z.literal("AS_BUILT"),
|
||||||
|
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||||
|
// Revision Fields
|
||||||
|
revisionLabel: z.string().default("0"),
|
||||||
|
title: z.string().optional(),
|
||||||
|
legacyDrawingNumber: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z.discriminatedUnion("drawingType", [
|
||||||
|
contractSchema,
|
||||||
|
shopSchema,
|
||||||
|
asBuiltSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type DrawingFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface DrawingUploadFormProps {
|
||||||
|
projectId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Discipline Hook
|
// Hooks
|
||||||
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines();
|
const { data: contractCategories } = useContractDrawingCategories();
|
||||||
|
const { data: shopMainCats } = useShopMainCategories(projectId);
|
||||||
|
|
||||||
|
const [selectedShopMainCat, setSelectedShopMainCat] = useState<number | undefined>();
|
||||||
|
const { data: shopSubCats } = useShopSubCategories(projectId, selectedShopMainCat);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -45,167 +91,233 @@ export function DrawingUploadForm() {
|
|||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<DrawingFormData>({
|
} = useForm<DrawingFormData>({
|
||||||
resolver: zodResolver(drawingSchema),
|
resolver: zodResolver(formSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
projectId,
|
||||||
|
drawingType: "CONTRACT",
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const drawingType = watch("drawingType");
|
const drawingType = watch("drawingType");
|
||||||
const createMutation = useCreateDrawing(drawingType); // Hook depends on type but defaults to undefined initially which is fine or handled
|
const createMutation = useCreateDrawing(drawingType);
|
||||||
|
|
||||||
|
// Reset logic when type changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Optional: clear fields or set defaults
|
||||||
|
}, [drawingType]);
|
||||||
|
|
||||||
const onSubmit = (data: DrawingFormData) => {
|
const onSubmit = (data: DrawingFormData) => {
|
||||||
// Only proceed if createMutation is valid for the type (it should be since we watch type)
|
|
||||||
if (!drawingType) return;
|
|
||||||
|
|
||||||
// Convert to FormData
|
|
||||||
// Note: Backend might expect JSON Body or Multipart/Form-Data depending on implementation.
|
|
||||||
// Assuming Multipart/Form-Data if file is involved, OR
|
|
||||||
// Two-step upload: 1. Upload File -> Get URL 2. Create Record with URL.
|
|
||||||
// The previous code assumed direct call.
|
|
||||||
// Let's assume the service handles FormData conversion if we pass plain object or we construct here.
|
|
||||||
// My previous assumption in implementation plan: "File upload will use FormData".
|
|
||||||
// I should check service again. `contract-drawing.service` takes `CreateContractDrawingDto`.
|
|
||||||
// Usually NestJS with FileUpload uses Interceptors and FormData.
|
|
||||||
|
|
||||||
// Creating FormData manually to be safe for file upload
|
|
||||||
/*
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('title', data.title);
|
|
||||||
// ...
|
|
||||||
// BUT useCreateDrawing calls service.create(data). Service uses apiClient.post(data).
|
|
||||||
// axios handles FormData automatically if passed directly, but nested objects are tricky.
|
|
||||||
// Let's pass the raw DTO and hope services handle it or assume Backend accepts DTO JSON and file separately?
|
|
||||||
// Actually standard Axios with FormData:
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Let's try to construct FormData here as generic approach for file uploads
|
// Common fields
|
||||||
// However, if I change the argument to FormData, Types might complain.
|
formData.append('projectId', String(data.projectId));
|
||||||
// Let's just pass `data` and let the developer (me) ensure Service handles it correctly or modify service later if failed.
|
|
||||||
// Wait, `contractDrawingService.create` takes `CreateContractDrawingDto`.
|
|
||||||
// I will assume for now I pass the object. If file upload fails, I will fix service.
|
|
||||||
|
|
||||||
// Actually better to handle FormData logic here since we have the File object
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('drawingNumber', data.drawingNumber);
|
|
||||||
formData.append('title', data.title);
|
|
||||||
formData.append('disciplineId', String(data.disciplineId));
|
|
||||||
formData.append('sheetNumber', data.sheetNumber);
|
|
||||||
if(data.scale) formData.append('scale', data.scale);
|
|
||||||
formData.append('file', data.file);
|
formData.append('file', data.file);
|
||||||
// Type specific fields if any? (Project ID?)
|
|
||||||
// Contract/Shop might have different fields. Assuming minimal common set.
|
|
||||||
|
|
||||||
createMutation.mutate(data as any, { // Passing raw data or FormData? Hook awaits 'any'.
|
if (data.drawingType === 'CONTRACT') {
|
||||||
// If I pass FormData, Axios sends it as multipart/form-data.
|
formData.append('contractDrawingNo', data.contractDrawingNo);
|
||||||
// If I pass JSON, it sends as JSON (and File is empty object).
|
formData.append('title', data.title);
|
||||||
// Since there is a File, I MUST use FormData for it to work with standard uploads.
|
formData.append('mapCatId', data.mapCatId);
|
||||||
// But wait, the `useCreateDrawing` calls `service.create` which calls `apiClient.post`.
|
if (data.volumeId) formData.append('volumeId', data.volumeId);
|
||||||
// If I pass FormData to `mutate`, it goes to `service.create`.
|
if (data.volumePage) formData.append('volumePage', String(data.volumePage));
|
||||||
// So I will pass FormData but `data as any` above cast allows it.
|
} else if (data.drawingType === 'SHOP') {
|
||||||
// BUT `data` argument in `onSubmit` is `DrawingFormData` (Object).
|
formData.append('drawingNumber', data.drawingNumber);
|
||||||
// I will pass `formData` to mutate.
|
formData.append('mainCategoryId', data.mainCategoryId);
|
||||||
// WARNING: Hooks expects correct type. I used `any` in hook definition.
|
formData.append('subCategoryId', data.subCategoryId);
|
||||||
onSuccess: () => {
|
formData.append('revisionLabel', data.revisionLabel || '0');
|
||||||
router.push("/drawings");
|
formData.append('title', data.title); // Revision Title
|
||||||
}
|
if (data.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
|
||||||
|
if (data.description) formData.append('description', data.description);
|
||||||
|
// Date default to now
|
||||||
|
} else if (data.drawingType === 'AS_BUILT') {
|
||||||
|
formData.append('drawingNumber', data.drawingNumber);
|
||||||
|
formData.append('revisionLabel', data.revisionLabel || '0');
|
||||||
|
if (data.title) formData.append('title', data.title);
|
||||||
|
if (data.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
|
||||||
|
if (data.description) formData.append('description', data.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(formData as any, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/drawings");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actually, to make it work with TypeScript and `mutate`, let's wrap logic
|
|
||||||
const handleFormSubmit = (data: DrawingFormData) => {
|
|
||||||
// Create FormData
|
|
||||||
const formData = new FormData();
|
|
||||||
Object.keys(data).forEach(key => {
|
|
||||||
if (key === 'file') {
|
|
||||||
formData.append(key, data.file);
|
|
||||||
} else {
|
|
||||||
formData.append(key, String((data as any)[key]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Append projectId if needed (hardcoded 1 for now)
|
|
||||||
formData.append('projectId', '1');
|
|
||||||
|
|
||||||
createMutation.mutate(formData as any, {
|
|
||||||
onSuccess: () => {
|
|
||||||
router.push("/drawings");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="max-w-2xl space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
|
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Drawing Type *</Label>
|
<Label>Drawing Type *</Label>
|
||||||
<Select onValueChange={(v) => setValue("drawingType", v as any)}>
|
<Select
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setValue("drawingType", v as any);
|
||||||
|
// Reset errors or fields if needed
|
||||||
|
}}
|
||||||
|
defaultValue="CONTRACT"
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select type" />
|
<SelectValue placeholder="Select type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
|
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
|
||||||
<SelectItem value="SHOP">Shop Drawing</SelectItem>
|
<SelectItem value="SHOP">Shop Drawing</SelectItem>
|
||||||
|
<SelectItem value="AS_BUILT">As Built Drawing</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.drawingType && (
|
|
||||||
<p className="text-sm text-destructive mt-1">{errors.drawingType.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{/* CONTRACT FIELDS */}
|
||||||
<div>
|
{drawingType === 'CONTRACT' && (
|
||||||
<Label htmlFor="drawingNumber">Drawing Number *</Label>
|
<>
|
||||||
<Input id="drawingNumber" {...register("drawingNumber")} placeholder="e.g. A-101" />
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{errors.drawingNumber && (
|
<div>
|
||||||
<p className="text-sm text-destructive mt-1">{errors.drawingNumber.message}</p>
|
<Label>Contract Drawing No *</Label>
|
||||||
)}
|
<Input {...register("contractDrawingNo")} placeholder="e.g. CD-001" />
|
||||||
</div>
|
{(errors as any).contractDrawingNo && (
|
||||||
<div>
|
<p className="text-sm text-destructive">{(errors as any).contractDrawingNo.message}</p>
|
||||||
<Label htmlFor="sheetNumber">Sheet Number *</Label>
|
)}
|
||||||
<Input id="sheetNumber" {...register("sheetNumber")} placeholder="e.g. 01" />
|
</div>
|
||||||
{errors.sheetNumber && (
|
<div>
|
||||||
<p className="text-sm text-destructive mt-1">{errors.sheetNumber.message}</p>
|
<Label>Title *</Label>
|
||||||
)}
|
<Input {...register("title")} placeholder="Drawing Title" />
|
||||||
</div>
|
{(errors as any).title && (
|
||||||
</div>
|
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Label htmlFor="title">Title *</Label>
|
<div>
|
||||||
<Input id="title" {...register("title")} placeholder="Drawing Title" />
|
<Label>Category *</Label>
|
||||||
{errors.title && (
|
<Select onValueChange={(v) => setValue("mapCatId", v)}>
|
||||||
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
|
<SelectTrigger>
|
||||||
)}
|
<SelectValue placeholder="Select Category" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{contractCategories?.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(errors as any).mapCatId && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).mapCatId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>Volume ID</Label>
|
||||||
|
<Input {...register("volumeId")} placeholder="Vol. 1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Page No.</Label>
|
||||||
|
<Input {...register("volumePage")} type="number" placeholder="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{/* SHOP FIELDS */}
|
||||||
<div>
|
{drawingType === 'SHOP' && (
|
||||||
<Label>Discipline *</Label>
|
<>
|
||||||
<Select
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
onValueChange={(v) => setValue("disciplineId", parseInt(v))}
|
<div>
|
||||||
disabled={isLoadingDisciplines}
|
<Label>Shop Drawing No *</Label>
|
||||||
>
|
<Input {...register("drawingNumber")} placeholder="e.g. SD-101" />
|
||||||
<SelectTrigger>
|
{(errors as any).drawingNumber && (
|
||||||
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
|
<p className="text-sm text-destructive">{(errors as any).drawingNumber.message}</p>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
</div>
|
||||||
{disciplines?.map((d: any) => (
|
<div>
|
||||||
<SelectItem key={d.id} value={String(d.id)}>
|
<Label>Legacy Number</Label>
|
||||||
{d.name} ({d.code})
|
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||||
</SelectItem>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{errors.disciplineId && (
|
|
||||||
<p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="scale">Scale</Label>
|
|
||||||
<Input id="scale" {...register("scale")} placeholder="e.g. 1:100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Main Category *</Label>
|
||||||
|
<Select onValueChange={(v) => {
|
||||||
|
setValue("mainCategoryId", v);
|
||||||
|
setSelectedShopMainCat(v ? parseInt(v) : undefined);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Main Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shopMainCats?.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(errors as any).mainCategoryId && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).mainCategoryId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Sub Category *</Label>
|
||||||
|
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Sub Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shopSubCats?.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(errors as any).subCategoryId && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).subCategoryId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Revision Title *</Label>
|
||||||
|
<Input {...register("title")} placeholder="Current Revision Title" />
|
||||||
|
{(errors as any).title && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea {...register("description")} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AS BUILT FIELDS */}
|
||||||
|
{drawingType === 'AS_BUILT' && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Drawing No *</Label>
|
||||||
|
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
|
||||||
|
{(errors as any).drawingNumber && (
|
||||||
|
<p className="text-sm text-destructive">{(errors as any).drawingNumber.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Legacy Number</Label>
|
||||||
|
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input {...register("title")} placeholder="Title" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea {...register("description")} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
<Label htmlFor="file">Drawing File *</Label>
|
<Label htmlFor="file">Drawing File *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="file"
|
id="file"
|
||||||
@@ -217,13 +329,11 @@ export function DrawingUploadForm() {
|
|||||||
if (file) setValue("file", file);
|
if (file) setValue("file", file);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Accepted: PDF, DWG (Max 50MB)
|
|
||||||
</p>
|
|
||||||
{errors.file && (
|
{errors.file && (
|
||||||
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -231,7 +341,7 @@ export function DrawingUploadForm() {
|
|||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending || !drawingType}>
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Upload Drawing
|
Upload Drawing
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
69
frontend/components/numbering/audit-logs-table.tsx
Normal file
69
frontend/components/numbering/audit-logs-table.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export function AuditLogsTable() {
|
||||||
|
const [logs, setLogs] = useState<any[]>([]); // Replace with AuditLog type
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLogs() {
|
||||||
|
try {
|
||||||
|
const data = await documentNumberingService.getMetrics(); // Using metrics endpoint for now as it contains logs
|
||||||
|
if (data && data.audit) {
|
||||||
|
setLogs(data.audit);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch audit logs", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Loading logs...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>Operation</TableHead>
|
||||||
|
<TableHead>Number</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center">No logs found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>{format(new Date(log.createdAt), "yyyy-MM-dd HH:mm:ss")}</TableCell>
|
||||||
|
<TableCell>{log.operation}</TableCell>
|
||||||
|
<TableCell>{log.generatedNumber}</TableCell>
|
||||||
|
<TableCell>{log.createdBy || "System"}</TableCell>
|
||||||
|
<TableCell>{log.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/components/numbering/bulk-import-form.tsx
Normal file
54
frontend/components/numbering/bulk-import-form.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
|
||||||
|
export function BulkImportForm({ projectId = 1 }: { projectId?: number }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("projectId", projectId.toString());
|
||||||
|
|
||||||
|
await documentNumberingService.bulkImport(formData);
|
||||||
|
toast.success("Bulk import initiated. Check audit logs for progress.");
|
||||||
|
setFile(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to import numbers.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border p-4 rounded-md space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
|
||||||
|
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
|
||||||
|
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label htmlFor="csv-file">CSV File</Label>
|
||||||
|
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleUpload} disabled={!file || loading}>
|
||||||
|
{loading ? "Importing..." : "Upload & Import"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/components/numbering/cancel-number-form.tsx
Normal file
86
frontend/components/numbering/cancel-number-form.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
import { CancelNumberDto } from "@/types/dto/numbering.dto";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
documentNumber: z.string().min(3, "Document Number is required"),
|
||||||
|
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CancelNumberFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function CancelNumberForm() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CancelNumberFormData>({
|
||||||
|
resolver: zodResolver(formSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
documentNumber: "",
|
||||||
|
reason: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: CancelNumberFormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dto: CancelNumberDto = values;
|
||||||
|
await documentNumberingService.cancelNumber(dto);
|
||||||
|
toast.success("Number cancelled successfully.");
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to cancel number. It may not exist or is already cancelled.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
|
||||||
|
<h3 className="text-lg font-medium">Cancel Number</h3>
|
||||||
|
<p className="text-sm text-gray-500">Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.</p>
|
||||||
|
|
||||||
|
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Document Number</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reason</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Reason for cancellation..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" disabled={loading}>
|
||||||
|
{loading ? "Cancelling..." : "Cancel Number"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/components/numbering/manual-override-form.tsx
Normal file
133
frontend/components/numbering/manual-override-form.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
import { ManualOverrideDto } from "@/types/dto/numbering.dto";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
projectId: z.coerce.number().min(1, "Project is required"),
|
||||||
|
originatorOrganizationId: z.coerce.number().min(1, "Originator is required"),
|
||||||
|
recipientOrganizationId: z.coerce.number().min(1, "Recipient is required"),
|
||||||
|
correspondenceTypeId: z.coerce.number().min(1, "Type is required"),
|
||||||
|
newLastNumber: z.coerce.number().min(1, "New number is required"),
|
||||||
|
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||||
|
resetScope: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
projectId: projectId,
|
||||||
|
originatorOrganizationId: 0,
|
||||||
|
recipientOrganizationId: 0,
|
||||||
|
correspondenceTypeId: 0,
|
||||||
|
newLastNumber: 0,
|
||||||
|
reason: "",
|
||||||
|
resetScope: "YEAR_2025" // Example, should be dynamic or selected
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dto: ManualOverrideDto = {
|
||||||
|
...values,
|
||||||
|
resetScope: values.resetScope || "YEAR_" + new Date().getFullYear()
|
||||||
|
};
|
||||||
|
await documentNumberingService.manualOverride(dto);
|
||||||
|
toast.success("Manual override applied successfully.");
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to apply override.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md mt-4">
|
||||||
|
<h3 className="text-lg font-medium">Manual Override Sequence</h3>
|
||||||
|
<p className="text-sm text-gray-500">Careful: This updates the LAST generated number. Next number will receive +1.</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
|
||||||
|
<FormField control={form.control} name="projectId" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Project ID</FormLabel>
|
||||||
|
<FormControl><Input type="number" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="correspondenceTypeId" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Type ID</FormLabel>
|
||||||
|
<FormControl><Input type="number" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="originatorOrganizationId" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Originator Org ID</FormLabel>
|
||||||
|
<FormControl><Input type="number" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
<FormField control={form.control} name="recipientOrganizationId" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Recipient Org ID</FormLabel>
|
||||||
|
<FormControl><Input type="number" {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField control={form.control} name="newLastNumber" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Set Last Number To</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
If you set 99, the next auto-generated number will be 100.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reason</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Why are you overriding?" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Applying..." : "Apply Override"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/components/numbering/metrics-dashboard.tsx
Normal file
81
frontend/components/numbering/metrics-dashboard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
import { NumberingMetrics } from "@/types/dto/numbering.dto";
|
||||||
|
|
||||||
|
export function MetricsDashboard() {
|
||||||
|
const [metrics, setMetrics] = useState<Partial<NumberingMetrics>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchMetrics() {
|
||||||
|
try {
|
||||||
|
const data = await documentNumberingService.getMetrics();
|
||||||
|
setMetrics(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch metrics", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchMetrics();
|
||||||
|
const interval = setInterval(fetchMetrics, 30000); // Poll every 30s
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Loading metrics...</div>;
|
||||||
|
if (!metrics) return <div>No metrics available.</div>;
|
||||||
|
|
||||||
|
// Mock data mapping if real data is missing from backend stub
|
||||||
|
const utilization = metrics.audit ? 45 : 0; // Placeholder until backend returns specific metric
|
||||||
|
const generationRate = 120; // Placeholder
|
||||||
|
const lockWaitP95 = 0.05; // Placeholder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Generation Rate</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{generationRate} /Hr</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+20.1% from last hour</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{utilization}%</div>
|
||||||
|
<Progress value={utilization} className="mt-2" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Lock Wait Time (P95)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{lockWaitP95}s</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Redis distributed lock latency</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { NumberingTemplate } from '@/lib/api/numbering';
|
import { NumberingTemplate } from '@/lib/api/numbering';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@@ -40,8 +39,10 @@ export interface TemplateEditorProps {
|
|||||||
template?: NumberingTemplate;
|
template?: NumberingTemplate;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
correspondenceTypes: any[];
|
correspondenceTypes: any[];
|
||||||
disciplines: any[];
|
disciplines: any[];
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
onSave: (data: Partial<NumberingTemplate>) => void;
|
onSave: (data: Partial<NumberingTemplate>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -51,16 +52,14 @@ export function TemplateEditor({
|
|||||||
projectId,
|
projectId,
|
||||||
projectName,
|
projectName,
|
||||||
correspondenceTypes,
|
correspondenceTypes,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
disciplines,
|
disciplines,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel
|
onCancel
|
||||||
}: TemplateEditorProps) {
|
}: TemplateEditorProps) {
|
||||||
const [format, setFormat] = useState(template?.formatTemplate || template?.templateFormat || '');
|
const [format, setFormat] = useState(template?.formatTemplate || '');
|
||||||
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
|
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
|
||||||
const [disciplineId, setDisciplineId] = useState<string>(template?.disciplineId?.toString() || '0');
|
const [reset, setReset] = useState(template?.resetSequenceYearly ?? true);
|
||||||
const [padding, setPadding] = useState(template?.paddingLength || 4);
|
|
||||||
const [reset, setReset] = useState(template?.resetAnnually ?? true);
|
|
||||||
const [isActive, setIsActive] = useState(template?.isActive ?? true);
|
|
||||||
|
|
||||||
const [preview, setPreview] = useState('');
|
const [preview, setPreview] = useState('');
|
||||||
|
|
||||||
@@ -78,18 +77,14 @@ export function TemplateEditor({
|
|||||||
const t = correspondenceTypes.find(ct => ct.id.toString() === typeId);
|
const t = correspondenceTypes.find(ct => ct.id.toString() === typeId);
|
||||||
if (t) replacement = t.typeCode;
|
if (t) replacement = t.typeCode;
|
||||||
}
|
}
|
||||||
if (v.key === '{DISCIPLINE}' && disciplineId !== '0') {
|
|
||||||
const d = disciplines.find(di => di.id.toString() === disciplineId);
|
|
||||||
if (d) replacement = d.disciplineCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
|
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
|
||||||
});
|
});
|
||||||
setPreview(previewText);
|
setPreview(previewText);
|
||||||
}, [format, typeId, disciplineId, correspondenceTypes, disciplines]);
|
}, [format, typeId, correspondenceTypes]);
|
||||||
|
|
||||||
const insertVariable = (variable: string) => {
|
const insertVariable = (variable: string) => {
|
||||||
setFormat((prev) => prev + variable);
|
setFormat((prev: string) => prev + variable);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -97,13 +92,8 @@ export function TemplateEditor({
|
|||||||
...template,
|
...template,
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
||||||
disciplineId: Number(disciplineId),
|
|
||||||
formatTemplate: format,
|
formatTemplate: format,
|
||||||
templateFormat: format, // Legacy support
|
resetSequenceYearly: reset,
|
||||||
paddingLength: padding,
|
|
||||||
resetAnnually: reset,
|
|
||||||
isActive: isActive,
|
|
||||||
exampleNumber: preview
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,9 +105,6 @@ export function TemplateEditor({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
|
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
|
||||||
<Badge variant={isActive ? "default" : "secondary"}>
|
|
||||||
{isActive ? 'Active' : 'Inactive'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
|
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +112,6 @@ export function TemplateEditor({
|
|||||||
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
|
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
|
||||||
Project: {projectName}
|
Project: {projectName}
|
||||||
</Badge>
|
</Badge>
|
||||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
|
||||||
<Checkbox checked={isActive} onCheckedChange={(c) => setIsActive(!!c)} />
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,7 +126,8 @@ export function TemplateEditor({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
||||||
{correspondenceTypes.map((type) => (
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
{correspondenceTypes.map((type: any) => (
|
||||||
<SelectItem key={type.id} value={type.id.toString()}>
|
<SelectItem key={type.id} value={type.id.toString()}>
|
||||||
{type.typeCode} - {type.typeName}
|
{type.typeCode} - {type.typeName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -155,36 +139,7 @@ export function TemplateEditor({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Discipline (Optional)</Label>
|
|
||||||
<Select value={disciplineId} onValueChange={setDisciplineId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="All/None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="0">All Disciplines (Default)</SelectItem>
|
|
||||||
{disciplines.map((d) => (
|
|
||||||
<SelectItem key={d.id} value={d.id.toString()}>
|
|
||||||
{d.disciplineCode} - {d.disciplineName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Specific discipline templates take precedence over 'All'.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
|
||||||
<Label>Padding Length</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={padding}
|
|
||||||
onChange={e => setPadding(Number(e.target.value))}
|
|
||||||
min={1} max={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Reset Rule</Label>
|
<Label>Reset Rule</Label>
|
||||||
<div className="flex items-center h-10">
|
<div className="flex items-center h-10">
|
||||||
|
|||||||
@@ -12,7 +12,28 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
|
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useOrganizations, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data';
|
||||||
|
import { Organization } from '@/types/organization';
|
||||||
|
|
||||||
|
// Local interfaces for Master Data since centralized ones are missing/fragmented
|
||||||
|
interface CorrespondenceType {
|
||||||
|
id: number;
|
||||||
|
typeCode: string;
|
||||||
|
typeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Discipline {
|
||||||
|
id: number;
|
||||||
|
disciplineCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TemplateTesterProps {
|
interface TemplateTesterProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,23 +43,45 @@ interface TemplateTesterProps {
|
|||||||
|
|
||||||
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
||||||
const [testData, setTestData] = useState({
|
const [testData, setTestData] = useState({
|
||||||
organizationId: "1",
|
originatorId: "",
|
||||||
disciplineId: "1",
|
recipientId: "",
|
||||||
|
correspondenceTypeId: "",
|
||||||
|
disciplineId: "",
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
});
|
});
|
||||||
const [generatedNumber, setGeneratedNumber] = useState('');
|
const [generatedNumber, setGeneratedNumber] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Master Data Hooks
|
||||||
|
const projectId = template?.projectId || 1;
|
||||||
|
const { data: organizations } = useOrganizations({ isActive: true });
|
||||||
|
const { data: correspondenceTypes } = useCorrespondenceTypes();
|
||||||
|
const { data: contracts } = useContracts(projectId);
|
||||||
|
|
||||||
|
// Use first contract ID for disciplines, fallback to 1 or undefined
|
||||||
|
const contractId = contracts?.[0]?.id;
|
||||||
|
const { data: disciplines } = useDisciplines(contractId);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!template) return;
|
if (!template) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Note: generateTestNumber expects keys: organizationId, disciplineId
|
const result = await numberingApi.previewNumber({
|
||||||
const result = await numberingApi.generateTestNumber(template.id ?? 0, {
|
projectId: projectId,
|
||||||
organizationId: testData.organizationId,
|
originatorId: parseInt(testData.originatorId || "0"),
|
||||||
disciplineId: testData.disciplineId
|
recipientOrganizationId: parseInt(testData.recipientId || "0"),
|
||||||
|
typeId: parseInt(testData.correspondenceTypeId || "0"),
|
||||||
|
disciplineId: parseInt(testData.disciplineId || "0"),
|
||||||
});
|
});
|
||||||
setGeneratedNumber(result.number);
|
setGeneratedNumber(result.previewNumber);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Failed to generate test number", error);
|
||||||
|
setGeneratedNumber("");
|
||||||
|
// Assuming toast is available globally or we can use console for now,
|
||||||
|
// but better to show visible error.
|
||||||
|
// Alert is primitive but effective for 'tester' component debugging if toast not imported.
|
||||||
|
// Actually, let's just set the error string in display if we can, or add a simple red text.
|
||||||
|
setGeneratedNumber(`Error: ${error.response?.data?.message || error.message || "Unknown error"}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,7 +89,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Test Number Generation</DialogTitle>
|
<DialogTitle>Test Number Generation</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -58,44 +101,109 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
|||||||
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
|
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
|
||||||
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
|
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Originator */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium">Organization</label>
|
<label className="text-xs font-medium">Originator (ORG)</label>
|
||||||
<Input
|
<Select
|
||||||
value={testData.organizationId}
|
value={testData.originatorId}
|
||||||
onChange={(e) => setTestData({...testData, organizationId: e.target.value})}
|
onValueChange={(val) => setTestData({...testData, originatorId: val})}
|
||||||
placeholder="Org ID"
|
>
|
||||||
/>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Originator" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(organizations as Organization[])?.map((org) => (
|
||||||
|
<SelectItem key={org.id} value={org.id.toString()}>
|
||||||
|
{org.organizationCode} - {org.organizationName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium">Discipline</label>
|
<label className="text-xs font-medium">Recipient (REC)</label>
|
||||||
<Input
|
<Select
|
||||||
|
value={testData.recipientId}
|
||||||
|
onValueChange={(val) => setTestData({...testData, recipientId: val})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Recipient" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(organizations as Organization[])?.map((org) => (
|
||||||
|
<SelectItem key={org.id} value={org.id.toString()}>
|
||||||
|
{org.organizationCode} - {org.organizationName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium">Document Type (TYPE)</label>
|
||||||
|
<Select
|
||||||
|
value={testData.correspondenceTypeId}
|
||||||
|
onValueChange={(val) => setTestData({...testData, correspondenceTypeId: val})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">Default (All Types)</SelectItem>
|
||||||
|
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={type.id.toString()}>
|
||||||
|
{type.typeCode} - {type.typeName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discipline */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium">Discipline (DIS)</label>
|
||||||
|
<Select
|
||||||
value={testData.disciplineId}
|
value={testData.disciplineId}
|
||||||
onChange={(e) => setTestData({...testData, disciplineId: e.target.value})}
|
onValueChange={(val) => setTestData({...testData, disciplineId: val})}
|
||||||
placeholder="Disc ID"
|
>
|
||||||
/>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Discipline" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">None</SelectItem>
|
||||||
|
{(disciplines as Discipline[])?.map((disc) => (
|
||||||
|
<SelectItem key={disc.id} value={disc.id.toString()}>
|
||||||
|
{disc.disciplineCode}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-4">
|
||||||
Format: {template?.formatTemplate}
|
Format Preview: {template?.formatTemplate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button onClick={handleGenerate} className="w-full" disabled={loading || !template}>
|
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Generate Test Number
|
Generate Test Number
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{generatedNumber && (
|
{generatedNumber && (
|
||||||
<Card className="p-4 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900 border text-center">
|
<Card className={`p-4 mt-4 border text-center ${generatedNumber.startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}>
|
||||||
<p className="text-sm text-muted-foreground mb-1">Generated Number:</p>
|
<p className="text-sm text-muted-foreground mb-1">{generatedNumber.startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
|
||||||
<p className="text-2xl font-mono font-bold text-green-700 dark:text-green-400">
|
<p className={`text-2xl font-mono font-bold ${generatedNumber.startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
|
||||||
{generatedNumber}
|
{generatedNumber}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
113
frontend/components/numbering/void-replace-form.tsx
Normal file
113
frontend/components/numbering/void-replace-form.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||||
|
import { VoidReplaceDto } from "@/types/dto/numbering.dto";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
documentNumber: z.string().min(3, "Document Number is required"),
|
||||||
|
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||||
|
replace: z.boolean(),
|
||||||
|
projectId: z.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
type VoidReplaceFormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function VoidReplaceForm({ projectId = 1 }: { projectId?: number }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<VoidReplaceFormData>({
|
||||||
|
resolver: zodResolver(formSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
documentNumber: "",
|
||||||
|
reason: "",
|
||||||
|
replace: false,
|
||||||
|
projectId: projectId
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dto: VoidReplaceDto = {
|
||||||
|
...values,
|
||||||
|
};
|
||||||
|
await documentNumberingService.voidAndReplace(dto);
|
||||||
|
toast.success("Number voided successfully. " + (values.replace ? "Replacement generated." : ""));
|
||||||
|
form.reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to void number. Check if it exists.");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
|
||||||
|
<h3 className="text-lg font-medium">Void & Replace Number</h3>
|
||||||
|
<p className="text-sm text-gray-500">Void a generated number. Useful for skipped numbers or errors.</p>
|
||||||
|
|
||||||
|
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Document Number</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reason</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Reason for voiding..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<FormField control={form.control} name="replace" render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
Generate Replacement?
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
If checked, a new number will be reserved immediately.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" disabled={loading}>
|
||||||
|
{loading ? "Processing..." : "Void Number"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -114,21 +114,25 @@ describe('use-correspondence hooks', () => {
|
|||||||
const mockResponse = { id: 1, title: 'New Correspondence' };
|
const mockResponse = { id: 1, title: 'New Correspondence' };
|
||||||
vi.mocked(correspondenceService.create).mockResolvedValue(mockResponse);
|
vi.mocked(correspondenceService.create).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const { wrapper, queryClient } = createTestQueryClient();
|
const { wrapper } = createTestQueryClient();
|
||||||
const { result } = renderHook(() => useCreateCorrespondence(), { wrapper });
|
const { result } = renderHook(() => useCreateCorrespondence(), { wrapper });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
title: 'New Correspondence',
|
subject: 'New Correspondence',
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
correspondenceTypeId: 1,
|
typeId: 1,
|
||||||
|
originatorId: 1,
|
||||||
|
recipients: []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(correspondenceService.create).toHaveBeenCalledWith({
|
expect(correspondenceService.create).toHaveBeenCalledWith({
|
||||||
title: 'New Correspondence',
|
subject: 'New Correspondence',
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
correspondenceTypeId: 1,
|
typeId: 1,
|
||||||
|
originatorId: 1,
|
||||||
|
recipients: []
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Correspondence created successfully');
|
expect(toast.success).toHaveBeenCalledWith('Correspondence created successfully');
|
||||||
});
|
});
|
||||||
@@ -146,9 +150,11 @@ describe('use-correspondence hooks', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
try {
|
try {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
title: '',
|
subject: '',
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
correspondenceTypeId: 1,
|
typeId: 1,
|
||||||
|
originatorId: 1,
|
||||||
|
recipients: []
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Expected to throw
|
// Expected to throw
|
||||||
@@ -163,7 +169,7 @@ describe('use-correspondence hooks', () => {
|
|||||||
|
|
||||||
describe('useUpdateCorrespondence', () => {
|
describe('useUpdateCorrespondence', () => {
|
||||||
it('should update correspondence and invalidate cache', async () => {
|
it('should update correspondence and invalidate cache', async () => {
|
||||||
const mockResponse = { id: 1, title: 'Updated Correspondence' };
|
const mockResponse = { id: 1, subject: 'Updated Correspondence' };
|
||||||
vi.mocked(correspondenceService.update).mockResolvedValue(mockResponse);
|
vi.mocked(correspondenceService.update).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const { wrapper } = createTestQueryClient();
|
const { wrapper } = createTestQueryClient();
|
||||||
@@ -172,12 +178,12 @@ describe('use-correspondence hooks', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { title: 'Updated Correspondence' },
|
data: { subject: 'Updated Correspondence' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(correspondenceService.update).toHaveBeenCalledWith(1, {
|
expect(correspondenceService.update).toHaveBeenCalledWith(1, {
|
||||||
title: 'Updated Correspondence',
|
subject: 'Updated Correspondence',
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Correspondence updated successfully');
|
expect(toast.success).toHaveBeenCalledWith('Correspondence updated successfully');
|
||||||
});
|
});
|
||||||
@@ -210,11 +216,11 @@ describe('use-correspondence hooks', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { recipientIds: [2, 3] },
|
data: { note: 'Ready for review' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(correspondenceService.submit).toHaveBeenCalledWith(1, { recipientIds: [2, 3] });
|
expect(correspondenceService.submit).toHaveBeenCalledWith(1, { note: 'Ready for review' });
|
||||||
expect(toast.success).toHaveBeenCalledWith('Correspondence submitted successfully');
|
expect(toast.success).toHaveBeenCalledWith('Correspondence submitted successfully');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -230,13 +236,13 @@ describe('use-correspondence hooks', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { action: 'approve', comment: 'LGTM' },
|
data: { action: 'APPROVE', comments: 'LGTM' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(correspondenceService.processWorkflow).toHaveBeenCalledWith(1, {
|
expect(correspondenceService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||||
action: 'approve',
|
action: 'APPROVE',
|
||||||
comment: 'LGTM',
|
comments: 'LGTM',
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Action completed successfully');
|
expect(toast.success).toHaveBeenCalledWith('Action completed successfully');
|
||||||
});
|
});
|
||||||
@@ -255,7 +261,7 @@ describe('use-correspondence hooks', () => {
|
|||||||
try {
|
try {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { action: 'approve' },
|
data: { action: 'APPROVE' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Expected to throw
|
// Expected to throw
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ describe('use-rfa hooks', () => {
|
|||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
subject: 'Test RFA',
|
subject: 'Test RFA',
|
||||||
|
rfaTypeId: 1,
|
||||||
|
toOrganizationId: 1
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +134,8 @@ describe('use-rfa hooks', () => {
|
|||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
subject: '',
|
subject: '',
|
||||||
|
rfaTypeId: 1,
|
||||||
|
toOrganizationId: 1
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Expected
|
// Expected
|
||||||
@@ -175,13 +179,13 @@ describe('use-rfa hooks', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { action: 'approve', comment: 'Approved' },
|
data: { action: 'APPROVE', comments: 'Approved' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(rfaService.processWorkflow).toHaveBeenCalledWith(1, {
|
expect(rfaService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||||
action: 'approve',
|
action: 'APPROVE',
|
||||||
comment: 'Approved',
|
comments: 'Approved',
|
||||||
});
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Workflow status updated successfully');
|
expect(toast.success).toHaveBeenCalledWith('Workflow status updated successfully');
|
||||||
});
|
});
|
||||||
@@ -200,7 +204,7 @@ describe('use-rfa hooks', () => {
|
|||||||
try {
|
try {
|
||||||
await result.current.mutateAsync({
|
await result.current.mutateAsync({
|
||||||
id: 1,
|
id: 1,
|
||||||
data: { action: 'reject' },
|
data: { action: 'REJECT' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Expected
|
// Expected
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
||||||
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
|
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
|
||||||
|
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service'; // Added
|
||||||
import { SearchContractDrawingDto, CreateContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
|
import { SearchContractDrawingDto, CreateContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
|
||||||
import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto';
|
import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto';
|
||||||
|
import { SearchAsBuiltDrawingDto, CreateAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto'; // Added
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type DrawingType = 'CONTRACT' | 'SHOP';
|
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT'; // Added AS_BUILT
|
||||||
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto;
|
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
|
||||||
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto;
|
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto | CreateAsBuiltDrawingDto;
|
||||||
|
|
||||||
export const drawingKeys = {
|
export const drawingKeys = {
|
||||||
all: ['drawings'] as const,
|
all: ['drawings'] as const,
|
||||||
@@ -25,8 +27,10 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (type === 'CONTRACT') {
|
if (type === 'CONTRACT') {
|
||||||
return contractDrawingService.getAll(params as SearchContractDrawingDto);
|
return contractDrawingService.getAll(params as SearchContractDrawingDto);
|
||||||
} else {
|
} else if (type === 'SHOP') {
|
||||||
return shopDrawingService.getAll(params as SearchShopDrawingDto);
|
return shopDrawingService.getAll(params as SearchShopDrawingDto);
|
||||||
|
} else {
|
||||||
|
return asBuiltDrawingService.getAll(params as SearchAsBuiltDrawingDto);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@@ -39,8 +43,10 @@ export function useDrawing(type: DrawingType, id: number | string) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (type === 'CONTRACT') {
|
if (type === 'CONTRACT') {
|
||||||
return contractDrawingService.getById(id);
|
return contractDrawingService.getById(id);
|
||||||
} else {
|
} else if (type === 'SHOP') {
|
||||||
return shopDrawingService.getById(id);
|
return shopDrawingService.getById(id);
|
||||||
|
} else {
|
||||||
|
return asBuiltDrawingService.getById(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -56,8 +62,10 @@ export function useCreateDrawing(type: DrawingType) {
|
|||||||
mutationFn: async (data: CreateDrawingData) => {
|
mutationFn: async (data: CreateDrawingData) => {
|
||||||
if (type === 'CONTRACT') {
|
if (type === 'CONTRACT') {
|
||||||
return contractDrawingService.create(data as CreateContractDrawingDto);
|
return contractDrawingService.create(data as CreateContractDrawingDto);
|
||||||
} else {
|
} else if (type === 'SHOP') {
|
||||||
return shopDrawingService.create(data as CreateShopDrawingDto);
|
return shopDrawingService.create(data as CreateShopDrawingDto);
|
||||||
|
} else {
|
||||||
|
return asBuiltDrawingService.create(data as CreateAsBuiltDrawingDto);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -105,3 +105,28 @@ export function useCorrespondenceTypes() {
|
|||||||
queryFn: () => masterDataService.getCorrespondenceTypes(),
|
queryFn: () => masterDataService.getCorrespondenceTypes(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Drawing Categories Hooks ---
|
||||||
|
|
||||||
|
export function useContractDrawingCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['contract-drawing-categories'],
|
||||||
|
queryFn: () => masterDataService.getContractDrawingCategories(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShopMainCategories(projectId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['shop-main-categories', projectId],
|
||||||
|
queryFn: () => masterDataService.getShopMainCategories(projectId),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShopSubCategories(projectId: number, mainCategoryId?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['shop-sub-categories', projectId, mainCategoryId],
|
||||||
|
queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { correspondenceService } from '../correspondence.service';
|
import { correspondenceService } from '../correspondence.service';
|
||||||
import apiClient from '@/lib/api/client';
|
import apiClient from '@/lib/api/client';
|
||||||
|
import { WorkflowActionDto } from '@/types/dto/correspondence/workflow-action.dto';
|
||||||
|
|
||||||
// apiClient is already mocked in vitest.setup.ts
|
// apiClient is already mocked in vitest.setup.ts
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ describe('correspondenceService', () => {
|
|||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should call GET /correspondences with params', async () => {
|
it('should call GET /correspondences with params', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: [{ id: 1, title: 'Test' }],
|
data: [{ id: 1, subject: 'Test' }],
|
||||||
meta: { total: 1 },
|
meta: { total: 1 },
|
||||||
};
|
};
|
||||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||||
@@ -39,7 +40,7 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
it('should call GET /correspondences/:id', async () => {
|
it('should call GET /correspondences/:id', async () => {
|
||||||
const mockData = { id: 1, title: 'Test' };
|
const mockData = { id: 1, subject: 'Test' };
|
||||||
// Service expects response.data.data (NestJS interceptor wrapper)
|
// Service expects response.data.data (NestJS interceptor wrapper)
|
||||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockData } });
|
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockData } });
|
||||||
|
|
||||||
@@ -62,9 +63,9 @@ describe('correspondenceService', () => {
|
|||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should call POST /correspondences with data', async () => {
|
it('should call POST /correspondences with data', async () => {
|
||||||
const createDto = {
|
const createDto = {
|
||||||
title: 'New Correspondence',
|
subject: 'New Correspondence',
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
correspondenceTypeId: 1,
|
typeId: 1,
|
||||||
};
|
};
|
||||||
const mockResponse = { id: 1, ...createDto };
|
const mockResponse = { id: 1, ...createDto };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||||
@@ -78,8 +79,8 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should call PUT /correspondences/:id with data', async () => {
|
it('should call PUT /correspondences/:id with data', async () => {
|
||||||
const updateData = { title: 'Updated Title' };
|
const updateData = { subject: 'Updated Title' };
|
||||||
const mockResponse = { id: 1, title: 'Updated Title' };
|
const mockResponse = { id: 1, subject: 'Updated Title' };
|
||||||
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
const result = await correspondenceService.update(1, updateData);
|
const result = await correspondenceService.update(1, updateData);
|
||||||
@@ -102,7 +103,7 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('submit', () => {
|
describe('submit', () => {
|
||||||
it('should call POST /correspondences/:id/submit', async () => {
|
it('should call POST /correspondences/:id/submit', async () => {
|
||||||
const submitDto = { recipientIds: [2, 3] };
|
const submitDto = { note: 'Ready for review' };
|
||||||
const mockResponse = { id: 1, status: 'submitted' };
|
const mockResponse = { id: 1, status: 'submitted' };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('processWorkflow', () => {
|
describe('processWorkflow', () => {
|
||||||
it('should call POST /correspondences/:id/workflow', async () => {
|
it('should call POST /correspondences/:id/workflow', async () => {
|
||||||
const workflowDto = { action: 'approve', comment: 'LGTM' };
|
const workflowDto: WorkflowActionDto = { action: 'APPROVE', comments: 'LGTM' };
|
||||||
const mockResponse = { id: 1, status: 'approved' };
|
const mockResponse = { id: 1, status: 'approved' };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('addReference', () => {
|
describe('addReference', () => {
|
||||||
it('should call POST /correspondences/:id/references', async () => {
|
it('should call POST /correspondences/:id/references', async () => {
|
||||||
const referenceDto = { referencedDocumentId: 2, referenceType: 'reply_to' };
|
const referenceDto = { targetId: 2, referenceType: 'reply_to' };
|
||||||
const mockResponse = { id: 1 };
|
const mockResponse = { id: 1 };
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ describe('correspondenceService', () => {
|
|||||||
|
|
||||||
describe('removeReference', () => {
|
describe('removeReference', () => {
|
||||||
it('should call DELETE /correspondences/:id/references with body', async () => {
|
it('should call DELETE /correspondences/:id/references with body', async () => {
|
||||||
const referenceDto = { referencedDocumentId: 2 };
|
const referenceDto = { targetId: 2 };
|
||||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
const result = await correspondenceService.removeReference(1, referenceDto);
|
const result = await correspondenceService.removeReference(1, referenceDto);
|
||||||
|
|||||||
41
frontend/lib/services/asbuilt-drawing.service.ts
Normal file
41
frontend/lib/services/asbuilt-drawing.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// File: lib/services/asbuilt-drawing.service.ts
|
||||||
|
import apiClient from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
CreateAsBuiltDrawingDto,
|
||||||
|
CreateAsBuiltDrawingRevisionDto,
|
||||||
|
SearchAsBuiltDrawingDto
|
||||||
|
} from "@/types/dto/drawing/asbuilt-drawing.dto";
|
||||||
|
|
||||||
|
export const asBuiltDrawingService = {
|
||||||
|
/**
|
||||||
|
* Get As Built Drawings list
|
||||||
|
*/
|
||||||
|
getAll: async (params: SearchAsBuiltDrawingDto) => {
|
||||||
|
const response = await apiClient.get("/drawings/asbuilt", { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get details by ID
|
||||||
|
*/
|
||||||
|
getById: async (id: string | number) => {
|
||||||
|
const response = await apiClient.get(`/drawings/asbuilt/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create New As Built Drawing
|
||||||
|
*/
|
||||||
|
create: async (data: CreateAsBuiltDrawingDto | FormData) => {
|
||||||
|
const response = await apiClient.post("/drawings/asbuilt", data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create New Revision
|
||||||
|
*/
|
||||||
|
createRevision: async (id: string | number, data: CreateAsBuiltDrawingRevisionDto) => {
|
||||||
|
const response = await apiClient.post(`/drawings/asbuilt/${id}/revisions`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ export const contractDrawingService = {
|
|||||||
/**
|
/**
|
||||||
* สร้างแบบสัญญาใหม่
|
* สร้างแบบสัญญาใหม่
|
||||||
*/
|
*/
|
||||||
create: async (data: CreateContractDrawingDto) => {
|
create: async (data: CreateContractDrawingDto | FormData) => {
|
||||||
const response = await apiClient.post("/drawings/contract", data);
|
const response = await apiClient.post("/drawings/contract", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
46
frontend/lib/services/document-numbering.service.ts
Normal file
46
frontend/lib/services/document-numbering.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import apiClient from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
NumberingMetrics,
|
||||||
|
ManualOverrideDto,
|
||||||
|
VoidReplaceDto,
|
||||||
|
CancelNumberDto,
|
||||||
|
AuditQueryParams
|
||||||
|
} from "@/types/dto/numbering.dto";
|
||||||
|
|
||||||
|
export const documentNumberingService = {
|
||||||
|
// --- Admin Dashboard Metrics ---
|
||||||
|
getMetrics: async (): Promise<NumberingMetrics> => {
|
||||||
|
const response = await apiClient.get("/admin/document-numbering/metrics");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Admin Tools ---
|
||||||
|
manualOverride: async (dto: ManualOverrideDto): Promise<void> => {
|
||||||
|
await apiClient.post("/admin/document-numbering/manual-override", dto);
|
||||||
|
},
|
||||||
|
|
||||||
|
voidAndReplace: async (dto: VoidReplaceDto): Promise<any> => {
|
||||||
|
const response = await apiClient.post("/admin/document-numbering/void-and-replace", dto);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelNumber: async (dto: CancelNumberDto): Promise<void> => {
|
||||||
|
await apiClient.post("/admin/document-numbering/cancel", dto);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkImport: async (data: FormData | any[]): Promise<any> => {
|
||||||
|
const isFormData = data instanceof FormData;
|
||||||
|
const config = isFormData ? { headers: { "Content-Type": "multipart/form-data" } } : {};
|
||||||
|
const response = await apiClient.post("/admin/document-numbering/bulk-import", data, config);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Audit Logs ---
|
||||||
|
getAuditLogs: async (params?: AuditQueryParams) => {
|
||||||
|
// NOTE: endpoint might be merged with metrics or separate
|
||||||
|
// Currently controller has getMetrics returning audit logs too.
|
||||||
|
// But if we want separate pagination later:
|
||||||
|
// return apiClient.get("/admin/document-numbering/audit", { params });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -191,5 +191,24 @@ export const masterDataService = {
|
|||||||
params: { projectId, correspondenceTypeId: typeId }
|
params: { projectId, correspondenceTypeId: typeId }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Drawing Categories ---
|
||||||
|
|
||||||
|
getContractDrawingCategories: async () => {
|
||||||
|
const response = await apiClient.get("/drawings/contract/categories");
|
||||||
|
return response.data.data || response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getShopMainCategories: async (projectId: number) => {
|
||||||
|
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
|
||||||
|
return response.data.data || response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getShopSubCategories: async (projectId: number, mainCategoryId?: number) => {
|
||||||
|
const response = await apiClient.get("/drawings/shop/sub-categories", {
|
||||||
|
params: { projectId, mainCategoryId }
|
||||||
|
});
|
||||||
|
return response.data.data || response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const shopDrawingService = {
|
|||||||
/**
|
/**
|
||||||
* สร้าง Shop Drawing ใหม่ (พร้อม Revision 0)
|
* สร้าง Shop Drawing ใหม่ (พร้อม Revision 0)
|
||||||
*/
|
*/
|
||||||
create: async (data: CreateShopDrawingDto) => {
|
create: async (data: CreateShopDrawingDto | FormData) => {
|
||||||
const response = await apiClient.post("/drawings/shop", data);
|
const response = await apiClient.post("/drawings/shop", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/__tests__"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
// Entity Interfaces
|
||||||
export interface DrawingRevision {
|
export interface DrawingRevision {
|
||||||
revisionId: number;
|
revisionId: number;
|
||||||
revisionNumber: string;
|
revisionNumber: string;
|
||||||
|
title?: string; // Added
|
||||||
|
legacyDrawingNumber?: string; // Added
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
revisionDescription?: string;
|
revisionDescription?: string;
|
||||||
revisedByName: string;
|
revisedByName: string;
|
||||||
@@ -8,19 +11,55 @@ export interface DrawingRevision {
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContractDrawing {
|
||||||
|
id: number;
|
||||||
|
contractDrawingNo: string;
|
||||||
|
title: string;
|
||||||
|
projectId: number;
|
||||||
|
mapCatId?: number;
|
||||||
|
volumeId?: number;
|
||||||
|
volumePage?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopDrawing {
|
||||||
|
id: number;
|
||||||
|
drawingNumber: string;
|
||||||
|
projectId: number;
|
||||||
|
mainCategoryId: number;
|
||||||
|
subCategoryId: number;
|
||||||
|
currentRevision?: DrawingRevision;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
// title removed
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsBuiltDrawing {
|
||||||
|
id: number;
|
||||||
|
drawingNumber: string;
|
||||||
|
projectId: number;
|
||||||
|
currentRevision?: DrawingRevision;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified Type for List
|
||||||
export interface Drawing {
|
export interface Drawing {
|
||||||
drawingId: number;
|
drawingId: number;
|
||||||
drawingNumber: string;
|
drawingNumber: string;
|
||||||
title: string;
|
title: string; // Display title (from current revision for Shop/AsBuilt)
|
||||||
discipline?: string | { disciplineCode: string; disciplineName: string };
|
discipline?: string | { disciplineCode: string; disciplineName: string };
|
||||||
type?: string;
|
type?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
sheetNumber?: string;
|
sheetNumber?: string;
|
||||||
|
legacyDrawingNumber?: string; // Added for display
|
||||||
scale?: string;
|
scale?: string;
|
||||||
issueDate?: string;
|
issueDate?: string;
|
||||||
revisionCount?: number;
|
revisionCount?: number;
|
||||||
revisions?: DrawingRevision[];
|
revisions?: DrawingRevision[];
|
||||||
|
volumePage?: number; // Contract only
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/types/dto/drawing/asbuilt-drawing.dto.ts
Normal file
38
frontend/types/dto/drawing/asbuilt-drawing.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// File: src/types/dto/drawing/asbuilt-drawing.dto.ts
|
||||||
|
|
||||||
|
// --- Create New As Built Drawing ---
|
||||||
|
export interface CreateAsBuiltDrawingDto {
|
||||||
|
projectId: number;
|
||||||
|
drawingNumber: string;
|
||||||
|
|
||||||
|
// First Revision Data
|
||||||
|
revisionLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
legacyDrawingNumber?: string;
|
||||||
|
revisionDate?: string; // ISO Date String
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
shopDrawingRevisionIds?: number[]; // Reference to Shop Drawing Revisions
|
||||||
|
attachmentIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create New Revision ---
|
||||||
|
export interface CreateAsBuiltDrawingRevisionDto {
|
||||||
|
revisionLabel: string;
|
||||||
|
title: string;
|
||||||
|
legacyDrawingNumber?: string;
|
||||||
|
revisionDate?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
shopDrawingRevisionIds?: number[];
|
||||||
|
attachmentIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
export interface SearchAsBuiltDrawingDto {
|
||||||
|
projectId: number;
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
page?: number; // Default: 1
|
||||||
|
pageSize?: number; // Default: 20
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@ export interface CreateContractDrawingDto {
|
|||||||
/** ชื่อแบบ */
|
/** ชื่อแบบ */
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
/** ID หมวดหมู่ย่อย */
|
/** ID หมวดหมู่ย่อย (Mapping) */
|
||||||
subCategoryId?: number;
|
mapCatId?: number;
|
||||||
|
|
||||||
/** ID เล่มของแบบ */
|
/** ID เล่มของแบบ */
|
||||||
volumeId?: number;
|
volumeId?: number;
|
||||||
|
|
||||||
|
/** เลขหน้าในเล่ม */
|
||||||
|
volumePage?: number;
|
||||||
|
|
||||||
/** รายการ ID ของไฟล์แนบ (PDF/DWG) */
|
/** รายการ ID ของไฟล์แนบ (PDF/DWG) */
|
||||||
attachmentIds?: number[];
|
attachmentIds?: number[];
|
||||||
}
|
}
|
||||||
@@ -30,7 +33,7 @@ export interface SearchContractDrawingDto {
|
|||||||
projectId: number;
|
projectId: number;
|
||||||
|
|
||||||
volumeId?: number;
|
volumeId?: number;
|
||||||
subCategoryId?: number;
|
mapCatId?: number;
|
||||||
search?: string; // ค้นหาจาก Title หรือ Number
|
search?: string; // ค้นหาจาก Title หรือ Number
|
||||||
|
|
||||||
page?: number; // Default: 1
|
page?: number; // Default: 1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CreateShopDrawingDto {
|
|||||||
revisionLabel?: string;
|
revisionLabel?: string;
|
||||||
revisionDate?: string; // ISO Date String
|
revisionDate?: string; // ISO Date String
|
||||||
description?: string;
|
description?: string;
|
||||||
|
legacyDrawingNumber?: string; // Legacy number for the first revision
|
||||||
contractDrawingIds?: number[]; // อ้างอิงแบบสัญญา
|
contractDrawingIds?: number[]; // อ้างอิงแบบสัญญา
|
||||||
attachmentIds?: number[];
|
attachmentIds?: number[];
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,8 @@ export interface CreateShopDrawingDto {
|
|||||||
// --- Create New Revision ---
|
// --- Create New Revision ---
|
||||||
export interface CreateShopDrawingRevisionDto {
|
export interface CreateShopDrawingRevisionDto {
|
||||||
revisionLabel: string;
|
revisionLabel: string;
|
||||||
|
title: string; // Title per revision
|
||||||
|
legacyDrawingNumber?: string;
|
||||||
revisionDate?: string;
|
revisionDate?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
contractDrawingIds?: number[];
|
contractDrawingIds?: number[];
|
||||||
|
|||||||
35
frontend/types/dto/numbering.dto.ts
Normal file
35
frontend/types/dto/numbering.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface NumberingMetrics {
|
||||||
|
audit: any[]; // Replace with specific AuditLog type if available
|
||||||
|
errors: any[]; // Replace with specific ErrorLog type
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualOverrideDto {
|
||||||
|
projectId: number;
|
||||||
|
originatorOrganizationId: number;
|
||||||
|
recipientOrganizationId: number;
|
||||||
|
correspondenceTypeId: number;
|
||||||
|
subTypeId?: number;
|
||||||
|
rfaTypeId?: number;
|
||||||
|
disciplineId?: number;
|
||||||
|
resetScope: string;
|
||||||
|
newLastNumber: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoidReplaceDto {
|
||||||
|
documentNumber: string;
|
||||||
|
reason: string;
|
||||||
|
replace: boolean;
|
||||||
|
projectId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelNumberDto {
|
||||||
|
documentNumber: string;
|
||||||
|
reason: string;
|
||||||
|
projectId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQueryParams {
|
||||||
|
projectId?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -20,4 +20,5 @@ export interface SearchOrganizationDto {
|
|||||||
projectId?: number;
|
projectId?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,19 +254,39 @@ AD-DN-001: Single Source of Truth for Numbering ระบบ เลือกใ
|
|||||||
|
|
||||||
**Counter Key**: `(project_id, originator_org_id, 0, correspondence_type_id, 0, rfa_type_id, discipline_id, 'NONE')`
|
**Counter Key**: `(project_id, originator_org_id, 0, correspondence_type_id, 0, rfa_type_id, discipline_id, 'NONE')`
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
### 3.11.3.4 Drawing
|
### 3.11.3.4 Drawing
|
||||||
|
|
||||||
**Status**: 🚧 **To Be Determined**
|
#### 3.11.3.4.1 Shop Drawing
|
||||||
|
**Example**: `LCBP3-C2-SDW-SW-BST-002-1`
|
||||||
|
|
||||||
Drawing Numbering ยังไม่ได้กำหนด Template เนื่องจาก:
|
**Token Breakdown**:
|
||||||
|
|
||||||
- มีความซับซ้อนสูง (Contract Drawing และ Shop Drawing มีกฎต่างกัน)
|
- `LCBP3-C2` = {PROJECT} = รหัสโครงการ
|
||||||
- อาจต้องใช้ระบบ Numbering แยกต่างหาก
|
- `SDW` = {DRAWING_TYPE} = ประเภทแบบ (SDW=Shop Drawing)
|
||||||
- ต้องพิจารณาร่วมกับ RFA ที่เกี่ยวข้อง
|
- `SW` = {main_category} = รหัสหมวดหลัก
|
||||||
|
- `BST` = {sub_category} = รหัสหมวดย่อย
|
||||||
|
- `002` = {SEQ:3}
|
||||||
|
- `1` = {REV} = Revision code
|
||||||
|
|
||||||
|
**Counter Key**: `(project_id, DRAWING_TYPE, main_category, sub_category)`
|
||||||
|
|
||||||
|
#### 3.11.3.4.2 As Built Drawing
|
||||||
|
|
||||||
|
**Example**: `LCBP3-C2-ASB-SW-BST-002-1`
|
||||||
|
|
||||||
|
**Token Breakdown**:
|
||||||
|
|
||||||
|
- `LCBP3-C2` = {PROJECT} = รหัสโครงการ
|
||||||
|
- `ASB` = {DRAWING_TYPE} = ประเภทแบบ (ASB=As Built Arawing)
|
||||||
|
- `SW` = {main_category} = รหัสหมวดหลัก
|
||||||
|
- `BST` = {sub_category} = รหัสหมวดย่อย
|
||||||
|
- `002` = {SEQ:3}
|
||||||
|
- `1` = {REV} = Revision code
|
||||||
|
|
||||||
|
**Counter Key**: `(project_id, DRAWING_TYPE, main_category, sub_category)`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -261,9 +261,9 @@ FCO (For Construction) / ASB (As-Built) / 3R (Revise) / 4X (Reject)
|
|||||||
**Hierarchy:**
|
**Hierarchy:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Volume (เล่ม)
|
Volume (เล่ม) - has volume_page
|
||||||
└─ Category (หมวดหมู่หลัก)
|
└─ Category (หมวดหมู่หลัก)
|
||||||
└─ Sub-Category (หมวดหมู่ย่อย)
|
└─ Sub-Category (หมวดหมู่ย่อย) -- Linked via Map Table
|
||||||
└─ Drawing (แบบ)
|
└─ Drawing (แบบ)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -271,24 +271,39 @@ Volume (เล่ม)
|
|||||||
|
|
||||||
**Tables:**
|
**Tables:**
|
||||||
|
|
||||||
- `shop_drawing_main_categories` - หมวดหมู่หลัก (ARCH, STR, MEP, etc.)
|
- `shop_drawing_main_categories` - หมวดหมู่หลัก (Project Specific)
|
||||||
- `shop_drawing_sub_categories` - หมวดหมู่ย่อย
|
- `shop_drawing_sub_categories` - หมวดหมู่ย่อย (Project Specific)
|
||||||
- `shop_drawings` - Master แบบก่อสร้าง
|
- `shop_drawings` - Master แบบก่อสร้าง (No title, number only)
|
||||||
- `shop_drawing_revisions` - Revisions
|
- `shop_drawing_revisions` - Revisions (Holds Title & Legacy Number)
|
||||||
- `shop_drawing_revision_contract_refs` - M:N อ้างอิงแบบคู่สัญญา
|
- `shop_drawing_revision_contract_refs` - M:N อ้างอิงแบบคู่สัญญา
|
||||||
|
|
||||||
**Revision Tracking:**
|
**Revision Tracking:**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Get latest revision of a shop drawing
|
-- Get latest revision of a shop drawing
|
||||||
SELECT sd.drawing_number, sdr.revision_label, sdr.revision_date
|
SELECT sd.shop_drawing_number, sdr.revision_label, sdr.revision_date
|
||||||
FROM shop_drawings sd
|
FROM shop_drawings sd
|
||||||
JOIN shop_drawing_revisions sdr ON sd.id = sdr.shop_drawing_id
|
JOIN shop_drawing_revisions sdr ON sd.id = sdr.shop_drawing_id
|
||||||
WHERE sd.drawing_number = 'SD-STR-001'
|
WHERE sd.shop_drawing_number = 'SD-STR-001'
|
||||||
ORDER BY sdr.revision_number DESC
|
ORDER BY sdr.revision_number DESC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 5.3 As Built Drawings (แบบสร้างจริง) [NEW v1.7.0]
|
||||||
|
|
||||||
|
**Tables:**
|
||||||
|
|
||||||
|
- `asbuilt_drawings` - Master แบบสร้างจริง
|
||||||
|
- `asbuilt_drawing_revisions` - Revisions history
|
||||||
|
- `asbuilt_revision_shop_revisions_refs` - เชื่อมโยงกับ Shop Drawing Revision source
|
||||||
|
- `asbuilt_drawing_revision_attachments` - ไฟล์แนบ (PDF/DWG)
|
||||||
|
|
||||||
|
**Business Rules:**
|
||||||
|
|
||||||
|
- As Built 1 ใบ อาจมาจาก Shop Drawing หลายใบ (Many-to-Many via refs table)
|
||||||
|
- แยก Counter distinct จาก Shop Drawing และ Contract Drawing
|
||||||
|
- รองรับไฟล์แนบหลายประเภท (PDF, DWG, SOURCE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. 🔄 Circulations & Transmittals
|
### 6. 🔄 Circulations & Transmittals
|
||||||
@@ -400,7 +415,16 @@ CREATE TABLE document_number_counters (
|
|||||||
current_year INT,
|
current_year INT,
|
||||||
version INT DEFAULT 0, -- Optimistic Lock
|
version INT DEFAULT 0, -- Optimistic Lock
|
||||||
last_number INT DEFAULT 0,
|
last_number INT DEFAULT 0,
|
||||||
PRIMARY KEY (project_id, originator_organization_id, correspondence_type_id, discipline_id, current_year)
|
PRIMARY KEY (
|
||||||
|
project_id,
|
||||||
|
originator_organization_id,
|
||||||
|
recipient_organization_id,
|
||||||
|
correspondence_type_id,
|
||||||
|
sub_type_id,
|
||||||
|
rfa_type_id,
|
||||||
|
discipline_id,
|
||||||
|
reset_scope
|
||||||
|
)
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ CREATE TABLE document_number_counters (
|
|||||||
rfa_type_id INT DEFAULT 0,
|
rfa_type_id INT DEFAULT 0,
|
||||||
discipline_id INT DEFAULT 0,
|
discipline_id INT DEFAULT 0,
|
||||||
reset_scope VARCHAR(20) NOT NULL,
|
reset_scope VARCHAR(20) NOT NULL,
|
||||||
last_number INT DEFAULT 0 NOT NULL,,
|
last_number INT DEFAULT 0 NOT NULL,
|
||||||
version INT DEFAULT 0 NOT NULL,
|
version INT DEFAULT 0 NOT NULL,
|
||||||
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
|
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
@@ -124,7 +124,7 @@ CREATE TABLE document_number_counters (
|
|||||||
PRIMARY KEY (
|
PRIMARY KEY (
|
||||||
project_id,
|
project_id,
|
||||||
originator_organization_id,
|
originator_organization_id,
|
||||||
COALESCE(recipient_organization_id, 0),
|
recipient_organization_id,
|
||||||
correspondence_type_id,
|
correspondence_type_id,
|
||||||
sub_type_id,
|
sub_type_id,
|
||||||
rfa_type_id,
|
rfa_type_id,
|
||||||
@@ -142,10 +142,8 @@ CREATE TABLE document_number_counters (
|
|||||||
|
|
||||||
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0),
|
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0),
|
||||||
CONSTRAINT chk_reset_scope_format CHECK (
|
CONSTRAINT chk_reset_scope_format CHECK (
|
||||||
reset_scope IN ('NONE') OR
|
reset_scope = 'NONE' OR
|
||||||
reset_scope LIKE 'YEAR_%' OR
|
reset_scope LIKE 'YEAR_%'
|
||||||
reset_scope LIKE 'MONTH_%' OR
|
|
||||||
reset_scope LIKE 'CONTRACT_%'
|
|
||||||
)
|
)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
COMMENT='Running Number Counters';
|
COMMENT='Running Number Counters';
|
||||||
|
|||||||
@@ -140,19 +140,28 @@ CREATE TABLE document_number_formats (
|
|||||||
-- Counter Table with Optimistic Locking
|
-- Counter Table with Optimistic Locking
|
||||||
CREATE TABLE document_number_counters (
|
CREATE TABLE document_number_counters (
|
||||||
project_id INT NOT NULL,
|
project_id INT NOT NULL,
|
||||||
doc_type_id INT NOT NULL,
|
correspondence_type_id INT NULL,
|
||||||
sub_type_id INT DEFAULT 0 COMMENT 'For Correspondence types, 0 = fallback',
|
originator_organization_id INT NOT NULL,
|
||||||
discipline_id INT DEFAULT 0 COMMENT 'For RFA/Drawing, 0 = fallback',
|
recipient_organization_id INT NOT NULL DEFAULT 0, -- 0 = no recipient (RFA)
|
||||||
recipient_type VARCHAR(20) DEFAULT NULL COMMENT 'For Transmittal: OWNER, CONTRACTOR, CONSULTANT, OTHER',
|
sub_type_id INT DEFAULT 0,
|
||||||
year INT NOT NULL COMMENT 'ปี พ.ศ. หรือ ค.ศ. ตาม template',
|
rfa_type_id INT DEFAULT 0,
|
||||||
last_number INT DEFAULT 0,
|
discipline_id INT DEFAULT 0,
|
||||||
version INT DEFAULT 0 NOT NULL COMMENT 'Version for Optimistic Lock',
|
reset_scope VARCHAR(20) NOT NULL,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
last_number INT DEFAULT 0 NOT NULL,
|
||||||
PRIMARY KEY (project_id, doc_type_id, sub_type_id, discipline_id, COALESCE(recipient_type, ''), year),
|
version INT DEFAULT 0 NOT NULL,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
FOREIGN KEY (doc_type_id) REFERENCES document_types(id),
|
updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
INDEX idx_counter_lookup (project_id, doc_type_id, year)
|
PRIMARY KEY (
|
||||||
) ENGINE=InnoDB COMMENT='Running number counters with optimistic locking';
|
project_id,
|
||||||
|
originator_organization_id,
|
||||||
|
recipient_organization_id,
|
||||||
|
correspondence_type_id,
|
||||||
|
sub_type_id,
|
||||||
|
rfa_type_id,
|
||||||
|
discipline_id,
|
||||||
|
reset_scope
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Running Number Counters';
|
||||||
|
|
||||||
-- Audit Trail
|
-- Audit Trail
|
||||||
CREATE TABLE document_number_audit (
|
CREATE TABLE document_number_audit (
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ status: TODO
|
|||||||
priority: HIGH
|
priority: HIGH
|
||||||
estimated_effort: 3-5 days
|
estimated_effort: 3-5 days
|
||||||
dependencies:
|
dependencies:
|
||||||
- specs/01-requirements/03.11-document-numbering.md (v1.6.2)
|
- specs/01-requirements/01-03.11-document-numbering.md (v1.6.2)
|
||||||
- specs/03-implementation/document-numbering.md (v1.6.2)
|
- specs/03-implementation/03-04-document-numbering.md (v1.6.2)
|
||||||
related_task: TASK-FE-017-document-numbering-refactor.md
|
related_task: TASK-FE-017-document-numbering-refactor.md
|
||||||
---
|
---
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Refactor Document Numbering module ตาม specification v1.6.2 โดยเน้น:
|
Refactor Document Numbering module ตาม specification v1.6.2 และ Implementation Guide โดยเน้น:
|
||||||
|
- Single Numbering System (Option A)
|
||||||
- Number State Machine (RESERVED → CONFIRMED → VOID → CANCELLED)
|
- Number State Machine (RESERVED → CONFIRMED → VOID → CANCELLED)
|
||||||
|
- Two-Phase Commit implementation
|
||||||
|
- Redis Distributed Lock
|
||||||
- Idempotency-Key support
|
- Idempotency-Key support
|
||||||
- Counter Key alignment ตาม requirements
|
- Complete Audit & Metrics
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,138 +27,128 @@ Refactor Document Numbering module ตาม specification v1.6.2 โดยเ
|
|||||||
### 1. Entity Updates
|
### 1. Entity Updates
|
||||||
|
|
||||||
#### 1.1 DocumentNumberCounter Entity
|
#### 1.1 DocumentNumberCounter Entity
|
||||||
- [ ] Rename `current_year` → ใช้ `reset_scope` pattern (YEAR_2025, NONE)
|
- [ ] Rename `current_year` → ใช้ `reset_scope` pattern (`YEAR_2025`, `NONE`)
|
||||||
- [ ] Ensure FK columns match: `correspondence_type_id`, `originator_organization_id`, `recipient_organization_id`
|
- [ ] Ensure FK columns match: `correspondence_type_id`, `originator_organization_id`, `recipient_organization_id`
|
||||||
- [ ] Add `rfa_type_id`, `sub_type_id`, `discipline_id` columns if missing
|
- [ ] Add `rfa_type_id`, `sub_type_id`, `discipline_id` columns
|
||||||
- [ ] Update Primary Key ให้ตรงกับ requirements spec
|
- [ ] Update Primary Key & Indices
|
||||||
|
- [ ] Add `version` column for optimistic locking
|
||||||
|
|
||||||
```typescript
|
#### 1.2 New Entities (Create)
|
||||||
// Expected Counter Key structure
|
- [ ] **DocumentNumberFormat**: Store templates per project/type (`document_number_formats` table)
|
||||||
interface CounterKey {
|
- [ ] **DocumentNumberReservation**: Store active reservations (`document_number_reservations` table)
|
||||||
projectId: number;
|
- [ ] **DocumentNumberAudit**: Store complete audit trail (`document_number_audit` table)
|
||||||
originatorOrganizationId: number;
|
- [ ] **DocumentNumberError**: Store error logs (`document_number_errors` table)
|
||||||
recipientOrganizationId: number; // 0 for RFA
|
|
||||||
correspondenceTypeId: number;
|
|
||||||
subTypeId: number; // 0 if not applicable
|
|
||||||
rfaTypeId: number; // 0 if not applicable
|
|
||||||
disciplineId: number; // 0 if not applicable
|
|
||||||
resetScope: string; // 'YEAR_2025', 'NONE'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 DocumentNumberAudit Entity
|
|
||||||
- [ ] Add `operation` enum: `RESERVE`, `CONFIRM`, `CANCEL`, `MANUAL_OVERRIDE`, `VOID`, `GENERATE`
|
|
||||||
- [ ] Ensure `counter_key` is stored as JSON
|
|
||||||
- [ ] Add `idempotency_key` column
|
|
||||||
|
|
||||||
#### 1.3 DocumentNumberReservation Entity (NEW if not exists)
|
|
||||||
- [ ] Create entity for Two-Phase Commit reservations
|
|
||||||
- [ ] Fields: `token`, `document_number`, `status`, `expires_at`, `metadata`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Service Updates
|
### 2. Service Updates
|
||||||
|
|
||||||
#### 2.1 DocumentNumberingService
|
#### 2.1 Core Services
|
||||||
- [ ] Implement `reserveNumber()` - Phase 1 of Two-Phase Commit
|
- [ ] **DocumentNumberingService**: Main orchestration (Reserve, Confirm, Cancel, Preview)
|
||||||
- [ ] Implement `confirmNumber()` - Phase 2 of Two-Phase Commit
|
- [ ] **CounterService**: Handle `incrementCounter` with DB optimistic lock & retry logic
|
||||||
- [ ] Implement `cancelNumber()` - Explicit cancel reservation
|
- [ ] **DocumentNumberingLockService**: Implement Redis Redlock (`acquireLock`, `releaseLock`)
|
||||||
- [ ] Add Idempotency-Key checking logic
|
- [ ] **ReservationService**: Handle Two-Phase Commit logic (TTL, cleanup)
|
||||||
- [ ] Update `generateNextNumber()` to use new CounterKey structure
|
|
||||||
|
|
||||||
#### 2.2 Counter Key Builder
|
#### 2.2 Helper Services
|
||||||
- [ ] Create helper to build counter key based on document type:
|
- [ ] **FormatService**: Format number string based on template & tokens
|
||||||
- Global (LETTER, MEMO, RFI): `(project, orig, recip, type, 0, 0, 0, YEAR_XXXX)`
|
- [ ] **TemplateService**: CRUD operations for `DocumentNumberFormat` and validation
|
||||||
- TRANSMITTAL: `(project, orig, recip, type, subType, 0, 0, YEAR_XXXX)`
|
- [ ] **AuditService**: Async logging to `DocumentNumberAudit`
|
||||||
- RFA: `(project, orig, 0, type, 0, rfaType, discipline, NONE)`
|
- [ ] **MetricsService**: Prometheus counters/gauges (utilization, lock wait time)
|
||||||
|
|
||||||
#### 2.3 ManualOverrideService
|
#### 2.3 Feature Services
|
||||||
- [ ] Implement `manualOverride()` with validation
|
- [ ] **ManualOverrideService**: Handle manual number assignment & sequence adjustment
|
||||||
- [ ] Auto-update counter if manual number > current
|
- [ ] **MigrationService**: Handle bulk import / legacy data migration
|
||||||
|
|
||||||
#### 2.4 VoidReplaceService
|
|
||||||
- [ ] Implement `voidAndReplace()` workflow
|
|
||||||
- [ ] Link new document to voided document
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Controller Updates
|
### 3. Controller Updates
|
||||||
|
|
||||||
#### 3.1 DocumentNumberingController
|
#### 3.1 DocumentNumberingController
|
||||||
- [ ] Add `POST /reserve` endpoint
|
- [ ] `POST /reserve`: Reserve number (Phase 1)
|
||||||
- [ ] Add `POST /confirm` endpoint
|
- [ ] `POST /confirm`: Confirm number (Phase 2)
|
||||||
- [ ] Add `POST /cancel` endpoint
|
- [ ] `POST /cancel`: Cancel reservation
|
||||||
- [ ] Add `Idempotency-Key` header validation middleware
|
- [ ] `POST /preview`: Preview next number
|
||||||
|
- [ ] `GET /sequences`: Get current sequence status
|
||||||
|
- [ ] Add `Idempotency-Key` header validation
|
||||||
|
|
||||||
#### 3.2 DocumentNumberingAdminController
|
#### 3.2 DocumentNumberingAdminController
|
||||||
- [ ] Add `POST /manual-override` endpoint
|
- [ ] `POST /manual-override`
|
||||||
- [ ] Add `POST /void-and-replace` endpoint
|
- [ ] `POST /void-and-replace`
|
||||||
- [ ] Add `POST /bulk-import` endpoint
|
- [ ] `POST /bulk-import`
|
||||||
- [ ] Add `GET /metrics` endpoint for monitoring dashboard
|
- [ ] `POST /templates`: Manage templates
|
||||||
|
|
||||||
|
#### 3.3 NumberingMetricsController
|
||||||
|
- [ ] `GET /metrics`: Expose utilization & health metrics for dashboard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Number State Machine
|
### 4. Logic & Algorithms
|
||||||
|
|
||||||
```mermaid
|
#### 4.1 Counter Key Builder
|
||||||
stateDiagram-v2
|
- Implement logic to build unique key tuple:
|
||||||
[*] --> RESERVED: reserve()
|
- Global: `(proj, orig, recip, type, 0, 0, 0, YEAR_XXXX)`
|
||||||
RESERVED --> CONFIRMED: confirm()
|
- Transmittal: `(proj, orig, recip, type, subType, 0, 0, YEAR_XXXX)`
|
||||||
RESERVED --> CANCELLED: cancel() or TTL expired
|
- RFA: `(proj, orig, 0, type, 0, rfaType, discipline, NONE)`
|
||||||
CONFIRMED --> VOID: void()
|
- Drawing: `(proj, TYPE, main, sub)` (separate namespace)
|
||||||
CANCELLED --> [*]
|
|
||||||
VOID --> [*]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.1 State Transitions
|
#### 4.2 State Machine
|
||||||
- [ ] Implement state validation before transitions
|
- [ ] Validate transitions: RESERVED -> CONFIRMED
|
||||||
- [ ] Log all transitions to audit table
|
- [ ] Auto-expire RESERVED -> CANCELLED (via Cron/TTL)
|
||||||
- [ ] TTL 5 minutes for RESERVED state
|
- [ ] CONFIRMED -> VOID
|
||||||
|
|
||||||
|
#### 4.3 Lock Strategy
|
||||||
|
- [ ] Try Redis Lock -> if valid -> Increment -> Release
|
||||||
|
- [ ] Fallback to DB Lock if Redis unavailable (optional/advanced)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Testing
|
### 5. Testing
|
||||||
|
|
||||||
#### 5.1 Unit Tests
|
#### 5.1 Unit Tests
|
||||||
- [ ] CounterService.incrementCounter()
|
- [ ] `CounterService` optimistic locking
|
||||||
- [ ] ReservationService.reserve/confirm/cancel()
|
- [ ] `TemplateValidator` grammar check
|
||||||
- [ ] TemplateValidator.validate()
|
- [ ] `ReservationService` expiry logic
|
||||||
- [ ] CounterKeyBuilder
|
|
||||||
|
|
||||||
#### 5.2 Integration Tests
|
#### 5.2 Integration Tests
|
||||||
- [ ] Two-Phase Commit flow
|
- [ ] Full Two-Phase Commit flow
|
||||||
- [ ] Idempotency-Key duplicate prevention
|
- [ ] Concurrent requests (check for duplicates)
|
||||||
- [ ] Redis lock + DB optimistic lock
|
- [ ] Idempotency-Key behavior
|
||||||
|
|
||||||
#### 5.3 Load Tests
|
|
||||||
- [ ] Concurrent number generation (1000 req/s)
|
|
||||||
- [ ] Zero duplicates verification
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files to Create/Modify
|
## Files to Create/Modify
|
||||||
|
|
||||||
| Action | Path |
|
| Action | Path |
|
||||||
| ------ | ------------------------------------------------------------------------------------------- |
|
| :----- | :------------------------------------------------------------------------------------------ |
|
||||||
|
| MODIFY | `backend/src/modules/document-numbering/document-numbering.module.ts` |
|
||||||
| MODIFY | `backend/src/modules/document-numbering/entities/document-number-counter.entity.ts` |
|
| MODIFY | `backend/src/modules/document-numbering/entities/document-number-counter.entity.ts` |
|
||||||
| MODIFY | `backend/src/modules/document-numbering/entities/document-number-audit.entity.ts` |
|
| CREATE | `backend/src/modules/document-numbering/entities/document-number-format.entity.ts` |
|
||||||
| CREATE | `backend/src/modules/document-numbering/entities/document-number-reservation.entity.ts` |
|
| CREATE | `backend/src/modules/document-numbering/entities/document-number-reservation.entity.ts` |
|
||||||
| MODIFY | `backend/src/modules/document-numbering/services/document-numbering.service.ts` |
|
| MODIFY | `backend/src/modules/document-numbering/entities/document-number-audit.entity.ts` |
|
||||||
| CREATE | `backend/src/modules/document-numbering/services/reservation.service.ts` |
|
| CREATE | `backend/src/modules/document-numbering/entities/document-number-error.entity.ts` |
|
||||||
| CREATE | `backend/src/modules/document-numbering/services/manual-override.service.ts` |
|
|
||||||
| MODIFY | `backend/src/modules/document-numbering/controllers/document-numbering.controller.ts` |
|
| MODIFY | `backend/src/modules/document-numbering/controllers/document-numbering.controller.ts` |
|
||||||
| MODIFY | `backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts` |
|
| MODIFY | `backend/src/modules/document-numbering/controllers/document-numbering-admin.controller.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/controllers/numbering-metrics.controller.ts` |
|
||||||
|
| MODIFY | `backend/src/modules/document-numbering/services/document-numbering.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/counter.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/document-numbering-lock.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/reservation.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/manual-override.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/format.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/template.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/audit.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/services/metrics.service.ts` |
|
||||||
|
| CREATE | `backend/src/modules/document-numbering/validators/template.validator.ts` |
|
||||||
| CREATE | `backend/src/modules/document-numbering/guards/idempotency.guard.ts` |
|
| CREATE | `backend/src/modules/document-numbering/guards/idempotency.guard.ts` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] All Counter Key ตรงกับ requirements v1.6.2
|
- [ ] Schema matches `specs/03-implementation/03-04-document-numbering.md`
|
||||||
- [ ] Number State Machine ทำงานถูกต้อง
|
- [ ] All 3 levels of locking (Redis, DB Optimistic, Unique Constraints) implemented
|
||||||
- [ ] Idempotency-Key ป้องกัน duplicate requests
|
- [ ] Zero duplicates in load test
|
||||||
- [ ] Zero duplicate numbers ใน concurrent load test
|
- [ ] Full audit trail visible
|
||||||
- [ ] Audit logs บันทึกทุก operation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
37
specs/06-tasks/TASK-BE-018-v170-refactor.md
Normal file
37
specs/06-tasks/TASK-BE-018-v170-refactor.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: 'Task: Backend Refactoring for Schema v1.7.0'
|
||||||
|
status: DONE
|
||||||
|
owner: Backend Team
|
||||||
|
created_at: 2025-12-23
|
||||||
|
related:
|
||||||
|
- specs/01-requirements/01-03.11-document-numbering.md
|
||||||
|
- specs/07-database/lcbp3-v1.7.0-schema.sql
|
||||||
|
- specs/07-database/data-dictionary-v1.7.0.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Update backend entities and logic to align with schema v1.7.0 and revised document numbering specifications.
|
||||||
|
|
||||||
|
## Scope of Work
|
||||||
|
|
||||||
|
### 1. Drawing Module
|
||||||
|
- **Contract Drawings:**
|
||||||
|
- Update `ContractDrawing` entity (map_cat_id, volume_page)
|
||||||
|
- Create `ContractDrawingSubcatCatMap` entity
|
||||||
|
- **Shop Drawings:**
|
||||||
|
- Update `ShopDrawingMainCategory` (add project_id)
|
||||||
|
- Update `ShopDrawingSubCategory` (add project_id, remove main_cat_id)
|
||||||
|
- Update `ShopDrawing` (remove title)
|
||||||
|
- Update `ShopDrawingRevision` (add title, legacy_number)
|
||||||
|
- **As Built Drawings (New):**
|
||||||
|
- Create entities for `asbuilt_drawings` and related tables.
|
||||||
|
|
||||||
|
### 2. Document Numbering Module
|
||||||
|
- **Counters:**
|
||||||
|
- Update `DocumentNumberCounter` entity to match 8-part Composite Key.
|
||||||
|
- Ensure strict typing for `reset_scope`.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [x] All entities match v1.7.0 schema
|
||||||
|
- [x] Application compiles without type errors
|
||||||
|
- [x] Document Numbering service supports new key structure
|
||||||
@@ -5,9 +5,9 @@ status: TODO
|
|||||||
priority: HIGH
|
priority: HIGH
|
||||||
estimated_effort: 2-3 days
|
estimated_effort: 2-3 days
|
||||||
dependencies:
|
dependencies:
|
||||||
- TASK-BE-017-document-numbering-refactor.md
|
- specs/06-tasks/TASK-BE-017-document-numbering-refactor.md
|
||||||
- specs/01-requirements/03.11-document-numbering.md (v1.6.2)
|
- specs/01-requirements/01-03.11-document-numbering.md (v1.6.2)
|
||||||
- specs/03-implementation/document-numbering.md (v1.6.2)
|
- specs/03-implementation/03-04-document-numbering.md (v1.6.2)
|
||||||
---
|
---
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
@@ -136,13 +136,13 @@ interface AuditQueryParams {
|
|||||||
|
|
||||||
### 4. Components to Create
|
### 4. Components to Create
|
||||||
|
|
||||||
| Component | Path | Description |
|
| Component | Path | Description |
|
||||||
| ------------------ | ----------------------------------------------- | --------------------------- |
|
| ------------------ | -------------------------------------------------------- | --------------------------- |
|
||||||
| MetricsDashboard | `components/numbering/metrics-dashboard.tsx` | Metrics charts and gauges |
|
| MetricsDashboard | `frontend/components/numbering/metrics-dashboard.tsx` | Metrics charts and gauges |
|
||||||
| AuditLogsTable | `components/numbering/audit-logs-table.tsx` | Filterable audit log viewer |
|
| AuditLogsTable | `frontend/components/numbering/audit-logs-table.tsx` | Filterable audit log viewer |
|
||||||
| ManualOverrideForm | `components/numbering/manual-override-form.tsx` | Admin tool form |
|
| ManualOverrideForm | `frontend/components/numbering/manual-override-form.tsx` | Admin tool form |
|
||||||
| VoidReplaceForm | `components/numbering/void-replace-form.tsx` | Admin tool form |
|
| VoidReplaceForm | `frontend/components/numbering/void-replace-form.tsx` | Admin tool form |
|
||||||
| BulkImportForm | `components/numbering/bulk-import-form.tsx` | CSV/Excel uploader |
|
| BulkImportForm | `frontend/components/numbering/bulk-import-form.tsx` | CSV/Excel uploader |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
53
specs/06-tasks/TASK-FE-019-v170-refactor.md
Normal file
53
specs/06-tasks/TASK-FE-019-v170-refactor.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: 'Task: Frontend Refactoring for Schema v1.7.0'
|
||||||
|
status: IN_PROGRESS
|
||||||
|
owner: Frontend Team
|
||||||
|
created_at: 2025-12-23
|
||||||
|
related:
|
||||||
|
- specs/06-tasks/TASK-BE-018-v170-refactor.md
|
||||||
|
- specs/07-database/data-dictionary-v1.7.0.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Update frontend application to align with the refactored backend (v1.7.0 schema). This includes supporting new field mappings, new As Built drawing type, and updated document numbering logic.
|
||||||
|
|
||||||
|
## Scope of Work
|
||||||
|
|
||||||
|
### 1. Type Definitions & API Client
|
||||||
|
- **Types**: Update `Drawing`, `ContractDrawing`, `ShopDrawing` interfaces to match new backend entities (e.g. `mapCatId`, `projectId` in categories).
|
||||||
|
- **API**: Update `drawing.service.ts` to support new filter parameters (`mapCatId`) and new endpoints for As Built drawings.
|
||||||
|
|
||||||
|
### 2. Drawing Upload Form (`DrawingUploadForm`)
|
||||||
|
- **General**: Refactor to support dynamic fields based on Drawing Type.
|
||||||
|
- **Contract Drawings**:
|
||||||
|
- Replace `subCategoryId` with `mapCatId` (fetch from `contract-drawing-categories`?).
|
||||||
|
- Add `volumePage` input.
|
||||||
|
- **Shop Drawings**:
|
||||||
|
- Remove `sheetNumber` (if not applicable) or map to `legacyDrawingNumber`.
|
||||||
|
- Add `legacyDrawingNumber` input.
|
||||||
|
- Handle `title` input (sent as revision title).
|
||||||
|
- Use Project-specific categories.
|
||||||
|
- **As Built Drawings (New)**:
|
||||||
|
- Add "AS_BUILT" option.
|
||||||
|
- Implement form fields similar to Shop Drawings (or Contract depending on spec).
|
||||||
|
|
||||||
|
### 3. Drawing List & Views (`DrawingList`)
|
||||||
|
- **Contract Drawings**: Show `volumePage`.
|
||||||
|
- **Shop Drawings**:
|
||||||
|
- Display `legacyDrawingNumber`.
|
||||||
|
- Display Title from *Current Revision*.
|
||||||
|
- Remove direct title column from sort/filter if backend doesn't support it anymore on master.
|
||||||
|
- **As Built Drawings**:
|
||||||
|
- Add new Tab/Page for As Built.
|
||||||
|
- Implement List View.
|
||||||
|
|
||||||
|
### 4. Logic & Hooks
|
||||||
|
- Update `useDrawings`, `useCreateDrawing` hooks to handle new types.
|
||||||
|
- Ensure validation schemas (`zod`) match backend constraints.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [x] Contract Drawing Upload works with `mapCatId` and `volumePage`
|
||||||
|
- [x] Shop Drawing Upload works with `legacyDrawingNumber` and Revision Title
|
||||||
|
- [x] As Built Drawing Upload and List implemented
|
||||||
|
- [x] Drawing List displays correct columns for all types
|
||||||
|
- [x] No TypeScript errors
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
เอกสารนี้สรุปโครงสร้างตาราง,
|
เอกสารนี้สรุปโครงสร้างตาราง,
|
||||||
FOREIGN KEYS (FK),
|
FOREIGN KEYS (FK),
|
||||||
และ Constraints ที่สำคัญทั้งหมดในฐานข้อมูล LCBP3 - DMS (v1.7.0) เพื่อใช้เป็นเอกสารอ้างอิงสำหรับทีมพัฒนา Backend (NestJS) และ Frontend (Next.js) โดยอิงจาก Requirements และ SQL Script ล่าสุด ** สถานะ: ** FINAL GUIDELINE ** วันที่: ** 2025 -12 -18 ** อ้างอิง: ** Requirements v1.7.0 & FullStackJS Guidelines v1.7.0 ** Classification: ** Internal Technical Documentation ## 📝 สรุปรายการปรับปรุง (Summary of Changes in v1.7.0)
|
และ Constraints ที่สำคัญทั้งหมดในฐานข้อมูล LCBP3 - DMS (v1.7.0) เพื่อใช้เป็นเอกสารอ้างอิงสำหรับทีมพัฒนา Backend (NestJS) และ Frontend (Next.js) โดยอิงจาก Requirements และ SQL Script ล่าสุด ** สถานะ: ** FINAL GUIDELINE ** วันที่: ** 2025 -12 -23 ** อ้างอิง: ** Requirements v1.7.0 & FullStackJS Guidelines v1.7.0 ** Classification: ** Internal Technical Documentation ## 📝 สรุปรายการปรับปรุง (Summary of Changes in v1.7.0)
|
||||||
1. **Document Numbering Overhaul**: ปรับปรุงโครงสร้าง `document_number_counters` เปลี่ยน PK เป็น Composite 8 columns และใช้ `reset_scope` แทน `current_year`
|
1. **Drawing Tables Restructuring**:
|
||||||
2. **Audit & Error Logging**: ปรับปรุงตาราง `document_number_audit`, `document_number_errors` และเพิ่ม `document_number_reservations`
|
- `contract_drawing_subcat_cat_maps`: เปลี่ยน PK จาก composite เป็น `id` AUTO_INCREMENT พร้อม UNIQUE constraint
|
||||||
3. **JSON Schemas**: เพิ่ม columns `version`, `table_name`, `ui_schema`, `virtual_columns`, `migration_script` ในตาราง `json_schemas`
|
- `contract_drawings`: เปลี่ยน `sub_cat_id` → `map_cat_id` และเพิ่ม `volume_page`
|
||||||
4. **Schema Cleanup**: ลบ `correspondence_id` ออกจาก `rfa_revisions` และปรับปรุง Virtual Columns ใน `correspondence_revisions` ---
|
- `shop_drawing_main_categories` และ `shop_drawing_sub_categories`: เพิ่ม `project_id` (เปลี่ยนจาก global เป็น project-specific)
|
||||||
|
- `shop_drawings`: ย้าย `title` ไปยัง `shop_drawing_revisions`
|
||||||
|
- `shop_drawing_revisions`: เพิ่ม `title` และ `legacy_drawing_number`
|
||||||
|
2. **AS Built Drawings (NEW)**: เพิ่มตาราง `asbuilt_drawings`, `asbuilt_drawing_revisions`, `asbuilt_revision_shop_revisions_refs`, และ `asbuilt_drawing_revision_attachments`
|
||||||
|
3. **Document Numbering Overhaul**: ปรับปรุงโครงสร้าง `document_number_counters` เปลี่ยน PK เป็น Composite 8 columns และใช้ `reset_scope` แทน `current_year`
|
||||||
|
4. **Audit & Error Logging**: ปรับปรุงตาราง `document_number_audit`, `document_number_errors` และเพิ่ม `document_number_reservations`
|
||||||
|
5. **JSON Schemas**: เพิ่ม columns `version`, `table_name`, `ui_schema`, `virtual_columns`, `migration_script` ในตาราง `json_schemas`
|
||||||
|
6. **Schema Cleanup**: ลบ `correspondence_id` ออกจาก `rfa_revisions` และปรับปรุง Virtual Columns ใน `correspondence_revisions` ---
|
||||||
|
|
||||||
## **1. 🏢 Core & Master Data Tables (องค์กร, โครงการ, สัญญา)**
|
## **1. 🏢 Core & Master Data Tables (องค์กร, โครงการ, สัญญา)**
|
||||||
|
|
||||||
@@ -718,19 +725,21 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.4 contract_drawing_subcat_cat_maps
|
### 5.4 contract_drawing_subcat_cat_maps (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Junction table mapping sub-categories to main categories (M:N)
|
**Purpose**: Junction table mapping sub-categories to main categories (M:N)
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| ----------- | --------- | --------------- | -------------------------- |
|
| ----------- | --------- | ------------------------------- | -------------------------- |
|
||||||
| project_id | INT | PRIMARY KEY, FK | Reference to projects |
|
| **id** | **INT** | **PRIMARY KEY, AUTO_INCREMENT** | **Unique mapping ID** |
|
||||||
| sub_cat_id | INT | PRIMARY KEY, FK | Reference to sub-category |
|
| project_id | INT | NOT NULL, FK | Reference to projects |
|
||||||
| cat_id | INT | PRIMARY KEY, FK | Reference to main category |
|
| sub_cat_id | INT | NOT NULL, FK | Reference to sub-category |
|
||||||
|
| cat_id | INT | NOT NULL, FK | Reference to main category |
|
||||||
|
|
||||||
**Indexes**:
|
**Indexes**:
|
||||||
|
|
||||||
* PRIMARY KEY (project_id, sub_cat_id, cat_id)
|
* PRIMARY KEY (id)
|
||||||
|
* **UNIQUE KEY (project_id, sub_cat_id, cat_id)**
|
||||||
* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
* FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE
|
* FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE CASCADE
|
||||||
* FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE
|
* FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats(id) ON DELETE CASCADE
|
||||||
@@ -740,47 +749,49 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
**Relationships**:
|
**Relationships**:
|
||||||
|
|
||||||
* Parent: projects, contract_drawing_sub_cats, contract_drawing_cats
|
* Parent: projects, contract_drawing_sub_cats, contract_drawing_cats
|
||||||
|
* Referenced by: contract_drawings
|
||||||
|
|
||||||
**Business Rules**:
|
**Business Rules**:
|
||||||
|
|
||||||
* Allows flexible categorization
|
* Allows flexible categorization
|
||||||
* One sub-category can belong to multiple main categories
|
* One sub-category can belong to multiple main categories
|
||||||
* All three fields required for uniqueness
|
* Composite uniqueness enforced via UNIQUE constraint
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.5 contract_drawings
|
### 5.5 contract_drawings (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Master table for contract drawings (from contract specifications)
|
**Purpose**: Master table for contract drawings (from contract specifications)
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| ----------- | ------------ | ----------------------------------- | ------------------------- |
|
| --------------- | ------------ | ----------------------------------- | ---------------------------------------- |
|
||||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
|
||||||
| project_id | INT | NOT NULL, FK | Reference to projects |
|
| project_id | INT | NOT NULL, FK | Reference to projects |
|
||||||
| condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number |
|
| condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number |
|
||||||
| title | VARCHAR(255) | NOT NULL | Drawing title |
|
| title | VARCHAR(255) | NOT NULL | Drawing title |
|
||||||
| sub_cat_id | INT | NULL, FK | Reference to sub-category |
|
| **map_cat_id** | **INT** | **NULL, FK** | **[CHANGED] Reference to mapping table** |
|
||||||
| volume_id | INT | NULL, FK | Reference to volume |
|
| volume_id | INT | NULL, FK | Reference to volume |
|
||||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
|
| **volume_page** | **INT** | **NULL** | **[NEW] Page number within volume** |
|
||||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
|
||||||
| deleted_at | DATETIME | NULL | Soft delete timestamp |
|
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
|
||||||
| updated_by | INT | NULL, FK | User who last updated |
|
| deleted_at | DATETIME | NULL | Soft delete timestamp |
|
||||||
|
| updated_by | INT | NULL, FK | User who last updated |
|
||||||
|
|
||||||
**Indexes**:
|
**Indexes**:
|
||||||
|
|
||||||
* PRIMARY KEY (id)
|
* PRIMARY KEY (id)
|
||||||
* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
* FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
* FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats(id) ON DELETE RESTRICT
|
* **FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps(id) ON DELETE RESTRICT**
|
||||||
* FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT
|
* FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT
|
||||||
* FOREIGN KEY (updated_by) REFERENCES users(user_id)
|
* FOREIGN KEY (updated_by) REFERENCES users(user_id)
|
||||||
* UNIQUE KEY (project_id, condwg_no)
|
* UNIQUE KEY (project_id, condwg_no)
|
||||||
* INDEX (sub_cat_id)
|
* INDEX (map_cat_id)
|
||||||
* INDEX (volume_id)
|
* INDEX (volume_id)
|
||||||
* INDEX (deleted_at)
|
* INDEX (deleted_at)
|
||||||
|
|
||||||
**Relationships**:
|
**Relationships**:
|
||||||
|
|
||||||
* Parent: projects, contract_drawing_sub_cats, contract_drawing_volumes, users
|
* Parent: projects, contract_drawing_subcat_cat_maps, contract_drawing_volumes, users
|
||||||
* Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments
|
* Referenced by: shop_drawing_revision_contract_refs, contract_drawing_attachments
|
||||||
|
|
||||||
**Business Rules**:
|
**Business Rules**:
|
||||||
@@ -789,16 +800,18 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
* Represents baseline/contract drawings
|
* Represents baseline/contract drawings
|
||||||
* Referenced by shop drawings for compliance tracking
|
* Referenced by shop drawings for compliance tracking
|
||||||
* Soft delete preserves history
|
* Soft delete preserves history
|
||||||
|
* **map_cat_id references the mapping table for flexible categorization**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.6 shop_drawing_main_categories
|
### 5.6 shop_drawing_main_categories (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Master table for shop drawing main categories (discipline-level)
|
**Purpose**: Master table for shop drawing main categories (discipline-level)
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| ------------------ | ------------ | ----------------------------------- | ------------------------------------ |
|
| ------------------ | ------------ | ----------------------------------- | ------------------------------------ |
|
||||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID |
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique category ID |
|
||||||
|
| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** |
|
||||||
| main_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Category code (ARCH, STR, MEP, etc.) |
|
| main_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Category code (ARCH, STR, MEP, etc.) |
|
||||||
| main_category_name | VARCHAR(255) | NOT NULL | Category name |
|
| main_category_name | VARCHAR(255) | NOT NULL | Category name |
|
||||||
| description | TEXT | NULL | Category description |
|
| description | TEXT | NULL | Category description |
|
||||||
@@ -810,31 +823,33 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
**Indexes**:
|
**Indexes**:
|
||||||
|
|
||||||
* PRIMARY KEY (id)
|
* PRIMARY KEY (id)
|
||||||
|
* **FOREIGN KEY (project_id) REFERENCES projects(id)**
|
||||||
* UNIQUE (main_category_code)
|
* UNIQUE (main_category_code)
|
||||||
* INDEX (is_active)
|
* INDEX (is_active)
|
||||||
* INDEX (sort_order)
|
* INDEX (sort_order)
|
||||||
|
|
||||||
**Relationships**:
|
**Relationships**:
|
||||||
|
|
||||||
* Referenced by: shop_drawing_sub_categories, shop_drawings
|
* **Parent: projects**
|
||||||
|
* Referenced by: shop_drawings, asbuilt_drawings
|
||||||
|
|
||||||
**Business Rules**:
|
**Business Rules**:
|
||||||
|
|
||||||
* Global categories (not project-specific)
|
* **[CHANGED] Project-specific categories (was global)**
|
||||||
* Typically represents engineering disciplines
|
* Typically represents engineering disciplines
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.7 shop_drawing_sub_categories
|
### 5.7 shop_drawing_sub_categories (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Master table for shop drawing sub-categories (component-level)
|
**Purpose**: Master table for shop drawing sub-categories (component-level)
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| ----------------- | ------------ | ----------------------------------- | ----------------------------------------------- |
|
| ----------------- | ------------ | ----------------------------------- | ----------------------------------------------- |
|
||||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID |
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique sub-category ID |
|
||||||
|
| **project_id** | **INT** | **NOT NULL, FK** | **[NEW] Reference to projects** |
|
||||||
| sub_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Sub-category code (STR-COLUMN, ARCH-DOOR, etc.) |
|
| sub_category_code | VARCHAR(50) | NOT NULL, UNIQUE | Sub-category code (STR-COLUMN, ARCH-DOOR, etc.) |
|
||||||
| sub_category_name | VARCHAR(255) | NOT NULL | Sub-category name |
|
| sub_category_name | VARCHAR(255) | NOT NULL | Sub-category name |
|
||||||
| main_category_id | INT | NOT NULL, FK | Reference to main category |
|
|
||||||
| description | TEXT | NULL | Sub-category description |
|
| description | TEXT | NULL | Sub-category description |
|
||||||
| sort_order | INT | DEFAULT 0 | Display order |
|
| sort_order | INT | DEFAULT 0 | Display order |
|
||||||
| is_active | TINYINT(1) | DEFAULT 1 | Active status |
|
| is_active | TINYINT(1) | DEFAULT 1 | Active status |
|
||||||
@@ -844,26 +859,25 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
**Indexes**:
|
**Indexes**:
|
||||||
|
|
||||||
* PRIMARY KEY (id)
|
* PRIMARY KEY (id)
|
||||||
|
* **FOREIGN KEY (project_id) REFERENCES projects(id)**
|
||||||
* UNIQUE (sub_category_code)
|
* UNIQUE (sub_category_code)
|
||||||
* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id)
|
|
||||||
* INDEX (main_category_id)
|
|
||||||
* INDEX (is_active)
|
* INDEX (is_active)
|
||||||
* INDEX (sort_order)
|
* INDEX (sort_order)
|
||||||
|
|
||||||
**Relationships**:
|
**Relationships**:
|
||||||
|
|
||||||
* Parent: shop_drawing_main_categories
|
* **Parent: projects**
|
||||||
* Referenced by: shop_drawings
|
* Referenced by: shop_drawings, asbuilt_drawings
|
||||||
|
|
||||||
**Business Rules**:
|
**Business Rules**:
|
||||||
|
|
||||||
* Global sub-categories (not project-specific)
|
* **[CHANGED] Project-specific sub-categories (was global)**
|
||||||
* Hierarchical under main categories
|
* **[REMOVED] No longer hierarchical under main categories**
|
||||||
* Represents specific drawing types or components
|
* Represents specific drawing types or components
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.8 shop_drawings
|
### 5.8 shop_drawings (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Master table for shop drawings (contractor-submitted)
|
**Purpose**: Master table for shop drawings (contractor-submitted)
|
||||||
|
|
||||||
@@ -872,7 +886,6 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
|
||||||
| project_id | INT | NOT NULL, FK | Reference to projects |
|
| project_id | INT | NOT NULL, FK | Reference to projects |
|
||||||
| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number |
|
| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number |
|
||||||
| title | VARCHAR(500) | NOT NULL | Drawing title |
|
|
||||||
| main_category_id | INT | NOT NULL, FK | Reference to main category |
|
| main_category_id | INT | NOT NULL, FK | Reference to main category |
|
||||||
| sub_category_id | INT | NOT NULL, FK | Reference to sub-category |
|
| sub_category_id | INT | NOT NULL, FK | Reference to sub-category |
|
||||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
|
||||||
@@ -904,22 +917,25 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
* Represents contractor shop drawings
|
* Represents contractor shop drawings
|
||||||
* Can have multiple revisions
|
* Can have multiple revisions
|
||||||
* Soft delete preserves history
|
* Soft delete preserves history
|
||||||
|
* **[CHANGED] Title moved to shop_drawing_revisions table**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5.9 shop_drawing_revisions
|
### 5.9 shop_drawing_revisions (UPDATE v1.7.0)
|
||||||
|
|
||||||
**Purpose**: Child table storing revision history of shop drawings (1:N)
|
**Purpose**: Child table storing revision history of shop drawings (1:N)
|
||||||
|
|
||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| --------------- | ----------- | --------------------------- | ------------------------------ |
|
| ------------------------- | ---------------- | --------------------------- | ---------------------------------------- |
|
||||||
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
|
||||||
| shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID |
|
| shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID |
|
||||||
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
|
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
|
||||||
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) |
|
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) |
|
||||||
| revision_date | DATE | NULL | Revision date |
|
| revision_date | DATE | NULL | Revision date |
|
||||||
| description | TEXT | NULL | Revision description/changes |
|
| **title** | **VARCHAR(500)** | **NOT NULL** | **[NEW] Drawing title** |
|
||||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp |
|
| description | TEXT | NULL | Revision description/changes |
|
||||||
|
| **legacy_drawing_number** | **VARCHAR(100)** | **NULL** | **[NEW] Original/legacy drawing number** |
|
||||||
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp |
|
||||||
|
|
||||||
**Indexes**:
|
**Indexes**:
|
||||||
|
|
||||||
@@ -931,7 +947,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
**Relationships**:
|
**Relationships**:
|
||||||
|
|
||||||
* Parent: shop_drawings
|
* Parent: shop_drawings
|
||||||
* Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments
|
* Referenced by: rfa_items, shop_drawing_revision_contract_refs, shop_drawing_revision_attachments, asbuilt_revision_shop_revisions_refs
|
||||||
|
|
||||||
**Business Rules**:
|
**Business Rules**:
|
||||||
|
|
||||||
@@ -939,6 +955,8 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
* Each revision can reference multiple contract drawings
|
* Each revision can reference multiple contract drawings
|
||||||
* Each revision can have multiple file attachments
|
* Each revision can have multiple file attachments
|
||||||
* Linked to RFAs for approval tracking
|
* Linked to RFAs for approval tracking
|
||||||
|
* **[NEW] Title stored at revision level for version-specific naming**
|
||||||
|
* **[NEW] legacy_drawing_number supports data migration from old systems**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -970,6 +988,148 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 5.11 asbuilt_drawings (NEW v1.7.0)
|
||||||
|
|
||||||
|
**Purpose**: Master table for AS Built drawings (final construction records)
|
||||||
|
|
||||||
|
| Column Name | Data Type | Constraints | Description |
|
||||||
|
| ---------------- | ------------ | ----------------------------------- | -------------------------- |
|
||||||
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
|
||||||
|
| project_id | INT | NOT NULL, FK | Reference to projects |
|
||||||
|
| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | AS Built drawing number |
|
||||||
|
| main_category_id | INT | NOT NULL, FK | Reference to main category |
|
||||||
|
| sub_category_id | INT | NOT NULL, FK | Reference to sub-category |
|
||||||
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
|
||||||
|
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
|
||||||
|
| deleted_at | DATETIME | NULL | Soft delete timestamp |
|
||||||
|
| updated_by | INT | NULL, FK | User who last updated |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
* PRIMARY KEY (id)
|
||||||
|
* UNIQUE (drawing_number)
|
||||||
|
* FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||||
|
* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id)
|
||||||
|
* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id)
|
||||||
|
* FOREIGN KEY (updated_by) REFERENCES users(user_id)
|
||||||
|
* INDEX (project_id)
|
||||||
|
* INDEX (main_category_id)
|
||||||
|
* INDEX (sub_category_id)
|
||||||
|
* INDEX (deleted_at)
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
|
||||||
|
* Parent: projects, shop_drawing_main_categories, shop_drawing_sub_categories, users
|
||||||
|
* Children: asbuilt_drawing_revisions
|
||||||
|
|
||||||
|
**Business Rules**:
|
||||||
|
|
||||||
|
* Drawing numbers are globally unique across all projects
|
||||||
|
* Represents final as-built construction drawings
|
||||||
|
* Can have multiple revisions
|
||||||
|
* Soft delete preserves history
|
||||||
|
* Uses same category structure as shop drawings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.12 asbuilt_drawing_revisions (NEW v1.7.0)
|
||||||
|
|
||||||
|
**Purpose**: Child table storing revision history of AS Built drawings (1:N)
|
||||||
|
|
||||||
|
| Column Name | Data Type | Constraints | Description |
|
||||||
|
| --------------------- | ------------ | --------------------------- | ------------------------------ |
|
||||||
|
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
|
||||||
|
| asbuilt_drawing_id | INT | NOT NULL, FK | Master AS Built drawing ID |
|
||||||
|
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
|
||||||
|
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) |
|
||||||
|
| revision_date | DATE | NULL | Revision date |
|
||||||
|
| title | VARCHAR(500) | NOT NULL | Drawing title |
|
||||||
|
| description | TEXT | NULL | Revision description/changes |
|
||||||
|
| legacy_drawing_number | VARCHAR(100) | NULL | Original/legacy drawing number |
|
||||||
|
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
* PRIMARY KEY (id)
|
||||||
|
* FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE
|
||||||
|
* UNIQUE KEY (asbuilt_drawing_id, revision_number)
|
||||||
|
* INDEX (revision_date)
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
|
||||||
|
* Parent: asbuilt_drawings
|
||||||
|
* Referenced by: asbuilt_revision_shop_revisions_refs, asbuilt_drawing_revision_attachments
|
||||||
|
|
||||||
|
**Business Rules**:
|
||||||
|
|
||||||
|
* Revision numbers are sequential starting from 0
|
||||||
|
* Each revision can reference multiple shop drawing revisions
|
||||||
|
* Each revision can have multiple file attachments
|
||||||
|
* Title stored at revision level for version-specific naming
|
||||||
|
* legacy_drawing_number supports data migration from old systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.13 asbuilt_revision_shop_revisions_refs (NEW v1.7.0)
|
||||||
|
|
||||||
|
**Purpose**: Junction table linking AS Built drawing revisions to shop drawing revisions (M:N)
|
||||||
|
|
||||||
|
| Column Name | Data Type | Constraints | Description |
|
||||||
|
| --------------------------- | --------- | --------------- | ---------------------------------- |
|
||||||
|
| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision |
|
||||||
|
| shop_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to shop drawing revision |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
* PRIMARY KEY (asbuilt_drawing_revision_id, shop_drawing_revision_id)
|
||||||
|
* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE
|
||||||
|
* FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions(id) ON DELETE CASCADE
|
||||||
|
* INDEX (shop_drawing_revision_id)
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
|
||||||
|
* Parent: asbuilt_drawing_revisions, shop_drawing_revisions
|
||||||
|
|
||||||
|
**Business Rules**:
|
||||||
|
|
||||||
|
* Tracks which shop drawings each AS Built drawing revision is based on
|
||||||
|
* Maintains construction document lineage
|
||||||
|
* One AS Built revision can reference multiple shop drawing revisions
|
||||||
|
* Supports traceability from final construction to approved shop drawings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.14 asbuilt_drawing_revision_attachments (NEW v1.7.0)
|
||||||
|
|
||||||
|
**Purpose**: Junction table linking AS Built drawing revisions to file attachments (M:N)
|
||||||
|
|
||||||
|
| Column Name | Data Type | Constraints | Description |
|
||||||
|
| --------------------------- | ------------------------------------- | --------------- | ------------------------------------- |
|
||||||
|
| asbuilt_drawing_revision_id | INT | PRIMARY KEY, FK | Reference to AS Built revision |
|
||||||
|
| attachment_id | INT | PRIMARY KEY, FK | Reference to attachment file |
|
||||||
|
| file_type | ENUM('PDF', 'DWG', 'SOURCE', 'OTHER') | NULL | File type classification |
|
||||||
|
| is_main_document | BOOLEAN | DEFAULT FALSE | Main document flag (1 = primary file) |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
* PRIMARY KEY (asbuilt_drawing_revision_id, attachment_id)
|
||||||
|
* FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions(id) ON DELETE CASCADE
|
||||||
|
* FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
|
||||||
|
* INDEX (attachment_id)
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
|
||||||
|
* Parent: asbuilt_drawing_revisions, attachments
|
||||||
|
|
||||||
|
**Business Rules**:
|
||||||
|
|
||||||
|
* Each AS Built revision can have multiple file attachments
|
||||||
|
* File types: PDF (documents), DWG (CAD files), SOURCE (source files), OTHER (miscellaneous)
|
||||||
|
* One attachment can be marked as main document per revision
|
||||||
|
* Cascade delete when revision is deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## **6. 🔄 Circulations Tables (ใบเวียนภายใน)**
|
## **6. 🔄 Circulations Tables (ใบเวียนภายใน)**
|
||||||
|
|
||||||
### 6.1 circulation_status_codes
|
### 6.1 circulation_status_codes
|
||||||
@@ -1281,6 +1441,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types |
|
| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types |
|
||||||
| format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) |
|
| format_string | VARCHAR(100) | NOT NULL | Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#) |
|
||||||
| description | TEXT | NULL | Format description |
|
| description | TEXT | NULL | Format description |
|
||||||
|
| reset_annually | BOOLEAN | DEFAULT TRUE | Start sequence new every year |
|
||||||
| is_active | TINYINT(1) | DEFAULT 1 | Active status |
|
| is_active | TINYINT(1) | DEFAULT 1 | Active status |
|
||||||
|
|
||||||
**Indexes**:
|
**Indexes**:
|
||||||
@@ -1341,17 +1502,20 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
|
|||||||
| Column Name | Data Type | Constraints | Description |
|
| Column Name | Data Type | Constraints | Description |
|
||||||
| :------------------------- | :----------- | :----------------- | :-------------------------------------- |
|
| :------------------------- | :----------- | :----------------- | :-------------------------------------- |
|
||||||
| id | INT | PK, AI | ID ของ audit record |
|
| id | INT | PK, AI | ID ของ audit record |
|
||||||
| document_id | INT | NOT NULL, FK | ID ของเอกสารที่สร้างเลขที่ |
|
| document_id | INT | NULL, FK | ID ของเอกสารที่สร้างเลขที่ (NULL if failed) |
|
||||||
| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร |
|
| document_type | VARCHAR(50) | NULL | ประเภทเอกสาร |
|
||||||
| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) |
|
| document_number | VARCHAR(100) | NOT NULL | เลขที่เอกสารที่สร้าง (ผลลัพธ์) |
|
||||||
| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. |
|
| operation | ENUM | DEFAULT 'CONFIRM' | RESERVE, CONFIRM, MANUAL_OVERRIDE, etc. |
|
||||||
| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID |
|
| status | ENUM | DEFAULT 'RESERVED' | RESERVED, CONFIRMED, CANCELLED, VOID |
|
||||||
| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) |
|
| counter_key | JSON | NOT NULL | Counter key ที่ใช้ (JSON 8 fields) |
|
||||||
| reservation_token | VARCHAR(36) | NULL | Token การจอง |
|
| reservation_token | VARCHAR(36) | NULL | Token การจอง |
|
||||||
|
| idempotency_key | VARCHAR(128) | NULL | Idempotency Key from request |
|
||||||
| originator_organization_id | INT | NULL | องค์กรผู้ส่ง |
|
| originator_organization_id | INT | NULL | องค์กรผู้ส่ง |
|
||||||
| recipient_organization_id | INT | NULL | องค์กรผู้รับ |
|
| recipient_organization_id | INT | NULL | องค์กรผู้รับ |
|
||||||
| template_used | VARCHAR(200) | NOT NULL | Template ที่ใช้ในการสร้าง |
|
| template_used | VARCHAR(200) | NOT NULL | Template ที่ใช้ในการสร้าง |
|
||||||
| user_id | INT | NOT NULL, FK | ผู้ขอสร้างเลขที่ |
|
| old_value | TEXT | NULL | Previous value |
|
||||||
|
| new_value | TEXT | NULL | New value |
|
||||||
|
| user_id | INT | NULL, FK | ผู้ขอสร้างเลขที่ |
|
||||||
| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ |
|
| is_success | BOOLEAN | DEFAULT TRUE | สถานะความสำเร็จ |
|
||||||
| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง |
|
| created_at | TIMESTAMP | DEFAULT NOW | วันที่/เวลาที่สร้าง |
|
||||||
| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) |
|
| total_duration_ms | INT | NULL | เวลารวมทั้งหมดในการสร้าง (ms) |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- DMS v1.6.0 Document Management System Database
|
-- DMS v1.7.0 Document Management System Database
|
||||||
-- Deploy Script Schema
|
-- Deploy Script Schema
|
||||||
-- Server: Container Station on QNAP TS-473A
|
-- Server: Container Station on QNAP TS-473A
|
||||||
-- Database service: MariaDB 11.8
|
-- Database service: MariaDB 11.8
|
||||||
@@ -10,11 +10,22 @@
|
|||||||
-- reverse proxy: jc21/nginx-proxy-manager:latest
|
-- reverse proxy: jc21/nginx-proxy-manager:latest
|
||||||
-- cron service: n8n
|
-- cron service: n8n
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- [v1.6.0 UPDATE] Refactor Schema
|
-- [v1.7.0 UPDATE] Refactor Schema
|
||||||
-- Update: Upgraded from v1.5.1
|
-- Update: Upgraded from v1.6.0
|
||||||
-- Last Updated: 2025-12-13
|
-- Last Updated: 2025-12-18
|
||||||
-- Major Changes:
|
-- Major Changes:
|
||||||
-- 1. ปรับปรุง: corespondences, correspondence_revisions, correspondence_recipients, rfas, rfa_revisions
|
-- 1. ปรับปรุง:
|
||||||
|
-- 1.1 TABLE contract_drawings
|
||||||
|
-- 1.2 TABLE contract_drawing_subcat_cat_maps
|
||||||
|
-- 1.3 TABLE shop_drawing_sub_categories
|
||||||
|
-- 1.4 TABLE shop_drawing_main_categories
|
||||||
|
-- 1.5 TABLE shop_drawings
|
||||||
|
-- 1.6 TABLE shop_drawing_revisions
|
||||||
|
-- 2. เพิ่ม:
|
||||||
|
-- 2.1 TABLE asbuilt_drawings
|
||||||
|
-- 2.2 TABLE asbuilt_drawing_revisions
|
||||||
|
-- 2.3 TABLE asbuilt_revision_shop_revisions_refs
|
||||||
|
-- 2.4 TABLE asbuilt_drawing_revision_attachments
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
@@ -73,6 +84,8 @@ DROP TABLE IF EXISTS document_number_reservations;
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
DROP TABLE IF EXISTS correspondence_tags;
|
DROP TABLE IF EXISTS correspondence_tags;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS asbuilt_revision_shop_revisions_refs;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS shop_drawing_revision_contract_refs;
|
DROP TABLE IF EXISTS shop_drawing_revision_contract_refs;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS contract_drawing_subcat_cat_maps;
|
DROP TABLE IF EXISTS contract_drawing_subcat_cat_maps;
|
||||||
@@ -86,6 +99,8 @@ DROP TABLE IF EXISTS circulation_attachments;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS shop_drawing_revision_attachments;
|
DROP TABLE IF EXISTS shop_drawing_revision_attachments;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS asbuilt_drawing_revision_attachments;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS correspondence_attachments;
|
DROP TABLE IF EXISTS correspondence_attachments;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS attachments;
|
DROP TABLE IF EXISTS attachments;
|
||||||
@@ -113,6 +128,8 @@ DROP TABLE IF EXISTS transmittal_items;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS shop_drawing_revisions;
|
DROP TABLE IF EXISTS shop_drawing_revisions;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS asbuilt_drawing_revisions;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS rfa_items;
|
DROP TABLE IF EXISTS rfa_items;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS rfa_revisions;
|
DROP TABLE IF EXISTS rfa_revisions;
|
||||||
@@ -135,6 +152,8 @@ DROP TABLE IF EXISTS contract_drawings;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS shop_drawings;
|
DROP TABLE IF EXISTS shop_drawings;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS asbuilt_drawings;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS rfas;
|
DROP TABLE IF EXISTS rfas;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS correspondences;
|
DROP TABLE IF EXISTS correspondences;
|
||||||
@@ -720,12 +739,7 @@ CREATE TABLE contract_drawing_subcat_cat_maps (
|
|||||||
project_id INT COMMENT 'ID ของโครงการ',
|
project_id INT COMMENT 'ID ของโครงการ',
|
||||||
sub_cat_id INT COMMENT 'ID ของหมวดหมู่ย่อย',
|
sub_cat_id INT COMMENT 'ID ของหมวดหมู่ย่อย',
|
||||||
cat_id INT COMMENT 'ID ของหมวดหมู่หลัก',
|
cat_id INT COMMENT 'ID ของหมวดหมู่หลัก',
|
||||||
PRIMARY KEY (
|
UNIQUE KEY ux_map_unique (project_id, sub_cat_id, cat_id),
|
||||||
id,
|
|
||||||
project_id,
|
|
||||||
sub_cat_id,
|
|
||||||
cat_id
|
|
||||||
),
|
|
||||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats (id) ON DELETE CASCADE,
|
FOREIGN KEY (sub_cat_id) REFERENCES contract_drawing_sub_cats (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats (id) ON DELETE CASCADE
|
FOREIGN KEY (cat_id) REFERENCES contract_drawing_cats (id) ON DELETE CASCADE
|
||||||
@@ -821,6 +835,49 @@ CREATE TABLE shop_drawing_revision_contract_refs (
|
|||||||
FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings (id) ON DELETE CASCADE
|
FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings (id) ON DELETE CASCADE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M :N)';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M :N)';
|
||||||
|
|
||||||
|
-- ตาราง Master เก็บข้อมูล "AS Built"
|
||||||
|
CREATE TABLE asbuilt_drawings (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||||
|
project_id INT NOT NULL COMMENT 'โครงการ',
|
||||||
|
drawing_number VARCHAR(100) NOT NULL UNIQUE COMMENT 'เลขที่ AS Built Drawing',
|
||||||
|
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
|
||||||
|
sub_category_id INT NOT NULL COMMENT 'หมวดหมู่ย่อย',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
|
||||||
|
deleted_at DATETIME NULL COMMENT 'วันที่ลบ',
|
||||||
|
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด',
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id),
|
||||||
|
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id),
|
||||||
|
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
|
||||||
|
|
||||||
|
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
|
||||||
|
CREATE TABLE asbuilt_drawing_revisions (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
|
||||||
|
asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID',
|
||||||
|
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
|
||||||
|
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
|
||||||
|
revision_date DATE COMMENT 'วันที่ของ Revision',
|
||||||
|
title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ',
|
||||||
|
description TEXT COMMENT 'คำอธิบายการแก้ไข',
|
||||||
|
legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ AS Built Drawing',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
||||||
|
FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE KEY ux_sd_rev_drawing_revision (asbuilt_drawing_id, revision_number)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1 :N)';
|
||||||
|
|
||||||
|
-- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawings (M:N)
|
||||||
|
CREATE TABLE asbuilt_revision_shop_revisions_refs (
|
||||||
|
asbuilt_drawing_revision_id INT COMMENT 'ID ของ AS Built Drawing Revision',
|
||||||
|
shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision',
|
||||||
|
PRIMARY KEY (
|
||||||
|
asbuilt_drawing_revision_id,
|
||||||
|
shop_drawing_revision_id
|
||||||
|
),
|
||||||
|
FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions (id) ON DELETE CASCADE
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (M :N)';
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 6. 🔄 Circulations (ใบเวียนภายใน)
|
-- 6. 🔄 Circulations (ใบเวียนภายใน)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
@@ -926,6 +983,25 @@ CREATE TABLE circulation_attachments (
|
|||||||
FOREIGN KEY (attachment_id) REFERENCES attachments (id) ON DELETE CASCADE
|
FOREIGN KEY (attachment_id) REFERENCES attachments (id) ON DELETE CASCADE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อม circulations กับ attachments (M :N)';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อม circulations กับ attachments (M :N)';
|
||||||
|
|
||||||
|
-- ตารางเชื่อม shop_drawing_revisions กับ attachments (M:N)
|
||||||
|
CREATE TABLE asbuilt_drawing_revision_attachments (
|
||||||
|
asbuilt_drawing_revision_id INT COMMENT 'ID ของ asbuilt Drawing Revision',
|
||||||
|
attachment_id INT COMMENT 'ID ของไฟล์แนบ',
|
||||||
|
file_type ENUM(
|
||||||
|
'PDF',
|
||||||
|
'DWG',
|
||||||
|
'SOURCE',
|
||||||
|
'OTHER '
|
||||||
|
) COMMENT 'ประเภทไฟล์',
|
||||||
|
is_main_document BOOLEAN DEFAULT FALSE COMMENT '(1 = ไฟล์หลัก)',
|
||||||
|
PRIMARY KEY (
|
||||||
|
asbuilt_drawing_revision_id,
|
||||||
|
attachment_id
|
||||||
|
),
|
||||||
|
FOREIGN KEY (asbuilt_drawing_revision_id) REFERENCES asbuilt_drawing_revisions (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (attachment_id) REFERENCES attachments (id) ON DELETE CASCADE
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อม asbuilt_drawing_revisions กับ attachments (M :N)';
|
||||||
|
|
||||||
-- ตารางเชื่อม shop_drawing_revisions กับ attachments (M:N)
|
-- ตารางเชื่อม shop_drawing_revisions กับ attachments (M:N)
|
||||||
CREATE TABLE shop_drawing_revision_attachments (
|
CREATE TABLE shop_drawing_revision_attachments (
|
||||||
shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision',
|
shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision',
|
||||||
@@ -973,13 +1049,10 @@ CREATE TABLE document_number_formats (
|
|||||||
project_id INT NOT NULL COMMENT 'โครงการ',
|
project_id INT NOT NULL COMMENT 'โครงการ',
|
||||||
correspondence_type_id INT NULL COMMENT 'ประเภทเอกสาร',
|
correspondence_type_id INT NULL COMMENT 'ประเภทเอกสาร',
|
||||||
discipline_id INT DEFAULT 0 COMMENT 'สาขางาน (0 = ทุกสาขา/ไม่ระบุ)',
|
discipline_id INT DEFAULT 0 COMMENT 'สาขางาน (0 = ทุกสาขา/ไม่ระบุ)',
|
||||||
format_template VARCHAR(255) NOT NULL COMMENT 'รูปแบบ Template (เช่น {ORG}-{RECIPIENT}-{SEQ:4}-{YEAR:BE})',
|
format_string VARCHAR(100) NOT NULL COMMENT 'Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#)',
|
||||||
reset_sequence_yearly TINYINT(1) DEFAULT 1,
|
description TEXT COMMENT 'Format description',
|
||||||
example_number VARCHAR(100) COMMENT 'ตัวอย่างเลขที่ได้จาก Template',
|
|
||||||
padding_length INT DEFAULT 4 COMMENT 'ความยาวของลำดับเลข (Padding)',
|
|
||||||
reset_annually BOOLEAN DEFAULT TRUE COMMENT 'เริ่มนับใหม่ทุกปี',
|
reset_annually BOOLEAN DEFAULT TRUE COMMENT 'เริ่มนับใหม่ทุกปี',
|
||||||
is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน',
|
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน',
|
||||||
description TEXT COMMENT 'คำอธิบายรูปแบบนี้',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
|
||||||
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
@@ -1038,10 +1111,11 @@ CREATE TABLE document_number_counters (
|
|||||||
-- Constraints
|
-- Constraints
|
||||||
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0),
|
CONSTRAINT chk_last_number_positive CHECK (last_number >= 0),
|
||||||
CONSTRAINT chk_reset_scope_format CHECK (
|
CONSTRAINT chk_reset_scope_format CHECK (
|
||||||
reset_scope IN ('NONE')
|
reset_scope IN ('NONE')
|
||||||
OR reset_scope LIKE 'YEAR_%'
|
OR reset_scope LIKE 'YEAR_%'
|
||||||
OR reset_scope LIKE 'MONTH_%'
|
OR reset_scope LIKE 'MONTH_%'
|
||||||
OR reset_scope LIKE 'CONTRACT_%')
|
OR reset_scope LIKE 'CONTRACT_%'
|
||||||
|
)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Running Number Counters - รองรับ 8-column composite PK';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Running Number Counters - รองรับ 8-column composite PK';
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@@ -1052,7 +1126,7 @@ CREATE TABLE document_number_counters (
|
|||||||
CREATE TABLE document_number_audit (
|
CREATE TABLE document_number_audit (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID ของ audit record',
|
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID ของ audit record',
|
||||||
-- Document Info
|
-- Document Info
|
||||||
document_id INT NOT NULL COMMENT 'ID ของเอกสารที่สร้างเลขที่ (correspondences.id)',
|
document_id INT NULL COMMENT 'ID ของเอกสารที่สร้างเลขที่ (correspondences.id) - NULL if failed/reserved',
|
||||||
document_type VARCHAR(50),
|
document_type VARCHAR(50),
|
||||||
document_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสารที่สร้าง (ผลลัพธ์)',
|
document_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสารที่สร้าง (ผลลัพธ์)',
|
||||||
operation ENUM(
|
operation ENUM(
|
||||||
@@ -1062,23 +1136,23 @@ CREATE TABLE document_number_audit (
|
|||||||
'VOID_REPLACE',
|
'VOID_REPLACE',
|
||||||
'CANCEL'
|
'CANCEL'
|
||||||
) NOT NULL DEFAULT 'CONFIRM' COMMENT 'ประเภทการดำเนินการ',
|
) NOT NULL DEFAULT 'CONFIRM' COMMENT 'ประเภทการดำเนินการ',
|
||||||
status ENUM(
|
STATUS ENUM(
|
||||||
'RESERVED',
|
'RESERVED',
|
||||||
'CONFIRMED',
|
'CONFIRMED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'VOID',
|
'VOID',
|
||||||
'MANUAL'
|
'MANUAL'
|
||||||
) NOT NULL DEFAULT 'RESERVED' COMMENT 'สถานะเลขที่เอกสาร',
|
) NOT NULL DEFAULT 'RESERVED' COMMENT 'สถานะเลขที่เอกสาร',
|
||||||
counter_key JSON NOT NULL COMMENT 'Counter key ที่ใช้ (JSON format) - 8 fields',
|
counter_key JSON NOT NULL COMMENT 'Counter key ที่ใช้ (JSON format) - 8 fields',
|
||||||
reservation_token VARCHAR(36) NULL,
|
reservation_token VARCHAR(36) NULL,
|
||||||
|
idempotency_key VARCHAR(128) NULL COMMENT 'Idempotency Key from request',
|
||||||
originator_organization_id INT NULL,
|
originator_organization_id INT NULL,
|
||||||
recipient_organization_id INT NULL,
|
recipient_organization_id INT NULL,
|
||||||
|
|
||||||
template_used VARCHAR(200) NOT NULL COMMENT 'Template ที่ใช้ในการสร้าง',
|
template_used VARCHAR(200) NOT NULL COMMENT 'Template ที่ใช้ในการสร้าง',
|
||||||
old_value TEXT NULL,
|
old_value TEXT NULL COMMENT 'Previous value for audit',
|
||||||
new_value TEXT NULL,
|
new_value TEXT NULL COMMENT 'New value for audit',
|
||||||
-- User Info
|
-- User Info
|
||||||
user_id INT NOT NULL COMMENT 'ผู้ขอสร้างเลขที่',
|
user_id INT NULL COMMENT 'ผู้ขอสร้างเลขที่',
|
||||||
ip_address VARCHAR(45) COMMENT 'IP address ของผู้ขอ (IPv4/IPv6)',
|
ip_address VARCHAR(45) COMMENT 'IP address ของผู้ขอ (IPv4/IPv6)',
|
||||||
user_agent TEXT COMMENT 'User agent string (browser info)',
|
user_agent TEXT COMMENT 'User agent string (browser info)',
|
||||||
is_success BOOLEAN DEFAULT TRUE,
|
is_success BOOLEAN DEFAULT TRUE,
|
||||||
@@ -1089,14 +1163,14 @@ CREATE TABLE document_number_audit (
|
|||||||
total_duration_ms INT COMMENT 'เวลารวมทั้งหมดในการสร้าง (milliseconds)',
|
total_duration_ms INT COMMENT 'เวลารวมทั้งหมดในการสร้าง (milliseconds)',
|
||||||
fallback_used ENUM('NONE', 'DB_LOCK', 'RETRY') DEFAULT 'NONE' COMMENT 'Fallback strategy ที่ถูกใช้ (NONE=normal, DB_LOCK=Redis down, RETRY=conflict)',
|
fallback_used ENUM('NONE', 'DB_LOCK', 'RETRY') DEFAULT 'NONE' COMMENT 'Fallback strategy ที่ถูกใช้ (NONE=normal, DB_LOCK=Redis down, RETRY=conflict)',
|
||||||
metadata JSON COMMENT 'Additional context data',
|
metadata JSON COMMENT 'Additional context data',
|
||||||
|
|
||||||
-- Indexes for performance
|
-- Indexes for performance
|
||||||
INDEX idx_document_id (document_id),
|
INDEX idx_document_id (document_id),
|
||||||
INDEX idx_user_id (user_id),
|
INDEX idx_user_id (user_id),
|
||||||
INDEX idx_status (status),
|
INDEX idx_status (STATUS),
|
||||||
INDEX idx_operation (operation),
|
INDEX idx_operation (operation),
|
||||||
INDEX idx_document_number (document_number),
|
INDEX idx_document_number (document_number),
|
||||||
INDEX idx_reservation_token (reservation_token),
|
INDEX idx_reservation_token (reservation_token),
|
||||||
|
INDEX idx_idempotency_key (idempotency_key),
|
||||||
INDEX idx_created_at (created_at),
|
INDEX idx_created_at (created_at),
|
||||||
-- Foreign Keys
|
-- Foreign Keys
|
||||||
FOREIGN KEY (document_id) REFERENCES correspondences (id) ON DELETE CASCADE,
|
FOREIGN KEY (document_id) REFERENCES correspondences (id) ON DELETE CASCADE,
|
||||||
@@ -1172,11 +1246,13 @@ CREATE TABLE document_number_reservations (
|
|||||||
INDEX idx_user_id (user_id),
|
INDEX idx_user_id (user_id),
|
||||||
INDEX idx_reserved_at (reserved_at),
|
INDEX idx_reserved_at (reserved_at),
|
||||||
-- Foreign Keys
|
-- Foreign Keys
|
||||||
FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE SET NULL,
|
FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
SET NULL,
|
||||||
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE,
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Document Number Reservations - Two-Phase Commit';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Document Number Reservations - Two-Phase Commit';
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 10. ⚙️ System & Logs (ระบบและ Log)
|
-- 10. ⚙️ System & Logs (ระบบและ Log)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
@@ -1488,7 +1564,6 @@ CREATE INDEX idx_backup_logs_completed_at ON backup_logs (completed_at);
|
|||||||
-- Additional Composite Indexes for Performance
|
-- Additional Composite Indexes for Performance
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Composite index for document_number_counters for faster lookups
|
-- Composite index for document_number_counters for faster lookups
|
||||||
|
|
||||||
-- Composite index for notifications for user-specific queries
|
-- Composite index for notifications for user-specific queries
|
||||||
CREATE INDEX idx_notifications_user_unread ON notifications (user_id, is_read, created_at);
|
CREATE INDEX idx_notifications_user_unread ON notifications (user_id, is_read, created_at);
|
||||||
|
|
||||||
@@ -1902,4 +1977,8 @@ CREATE INDEX idx_correspondences_project_type ON correspondences (project_id, co
|
|||||||
|
|
||||||
CREATE INDEX idx_corr_revisions_status_current ON correspondence_revisions (correspondence_status_id, is_current);
|
CREATE INDEX idx_corr_revisions_status_current ON correspondence_revisions (correspondence_status_id, is_current);
|
||||||
|
|
||||||
|
CREATE INDEX IDX_AUDIT_DOC_ID ON document_number_audit (document_id);
|
||||||
|
CREATE INDEX IDX_AUDIT_STATUS ON document_number_audit (status);
|
||||||
|
CREATE INDEX IDX_AUDIT_OPERATION ON document_number_audit (operation);
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,3 +13,6 @@
|
|||||||
## TABLE shop_drawing_revisions
|
## TABLE shop_drawing_revisions
|
||||||
- add title
|
- add title
|
||||||
- add legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
- add legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
||||||
|
## TABLE asbuilt_drawings
|
||||||
|
## TABLE asbuilt_drawing_revisions
|
||||||
|
## TABLE asbuilt_revision_shop_revisions_refs
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Session History - 2025-12-23: Document Numbering Form Refactoring
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Refactor and debug the "Test Number Generation" (Template Tester) form to support real API validation and master data integration.
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### 1. Frontend Refactoring (`template-tester.tsx`)
|
||||||
|
- **Master Data Integration**: Replaced manual text inputs with `Select` components for Originator, Recipient, Document Type, and Discipline.
|
||||||
|
- **Dynamic Data Hook**:
|
||||||
|
- Integrated `useOrganizations`, `useCorrespondenceTypes`, and `useDisciplines`.
|
||||||
|
- Fixed empty Discipline list by adding `useContracts` to fetch active contracts for the project and deriving `contractId` dynamically.
|
||||||
|
- **API Integration**: Switched from mock `generateTestNumber` to backend `previewNumber` endpoint.
|
||||||
|
- **UI Enhancements**:
|
||||||
|
- Added "Default (All Types)" and "None" options to dropdowns.
|
||||||
|
- Improved error feedback with a visible error card if generation fails.
|
||||||
|
- **Type Safety**:
|
||||||
|
- Resolved multiple lint errors (`Unexpected any`, missing properties).
|
||||||
|
- Updated `SearchOrganizationDto` in `organization.dto.ts` to include `isActive`.
|
||||||
|
|
||||||
|
### 2. Backend API Harmonization
|
||||||
|
- **DTO Updates**:
|
||||||
|
- Refactored `PreviewNumberDto` to use `originatorId` and `typeId` (aligned with frontend naming).
|
||||||
|
- Added `@Type(() => Number)` and `@IsInt()` to ensure proper payload transformation.
|
||||||
|
- **Service Logic**:
|
||||||
|
- Fixed `CounterService` mapping to correctly use the entity property `originatorId` instead of the DTO naming `originatorOrganizationId` in WHERE clauses and creation logic.
|
||||||
|
- Updated `DocumentNumberingController` to map the new DTO properties.
|
||||||
|
|
||||||
|
### 3. Troubleshooting & Reversion
|
||||||
|
- **Issue**: "Format Preview" was reported as missing.
|
||||||
|
- **Action**: Attempted a property rename from `formatTemplate` to `formatString` across the frontend based on database column naming.
|
||||||
|
- **Result**: This caused the entire Document Numbering page to fail (UI became empty) because the backend entity still uses the property name `formatTemplate`.
|
||||||
|
- **Resolution**: Reverted all renaming changes back to `formatTemplate`. The initial "missing" issue was resolved by ensuring proper prop passing and data loading.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- **Test Generation Form**: Fully functional and integrated with real master data.
|
||||||
|
- **Preview API**: Validated and working with correct database mapping.
|
||||||
|
- **Next Steps**: Monitor for any further data-specific generation errors (e.g., Template format parsing).
|
||||||
|
|
||||||
|
---
|
||||||
|
**Reference Task**: [TASK-FE-017-document-numbering-refactor.md](../06-tasks/TASK-FE-017-document-numbering-refactor.md)
|
||||||
38
specs/09-history/2025-12-23-frontend-refactor-v170.md
Normal file
38
specs/09-history/2025-12-23-frontend-refactor-v170.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Session History: Frontend Refactoring for v1.7.0 Schema
|
||||||
|
|
||||||
|
**Date:** 2025-12-23
|
||||||
|
**Objective:** Refactor frontend components and services to align with the v1.7.0 database schema and document numbering requirements.
|
||||||
|
|
||||||
|
## 1. Summary of Changes
|
||||||
|
|
||||||
|
### Frontend Refactoring
|
||||||
|
- **`DrawingUploadForm` Refactor:**
|
||||||
|
- Implemented dynamic validation validation schemas using Zod discriminated unions.
|
||||||
|
- Added support for Contract Drawing fields: `mapCatId`, `volumePage`.
|
||||||
|
- Added support for Shop/AsBuilt fields: `legacyDrawingNumber`, `revisionTitle`.
|
||||||
|
- Added full support for `AS_BUILT` drawing type.
|
||||||
|
- Dynamically passes `projectId` to context hooks.
|
||||||
|
- **`DrawingList` & `DrawingCard`:**
|
||||||
|
- Added `AS_BUILT` tab support.
|
||||||
|
- Implemented conditional rendering for new columns (`Volume Page`, `Legacy No.`, `Rev. Title`).
|
||||||
|
- **Service Layer Updates:**
|
||||||
|
- Migrated `ContractDrawingService`, `ShopDrawingService`, and `AsbuiltDrawingService` to use `FormData` for all creation/upload methods to ensure correct binary file handling.
|
||||||
|
- Updated Types to fully match backend DTOs.
|
||||||
|
- **Documentation:**
|
||||||
|
- Updated `task.md` and `walkthrough.md`.
|
||||||
|
|
||||||
|
## 2. Issues Encountered & Status
|
||||||
|
|
||||||
|
### Resolved
|
||||||
|
- Fixed `Unexpected any` lint errors in `DrawingUploadForm` (mostly).
|
||||||
|
- Resolved type mismatches in state identifiers.
|
||||||
|
|
||||||
|
### Known Issues (Pending Fix)
|
||||||
|
- **Build Failure**: `pnpm build` failed in `frontend/app/(admin)/admin/numbering/[id]/page.tsx`.
|
||||||
|
- **Error**: `Object literal may only specify known properties, and 'templateId' does not exist in type 'Partial<NumberingTemplate>'.`
|
||||||
|
- **Location**: `numberingApi.saveTemplate({ ...data, templateId: parseInt(params.id) });`
|
||||||
|
- **Cause**: The `saveTemplate` method likely expects a specific DTO that conflicts with the spread `...data` or the explicit `templateId` property assignment. This needs to be addressed in the next session.
|
||||||
|
|
||||||
|
## 3. Next Steps
|
||||||
|
- Fix the build error in `admin/numbering/[id]/page.tsx`.
|
||||||
|
- Proceed with full end-to-end testing of the drawing upload flows.
|
||||||
Reference in New Issue
Block a user