feat(migration): ADR-028 migration architecture refactor

- เพิ่ม POST /api/ai/jobs + GET /api/ai/jobs/:jobId endpoints (FR-001, FR-002)
- เพิ่ม BullMQ Worker MigrateDocumentWorker + OCR auto-detect (FR-003, FR-004)
- เพิ่ม cleanup-temp-files + expire-pending-reviews workers (FR-005, FR-005a/b)
- สร้าง SQL deltas: tags, correspondence_tags, alter migration_review_queue (FR-006, ADR-009)
- เพิ่ม MigrationReviewService.commitRecord() + SELECT FOR UPDATE (FR-007, FR-007a)
- เพิ่ม CASL permission migration.commit + MigrationReviewController (FR-007)
- สร้าง TagsModule + TagsService + TagsController (US3)
- สร้าง Migration Review Queue frontend page + ReviewQueueTable (US2)
- อัปเดต n8n guide: deterministic Idempotency-Key + token pre-flight (FR-001a, FR-010a/b)
- สร้าง spec.md, plan.md, tasks.md, data-model.md, contracts/, quickstart.md
- สร้าง ADR-028 document + validation-report.md (PASS 32/32 tasks, 173/173 tests)
This commit is contained in:
2026-05-22 17:10:07 +07:00
parent 990d80e16d
commit a2973be208
55 changed files with 4256 additions and 107 deletions
@@ -3,6 +3,7 @@
// - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032).
// - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039).
// - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record<string, jest.Mock>
// - 2026-05-22: เพิ่ม Mock dependencies (ProjectRepository, AiAuditLogRepository, TagsService, MigrationService) เพื่อแก้ปัญหา Nest resolve dependency ใน unit test และปรับโครงสร้างฟังก์ชันไม่มีบรรทัดว่าง (Zero Blank Lines) ตามกฎเหล็ก
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
@@ -14,6 +15,10 @@ import { AiRagService } from '../ai-rag.service';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { OcrService } from '../services/ocr.service';
import { OllamaService } from '../services/ollama.service';
import { Project } from '../../project/entities/project.entity';
import { AiAuditLog } from '../entities/ai-audit-log.entity';
import { TagsService } from '../../tags/tags.service';
import { MigrationService } from '../../migration/migration.service';
describe('AiBatchProcessor', () => {
let processor: AiBatchProcessor;
@@ -38,13 +43,17 @@ describe('AiBatchProcessor', () => {
.mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }),
};
const mockOllamaService = {
getMainModelName: jest.fn().mockReturnValue('gemma4:e4b'),
generate: jest.fn().mockResolvedValue(
JSON.stringify({
documentNumber: 'LCBP3-CIV-001',
subject: 'Foundation Inspection Report',
discipline: 'Civil',
category: 'Correspondence',
date: '2026-05-20',
confidence: 0.95,
tags: ['foundation'],
summary: 'summary text',
})
),
};
@@ -52,8 +61,35 @@ describe('AiBatchProcessor', () => {
setex: jest.fn().mockResolvedValue('OK'),
};
const mockAttachmentRepo = {
findOne: jest.fn().mockResolvedValue({
id: 1,
publicId: 'doc-uuid-123',
filePath: '/files/test.pdf',
uploadedByUserId: 10,
}),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const mockProjectRepo = {
findOne: jest.fn().mockResolvedValue({
id: 2,
publicId: 'proj-uuid-456',
}),
};
const mockAiAuditLogRepo = {
create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}),
};
const mockTagsService = {
findOrCreateTags: jest
.fn()
.mockResolvedValue([
{ id: 5, publicId: 'tag-uuid-999', tagName: 'foundation' },
]),
};
const mockMigrationService = {
createError: jest.fn().mockResolvedValue(undefined),
enqueueRecord: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -67,6 +103,16 @@ describe('AiBatchProcessor', () => {
provide: getRepositoryToken(Attachment),
useValue: mockAttachmentRepo,
},
{
provide: getRepositoryToken(Project),
useValue: mockProjectRepo,
},
{
provide: getRepositoryToken(AiAuditLog),
useValue: mockAiAuditLogRepo,
},
{ provide: TagsService, useValue: mockTagsService },
{ provide: MigrationService, useValue: mockMigrationService },
],
}).compile();
processor = module.get<AiBatchProcessor>(AiBatchProcessor);
@@ -148,4 +194,42 @@ describe('AiBatchProcessor', () => {
expect.stringContaining('completed')
);
});
it('ควรประมวลผล migrate-document โดยจำลอง OCR, AI และเรียก migrationService.enqueueRecord', async () => {
const job = {
id: 'job-migrate',
data: {
jobType: 'migrate-document',
documentPublicId: 'doc-uuid-123',
projectPublicId: 'proj-uuid-456',
payload: {
documentNumber: 'LEGACY-001',
title: 'Legacy Title',
senderOrgId: 1,
receiverOrgId: 2,
},
idempotencyKey: 'idem-migrate-123',
batchId: 'batch-999',
},
} as unknown as Job<AiBatchJobData>;
await processor.process(job);
expect(attachmentRepo.findOne).toHaveBeenCalledWith({
where: { publicId: 'doc-uuid-123' },
});
expect(ocrService.detectAndExtract).toHaveBeenCalledWith({
pdfPath: '/files/test.pdf',
});
expect(ollamaService.generate).toHaveBeenCalledTimes(1);
expect(mockTagsService.findOrCreateTags).toHaveBeenCalledTimes(1);
expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith(
expect.objectContaining({
documentNumber: 'LCBP3-CIV-001',
subject: 'Foundation Inspection Report',
category: 'Correspondence',
isValid: true,
confidence: 0.95,
})
);
expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1);
expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1);
});
});