260228:1412 20260228: setup n8n
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -48,6 +48,7 @@ import { MonitoringModule } from './modules/monitoring/monitoring.module';
|
|||||||
import { ResilienceModule } from './common/resilience/resilience.module';
|
import { ResilienceModule } from './common/resilience/resilience.module';
|
||||||
import { SearchModule } from './modules/search/search.module';
|
import { SearchModule } from './modules/search/search.module';
|
||||||
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||||
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -158,6 +159,7 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
|||||||
NotificationModule,
|
NotificationModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
AuditLogModule,
|
AuditLogModule,
|
||||||
|
MigrationModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ describe('FileStorageService', () => {
|
|||||||
attachmentRepo = module.get(getRepositoryToken(Attachment));
|
attachmentRepo = module.get(getRepositoryToken(Attachment));
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
(fs.ensureDirSync as jest.Mock).mockReturnValue(true);
|
(fs.ensureDirSync as unknown as jest.Mock).mockReturnValue(true);
|
||||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
(fs.writeFile as unknown as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.pathExists as jest.Mock).mockResolvedValue(true);
|
(fs.pathExists as unknown as jest.Mock).mockResolvedValue(true);
|
||||||
(fs.move as jest.Mock).mockResolvedValue(undefined);
|
(fs.move as unknown as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.remove as jest.Mock).mockResolvedValue(undefined);
|
(fs.remove as unknown as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.readFile as unknown as jest.Mock).mockResolvedValue(
|
||||||
|
Buffer.from('test')
|
||||||
|
);
|
||||||
|
(fs.stat as unknown as jest.Mock).mockResolvedValue({ size: 1024 });
|
||||||
|
(fs.ensureDir as unknown as jest.Mock).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -86,7 +91,7 @@ describe('FileStorageService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestException if write fails', async () => {
|
it('should throw BadRequestException if write fails', async () => {
|
||||||
(fs.writeFile as jest.Mock).mockRejectedValueOnce(
|
(fs.writeFile as unknown as jest.Mock).mockRejectedValueOnce(
|
||||||
new Error('Write error')
|
new Error('Write error')
|
||||||
);
|
);
|
||||||
await expect(service.upload(mockFile, 1)).rejects.toThrow(
|
await expect(service.upload(mockFile, 1)).rejects.toThrow(
|
||||||
|
|||||||
@@ -201,6 +201,77 @@ export class FileStorageService {
|
|||||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ NEW: Import Staging File (For Legacy Migration)
|
||||||
|
* ย้ายไฟล์จาก staging_ai ไปยัง permanent storage โดยตรง
|
||||||
|
*/
|
||||||
|
async importStagingFile(
|
||||||
|
sourceFilePath: string,
|
||||||
|
userId: number,
|
||||||
|
options?: { issueDate?: Date; documentType?: string }
|
||||||
|
): Promise<Attachment> {
|
||||||
|
if (!(await fs.pathExists(sourceFilePath))) {
|
||||||
|
this.logger.error(`Staging file not found: ${sourceFilePath}`);
|
||||||
|
throw new NotFoundException(`Source file not found: ${sourceFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get file stats & checksum
|
||||||
|
const stats = await fs.stat(sourceFilePath);
|
||||||
|
const fileExt = path.extname(sourceFilePath);
|
||||||
|
const originalFilename = path.basename(sourceFilePath);
|
||||||
|
const storedFilename = `${uuidv4()}${fileExt}`;
|
||||||
|
|
||||||
|
// Determine mime type basic
|
||||||
|
let mimeType = 'application/octet-stream';
|
||||||
|
if (fileExt.toLowerCase() === '.pdf') mimeType = 'application/pdf';
|
||||||
|
else if (fileExt.toLowerCase() === '.xlsx')
|
||||||
|
mimeType =
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
|
||||||
|
const fileBuffer = await fs.readFile(sourceFilePath);
|
||||||
|
const checksum = this.calculateChecksum(fileBuffer);
|
||||||
|
|
||||||
|
// 2. Generate Permanent Path
|
||||||
|
const refDate = options?.issueDate || new Date();
|
||||||
|
const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate;
|
||||||
|
const year = effectiveDate.getFullYear().toString();
|
||||||
|
const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const docTypeFolder = options?.documentType || 'General';
|
||||||
|
|
||||||
|
const permanentDir = path.join(
|
||||||
|
this.permanentDir,
|
||||||
|
docTypeFolder,
|
||||||
|
year,
|
||||||
|
month
|
||||||
|
);
|
||||||
|
await fs.ensureDir(permanentDir);
|
||||||
|
|
||||||
|
const newPath = path.join(permanentDir, storedFilename);
|
||||||
|
|
||||||
|
// 3. Move File
|
||||||
|
try {
|
||||||
|
await fs.move(sourceFilePath, newPath, { overwrite: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to move staging file to ${newPath}`, error);
|
||||||
|
throw new BadRequestException('Failed to process staging file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Database Record
|
||||||
|
const attachment = this.attachmentRepository.create({
|
||||||
|
originalFilename,
|
||||||
|
storedFilename,
|
||||||
|
filePath: newPath,
|
||||||
|
mimeType,
|
||||||
|
fileSize: stats.size,
|
||||||
|
isTemporary: false,
|
||||||
|
referenceDate: effectiveDate,
|
||||||
|
checksum,
|
||||||
|
uploadedByUserId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.attachmentRepository.save(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ✅ NEW: Delete File
|
* ✅ NEW: Delete File
|
||||||
* ลบไฟล์ออกจาก Disk และ Database
|
* ลบไฟล์ออกจาก Disk และ Database
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('CorrespondenceController', () => {
|
|||||||
const createDto = {
|
const createDto = {
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
typeId: 1,
|
typeId: 1,
|
||||||
title: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await controller.create(
|
const result = await controller.create(
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ describe('ManualOverrideService', () => {
|
|||||||
originatorOrganizationId: 2,
|
originatorOrganizationId: 2,
|
||||||
recipientOrganizationId: 3,
|
recipientOrganizationId: 3,
|
||||||
correspondenceTypeId: 4,
|
correspondenceTypeId: 4,
|
||||||
subTypeId: null,
|
subTypeId: 5,
|
||||||
rfaTypeId: null,
|
rfaTypeId: 6,
|
||||||
disciplineId: null,
|
disciplineId: 7,
|
||||||
resetScope: 'YEAR_2024',
|
resetScope: 'YEAR_2024',
|
||||||
newLastNumber: 999,
|
newLastNumber: 999,
|
||||||
reason: 'System sync',
|
reason: 'System sync',
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ImportCorrespondenceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
document_number!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
category!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
source_file_path!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
ai_confidence?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
ai_issues?: any;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
migrated_by!: string; // "SYSTEM_IMPORT"
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
batch_id!: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
details?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('import_transactions')
|
||||||
|
export class ImportTransaction {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Index('idx_idem_key', { unique: true })
|
||||||
|
@Column({ name: 'idempotency_key', length: 255, unique: true })
|
||||||
|
idempotencyKey!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'document_number', length: 100, nullable: true })
|
||||||
|
documentNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'batch_id', length: 100, nullable: true })
|
||||||
|
batchId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'status_code', default: 201 })
|
||||||
|
statusCode!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
58
backend/src/modules/migration/migration.controller.spec.ts
Normal file
58
backend/src/modules/migration/migration.controller.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { MigrationController } from './migration.controller';
|
||||||
|
import { MigrationService } from './migration.service';
|
||||||
|
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||||
|
|
||||||
|
describe('MigrationController', () => {
|
||||||
|
let controller: MigrationController;
|
||||||
|
let service: MigrationService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [MigrationController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: MigrationService,
|
||||||
|
useValue: {
|
||||||
|
importCorrespondence: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ message: 'Success' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<MigrationController>(MigrationController);
|
||||||
|
service = module.get<MigrationService>(MigrationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call importCorrespondence on service', async () => {
|
||||||
|
const dto: ImportCorrespondenceDto = {
|
||||||
|
document_number: 'DOC-001',
|
||||||
|
title: 'Legacy Record',
|
||||||
|
category: 'Correspondence',
|
||||||
|
source_file_path: '/staging_ai/test.pdf',
|
||||||
|
migrated_by: 'SYSTEM_IMPORT',
|
||||||
|
batch_id: 'batch1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const idempotencyKey = 'key123';
|
||||||
|
const user = { userId: 5 };
|
||||||
|
|
||||||
|
const result = await controller.importCorrespondence(
|
||||||
|
dto,
|
||||||
|
idempotencyKey,
|
||||||
|
user
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ message: 'Success' });
|
||||||
|
expect(service.importCorrespondence).toHaveBeenCalledWith(
|
||||||
|
dto,
|
||||||
|
idempotencyKey,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
backend/src/modules/migration/migration.controller.ts
Normal file
30
backend/src/modules/migration/migration.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common';
|
||||||
|
import { MigrationService } from './migration.service';
|
||||||
|
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
@ApiTags('Migration')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('migration')
|
||||||
|
export class MigrationController {
|
||||||
|
constructor(private readonly migrationService: MigrationService) {}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: 'Import generic legacy correspondence record via n8n integration' })
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'Idempotency-Key',
|
||||||
|
description: 'Unique key per document and batch to prevent duplicate inserts',
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
async importCorrespondence(
|
||||||
|
@Body() dto: ImportCorrespondenceDto,
|
||||||
|
@Headers('idempotency-key') idempotencyKey: string,
|
||||||
|
@CurrentUser() user: any
|
||||||
|
) {
|
||||||
|
const userId = user?.id || user?.userId || 5;
|
||||||
|
return this.migrationService.importCorrespondence(dto, idempotencyKey, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/modules/migration/migration.module.ts
Normal file
28
backend/src/modules/migration/migration.module.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { MigrationController } from './migration.controller';
|
||||||
|
import { MigrationService } from './migration.service';
|
||||||
|
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||||
|
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||||
|
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||||
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
|
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||||
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
// Import any other required modules for JwtAuthGuard (usually AuthModule or similar, but global guards handle this mostly)
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
ImportTransaction,
|
||||||
|
Correspondence,
|
||||||
|
CorrespondenceRevision,
|
||||||
|
CorrespondenceType,
|
||||||
|
CorrespondenceStatus,
|
||||||
|
Project,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [MigrationController],
|
||||||
|
providers: [MigrationService],
|
||||||
|
exports: [MigrationService],
|
||||||
|
})
|
||||||
|
export class MigrationModule {}
|
||||||
83
backend/src/modules/migration/migration.service.spec.ts
Normal file
83
backend/src/modules/migration/migration.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { MigrationService } from './migration.service';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||||
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
|
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||||
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
describe('MigrationService', () => {
|
||||||
|
let service: MigrationService;
|
||||||
|
|
||||||
|
const mockTransactionRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTypeRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStatusRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProjectRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQueryRunner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
startTransaction: jest.fn(),
|
||||||
|
commitTransaction: jest.fn(),
|
||||||
|
rollbackTransaction: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
manager: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDataSource = {
|
||||||
|
createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MigrationService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(ImportTransaction),
|
||||||
|
useValue: mockTransactionRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceType),
|
||||||
|
useValue: mockTypeRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(CorrespondenceStatus),
|
||||||
|
useValue: mockStatusRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Project),
|
||||||
|
useValue: mockProjectRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DataSource,
|
||||||
|
useValue: mockDataSource,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MigrationService>(MigrationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
244
backend/src/modules/migration/migration.service.ts
Normal file
244
backend/src/modules/migration/migration.service.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||||
|
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||||
|
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||||
|
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||||
|
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||||
|
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||||
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { FileStorageService } from '../../common/file-storage/file-storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MigrationService {
|
||||||
|
private readonly logger = new Logger(MigrationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@InjectRepository(ImportTransaction)
|
||||||
|
private readonly importTransactionRepo: Repository<ImportTransaction>,
|
||||||
|
@InjectRepository(CorrespondenceType)
|
||||||
|
private readonly correspondenceTypeRepo: Repository<CorrespondenceType>,
|
||||||
|
@InjectRepository(CorrespondenceStatus)
|
||||||
|
private readonly correspondenceStatusRepo: Repository<CorrespondenceStatus>,
|
||||||
|
@InjectRepository(Project)
|
||||||
|
private readonly projectRepo: Repository<Project>,
|
||||||
|
private readonly fileStorageService: FileStorageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async importCorrespondence(
|
||||||
|
dto: ImportCorrespondenceDto,
|
||||||
|
idempotencyKey: string,
|
||||||
|
userId: number
|
||||||
|
) {
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
throw new BadRequestException('Idempotency-Key header is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Idempotency Check
|
||||||
|
const existingTransaction = await this.importTransactionRepo.findOne({
|
||||||
|
where: { idempotencyKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTransaction) {
|
||||||
|
if (existingTransaction.statusCode === 201) {
|
||||||
|
this.logger.log(
|
||||||
|
`Idempotency key ${idempotencyKey} already processed. Returning cached success.`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message: 'Already processed',
|
||||||
|
transaction: existingTransaction,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Transaction failed previously with status ${existingTransaction.statusCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Dependencies
|
||||||
|
const type = await this.correspondenceTypeRepo.findOne({
|
||||||
|
where: { typeName: dto.category },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If exact name isn't found, try typeCode just in case
|
||||||
|
const typeId = type
|
||||||
|
? type.id
|
||||||
|
: (
|
||||||
|
await this.correspondenceTypeRepo.findOne({
|
||||||
|
where: { typeCode: dto.category },
|
||||||
|
})
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
if (!typeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Category "${dto.category}" not found in system.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate documents typically end up as 'Closed by Owner' or a similar terminal state, unless specifically pending.
|
||||||
|
// For legacy, let's use a default terminal status 'CLBOWN' if available. If not, fallback to 'DRAFT'.
|
||||||
|
let status = await this.correspondenceStatusRepo.findOne({
|
||||||
|
where: { statusCode: 'CLBOWN' },
|
||||||
|
});
|
||||||
|
if (!status) {
|
||||||
|
status = await this.correspondenceStatusRepo.findOne({
|
||||||
|
where: { statusCode: 'DRAFT' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!status) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'CRITICAL: No default correspondence status found (missing CLBOWN/DRAFT)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We assume migration runs for LCBP3 project
|
||||||
|
const project = await this.projectRepo.findOne({
|
||||||
|
where: { projectCode: 'LCBP3' },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'Project LCBP3 not found in database'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Find or Create Correspondence
|
||||||
|
let correspondence = await queryRunner.manager.findOne(Correspondence, {
|
||||||
|
where: {
|
||||||
|
correspondenceNumber: dto.document_number,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!correspondence) {
|
||||||
|
correspondence = queryRunner.manager.create(Correspondence, {
|
||||||
|
correspondenceNumber: dto.document_number,
|
||||||
|
correspondenceTypeId: typeId,
|
||||||
|
projectId: project.id,
|
||||||
|
isInternal: false,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(correspondence);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. File Handling
|
||||||
|
// We will map the source file and create an Attachment record using FileStorageService
|
||||||
|
// For legacy migrations, we pass document_number mapping logic or basic processing
|
||||||
|
let attachmentId: number | null = null;
|
||||||
|
if (dto.source_file_path) {
|
||||||
|
try {
|
||||||
|
const attachment = await this.fileStorageService.importStagingFile(
|
||||||
|
dto.source_file_path,
|
||||||
|
userId,
|
||||||
|
{ documentType: dto.category } // use category from DTO directly
|
||||||
|
);
|
||||||
|
attachmentId = attachment.id;
|
||||||
|
} catch (fileError: unknown) {
|
||||||
|
const errMsg =
|
||||||
|
fileError instanceof Error ? fileError.message : String(fileError);
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to import file for [${dto.document_number}], continuing without attachment: ${errMsg}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create Revision
|
||||||
|
const revisionCount = await queryRunner.manager.count(
|
||||||
|
CorrespondenceRevision,
|
||||||
|
{
|
||||||
|
where: { correspondenceId: correspondence.id },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine revision number. Support mapping multiple batches to the same document number by incrementing revision.
|
||||||
|
const revNum = revisionCount;
|
||||||
|
const revision = queryRunner.manager.create(CorrespondenceRevision, {
|
||||||
|
correspondenceId: correspondence.id,
|
||||||
|
revisionNumber: revNum,
|
||||||
|
revisionLabel: revNum === 0 ? '0' : revNum.toString(),
|
||||||
|
isCurrent: true,
|
||||||
|
statusId: status.id,
|
||||||
|
subject: dto.title,
|
||||||
|
description: 'Migrated from legacy system via Auto Ingest',
|
||||||
|
details: {
|
||||||
|
...dto.details,
|
||||||
|
ai_confidence: dto.ai_confidence,
|
||||||
|
ai_issues: dto.ai_issues as unknown,
|
||||||
|
source_file_path: dto.source_file_path,
|
||||||
|
attachment_id: attachmentId, // Link attachment ID if successful
|
||||||
|
},
|
||||||
|
schemaVersion: 1,
|
||||||
|
createdBy: userId, // Bot ID
|
||||||
|
});
|
||||||
|
|
||||||
|
if (revisionCount > 0) {
|
||||||
|
await queryRunner.manager.update(
|
||||||
|
CorrespondenceRevision,
|
||||||
|
{ correspondenceId: correspondence.id, isCurrent: true },
|
||||||
|
{ isCurrent: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.manager.save(revision);
|
||||||
|
|
||||||
|
// 5. Track Transaction
|
||||||
|
const transaction = queryRunner.manager.create(ImportTransaction, {
|
||||||
|
idempotencyKey,
|
||||||
|
documentNumber: dto.document_number,
|
||||||
|
batchId: dto.batch_id,
|
||||||
|
statusCode: 201,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(transaction);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ingested document [${dto.document_number}] successfully (Batch: ${dto.batch_id})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Import successful',
|
||||||
|
correspondenceId: correspondence.id,
|
||||||
|
revisionId: revision.id,
|
||||||
|
transactionId: transaction.id,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Import failed for document [${dto.document_number}]: ${errorMessage}`,
|
||||||
|
errorStack
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedTransaction = this.importTransactionRepo.create({
|
||||||
|
idempotencyKey,
|
||||||
|
documentNumber: dto.document_number,
|
||||||
|
batchId: dto.batch_id,
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
await this.importTransactionRepo.save(failedTransaction).catch(() => {});
|
||||||
|
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'Migration import failed: ' + errorMessage
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ describe('ProjectController', () => {
|
|||||||
const mockResult = { data: [], meta: {} };
|
const mockResult = { data: [], meta: {} };
|
||||||
(mockProjectService.findAll as jest.Mock).mockResolvedValue(mockResult);
|
(mockProjectService.findAll as jest.Mock).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
const result = await controller.findAll({});
|
const result = await controller.findAll({ page: 1, limit: 10 });
|
||||||
|
|
||||||
expect(mockProjectService.findAll).toHaveBeenCalled();
|
expect(mockProjectService.findAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe('WorkflowDslParser', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockRepository = {
|
mockRepository = {
|
||||||
save: jest.fn((def) => Promise.resolve(def)),
|
save: jest.fn((def) => Promise.resolve(def)) as unknown as jest.Mock,
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ describe('WorkflowDslParser', () => {
|
|||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toBeDefined();
|
expect(result.errors).toBeDefined();
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
expect(result.errors?.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ describe('WorkflowDslParser', () => {
|
|||||||
it('should throw error if definition not found', async () => {
|
it('should throw error if definition not found', async () => {
|
||||||
mockRepository.findOne = jest.fn().mockResolvedValue(null);
|
mockRepository.findOne = jest.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(parser.getParsedDsl(999)).rejects.toThrow(
|
await expect(parser.getParsedDsl('999')).rejects.toThrow(
|
||||||
BadRequestException
|
BadRequestException
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ title: 'Data & Storage: Data Dictionary and Data Model Architecture'
|
|||||||
version: 1.8.0
|
version: 1.8.0
|
||||||
status: released
|
status: released
|
||||||
owner: Nattanin Peancharoen
|
owner: Nattanin Peancharoen
|
||||||
last_updated: 2026-02-22
|
last_updated: 2026-02-28
|
||||||
related:
|
related:
|
||||||
- specs/01-requirements/02-architecture.md
|
- specs/01-requirements/02-architecture.md
|
||||||
- specs/01-requirements/03-functional-requirements.md
|
- specs/01-requirements/03-functional-requirements.md
|
||||||
- docs/4_Data_Dictionary_V1_4_5.md
|
|
||||||
- docs/8_lcbp3_v1_4_5.sql
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 1. Data Model Architecture Overview
|
# 1. Data Model Architecture Overview
|
||||||
@@ -2043,6 +2041,92 @@ PARTITION BY RANGE (YEAR(created_at)) (
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### 16.3 Temporary Migration Tracking Tables (V1.8.0 n8n Migration)
|
||||||
|
|
||||||
|
ตารางเหล่านี้ถูกใช้ชั่วคราวระหว่างกระบวนการ Migrate เอกสาร PDF 20,000 ฉบับด้วย n8n (ดูรายละเอียดใน | ||||||