From 15b447ceeb00373bd3b318505994ca92d21ed5fc Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 10 Mar 2026 17:05:30 +0700 Subject: [PATCH] 260310:1705 20260310:1700 Refactor rfas --- .../correspondence-revision.entity.ts | 6 + .../dto/import-correspondence.dto.ts | 4 + .../modules/migration/migration.service.ts | 15 +- .../rfa/entities/rfa-revision.entity.ts | 69 +- .../src/modules/rfa/entities/rfa.entity.ts | 5 +- .../src/modules/rfa/rfa-workflow.service.ts | 29 +- backend/src/modules/rfa/rfa.module.ts | 4 + backend/src/modules/rfa/rfa.service.ts | 141 +- specs/03-Data-and-Storage/.mountcheck.js | 45 + .../03-01-data-dictionary.md | 39 +- specs/03-Data-and-Storage/AI Prompt.js | 142 ++ specs/03-Data-and-Storage/Legacy-Import.json | 1003 ++++++++++++ specs/03-Data-and-Storage/db_output.txt | 61 + specs/03-Data-and-Storage/dbquery.txt | 9 + .../lcbp3-v1.8.0-schema-02-tables.sql | 33 +- .../lcbp3-v1.8.0-schema-03-views-indexes.sql | 38 +- .../lcbp3-v1.8.0-seed-basic.sql | 50 - specs/03-Data-and-Storage/n8n.workflow.json | 374 +++-- specs/03-Data-and-Storage/package.json | 16 + specs/03-Data-and-Storage/test-db.js | 28 + specs/03-Data-and-Storage/testOCR.json | 0 .../lcbp3-v1.8.0-schema-02-tables.sql | 1355 +++++++++++++++++ 22 files changed, 3086 insertions(+), 380 deletions(-) create mode 100644 specs/03-Data-and-Storage/.mountcheck.js create mode 100644 specs/03-Data-and-Storage/AI Prompt.js create mode 100644 specs/03-Data-and-Storage/Legacy-Import.json create mode 100644 specs/03-Data-and-Storage/db_output.txt create mode 100644 specs/03-Data-and-Storage/dbquery.txt create mode 100644 specs/03-Data-and-Storage/package.json create mode 100644 specs/03-Data-and-Storage/test-db.js delete mode 100644 specs/03-Data-and-Storage/testOCR.json create mode 100644 specs/99-archives/lcbp3-v1.8.0-schema-02-tables.sql diff --git a/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts index 452f3d3..a4f6132 100644 --- a/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts +++ b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts @@ -7,7 +7,9 @@ import { JoinColumn, CreateDateColumn, Index, + OneToOne, } from 'typeorm'; +import { RfaRevision } from '../../rfa/entities/rfa-revision.entity'; import { Correspondence } from './correspondence.entity'; import { CorrespondenceStatus } from './correspondence-status.entity'; import { User } from '../../user/entities/user.entity'; @@ -107,4 +109,8 @@ export class CorrespondenceRevision { @ManyToOne(() => User) @JoinColumn({ name: 'created_by' }) creator?: User; + + // Added inverse relation for CTI mapping to subclasses (RFA) + @OneToOne(() => RfaRevision, (rfaRev) => rfaRev.correspondenceRevision) + rfaRevision?: RfaRevision; } diff --git a/backend/src/modules/migration/dto/import-correspondence.dto.ts b/backend/src/modules/migration/dto/import-correspondence.dto.ts index e9b01cd..93804af 100644 --- a/backend/src/modules/migration/dto/import-correspondence.dto.ts +++ b/backend/src/modules/migration/dto/import-correspondence.dto.ts @@ -54,6 +54,10 @@ export class ImportCorrespondenceDto { @IsOptional() received_date?: string; + @IsNumber() + @IsOptional() + discipline_id?: number; + @IsString() @IsOptional() body?: string; diff --git a/backend/src/modules/migration/migration.service.ts b/backend/src/modules/migration/migration.service.ts index 687769c..74d3c02 100644 --- a/backend/src/modules/migration/migration.service.ts +++ b/backend/src/modules/migration/migration.service.ts @@ -127,22 +127,25 @@ export class MigrationService { correspondenceNumber: dto.document_number, correspondenceTypeId: typeId, projectId: project.id, + disciplineId: dto.discipline_id || undefined, isInternal: false, createdBy: userId, }); await queryRunner.manager.save(correspondence); + } else if (dto.discipline_id && !correspondence.disciplineId) { + // อัพเดต discipline_id หากเอกสารเดิมยังไม่มี + correspondence.disciplineId = dto.discipline_id; + 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 + { documentType: dto.category } ); attachmentId = attachment.id; } catch (fileError: unknown) { @@ -163,7 +166,6 @@ export class MigrationService { } ); - // 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, @@ -173,15 +175,16 @@ export class MigrationService { statusId: status.id, subject: dto.title, description: 'Migrated from legacy system via Auto Ingest', + body: dto.body || undefined, // Map from DTO 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 + attachment_id: attachmentId, }, schemaVersion: 1, - createdBy: userId, // Bot ID + createdBy: userId, }); if (revisionCount > 0) { diff --git a/backend/src/modules/rfa/entities/rfa-revision.entity.ts b/backend/src/modules/rfa/entities/rfa-revision.entity.ts index 77f0134..18f4d84 100644 --- a/backend/src/modules/rfa/entities/rfa-revision.entity.ts +++ b/backend/src/modules/rfa/entities/rfa-revision.entity.ts @@ -1,39 +1,27 @@ // File: src/modules/rfa/entities/rfa-revision.entity.ts import { Column, - CreateDateColumn, Entity, JoinColumn, ManyToOne, OneToMany, - PrimaryGeneratedColumn, - Unique, + PrimaryColumn, + OneToOne, } from 'typeorm'; -import { User } from '../../user/entities/user.entity'; +import { CorrespondenceRevision } from '../../correspondence/entities/correspondence-revision.entity'; import { RfaApproveCode } from './rfa-approve-code.entity'; import { RfaItem } from './rfa-item.entity'; import { RfaStatusCode } from './rfa-status-code.entity'; import { RfaWorkflow } from './rfa-workflow.entity'; -import { Rfa } from './rfa.entity'; @Entity('rfa_revisions') -@Unique(['rfaId', 'revisionNumber']) -@Unique(['rfaId', 'isCurrent']) export class RfaRevision { - @PrimaryGeneratedColumn() + @PrimaryColumn() id!: number; - @Column({ name: 'rfa_id' }) - rfaId!: number; - - @Column({ name: 'revision_number' }) - revisionNumber!: number; - - @Column({ name: 'revision_label', length: 10, nullable: true }) - revisionLabel?: string; - - @Column({ name: 'is_current', default: false }) - isCurrent!: boolean; + @OneToOne(() => CorrespondenceRevision, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id' }) + correspondenceRevision!: CorrespondenceRevision; @Column({ name: 'rfa_status_code_id' }) rfaStatusCodeId!: number; @@ -41,33 +29,9 @@ export class RfaRevision { @Column({ name: 'rfa_approve_code_id', nullable: true }) rfaApproveCodeId?: number; - @Column({ length: 500 }) - subject!: string; - - @Column({ name: 'document_date', type: 'date', nullable: true }) - documentDate?: Date; - - @Column({ name: 'issued_date', type: 'date', nullable: true }) - issuedDate?: Date; - - @Column({ name: 'received_date', type: 'datetime', nullable: true }) - receivedDate?: Date; - - @Column({ name: 'due_date', type: 'datetime', nullable: true }) - dueDate?: Date; - @Column({ name: 'approved_date', type: 'date', nullable: true }) approvedDate?: Date; - @Column({ type: 'text', nullable: true }) - description?: string; - - @Column({ type: 'text', nullable: true }) - body?: string; - - @Column({ type: 'text', nullable: true }) - remarks?: string; - // --- JSON & Schema Section --- @Column({ type: 'json', nullable: true }) @@ -87,23 +51,8 @@ export class RfaRevision { }) vRefDrawingCount?: number; - // --- Timestamp --- - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; - - @Column({ name: 'created_by', nullable: true }) - createdBy?: number; - - @Column({ name: 'updated_by', nullable: true }) - updatedBy?: number; - // --- Relations --- - @ManyToOne(() => Rfa) - @JoinColumn({ name: 'rfa_id' }) - rfa!: Rfa; - @ManyToOne(() => RfaStatusCode) @JoinColumn({ name: 'rfa_status_code_id' }) statusCode!: RfaStatusCode; @@ -112,10 +61,6 @@ export class RfaRevision { @JoinColumn({ name: 'rfa_approve_code_id' }) approveCode?: RfaApproveCode; - @ManyToOne(() => User) - @JoinColumn({ name: 'created_by' }) - creator?: User; - @OneToMany(() => RfaItem, (item) => item.rfaRevision, { cascade: true }) items!: RfaItem[]; diff --git a/backend/src/modules/rfa/entities/rfa.entity.ts b/backend/src/modules/rfa/entities/rfa.entity.ts index 5abefd5..0b7183d 100644 --- a/backend/src/modules/rfa/entities/rfa.entity.ts +++ b/backend/src/modules/rfa/entities/rfa.entity.ts @@ -5,14 +5,12 @@ import { Entity, JoinColumn, ManyToOne, - OneToMany, PrimaryColumn, OneToOne, } from 'typeorm'; import { User } from '../../user/entities/user.entity'; import { Correspondence } from '../../correspondence/entities/correspondence.entity'; // Import -import { RfaRevision } from './rfa-revision.entity'; import { RfaType } from './rfa-type.entity'; @Entity('rfas') @@ -45,6 +43,5 @@ export class Rfa { @JoinColumn({ name: 'created_by' }) creator?: User; - @OneToMany(() => RfaRevision, (revision) => revision.rfa) - revisions!: RfaRevision[]; + // Revisions are accessed via correspondence.revisions -> rfaRevision } diff --git a/backend/src/modules/rfa/rfa-workflow.service.ts b/backend/src/modules/rfa/rfa-workflow.service.ts index 2331aa0..f4da10e 100644 --- a/backend/src/modules/rfa/rfa-workflow.service.ts +++ b/backend/src/modules/rfa/rfa-workflow.service.ts @@ -12,6 +12,7 @@ import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaRevision } from './entities/rfa-revision.entity'; import { RfaStatusCode } from './entities/rfa-status-code.entity'; import { Rfa } from './entities/rfa.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; // DTOs import { WorkflowTransitionDto } from '../workflow-engine/dto/workflow-transition.dto'; @@ -27,6 +28,8 @@ export class RfaWorkflowService { private readonly rfaRepo: Repository, @InjectRepository(RfaRevision) private readonly revisionRepo: Repository, + @InjectRepository(CorrespondenceRevision) + private readonly corrRevisionRepo: Repository, @InjectRepository(RfaStatusCode) private readonly statusRepo: Repository, @InjectRepository(RfaApproveCode) @@ -44,16 +47,16 @@ export class RfaWorkflowService { try { // 1. ดึงข้อมูล Revision ปัจจุบัน - const revision = await this.revisionRepo.findOne({ - where: { id: rfaId, isCurrent: true }, + const corrRevision = await this.corrRevisionRepo.findOne({ + where: { correspondenceId: rfaId, isCurrent: true }, relations: [ - 'rfa', - 'rfa.correspondence', - 'rfa.correspondence.discipline', + 'rfaRevision', + 'correspondence', + 'correspondence.discipline', ], }); - if (!revision) { + if (!corrRevision || !corrRevision.rfaRevision) { throw new NotFoundException( `Current Revision for RFA ID ${rfaId} not found` ); @@ -61,8 +64,8 @@ export class RfaWorkflowService { // 2. สร้าง Context (ข้อมูลประกอบการตัดสินใจ) const context = { - rfaType: revision.rfa.rfaTypeId, - discipline: revision.rfa.correspondence?.discipline, + rfaType: corrRevision.correspondence?.correspondenceTypeId, + discipline: corrRevision.correspondence?.discipline, ownerId: userId, // อาจเพิ่มเงื่อนไขอื่นๆ เช่น จำนวนวัน, ความเร่งด่วน }; @@ -72,7 +75,7 @@ export class RfaWorkflowService { const instance = await this.workflowEngine.createInstance( this.WORKFLOW_CODE, 'rfa_revision', - revision.id.toString(), + corrRevision.id.toString(), context ); @@ -87,7 +90,7 @@ export class RfaWorkflowService { // 5. Sync สถานะกลับตาราง RFA Revision await this.syncStatus( - revision, + corrRevision.rfaRevision, transitionResult.nextState, undefined, queryRunner @@ -132,13 +135,13 @@ export class RfaWorkflowService { // 2. Sync สถานะกลับตารางเดิม const instance = await this.workflowEngine.getInstanceById(instanceId); if (instance && instance.entityType === 'rfa_revision') { - const revision = await this.revisionRepo.findOne({ + const rfaRev = await this.revisionRepo.findOne({ where: { id: parseInt(instance.entityId) }, }); - if (revision) { + if (rfaRev) { // เช็คว่า Action นี้มีการระบุ Approve Code มาใน Payload หรือไม่ (เช่น '1A', '3R') const approveCodeStr = dto.payload?.approveCode; - await this.syncStatus(revision, result.nextState, approveCodeStr); + await this.syncStatus(rfaRev, result.nextState, approveCodeStr); } } diff --git a/backend/src/modules/rfa/rfa.module.ts b/backend/src/modules/rfa/rfa.module.ts index dabce1b..caa987c 100644 --- a/backend/src/modules/rfa/rfa.module.ts +++ b/backend/src/modules/rfa/rfa.module.ts @@ -5,6 +5,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; // Entities import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; +import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; @@ -41,6 +43,8 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module' RfaStatusCode, RfaApproveCode, Correspondence, + CorrespondenceRevision, + CorrespondenceStatus, ShopDrawingRevision, RfaWorkflow, RfaWorkflowTemplate, diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index 28ee30f..0818b71 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -14,6 +14,8 @@ import { DataSource, In, Repository } from 'typeorm'; // Entities import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; +import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; @@ -54,6 +56,10 @@ export class RfaService { private correspondenceRepo: Repository, @InjectRepository(RfaType) private rfaTypeRepo: Repository, + @InjectRepository(CorrespondenceRevision) + private corrRevRepo: Repository, + @InjectRepository(CorrespondenceStatus) + private corrStatusRepo: Repository, @InjectRepository(RfaStatusCode) private rfaStatusRepo: Repository, @InjectRepository(RfaApproveCode) @@ -120,6 +126,18 @@ export class RfaService { }, }); + // Get Generic Draft Status for Correspondence + const corrStatusDraft = await queryRunner.manager.findOne( + CorrespondenceStatus, + { + where: { statusCode: 'DRAFT' }, + } + ); + if (!corrStatusDraft) + throw new InternalServerErrorException( + 'Correspondence Status DRAFT not found' + ); + // 1. Create Correspondence Record const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber.number, @@ -134,20 +152,19 @@ export class RfaService { // 2. Create Rfa Master Record const rfa = queryRunner.manager.create(Rfa, { + id: savedCorr.id, // ✅ CTI Key share rfaTypeId: createDto.rfaTypeId, createdBy: user.user_id, - disciplineId: createDto.disciplineId, // ✅ Add disciplineId }); const savedRfa = await queryRunner.manager.save(rfa); - // 3. Create First Revision (Draft) - const rfaRevision = queryRunner.manager.create(RfaRevision, { + // 3. Create First Correspondence Revision + const corrRevision = queryRunner.manager.create(CorrespondenceRevision, { correspondenceId: savedCorr.id, - rfaId: savedRfa.id, revisionNumber: 0, revisionLabel: '0', isCurrent: true, - rfaStatusCodeId: statusDraft.id, + statusId: corrStatusDraft.id, subject: createDto.subject, body: createDto.body, remarks: createDto.remarks, @@ -157,6 +174,14 @@ export class RfaService { : new Date(), dueDate: createDto.dueDate ? new Date(createDto.dueDate) : undefined, createdBy: user.user_id, + schemaVersion: 1, + }); + const savedCorrRev = await queryRunner.manager.save(corrRevision); + + // 4. Create First RFA Revision (CTI Extends CorrespondenceRevision) + const rfaRevision = queryRunner.manager.create(RfaRevision, { + id: savedCorrRev.id, // ✅ Matches correspondence revision id + rfaStatusCodeId: statusDraft.id, details: createDto.details, schemaVersion: 1, }); @@ -246,11 +271,12 @@ export class RfaService { const queryBuilder = this.rfaRepo .createQueryBuilder('rfa') .leftJoinAndSelect('rfa.correspondence', 'corr') - .leftJoinAndSelect('rfa.revisions', 'rev') + .leftJoinAndSelect('corr.revisions', 'corrRev') + .leftJoinAndSelect('corrRev.rfaRevision', 'rfaRev') .leftJoinAndSelect('corr.project', 'project') .leftJoinAndSelect('corr.discipline', 'discipline') - .leftJoinAndSelect('rev.statusCode', 'status') - .leftJoinAndSelect('rev.items', 'items') + .leftJoinAndSelect('rfaRev.statusCode', 'status') + .leftJoinAndSelect('rfaRev.items', 'items') .leftJoinAndSelect('items.shopDrawingRevision', 'sdRev') .leftJoinAndSelect('sdRev.attachments', 'attachments'); @@ -258,9 +284,11 @@ export class RfaService { const revStatus = query.revisionStatus || 'CURRENT'; if (revStatus === 'CURRENT') { - queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: true }); + queryBuilder.where('corrRev.isCurrent = :isCurrent', { isCurrent: true }); } else if (revStatus === 'OLD') { - queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: false }); + queryBuilder.where('corrRev.isCurrent = :isCurrent', { + isCurrent: false, + }); } // If 'ALL', no filter @@ -274,7 +302,7 @@ export class RfaService { if (search) { queryBuilder.andWhere( - '(corr.correspondenceNumber LIKE :search OR rev.subject LIKE :search)', + '(corr.correspondenceNumber LIKE :search OR corrRev.subject LIKE :search)', { search: `%${search}%` } ); } @@ -289,8 +317,20 @@ export class RfaService { `[DEBUG] RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}` ); + // Map `revisions` property back to the expected payload for the frontend + const mappedItems = items.map((rfa) => { + const mappedRfa = { ...rfa } as any; + mappedRfa.revisions = + rfa.correspondence?.revisions?.map((cr) => ({ + ...cr, + ...(cr.rfaRevision || {}), + id: cr.rfaRevision?.id || cr.id, + })) || []; + return mappedRfa; + }); + return { - data: items, + data: mappedItems, meta: { total, page, @@ -300,21 +340,22 @@ export class RfaService { }; } - async findOne(id: number) { + async findOne(id: number, rawEntities = false) { const rfa = await this.rfaRepo.findOne({ where: { id }, relations: [ - 'correspondence', // ✅ Add relation to master correspondence + 'correspondence', 'rfaType', - 'revisions', - 'revisions.statusCode', - 'revisions.approveCode', - 'revisions.items', - 'revisions.items.shopDrawingRevision', - 'revisions.items.shopDrawingRevision.shopDrawing', + 'correspondence.revisions', + 'correspondence.revisions.rfaRevision', + 'correspondence.revisions.rfaRevision.statusCode', + 'correspondence.revisions.rfaRevision.approveCode', + 'correspondence.revisions.rfaRevision.items', + 'correspondence.revisions.rfaRevision.items.shopDrawingRevision', + 'correspondence.revisions.rfaRevision.items.shopDrawingRevision.shopDrawing', ], order: { - revisions: { revisionNumber: 'DESC' }, + correspondence: { revisions: { revisionNumber: 'DESC' } }, }, }); @@ -322,16 +363,33 @@ export class RfaService { throw new NotFoundException(`RFA ID ${id} not found`); } - return rfa; + if (rawEntities) { + return rfa; + } + + // Map to structure expected by frontend DTO + const mappedRfa = { ...rfa } as any; + mappedRfa.revisions = + rfa.correspondence?.revisions?.map((cr) => ({ + ...cr, + ...(cr.rfaRevision || {}), + id: cr.rfaRevision?.id || cr.id, + })) || []; + + return mappedRfa; } async submit(rfaId: number, templateId: number, user: User) { - const rfa = await this.findOne(rfaId); - const currentRevision = rfa.revisions.find((r) => r.isCurrent); - - if (!currentRevision) + const rfa = await this.findOne(rfaId, true); + const currentCorrRev = rfa.correspondence?.revisions?.find( + (r: any) => r.isCurrent + ); + if (!currentCorrRev || !currentCorrRev.rfaRevision) throw new NotFoundException('Current revision not found'); - if (currentRevision.statusCode.statusCode !== 'DFT') { + + const currentRfaRev = currentCorrRev.rfaRevision; + + if (currentRfaRev.statusCode.statusCode !== 'DFT') { throw new BadRequestException('Only DRAFT documents can be submitted'); } @@ -366,9 +424,10 @@ export class RfaService { try { // Update Revision Status - currentRevision.rfaStatusCodeId = statusForApprove.id; - currentRevision.issuedDate = new Date(); - await queryRunner.manager.save(currentRevision); + currentRfaRev.rfaStatusCodeId = statusForApprove.id; + currentCorrRev.issuedDate = new Date(); + await queryRunner.manager.save(currentRfaRev); + await queryRunner.manager.save(currentCorrRev); // Create First Routing Step const firstStep = steps[0]; @@ -395,7 +454,7 @@ export class RfaService { if (recipientUserId) { await this.notificationService.send({ userId: recipientUserId, - title: `RFA Submitted: ${currentRevision.subject}`, + title: `RFA Submitted: ${currentCorrRev.subject}`, message: `RFA ${rfa.correspondence.correspondenceNumber} submitted for approval.`, type: 'SYSTEM', entityType: 'rfa', @@ -415,13 +474,15 @@ export class RfaService { async processAction(rfaId: number, dto: WorkflowActionDto, user: User) { // Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB - // ใช้ this.workflowEngine.processAction (Legacy Support) - // ... (สามารถใช้ Code เดิมจากที่คุณแนบมาได้เลย เพราะ Logic ถูกต้องแล้วสำหรับการใช้ CorrespondenceRouting) ... - const rfa = await this.findOne(rfaId); - const currentRevision = rfa.revisions.find((r) => r.isCurrent); - if (!currentRevision) + const rfa = await this.findOne(rfaId, true); + const currentCorrRev = rfa.correspondence?.revisions?.find( + (r: any) => r.isCurrent + ); + if (!currentCorrRev || !currentCorrRev.rfaRevision) throw new NotFoundException('Current revision not found'); + const currentRfaRev = currentCorrRev.rfaRevision; + const currentRouting = await this.routingRepo.findOne({ where: { correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id @@ -509,16 +570,16 @@ export class RfaService { }, }); // Logic Map Code อย่างง่าย if (approveCode) { - currentRevision.rfaApproveCodeId = approveCode.id; - currentRevision.approvedDate = new Date(); + currentRfaRev.rfaApproveCodeId = approveCode.id; + currentRfaRev.approvedDate = new Date(); } } else { const rejectCode = await this.rfaApproveRepo.findOne({ where: { approveCode: '4X' }, }); - if (rejectCode) currentRevision.rfaApproveCodeId = rejectCode.id; + if (rejectCode) currentRfaRev.rfaApproveCodeId = rejectCode.id; } - await queryRunner.manager.save(currentRevision); + await queryRunner.manager.save(currentRfaRev); } await queryRunner.commitTransaction(); diff --git a/specs/03-Data-and-Storage/.mountcheck.js b/specs/03-Data-and-Storage/.mountcheck.js new file mode 100644 index 0000000..809be0d --- /dev/null +++ b/specs/03-Data-and-Storage/.mountcheck.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const config = $('Set Configuration').first().json.config; + +// Check file mount and inputs +try { + if (!fs.existsSync(config.EXCEL_FILE)) { + throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`); + } + if (!fs.existsSync(config.SOURCE_PDF_DIR)) { + throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`); + } + + const files = fs.readdirSync(config.SOURCE_PDF_DIR); + + // Check write permission to log path + fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString()); + + // Grab categories out of the previous node (Fetch Categories) if available + // otherwise use fallback array + let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other']; + try { + const upstreamData = $('Fetch Categories').first()?.json?.data; + if (upstreamData && Array.isArray(upstreamData)) { + categories = upstreamData.map(c => c.name || c.type || c); // very loose mapping depending on API response + } + } catch(e) {} + + // Grab existing tags from Fetch Tags node + let existingTags = []; + try { + const tagData = $('Fetch Tags').first()?.json?.data || []; + existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : []; + } catch(e) {} + + return [{ json: { + preflight_ok: true, + pdf_count_in_source: files.length, + excel_target: config.EXCEL_FILE, + system_categories: categories, + existing_tags: existingTags, + timestamp: new Date().toISOString() + }}]; +} catch (err) { + throw new Error(`Pre-flight check failed: ${err.message}`); +} \ No newline at end of file diff --git a/specs/03-Data-and-Storage/03-01-data-dictionary.md b/specs/03-Data-and-Storage/03-01-data-dictionary.md index fa38079..f1b143d 100644 --- a/specs/03-Data-and-Storage/03-01-data-dictionary.md +++ b/specs/03-Data-and-Storage/03-01-data-dictionary.md @@ -622,47 +622,28 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga **Purpose**: Child table storing revision history of RFAs (1:N) -| Column Name | Data Type | Constraints | Description | -| ----------- | --------- | --------------------------- | ------------------ | -| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | - -| rfa_id | INT | NOT NULL, FK | Master RFA ID | -| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | -| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | -| is_current | BOOLEAN | DEFAULT FALSE | Current revision flag | -| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | -| rfa_approve_code_id | INT | NULL, FK | Approval result code | -| title | VARCHAR(255) | NOT NULL | RFA title | -| document_date | DATE | NULL | Document date | -| issued_date | DATE | NULL | Issue date for approval | -| received_date | DATETIME | NULL | Received date | -| approved_date | DATE | NULL | Approval date | -| description | TEXT | NULL | Revision description | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | Revision creation timestamp | -| created_by | INT | NULL, FK | User who created revision | -| updated_by | INT | NULL, FK | User who last updated | -| details | JSON | NULL | Type-specific details (e.g., RFI questions) | -| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | -| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | +| Column Name | Data Type | Constraints | Description | +| ------------------- | --------- | --------------------------------- | ----------------------------------------------------------- | +| id | INT | PK, FK | Master Revision ID (Shared with correspondence_revisions) | +| rfa_status_code_id | INT | NOT NULL, FK | Current RFA status | +| rfa_approve_code_id | INT | NULL, FK | Approval result code | +| details | JSON | NULL | Type-specific details (e.g., RFI questions) | +| v_ref_drawing_count | INT | GENERATED ALWAYS AS (...) VIRTUAL | Virtual Column ดึง Drawing Count จาก JSON details เพื่อทำ Index | +| schema_version | INT | DEFAULT 1 | Version of the schema used with this details | **Indexes**: * PRIMARY KEY (id) -* FOREIGN KEY (rfa_id) REFERENCES rfas(id) ON DELETE CASCADE +* FOREIGN KEY (id) REFERENCES correspondence_revisions(id) ON DELETE CASCADE * FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes(id) * FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes(id) ON DELETE SET NULL -* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL -* FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL -* UNIQUE KEY (rfa_id, revision_number) -* UNIQUE KEY (rfa_id, is_current) * INDEX (rfa_status_code_id) * INDEX (rfa_approve_code_id) -* INDEX (is_current) * INDEX (v_ref_drawing_count): ตัวอย่างการ Index ข้อมูลตัวเลขใน JSON **Relationships**: -* Parent: correspondences, rfas, rfa_status_codes, rfa_approve_codes, users +* Parent: correspondence_revisions, rfas, rfa_status_codes, rfa_approve_codes * Children: rfa_items --- diff --git a/specs/03-Data-and-Storage/AI Prompt.js b/specs/03-Data-and-Storage/AI Prompt.js new file mode 100644 index 0000000..12e29f7 --- /dev/null +++ b/specs/03-Data-and-Storage/AI Prompt.js @@ -0,0 +1,142 @@ +const config = $('Set Configuration').first().json.config; +const fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 }; +const isFallback = fallbackState.is_fallback_active || false; +const model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY; + +// Read DB Context +const dbContext = $('Fetch DB Context').all().map(i => i.json); +const dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2})); +const dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2})); +const dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2})); +const dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1})); +const dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2})); + +let systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other']; +try { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {} + +const pdfItems = $('Extract PDF Text').all(); +// File Validator passes all original Excel JSON fields through (sender, receiver, project_code, etc.) +// Read PDF File overwrites the JSON with binary data, so we must go back one step +const metaItems = $('File Validator').all(); + +return pdfItems.map((pdfItem, i) => { + const item = metaItems[i] || pdfItem; + + const docNum = String(item.json.document_number || ''); + const title = String(item.json.title || ''); + const legacyNum = String(item.json.legacy_number || ''); + const issuedDate = String(item.json.issued_date || ''); + const receivedDate = String(item.json.received_date || ''); + const corrType = String(item.json.correspondence_type || ''); + const senderCode = String(item.json.sender || ''); + const receiverCode = String(item.json.receiver || ''); + const projectCode = String(item.json.project_code || ''); + + // JavaScript pre-mapping + const findOrgId = (code) => { + if (!code) return null; + const match = dbOrgs.find(o => o.code === code || o.name === code); + return match ? match.id : null; + }; + + const findProjectId = (code) => { + if (!code) return config.PROJECT_ID; // Fallback to config + const match = dbProjects.find(p => p.code === code || p.name === code); + return match ? match.id : config.PROJECT_ID; + }; + + const senderId = findOrgId(senderCode); + const receiverId = findOrgId(receiverCode); + const projectId = findProjectId(projectCode); + // Excel corrType is likely already the ID based on requirements, but fallback matching to ID if needed + const corrMatch = dbCorrTypes.find(c => String(c.id) === corrType || c.code === corrType || c.name === corrType); + const corrTypeId = corrMatch ? corrMatch.id : (isNaN(parseInt(corrType)) ? null : parseInt(corrType)); + + const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa'); + + const systemPrompt = `You are an expert Document Controller for a construction project (LCBP3) in Thailand. +The documents are primarily in THAI and ENGLISH. +Your task is to classify documents and extract metadata from OCR text. +Respond ONLY with valid JSON.`; + + // Use pdfItem for the OCR extracted data, NOT the metaItem + const pdfText = String(pdfItem.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\s\.\/\-:\[\]\(\)]/g, ' '); + + const userPrompt = `Analyze this document: +[EXCEL METADATA] +Document Number: ${docNum || 'Not provided'} +Title: ${title || 'Not provided'} +Issued Date: ${issuedDate || 'Not provided'} +Received Date: ${receivedDate || 'Not provided'} + +[DATABASE REFERENCES] +Disciplines: ${JSON.stringify(dbDisciplines)} +Tags: ${JSON.stringify(dbTags)} + +[OCR TEXT EXTRACTION] +${pdfText} + +Rules: +1. Category: Must be one of ${JSON.stringify(systemCategories)}. If Document Number contains "-RFA-", category MUST be "RFA". +2. Respond with EXACTLY 8 fields in JSON format: + - "discipline_id": Find 'id' from Disciplines array analyzing text to match 'th' or 'en'. If no match, use ID=64 (from contract LCBP3-C2). + - "subject": Document subject. If OCR is close to EXCEL METADATA Title, use EXCEL METADATA. + - "issued_date": Verify from OCR text if it matches ${issuedDate}, format YYYY-MM-DD. + - "received_date": Verify from OCR text. If empty, default to issued_date. + - "status": Extract status (e.g., For Information, Approve, Reject, Resubmit). This will be exported as "remark". + - "summary": 4-5 lines of Thai summary from OCR. This will be exported as "body". + - "tags": REQUIRED. Identify 2-5 main topics/themes from the document (from Title, subject matter, and OCR text). For each topic, return an object with: + * "tag_name": short topic name in Thai (2-5 words), e.g. "คอนกรีตผสม", "ทดสอบวัสดุ" + * "description": one sentence in Thai describing this topic (use key point details). e.g. "การทดสอบค่า slump ของคอนกรีตผสมที่หน้างาน" + Return as: [{"tag_name": "...", "description": "..."}, ...] + - "key_points": Array of 3-5 string key points extracted from the document (in Thai). + +3. IMPORTANT: You MUST REPLACE the 'null' values in the template below with the actual Integer IDs or text you found. DO NOT reply with literal 'null' if you found a match! + +Respond ONLY with this EXACT JSON structure: +{ + "discipline_id": 64, + "subject": "${title}", + "issued_date": "${issuedDate}", + "received_date": "${receivedDate || issuedDate}", + "status": null, + "summary": "สรุปเนื้อหา 4-5 บรรทัด...", + "tags": [{"tag_name": "ชื่อหัวข้อ", "description": "คำอธิบาย key point ของหัวข้อนี้"}], + "key_points": ["จุดสำคัญที่ 1", "จุดสำคัญที่ 2", "จุดสำคัญที่ 3"], + "category": "${isRFA ? 'RFA' : 'Correspondence'}", + "confidence": 0.95 +}`; + + return { + json: { + ...item.json, + active_model: model, + is_fallback: isFallback, + system_categories: systemCategories, + pre_mapped: { + project_id: projectId, + sender_id: senderId, + receiver_id: receiverId, + correspondence_type_id: corrTypeId + }, + _debug_mapping: { + excel_project_code: projectCode, + excel_sender: senderCode, + excel_receiver: receiverCode, + excel_corr_type: corrType, + matched_project: dbProjects.find(p => p.code === projectCode || p.name === projectCode) || null, + first_org_sample: dbOrgs[0] || null + }, + ollama_payload: { + model: model, + prompt: `${systemPrompt}\n\n${userPrompt}`, + stream: false, + format: 'json', + options: { + temperature: 0.1, + num_ctx: 8192 + } + } + } + }; +}); diff --git a/specs/03-Data-and-Storage/Legacy-Import.json b/specs/03-Data-and-Storage/Legacy-Import.json new file mode 100644 index 0000000..4bfa9a9 --- /dev/null +++ b/specs/03-Data-and-Storage/Legacy-Import.json @@ -0,0 +1,1003 @@ +{ + "name": "LCBP3 Migration Workflow v1.8.0", + "nodes": [ + { + "parameters": {}, + "id": "c41e7a06-5115-48e8-a8ce-821bb3e4d2dc", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 4640, + 3696 + ], + "notes": "กดรันด้วยตนเอง" + }, + { + "parameters": { + "jsCode": "// ============================================\n// CONFIGURATION - แก้ไขค่าที่นี่\n// ============================================\nconst CONFIG = {\n // Ollama Settings\n OLLAMA_HOST: 'http://192.168.20.100:11434',\n OLLAMA_MODEL_PRIMARY: 'scb10x/typhoon2.1-gemma3-4b',\n OLLAMA_MODEL_FALLBACK: 'mistral:7b-instruct-q4_K_M',\n \n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzcyNzc0MzI5LCJleHAiOjQ5Mjg1MzQzMjl9.TtA8zoHy7G9J5jPgYQPv7yw-9X--B_hl-Nv-c9V4PaA',\n \n // Batch Settings\n BATCH_SIZE: 2,\n BATCH_ID: 'migration_20260226',\n DELAY_MS: 2000,\n \n // Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n MAX_RETRY: 3,\n FALLBACK_THRESHOLD: 5,\n \n // Source Definitions - แก้ไขโฟลเดอร์และไฟล์ทำงานที่นี่\n EXCEL_FILE: '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n \n // Database\n DB_HOST: '192.168.10.8',\n DB_PORT: 3306,\n DB_NAME: 'lcbp3',\n DB_USER: 'migration_bot',\n DB_PASSWORD: 'Center2025',\n PROJECT_ID: 1\n};\n\nreturn [{ json: { config_loaded: true, timestamp: new Date().toISOString(), config: CONFIG } }];" + }, + "id": "bc8c9b9d-284d-4ce5-b7ff-d5b4bb36e748", + "name": "Set Configuration", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 4832, + 3696 + ], + "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "ccb5fea4-773d-4584-a14c-88845f4c2bc3", + "name": "Fetch Categories", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 5040, + 3696 + ], + "notes": "ดึง Categories จาก Backend" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "f1a2b3c4-d5e6-7f8g-9h0i-j1k2l3m4n5o6", + "name": "Fetch Tags", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 5040, + 3856 + ], + "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/health", + "options": { + "timeout": 5000 + } + }, + "id": "0fe2cc93-7d88-4290-8170-2863e087afd3", + "name": "Check Backend Health", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 5008, + 3328 + ], + "onError": "continueErrorOutput", + "notes": "ตรวจสอบ Backend พร้อมใช้งาน" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\n// Check file mount and inputs\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories out of the previous node (Fetch Categories) if available\n // otherwise use fallback array\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c); // very loose mapping depending on API response\n }\n } catch(e) {}\n \n // Grab existing tags from Fetch Tags node\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}" + }, + "id": "5bdb31ca-9588-404d-92ce-3438bdd9835b", + "name": "File Mount Check", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5248, + 3392 + ], + "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT last_processed_index, status FROM migration_progress WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", + "options": {} + }, + "id": "2907a4ca-2a46-45ef-8920-9684d00ffda7", + "name": "Read Checkpoint", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 5504, + 3376 + ], + "alwaysOutputData": true, + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "onError": "continueErrorOutput", + "notes": "อ่านตำแหน่งล่าสุดที่ประมวลผล" + }, + { + "parameters": { + "fileSelector": "={{ $json.excel_target }}", + "options": {} + }, + "id": "f035d28b-413b-4386-bbef-d242cd22aa8f", + "name": "Read Excel Binary", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [ + 5040, + 4112 + ], + "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" + }, + { + "parameters": { + "options": {} + }, + "id": "35727d79-e3c2-4fdf-8bc3-064914393cf7", + "name": "Read Excel", + "type": "n8n-nodes-base.spreadsheetFile", + "typeVersion": 2, + "position": [ + 5264, + 3968 + ], + "notes": "แปลงข้อมูล Excel เป็น JSON Data" + }, + { + "parameters": { + "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Encoding Normalization\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const docNum = item.document_number || item.correspondence_number || item['Document Number'] || item['Corr. No.'];\n // Use File name from Excel directly - must exist\n const excelFileName = item['File name'] || item.file_name || item['File Name'] || item.filename;\n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n const fileName = normalize(excelFileName);\n return {\n json: {\n document_number: normalize(docNum),\n title: normalize(item.title || item.Title || item['Subject']),\n legacy_number: normalize(item.legacy_number || item['Legacy Number'] || item['Response Doc.'] || ''),\n excel_revision: item.revision || item.Revision || item.rev || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: fileName,\n issued_date: normalize(item.issued_date || item.Issued_date || item['Issued Date'] || item.date || item.Date || item.document_date || item.Document_Date),\n received_date: normalize(item.received_date || item.Received_date || item['Received Date'] || item.receive || item.Receive),\n correspondence_type: normalize(item.correspondence_types || item.correspondence_type || item['Correspondence Types'] || item['Correspondence Type'])\n }\n };\n});" + }, + "id": "49c98c75-456b-4a1d-a203-a5b2bf19fd15", + "name": "Process Batch + Encoding", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5712, + 3360 + ], + "alwaysOutputData": true, + "notes": "ตัด Batch + Normalize UTF-8" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME', file_exists: false }\n });\n continue;\n }\n \n // Use file name from Excel directly, add .pdf if missing\n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) {\n safeName += '.pdf';\n }\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n // Path traversal check\n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY', file_exists: false }\n });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({\n ...item,\n json: { ...item.json, file_valid: true, file_exists: true, file_size: stats.size, file_path: filePath }\n });\n } else {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND', file_exists: false }\n });\n }\n } catch (err) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR', file_exists: false }\n });\n }\n}\n\nreturn [...validated, ...errors];" + }, + "id": "51e91c88-98cd-4df4-81ac-e452b25e5c06", + "name": "File Validator", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 5904, + 3264 + ], + "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT is_fallback_active, recent_error_count FROM migration_fallback_state WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", + "options": {} + }, + "id": "88c205b6-9b94-4a4f-ad53-ab3cad6fde27", + "name": "Check Fallback State", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 6032, + 3488 + ], + "alwaysOutputData": true, + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "onError": "continueErrorOutput", + "notes": "ตรวจสอบว่าต้องใช้ Fallback Model หรือไม่" + }, + { + "parameters": { + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 };\nconst isFallback = fallbackState.is_fallback_active || false;\nconst model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY;\n\n// Read DB Context\nconst dbContext = $('Fetch DB Context').all().map(i => i.json);\nconst dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2}));\nconst dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2}));\nconst dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2}));\nconst dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1}));\nconst dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2}));\n\nlet systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\ntry { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {}\n\nconst items = $('Extract PDF Text').all();\n\nreturn items.map(item => {\n const docNum = String(item.json.document_number || '');\n const title = String(item.json.title || '');\n const legacyNum = String(item.json.legacy_number || '');\n const issuedDate = String(item.json.issued_date || '');\n const receivedDate = String(item.json.received_date || '');\n const corrType = String(item.json.correspondence_type || '');\n\n const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa');\n\n const systemPrompt = `You are an expert Document Controller for a construction project (LCBP3) in Thailand.\nThe documents are primarily in THAI and ENGLISH.\nYour task is to classify documents and extract metadata from OCR text.\nRespond ONLY with valid JSON.`;\n\n const pdfText = String(item.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\\s\\.\\/\\-:\\[\\]\\(\\)]/g, ' ');\n\n const userPrompt = `Analyze this document:\n[EXCEL METADATA]\nDocument Number: ${docNum || 'Not provided'}\nTitle: ${title || 'Not provided'}\nIssued Date: ${issuedDate || 'Not provided'}\nReceived Date: ${receivedDate || 'Not provided'}\nCorrespondence Type: ${corrType || 'Not provided'}\n\n[DATABASE REFERENCES]\nProjects: ${JSON.stringify(dbProjects)}\nDisciplines: ${JSON.stringify(dbDisciplines)}\nOrganizations: ${JSON.stringify(dbOrgs)}\nTags: ${JSON.stringify(dbTags)}\nCorrespondence Types: ${JSON.stringify(dbCorrTypes)}\n\n[OCR TEXT EXTRACTION]\n${pdfText}\n\nRules:\n1. Category: Must be one of ${JSON.stringify(systemCategories)}. If Document Number contains \"-RFA-\", category MUST be \"RFA\".\n2. Respond with EXACTLY 11 fields in JSON format:\n - \"project_id\": Find ID from table projects matching the project_code in text.\n - \"discipline_id\": Find ID from table disciplines analyzing text to match code_name_th. If no match, use ID=64 (from contract LCBP3-C2).\n - \"sender_id\": Find ID from table organizations matching Sender.\n - \"receiver_id\": Find ID from table organizations matching Receiver.\n - \"correspondence_type_id\": Find ID from table correspondence_types matching the EXCEL METADATA 'Correspondence Type'.\n - \"subject\": Document subject. If OCR is close to EXCEL METADATA Title, use EXCEL METADATA.\n - \"issued_date\": Verify from OCR text if it matches ${issuedDate}, format YYYY-MM-DD.\n - \"received_date\": Verify from OCR text. If empty, default to issued_date.\n - \"status\": Extract status (e.g., Approve, Reject, Resubmit). This will be exported as \"remark\".\n - \"summary\": 4-5 lines of Thai summary from OCR. This will be exported as \"body\".\n - \"tags\": Array of tag IDs from database matching the text. If not in DB, return string name.\n - \"key_points\": Array of string key points extracted.\n\nRespond ONLY with this EXACT JSON structure:\n{\n \"project_id\": null,\n \"discipline_id\": 64,\n \"sender_id\": null,\n \"receiver_id\": null,\n \"correspondence_type_id\": null,\n \"subject\": \"${title}\",\n \"issued_date\": \"${issuedDate}\",\n \"received_date\": \"${receivedDate || issuedDate}\",\n \"status\": null,\n \"summary\": \"สรุปเนื้อหา 4-5 บรรทัด...\",\n \"tags\": [],\n \"key_points\": [],\n \"category\": \"${isRFA ? 'RFA' : 'Correspondence'}\",\n \"confidence\": 0.95\n}`;\n\n return {\n json: {\n ...item.json,\n active_model: model,\n is_fallback: isFallback,\n system_categories: systemCategories,\n ollama_payload: {\n model: model,\n prompt: `${systemPrompt}\\n\\n${userPrompt}`,\n stream: false,\n format: 'json'\n }\n }\n };\n});" + }, + "id": "9f82950f-7533-4cbd-8e1e-8e441c1cb2a5", + "name": "Build AI Prompt", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6032, + 3896 + ], + "notes": "สร้าง Prompt โดยใช้ Categories จาก System" + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Set Configuration').first().json.config.OLLAMA_HOST}}/api/generate", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.ollama_payload }}", + "options": { + "timeout": 120000 + } + }, + "id": "ae9b6be5-284c-44db-b7f0-b4839a59230e", + "name": "Ollama AI Analysis", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 6240, + 3696 + ], + "notes": "เรียก Ollama วิเคราะห์เอกสาร" + }, + { + "parameters": { + "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\nfor (let i = 0; i < ollamaItems.length; i++) {\n const ollamaItem = ollamaItems[i];\n const originalItem = originalItems[i];\n if (!originalItem) continue;\n const baseJson = originalItem.json;\n\n try {\n let raw = ollamaItem.json.response || '';\n raw = raw.replace(/\\x60\\x60\\x60json/gi, '').replace(/\\x60\\x60\\x60/g, '').trim();\n if (!raw) throw new Error('Empty response from AI');\n\n const result = JSON.parse(raw);\n\n // Map AI result to database fields\n let tags = Array.isArray(result.tags) ? result.tags : [];\n\n // Enum Validation for Category\n const systemCategories = baseJson.system_categories || [];\n let finalCategory = result.category;\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n\n results.push({\n json: {\n ...baseJson,\n ai_result: {\n suggested_category: finalCategory,\n confidence: result.confidence || 0.8,\n project_id: result.project_id,\n discipline_id: result.discipline_id || 64,\n sender_id: result.sender_id,\n receiver_id: result.receiver_id,\n correspondence_type_id: result.correspondence_type_id || null,\n subject: result.subject || baseJson.title,\n issued_date: result.issued_date || baseJson.issued_date,\n received_date: result.received_date || baseJson.received_date || result.issued_date || baseJson.issued_date,\n remark: result.status,\n body: result.summary,\n tags: tags,\n key_points: result.key_points || []\n },\n parse_error: null\n }\n });\n } catch (err) {\n results.push({\n json: {\n ...baseJson,\n ai_result: null,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response,\n error_type: 'AI_PARSE_ERROR'\n }\n });\n }\n}\nreturn results;" + }, + "id": "281dc950-a3b6-4412-a0b4-76663b8c37ea", + "name": "Parse & Validate AI Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6432, + 3696 + ], + "notes": "Parse JSON + Validate Schema + Enum Check" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_fallback_state (batch_id, recent_error_count, is_fallback_active) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', 1, FALSE) ON DUPLICATE KEY UPDATE recent_error_count = recent_error_count + 1, is_fallback_active = CASE WHEN recent_error_count + 1 >= {{$('Set Configuration').first().json.config.FALLBACK_THRESHOLD}} THEN TRUE ELSE is_fallback_active END, updated_at = NOW()", + "options": {} + }, + "id": "41904cb6-b6f3-4a32-9dd5-c44e8e0cefab", + "name": "Update Fallback State", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 6640, + 3888 + ], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "เพิ่ม Error count และตรวจสอบ Fallback threshold" + }, + { + "parameters": { + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $('Parse & Validate AI Response').all();\n\nconst results = [];\n\nfor (const item of items) {\n const data = item.json;\n \n // Base structure ensuring we keep all existing data\n let resultItem = { json: { ...data } };\n \n // Handle Parse Errors from upstream\n if (data.parse_error || !data.ai_result) {\n resultItem.json.route_index = 3;\n results.push(resultItem);\n continue;\n }\n \n const ai = data.ai_result;\n \n // Revision Drift Protection\n if (data.current_db_revision !== undefined) {\n const expectedRev = data.current_db_revision + 1;\n if (parseInt(data.excel_revision) !== expectedRev) {\n resultItem.json.review_reason = `Revision drift: Excel=${data.excel_revision}, Expected=${expectedRev}`;\n resultItem.json.route_index = 1;\n results.push(resultItem);\n continue;\n }\n }\n \n // Confidence Routing\n if (ai.confidence >= config.CONFIDENCE_HIGH && ai.is_valid === true) {\n resultItem.json.route_index = 0;\n } else if (ai.confidence >= config.CONFIDENCE_LOW) {\n resultItem.json.review_reason = `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n resultItem.json.route_index = 1;\n } else {\n resultItem.json.reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n resultItem.json.route_index = 2;\n }\n results.push(resultItem);\n}\n\nreturn results;" + }, + "id": "897dfc43-9f4f-4a9b-8727-64f3483ac56a", + "name": "Confidence Router", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6640, + 3696 + ], + "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)" + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/migration/import", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + }, + { + "name": "Idempotency-Key", + "value": "={{$json.document_number}}:{{$('Set Configuration').first().json.config.BATCH_ID}}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"document_number\": \"{{$json.document_number}}\",\n \"title\": \"{{$json.ai_result.subject}}\",\n \"category\": \"{{$json.ai_result.suggested_category}}\",\n \"source_file_path\": \"{{$json.file_path}}\",\n \"ai_confidence\": {{$json.ai_result.confidence}},\n \"migrated_by\": \"SYSTEM_IMPORT\",\n \"batch_id\": \"{{$('Set Configuration').first().json.config.BATCH_ID}}\",\n \"project_id\": {{$json.ai_result.project_id || $('Set Configuration').first().json.config.PROJECT_ID}},\n \"discipline_id\": {{$json.ai_result.discipline_id}},\n \"sender_id\": {{$json.ai_result.sender_id || null}},\n \"receiver_id\": {{$json.ai_result.receiver_id || null}},\n \"correspondence_type_id\": {{$json.ai_result.correspondence_type_id || null}},\n \"issued_date\": \"{{$json.ai_result.issued_date}}\",\n \"received_date\": \"{{$json.ai_result.received_date}}\",\n \"body\": {{JSON.stringify($json.ai_result.body || \"\")}},\n \"details\": {\n \"legacy_number\": \"{{$json.legacy_number}}\",\n \"remark\": \"{{$json.ai_result.remark}}\",\n \"key_points\": {{JSON.stringify($json.ai_result.key_points || [])}},\n \"tags\": {{JSON.stringify($json.ai_result.tags || [])}}\n }\n}", + "options": { + "timeout": 30000 + } + }, + "id": "49762c5d-0cb3-4acf-97f7-7e22905148dc", + "name": "Import to Backend", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 6832, + 3488 + ], + "notes": "ส่งข้อมูลเข้า LCBP3 Backend พร้อม Idempotency-Key" + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst shouldCheckpoint = item.json.original_index % 10 === 0;\n\nreturn [{\n json: {\n ...item.json,\n should_update_checkpoint: shouldCheckpoint,\n checkpoint_index: item.json.original_index,\n import_status: 'success',\n timestamp: new Date().toISOString()\n }\n}];" + }, + "id": "7fd03017-f08c-4e93-9486-36069f91ce57", + "name": "Flag Checkpoint", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 7040, + 3488 + ], + "notes": "กำหนดว่าจะบันทึก Checkpoint หรือไม่ (ทุก 10 records)" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.checkpoint_index}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.checkpoint_index}}, updated_at = NOW()", + "options": {} + }, + "id": "27b26d7b-2b57-479f-81ca-8d9319a45a7d", + "name": "Save Checkpoint", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 7232, + 3488 + ], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "บันทึกความคืบหน้าลง Database" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.suggested_title || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify($json.ai_result.detected_issues)}}', '{{$json.review_reason}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason}}', created_at = NOW()", + "options": {} + }, + "id": "5d9547a9-36c8-434d-93e2-405be47d4e43", + "name": "Insert Review Queue", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 6832, + 3696 + ], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "บันทึกรายการที่ต้องตรวจสอบโดยคน (ไม่สร้าง Correspondence)" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,ai_issues\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(JSON.stringify(item.json.ai_result?.detected_issues || []))\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];" + }, + "id": "e933dc6a-885c-4607-916f-f28c655ceac4", + "name": "Log Reject to CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6832, + 3888 + ], + "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/error_log.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,raw_ai_response\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type || 'UNKNOWN'),\n esc(item.json.error || item.json.parse_error),\n esc(item.json.raw_ai_response || '')\n ].join(',') + '\\n';\n \n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;" + }, + "id": "cda3d253-a14d-4ec5-adaa-3e7b276be1f2", + "name": "Log Error to CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 6032, + 4096 + ], + "notes": "บันทึก Error ลง CSV (จาก File Validator)" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, raw_ai_response, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', '{{$json.raw_ai_response || \"\"}}', NOW())", + "options": {} + }, + "id": "1ee11a28-e339-42ac-9066-0ff6dac30920", + "name": "Log Error to DB", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 6640, + 4096 + ], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "บันทึก Error ลง MariaDB" + }, + { + "parameters": { + "amount": "={{$('Set Configuration').first().json.config.DELAY_MS}}", + "unit": "milliseconds" + }, + "id": "0bd637f6-6260-44ab-a27e-d7f4cb372ce4", + "name": "Delay", + "type": "n8n-nodes-base.wait", + "typeVersion": 1, + "position": [ + 7440, + 3696 + ], + "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", + "notes": "หน่วงเวลาระหว่าง Batches" + }, + { + "id": "23d11b5e-49b4-4b53-911b-76b6bb77aab8", + "name": "Route by Confidence", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 6840, + 3696 + ], + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Auto Ingest" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Review Queue" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 2, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Reject" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 3, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Error Log" + } + ] + } + } + }, + { + "parameters": { + "fileSelector": "={{ $json.file_path }}", + "options": {} + }, + "id": "node-read-pdf-1", + "name": "Read PDF File", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [ + 5904, + 3064 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "method": "PUT", + "url": "http://tika:9998/tika", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "text/plain" + }, + { + "name": "X-Tika-OCRLanguage", + "value": "tha+eng" + }, + { + "name": "X-Tika-PDFOcrStrategy", + "value": "ocr_only" + } + ] + }, + "sendBody": true, + "contentType": "binaryData", + "inputDataFieldName": "data", + "options": {}, + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "maxPages", + "value": "2" + } + ] + } + }, + "id": "node-extract-pdf-1", + "name": "Extract PDF Text", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 6032, + 3064 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT 'projects' as type, id, project_code as text1, project_name as text2 FROM projects\nUNION ALL\nSELECT 'disciplines' as type, id, code_name_th as text1, code_name_en as text2 FROM disciplines\nUNION ALL\nSELECT 'organizations' as type, id, organization_name as text1, organization_code as text2 FROM organizations\nUNION ALL\nSELECT 'tags' as type, id, tag_name as text1, description as text2 FROM tags\nUNION ALL\nSELECT 'correspondence_types' as type, id, type_code as text1, type_name as text2 FROM correspondence_types", + "options": {} + }, + "id": "fetch-db-context-node-id", + "name": "Fetch DB Context", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [ + 6032, + 3696 + ], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "alwaysOutputData": true, + "notes": "ดึงข้อมูลจาก Database ส่งให้ AI" + } + ], + "pinData": {}, + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Set Configuration", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Configuration": { + "main": [ + [ + { + "node": "Check Backend Health", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Backend Health": { + "main": [ + [ + { + "node": "Fetch Categories", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Categories": { + "main": [ + [ + { + "node": "Fetch Tags", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Tags": { + "main": [ + [ + { + "node": "File Mount Check", + "type": "main", + "index": 0 + } + ] + ] + }, + "File Mount Check": { + "main": [ + [ + { + "node": "Read Excel Binary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Excel Binary": { + "main": [ + [ + { + "node": "Read Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Excel": { + "main": [ + [ + { + "node": "Read Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Checkpoint": { + "main": [ + [ + { + "node": "Process Batch + Encoding", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Batch + Encoding": { + "main": [ + [ + { + "node": "File Validator", + "type": "main", + "index": 0 + } + ] + ] + }, + "File Validator": { + "main": [ + [ + { + "node": "Read PDF File", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Fallback State": { + "main": [ + [ + { + "node": "Fetch DB Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build AI Prompt": { + "main": [ + [ + { + "node": "Ollama AI Analysis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ollama AI Analysis": { + "main": [ + [ + { + "node": "Parse & Validate AI Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse & Validate AI Response": { + "main": [ + [ + { + "node": "Confidence Router", + "type": "main", + "index": 0 + } + ] + ] + }, + "Confidence Router": { + "main": [ + [ + { + "node": "Route by Confidence", + "type": "main", + "index": 0 + } + ] + ] + }, + "Insert Review Queue": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Reject to CSV": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Import to Backend": { + "main": [ + [ + { + "node": "Flag Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Flag Checkpoint": { + "main": [ + [ + { + "node": "Save Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save Checkpoint": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Error to CSV": { + "main": [ + [ + { + "node": "Log Error to DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Error to DB": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delay": { + "main": [ + [ + { + "node": "Read Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route by Confidence": { + "main": [ + [ + { + "node": "Import to Backend", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Insert Review Queue", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Reject to CSV", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Error to CSV", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read PDF File": { + "main": [ + [ + { + "node": "Extract PDF Text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDF Text": { + "main": [ + [ + { + "node": "Check Fallback State", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch DB Context": { + "main": [ + [ + { + "node": "Build AI Prompt", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "availableInMCP": false + }, + "versionId": "c52d7a07-398b-495e-b384-fb4f02ef3fed", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7" + }, + "id": "u7CLP05AyFb8Um0P", + "tags": [] +} diff --git a/specs/03-Data-and-Storage/db_output.txt b/specs/03-Data-and-Storage/db_output.txt new file mode 100644 index 0000000..279d76c --- /dev/null +++ b/specs/03-Data-and-Storage/db_output.txt @@ -0,0 +1,61 @@ +Organizations: [ + { + id: 1, + organization_name: 'การท่าเรือแห่งประเทศไทย', + organization_code: 'กทท.' + }, + { + id: 10, + organization_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3', + organization_code: 'สคฉ.3' + }, + { + id: 11, + organization_name: 'ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน', + organization_code: 'สคฉ.3-01' + }, + { + id: 12, + organization_name: 'ตรวจรับพัสดุ งานทางทะเล', + organization_code: 'สคฉ.3-02' + }, + { + id: 13, + organization_name: 'ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค', + organization_code: 'สคฉ.3-03' + } +] +Projects: [ + { + id: 1, + project_code: 'LCBP3', + project_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)' + }, + { + id: 2, + project_code: 'LCBP3-C1', + project_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1) งานก่อสร้างงานทางทะเล' + }, + { + id: 3, + project_code: 'LCBP3-C2', + project_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค' + }, + { + id: 4, + project_code: 'LCBP3-C3', + project_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 3) งานก่อสร้าง' + }, + { + id: 5, + project_code: 'LCBP3-C4', + project_name: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 4) งานก่อสร้าง' + } +] +Correspondence Types: [ + { id: 1, type_code: 'RFA', type_name: 'Request for Approval' }, + { id: 2, type_code: 'RFI', type_name: 'Request for Information' }, + { id: 3, type_code: 'TRANSMITTAL', type_name: 'Transmittal' }, + { id: 4, type_code: 'EMAIL', type_name: 'Email' }, + { id: 5, type_code: 'INSTRUCTION', type_name: 'Instruction' } +] diff --git a/specs/03-Data-and-Storage/dbquery.txt b/specs/03-Data-and-Storage/dbquery.txt new file mode 100644 index 0000000..ae013f1 --- /dev/null +++ b/specs/03-Data-and-Storage/dbquery.txt @@ -0,0 +1,9 @@ +SELECT 'projects' as type, id, project_code as text1, project_name as text2 FROM projects +UNION ALL +SELECT 'disciplines' as type, id, code_name_th as text1, code_name_en as text2 FROM disciplines +UNION ALL +SELECT 'organizations' as type, id, organization_name as text1, organization_code as text2 FROM organizations +UNION ALL +SELECT 'tags' as type, id, tag_name as text1, description as text2 FROM tags +UNION ALL +SELECT 'correspondence_types' as type, id, type_code as text1, type_name as text2 FROM correspondence_types diff --git a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql index 3868f6b..616191b 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql @@ -440,47 +440,22 @@ CREATE TABLE rfas ( -- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ rfas (1:N) CREATE TABLE rfa_revisions ( - id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', - rfa_id INT NOT NULL COMMENT 'Master ID ของ RFA', - revision_number INT NOT NULL COMMENT 'หมายเลข Revision (0, 1, 2...)', - revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)', - is_current BOOLEAN DEFAULT FALSE COMMENT '(1 = Revision ปัจจุบัน)', - -- ข้อมูลเฉพาะของ RFA Revision ที่ซับซ้อน + id INT PRIMARY KEY COMMENT 'ID (แชร์กับ correspondence_revisions)', rfa_status_code_id INT NOT NULL COMMENT 'สถานะ RFA', rfa_approve_code_id INT COMMENT 'ผลการอนุมัติ', - subject VARCHAR(500) NOT NULL COMMENT 'หัวข้อเรื่อง', - description TEXT COMMENT 'คำอธิบายการแก้ไขใน Revision นี้', - body TEXT NULL COMMENT 'เนื้อความ (ถ้ามี)', - remarks TEXT COMMENT 'หมายเหตุ', - document_date DATE COMMENT 'วันที่ในเอกสาร', - issued_date DATE COMMENT 'วันที่ส่งขออนุมัติ', - received_date DATETIME COMMENT 'วันที่ลงรับเอกสาร', - due_date DATETIME COMMENT 'วันที่ครบกำหนด', approved_date DATE COMMENT 'วันที่อนุมัติ', - -- Standard Meta Columns - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้างเอกสาร', - created_by INT COMMENT 'ผู้สร้าง', - updated_by INT COMMENT 'ผู้แก้ไขล่าสุด', - -- ส่วนของ JSON และ Schema Version details JSON NULL COMMENT 'RFA Specific Details', schema_version INT DEFAULT 1 COMMENT 'Version ของ JSON Schema', - -- Generated Virtual Columns (ดึงค่าจาก JSON โดยอัตโนมัติ) v_ref_drawing_count INT GENERATED ALWAYS AS ( JSON_UNQUOTE( JSON_EXTRACT(details, '$.drawingCount') ) ) VIRTUAL, - FOREIGN KEY (rfa_id) REFERENCES rfas (id) ON DELETE CASCADE, + FOREIGN KEY (id) REFERENCES correspondence_revisions (id) ON DELETE CASCADE, FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes (id), FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes (id) ON DELETE - SET NULL, - FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE - SET NULL, - FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE - SET NULL, - UNIQUE KEY uq_rr_rev_number (rfa_id, revision_number), - UNIQUE KEY uq_rr_current (rfa_id, is_current) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ rfas (1 :N)'; + SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางขยายของ correspondence_revisions สำหรับ RFA (1:1)'; -- ตารางเชื่อมระหว่าง rfa_revisions (ที่เป็นประเภท DWG) กับ shop_drawing_revisions (M:N) CREATE TABLE rfa_items ( diff --git a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-03-views-indexes.sql b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-03-views-indexes.sql index e5a1d1c..1c98634 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-03-views-indexes.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-03-views-indexes.sql @@ -3,6 +3,7 @@ -- รัน: หลังจาก 02-schema-tables.sql เสร็จ -- ========================================================== SET NAMES utf8mb4; + SET time_zone = '+07:00'; -- ============================================================ @@ -176,12 +177,12 @@ SELECT r.id AS rfa_id, c.originator_id, org.organization_name AS originator_name, rr.id AS revision_id, - rr.revision_number, - rr.revision_label, - rr.subject, - rr.document_date, - rr.issued_date, - rr.received_date, + cr.revision_number, + cr.revision_label, + cr.subject, + cr.document_date, + cr.issued_date, + cr.received_date, rr.approved_date, rr.rfa_status_code_id, rsc.status_code AS rfa_status_code, @@ -189,21 +190,22 @@ SELECT r.id AS rfa_id, rr.rfa_approve_code_id, rac.approve_code AS rfa_approve_code, rac.approve_name AS rfa_approve_name, - rr.created_by, + cr.created_by, u.username AS created_by_username, - rr.created_at AS revision_created_at + cr.created_at AS revision_created_at FROM rfas r INNER JOIN rfa_types rt ON r.rfa_type_id = rt.id - INNER JOIN rfa_revisions rr ON r.id = rr.rfa_id -- RFA uses shared primary key with correspondences (1:1) - INNER JOIN correspondences c ON r.id = c.id -- [FIX 1] เพิ่มการ Join ตาราง disciplines + INNER JOIN correspondences c ON r.id = c.id + INNER JOIN correspondence_revisions cr ON c.id = cr.correspondence_id + INNER JOIN rfa_revisions rr ON cr.id = rr.id -- RFA uses shared primary key with correspondence_revisions (1:1) + -- [FIX 1] เพิ่มการ Join ตาราง disciplines LEFT JOIN disciplines d ON c.discipline_id = d.id INNER JOIN projects p ON c.project_id = p.id INNER JOIN organizations org ON c.originator_id = org.id INNER JOIN rfa_status_codes rsc ON rr.rfa_status_code_id = rsc.id LEFT JOIN rfa_approve_codes rac ON rr.rfa_approve_code_id = rac.id - LEFT JOIN users u ON rr.created_by = u.user_id -WHERE rr.is_current = TRUE - AND r.deleted_at IS NULL + LEFT JOIN users u ON cr.created_by = u.user_id +WHERE cr.is_current = TRUE AND c.deleted_at IS NULL; -- View แสดงความสัมพันธ์ทั้งหมดระหว่าง Contract, Project, และ Organization @@ -249,7 +251,7 @@ SELECT -- 1. Workflow Instance Info ELSE 'N/A' END AS document_number, CASE - WHEN wi.entity_type = 'rfa_revision' THEN rfa_rev.subject + WHEN wi.entity_type = 'rfa_revision' THEN rfa_corr_rev.subject WHEN wi.entity_type = 'circulation' THEN circ.circulation_subject WHEN wi.entity_type = 'correspondence_revision' THEN corr_rev.subject ELSE 'Unknown Document' @@ -262,7 +264,8 @@ FROM workflow_instances wi JOIN workflow_definitions wd ON wi.definition_id = wd.id -- 5. Joins for RFA (ซับซ้อนหน่อยเพราะ RFA ผูกกับ Correspondence อีกที) LEFT JOIN rfa_revisions rfa_rev ON wi.entity_type = 'rfa_revision' AND wi.entity_id = CAST(rfa_rev.id AS CHAR) - LEFT JOIN correspondences rfa_corr ON rfa_rev.id = rfa_corr.id -- 6. Joins for Circulation + LEFT JOIN correspondence_revisions rfa_corr_rev ON rfa_rev.id = rfa_corr_rev.id + LEFT JOIN correspondences rfa_corr ON rfa_corr_rev.correspondence_id = rfa_corr.id -- 6. Joins for Circulation LEFT JOIN circulations circ ON wi.entity_type = 'circulation' AND wi.entity_id = CAST(circ.id AS CHAR) -- 7. Joins for Correspondence LEFT JOIN correspondence_revisions corr_rev ON wi.entity_type = 'correspondence_revision' @@ -497,9 +500,7 @@ CREATE INDEX idx_corr_revisions_current_status ON correspondence_revisions (is_c CREATE INDEX idx_corr_revisions_correspondence_current ON correspondence_revisions (correspondence_id, is_current); -- Indexes for v_current_rfas performance -CREATE INDEX idx_rfa_revisions_current_status ON rfa_revisions (is_current, rfa_status_code_id); - -CREATE INDEX idx_rfa_revisions_rfa_current ON rfa_revisions (rfa_id, is_current); +CREATE INDEX idx_rfa_revisions_status ON rfa_revisions (rfa_status_code_id); -- Indexes for document statistics performance CREATE INDEX idx_correspondences_project_type ON correspondences (project_id, correspondence_type_id); @@ -513,4 +514,3 @@ 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; - diff --git a/specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-basic.sql b/specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-basic.sql index e05d1e0..1699d7e 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-basic.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-basic.sql @@ -1924,56 +1924,6 @@ VALUES ( NULL ); -INSERT INTO `rfas` ( - `id`, - `rfa_type_id`, - `created_at`, - `created_by`, - `deleted_at` - ) -VALUES (2, 68, '2025-12-06 05:40:02', 1, NULL); - -INSERT INTO `rfa_revisions` ( - `id`, - `rfa_id`, - `revision_number`, - `revision_label`, - `is_current`, - `rfa_status_code_id`, - `rfa_approve_code_id`, - `subject`, - `document_date`, - `issued_date`, - `received_date`, - `approved_date`, - `description`, - `details`, - `schema_version`, - `created_at`, - `created_by`, - `updated_by` - ) -VALUES ( - 1, - 2, - 1, - 'A', - 0, - 2, - NULL, - 'ขออนุมัติผลการทดสอบเสาเข็มแบบ Dynamic Load Test สำหรับงานเสาเข็มตอกของอาคารสถานีไฟฟ้าย่อย 22 kV. No. 6', - '2025-12-03', - '2025-12-04', - '2025-12-04 12:40:19', - NULL, - NULL, - NULL, - 1, - '2025-12-06 05:41:25', - NULL, - NULL - ); - -- ========================================================== -- 20. Workflow Definitions (Unified Workflow Engine) -- ========================================================== diff --git a/specs/03-Data-and-Storage/n8n.workflow.json b/specs/03-Data-and-Storage/n8n.workflow.json index 4bfa9a9..66b797a 100644 --- a/specs/03-Data-and-Storage/n8n.workflow.json +++ b/specs/03-Data-and-Storage/n8n.workflow.json @@ -1,29 +1,69 @@ -{ +{ "name": "LCBP3 Migration Workflow v1.8.0", "nodes": [ { - "parameters": {}, - "id": "c41e7a06-5115-48e8-a8ce-821bb3e4d2dc", - "name": "Manual Trigger", - "type": "n8n-nodes-base.manualTrigger", - "typeVersion": 1, + "parameters": { + "formTitle": "LCBP3 Migration - เลือก Model", + "formDescription": "กรุณาเลือก Ollama Model และตั้งค่าก่อนรัน", + "formFields": { + "values": [ + { + "fieldLabel": "Ollama Model (Primary)", + "fieldType": "dropdown", + "fieldOptions": { + "values": [ + { + "option": "qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)" + }, + { + "option": "scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)" + }, + { + "option": "promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)" + } + ] + }, + "requiredField": true + }, + { + "fieldLabel": "Batch Size", + "fieldType": "number", + "placeholder": "2", + "requiredField": false + }, + { + "fieldLabel": "Excel File Path", + "fieldType": "text", + "placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx", + "requiredField": false + } + ] + }, + "responseMode": "onReceived", + "options": {} + }, + "id": "9e2cd13c-4d26-4bd8-98e3-128cbd6bcfcc", + "name": "Form Trigger", + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.2, "position": [ - 4640, - 3696 + 14208, + 6800 ], - "notes": "กดรันด้วยตนเอง" + "webhookId": "lcbp3-migration-form", + "notes": "เปิด URL เพื่อเลือก Model ก่อนรัน" }, { "parameters": { - "jsCode": "// ============================================\n// CONFIGURATION - แก้ไขค่าที่นี่\n// ============================================\nconst CONFIG = {\n // Ollama Settings\n OLLAMA_HOST: 'http://192.168.20.100:11434',\n OLLAMA_MODEL_PRIMARY: 'scb10x/typhoon2.1-gemma3-4b',\n OLLAMA_MODEL_FALLBACK: 'mistral:7b-instruct-q4_K_M',\n \n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzcyNzc0MzI5LCJleHAiOjQ5Mjg1MzQzMjl9.TtA8zoHy7G9J5jPgYQPv7yw-9X--B_hl-Nv-c9V4PaA',\n \n // Batch Settings\n BATCH_SIZE: 2,\n BATCH_ID: 'migration_20260226',\n DELAY_MS: 2000,\n \n // Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n MAX_RETRY: 3,\n FALLBACK_THRESHOLD: 5,\n \n // Source Definitions - แก้ไขโฟลเดอร์และไฟล์ทำงานที่นี่\n EXCEL_FILE: '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n \n // Database\n DB_HOST: '192.168.10.8',\n DB_PORT: 3306,\n DB_NAME: 'lcbp3',\n DB_USER: 'migration_bot',\n DB_PASSWORD: 'Center2025',\n PROJECT_ID: 1\n};\n\nreturn [{ json: { config_loaded: true, timestamp: new Date().toISOString(), config: CONFIG } }];" + "jsCode": "// Read model selected from Form Trigger dropdown\nconst formData = $('Form Trigger').first()?.json || {};\nconst selectedModelLabel = String(formData['Ollama Model (Primary)'] || '');\n\n// Extract just the model ID (before the space in the label)\nconst MODEL_MAP = {\n 'qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)': 'qwen2.5:7b-instruct-q4_K_M',\n 'scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)': 'scb10x/typhoon2.1-gemma3-4b',\n 'promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)': 'promptnow/openthaigpt1.5-7b-instruct-q4_k_m'\n};\nconst selectedModel = MODEL_MAP[selectedModelLabel] || 'qwen2.5:7b-instruct-q4_K_M';\n\nconst batchSizeInput = parseInt(formData['Batch Size'] || '0');\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\n\nconst CONFIG = {\n // Ollama Settings\n OLLAMA_HOST: 'http://192.168.20.100:11434',\n // Model selected from Form UI\n OLLAMA_MODEL_PRIMARY: selectedModel,\n // Fallback\n OLLAMA_MODEL_FALLBACK: 'mistral:7b-instruct-q4_K_M',\n \n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzcyNzc0MzI5LCJleHAiOjQ5Mjg1MzQzMjl9.TtA8zoHy7G9J5jPgYQPv7yw-9X--B_hl-Nv-c9V4PaA',\n \n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 2,\n BATCH_ID: (() => { const d = new Date(Date.now() + 7 * 3600000); const s = d.toISOString(); return s.substring(0,10).replace(/-/g,'') + ':' + s.substring(11,16).replace(/:/g,''); })(),\n DELAY_MS: 2000,\n \n // Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n MAX_RETRY: 3,\n FALLBACK_THRESHOLD: 5,\n \n // Source Definitions - แก้ไขโฟลเดอร์และไฟล์ทำงานที่นี่\n EXCEL_FILE: excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n \n // Database\n DB_HOST: '192.168.10.8',\n DB_PORT: 3306,\n DB_NAME: 'lcbp3',\n DB_USER: 'migration_bot',\n DB_PASSWORD: 'Center2025',\n // Project ID: 1=LCBP3, 2=LCBP3-C1, 3=LCBP3-C2\n PROJECT_ID: 3\n};\n\nreturn [{ json: { config_loaded: true, timestamp: new Date().toISOString(), config: CONFIG } }];" }, - "id": "bc8c9b9d-284d-4ce5-b7ff-d5b4bb36e748", + "id": "9230401b-51f2-4744-b055-100a883a5bad", "name": "Set Configuration", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 4832, - 3696 + 14400, + 6800 ], "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน" }, @@ -43,13 +83,13 @@ "timeout": 10000 } }, - "id": "ccb5fea4-773d-4584-a14c-88845f4c2bc3", + "id": "5142fb05-369a-4b04-b98f-4208b71254c7", "name": "Fetch Categories", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 5040, - 3696 + 14816, + 6224 ], "notes": "ดึง Categories จาก Backend" }, @@ -69,13 +109,13 @@ "timeout": 10000 } }, - "id": "f1a2b3c4-d5e6-7f8g-9h0i-j1k2l3m4n5o6", + "id": "af45bbb3-cf8e-463b-81b6-637bf5defde2", "name": "Fetch Tags", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 5040, - 3856 + 15088, + 6224 ], "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend" }, @@ -86,13 +126,13 @@ "timeout": 5000 } }, - "id": "0fe2cc93-7d88-4290-8170-2863e087afd3", + "id": "b4b8dd0c-8ed4-4c1c-a70d-c439589d2d81", "name": "Check Backend Health", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 5008, - 3328 + 14576, + 6432 ], "onError": "continueErrorOutput", "notes": "ตรวจสอบ Backend พร้อมใช้งาน" @@ -101,13 +141,13 @@ "parameters": { "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\n// Check file mount and inputs\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories out of the previous node (Fetch Categories) if available\n // otherwise use fallback array\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c); // very loose mapping depending on API response\n }\n } catch(e) {}\n \n // Grab existing tags from Fetch Tags node\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}" }, - "id": "5bdb31ca-9588-404d-92ce-3438bdd9835b", + "id": "95cddf96-a761-479b-b5fc-78ec7765d0c6", "name": "File Mount Check", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 5248, - 3392 + 14912, + 6528 ], "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า" }, @@ -117,13 +157,13 @@ "query": "SELECT last_processed_index, status FROM migration_progress WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", "options": {} }, - "id": "2907a4ca-2a46-45ef-8920-9684d00ffda7", + "id": "3456794f-f91a-42ae-91e6-3619e5cbbfcb", "name": "Read Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 5504, - 3376 + 15120, + 6768 ], "alwaysOutputData": true, "credentials": { @@ -140,13 +180,13 @@ "fileSelector": "={{ $json.excel_target }}", "options": {} }, - "id": "f035d28b-413b-4386-bbef-d242cd22aa8f", + "id": "25329cd6-0bd2-4391-8680-20eaa32c817b", "name": "Read Excel Binary", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [ - 5040, - 4112 + 14608, + 7216 ], "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" }, @@ -154,27 +194,27 @@ "parameters": { "options": {} }, - "id": "35727d79-e3c2-4fdf-8bc3-064914393cf7", + "id": "85a6561d-4b7b-454a-aba9-18b612b5abfb", "name": "Read Excel", "type": "n8n-nodes-base.spreadsheetFile", "typeVersion": 2, "position": [ - 5264, - 3968 + 14784, + 6944 ], "notes": "แปลงข้อมูล Excel เป็น JSON Data" }, { "parameters": { - "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Encoding Normalization\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const docNum = item.document_number || item.correspondence_number || item['Document Number'] || item['Corr. No.'];\n // Use File name from Excel directly - must exist\n const excelFileName = item['File name'] || item.file_name || item['File Name'] || item.filename;\n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n const fileName = normalize(excelFileName);\n return {\n json: {\n document_number: normalize(docNum),\n title: normalize(item.title || item.Title || item['Subject']),\n legacy_number: normalize(item.legacy_number || item['Legacy Number'] || item['Response Doc.'] || ''),\n excel_revision: item.revision || item.Revision || item.rev || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: fileName,\n issued_date: normalize(item.issued_date || item.Issued_date || item['Issued Date'] || item.date || item.Date || item.document_date || item.Document_Date),\n received_date: normalize(item.received_date || item.Received_date || item['Received Date'] || item.receive || item.Receive),\n correspondence_type: normalize(item.correspondence_types || item.correspondence_type || item['Correspondence Types'] || item['Correspondence Type'])\n }\n };\n});" + "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Encoding Normalization\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n // Safe getter to handle whitespace or case in Excel column names\n const getVal = (possibleKeys) => {\n const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n if (exactMatch) return item[exactMatch];\n \n // Fallback: Check lowercase/trimmed keys if exact match fails\n const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n for (const pk of possibleKeys) {\n const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n if (found) return item[found.original];\n }\n return '';\n };\n\n const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n \n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n \n return {\n json: {\n document_number: normalize(docNum),\n title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n excel_revision: getVal(['revision', 'Revision', 'rev']) || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: normalize(excelFileName),\n issued_date: normalize(getVal(['issued_date', 'Issued_date', 'Issued Date', 'date', 'Date', 'document_date', 'Document_Date'])),\n received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date', 'receive', 'Receive'])),\n correspondence_type: normalize(getVal(['correspondence_types', 'correspondence_type', 'Correspondence Types', 'Correspondence Type'])),\n sender: normalize(getVal(['sender', 'Sender', 'from', 'From'])),\n receiver: normalize(getVal(['receiver', 'Receiver', 'to', 'To'])),\n project_code: normalize(getVal(['project_code', 'Project Code', 'project', 'Project'])),\n _raw_excel_keys: Object.keys(item),\n _raw_excel: item\n }\n };\n});" }, - "id": "49c98c75-456b-4a1d-a203-a5b2bf19fd15", + "id": "a119287e-2cdd-4cb0-9c3e-c07648c732ef", "name": "Process Batch + Encoding", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 5712, - 3360 + 15280, + 6464 ], "alwaysOutputData": true, "notes": "ตัด Batch + Normalize UTF-8" @@ -183,13 +223,13 @@ "parameters": { "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME', file_exists: false }\n });\n continue;\n }\n \n // Use file name from Excel directly, add .pdf if missing\n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) {\n safeName += '.pdf';\n }\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n // Path traversal check\n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY', file_exists: false }\n });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({\n ...item,\n json: { ...item.json, file_valid: true, file_exists: true, file_size: stats.size, file_path: filePath }\n });\n } else {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND', file_exists: false }\n });\n }\n } catch (err) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR', file_exists: false }\n });\n }\n}\n\nreturn [...validated, ...errors];" }, - "id": "51e91c88-98cd-4df4-81ac-e452b25e5c06", + "id": "afba94df-aa7a-49a2-9cd0-c45dae863adf", "name": "File Validator", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 5904, - 3264 + 15472, + 6368 ], "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config" }, @@ -199,13 +239,13 @@ "query": "SELECT is_fallback_active, recent_error_count FROM migration_fallback_state WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", "options": {} }, - "id": "88c205b6-9b94-4a4f-ad53-ab3cad6fde27", + "id": "5f37b586-a782-405e-94c5-15c8d0be83cd", "name": "Check Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 6032, - 3488 + 16112, + 6400 ], "alwaysOutputData": true, "credentials": { @@ -219,15 +259,15 @@ }, { "parameters": { - "jsCode": "const config = $('Set Configuration').first().json.config;\nconst fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 };\nconst isFallback = fallbackState.is_fallback_active || false;\nconst model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY;\n\n// Read DB Context\nconst dbContext = $('Fetch DB Context').all().map(i => i.json);\nconst dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2}));\nconst dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2}));\nconst dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2}));\nconst dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1}));\nconst dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2}));\n\nlet systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\ntry { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {}\n\nconst items = $('Extract PDF Text').all();\n\nreturn items.map(item => {\n const docNum = String(item.json.document_number || '');\n const title = String(item.json.title || '');\n const legacyNum = String(item.json.legacy_number || '');\n const issuedDate = String(item.json.issued_date || '');\n const receivedDate = String(item.json.received_date || '');\n const corrType = String(item.json.correspondence_type || '');\n\n const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa');\n\n const systemPrompt = `You are an expert Document Controller for a construction project (LCBP3) in Thailand.\nThe documents are primarily in THAI and ENGLISH.\nYour task is to classify documents and extract metadata from OCR text.\nRespond ONLY with valid JSON.`;\n\n const pdfText = String(item.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\\s\\.\\/\\-:\\[\\]\\(\\)]/g, ' ');\n\n const userPrompt = `Analyze this document:\n[EXCEL METADATA]\nDocument Number: ${docNum || 'Not provided'}\nTitle: ${title || 'Not provided'}\nIssued Date: ${issuedDate || 'Not provided'}\nReceived Date: ${receivedDate || 'Not provided'}\nCorrespondence Type: ${corrType || 'Not provided'}\n\n[DATABASE REFERENCES]\nProjects: ${JSON.stringify(dbProjects)}\nDisciplines: ${JSON.stringify(dbDisciplines)}\nOrganizations: ${JSON.stringify(dbOrgs)}\nTags: ${JSON.stringify(dbTags)}\nCorrespondence Types: ${JSON.stringify(dbCorrTypes)}\n\n[OCR TEXT EXTRACTION]\n${pdfText}\n\nRules:\n1. Category: Must be one of ${JSON.stringify(systemCategories)}. If Document Number contains \"-RFA-\", category MUST be \"RFA\".\n2. Respond with EXACTLY 11 fields in JSON format:\n - \"project_id\": Find ID from table projects matching the project_code in text.\n - \"discipline_id\": Find ID from table disciplines analyzing text to match code_name_th. If no match, use ID=64 (from contract LCBP3-C2).\n - \"sender_id\": Find ID from table organizations matching Sender.\n - \"receiver_id\": Find ID from table organizations matching Receiver.\n - \"correspondence_type_id\": Find ID from table correspondence_types matching the EXCEL METADATA 'Correspondence Type'.\n - \"subject\": Document subject. If OCR is close to EXCEL METADATA Title, use EXCEL METADATA.\n - \"issued_date\": Verify from OCR text if it matches ${issuedDate}, format YYYY-MM-DD.\n - \"received_date\": Verify from OCR text. If empty, default to issued_date.\n - \"status\": Extract status (e.g., Approve, Reject, Resubmit). This will be exported as \"remark\".\n - \"summary\": 4-5 lines of Thai summary from OCR. This will be exported as \"body\".\n - \"tags\": Array of tag IDs from database matching the text. If not in DB, return string name.\n - \"key_points\": Array of string key points extracted.\n\nRespond ONLY with this EXACT JSON structure:\n{\n \"project_id\": null,\n \"discipline_id\": 64,\n \"sender_id\": null,\n \"receiver_id\": null,\n \"correspondence_type_id\": null,\n \"subject\": \"${title}\",\n \"issued_date\": \"${issuedDate}\",\n \"received_date\": \"${receivedDate || issuedDate}\",\n \"status\": null,\n \"summary\": \"สรุปเนื้อหา 4-5 บรรทัด...\",\n \"tags\": [],\n \"key_points\": [],\n \"category\": \"${isRFA ? 'RFA' : 'Correspondence'}\",\n \"confidence\": 0.95\n}`;\n\n return {\n json: {\n ...item.json,\n active_model: model,\n is_fallback: isFallback,\n system_categories: systemCategories,\n ollama_payload: {\n model: model,\n prompt: `${systemPrompt}\\n\\n${userPrompt}`,\n stream: false,\n format: 'json'\n }\n }\n };\n});" + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 };\nconst isFallback = fallbackState.is_fallback_active || false;\nconst model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY;\n\n// Read DB Context\nconst dbContext = $('Fetch DB Context').all().map(i => i.json);\nconst dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2}));\nconst dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2}));\nconst dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2}));\nconst dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1}));\nconst dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2}));\n\nlet systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\ntry { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {}\n\nconst pdfItems = $('Extract PDF Text').all();\n// File Validator passes all original Excel JSON fields through (sender, receiver, project_code, etc.)\n// Read PDF File overwrites the JSON with binary data, so we must go back one step\nconst metaItems = $('File Validator').all();\n\nreturn pdfItems.map((pdfItem, i) => {\n const item = metaItems[i] || pdfItem;\n\n const docNum = String(item.json.document_number || '');\n const title = String(item.json.title || '');\n const legacyNum = String(item.json.legacy_number || '');\n const issuedDate = String(item.json.issued_date || '');\n const receivedDate = String(item.json.received_date || '');\n const corrType = String(item.json.correspondence_type || '');\n const senderCode = String(item.json.sender || '');\n const receiverCode = String(item.json.receiver || '');\n const projectCode = String(item.json.project_code || '');\n\n // JavaScript pre-mapping\n const findOrgId = (code) => {\n if (!code) return null;\n const match = dbOrgs.find(o => o.code === code || o.name === code);\n return match ? match.id : null;\n };\n\n const findProjectId = (code) => {\n if (!code) return config.PROJECT_ID; // Fallback to config\n const match = dbProjects.find(p => p.code === code || p.name === code);\n return match ? match.id : config.PROJECT_ID;\n };\n\n const senderId = findOrgId(senderCode);\n const receiverId = findOrgId(receiverCode);\n const projectId = findProjectId(projectCode);\n // Excel corrType is likely already the ID based on requirements, but fallback matching to ID if needed\n const corrMatch = dbCorrTypes.find(c => String(c.id) === corrType || c.code === corrType || c.name === corrType);\n const corrTypeId = corrMatch ? corrMatch.id : (isNaN(parseInt(corrType)) ? null : parseInt(corrType));\n\n const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa');\n\n const systemPrompt = `You are an expert Document Controller for a construction project (LCBP3) in Thailand.\nThe documents are primarily in THAI and ENGLISH.\nYour task is to classify documents and extract metadata from OCR text.\nRespond ONLY with valid JSON.`;\n\n // Use pdfItem for the OCR extracted data, NOT the metaItem\n const pdfText = String(pdfItem.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\\s\\.\\/\\-:\\[\\]\\(\\)]/g, ' ');\n\n const userPrompt = `Analyze this document:\n[EXCEL METADATA]\nDocument Number: ${docNum || 'Not provided'}\nTitle: ${title || 'Not provided'}\nIssued Date: ${issuedDate || 'Not provided'}\nReceived Date: ${receivedDate || 'Not provided'}\n\n[DATABASE REFERENCES]\nDisciplines: ${JSON.stringify(dbDisciplines)}\nTags: ${JSON.stringify(dbTags)}\n\n[OCR TEXT EXTRACTION]\n${pdfText}\n\nRules:\n1. Category: Must be one of ${JSON.stringify(systemCategories)}. If Document Number contains \"-RFA-\", category MUST be \"RFA\".\n2. Respond with EXACTLY 8 fields in JSON format:\n - \"discipline_id\": Find 'id' from Disciplines array analyzing text to match 'th' or 'en'. If no match, use ID=64 (from contract LCBP3-C2).\n - \"subject\": Document subject. If OCR is close to EXCEL METADATA Title, use EXCEL METADATA.\n - \"issued_date\": Verify from OCR text if it matches ${issuedDate}, format YYYY-MM-DD.\n - \"received_date\": Verify from OCR text. If empty, default to issued_date.\n - \"status\": Extract status (e.g., For Information, Approve, Reject, Resubmit). This will be exported as \"remark\".\n - \"summary\": 4-5 lines of Thai summary from OCR. This will be exported as \"body\".\n - \"tags\": REQUIRED. Identify 2-5 main topics/themes from the document (from Title, subject matter, and OCR text). For each topic, return an object with:\n * \"tag_name\": short topic name in Thai (2-5 words), e.g. \"คอนกรีตผสม\", \"ทดสอบวัสดุ\"\n * \"description\": one sentence in Thai describing this topic (use key point details). e.g. \"การทดสอบค่า slump ของคอนกรีตผสมที่หน้างาน\"\n Return as: [{\"tag_name\": \"...\", \"description\": \"...\"}, ...]\n - \"key_points\": Array of 3-5 string key points extracted from the document (in Thai).\n\n3. IMPORTANT: You MUST REPLACE the 'null' values in the template below with the actual Integer IDs or text you found. DO NOT reply with literal 'null' if you found a match!\n\nRespond ONLY with this EXACT JSON structure:\n{\n \"discipline_id\": 64,\n \"subject\": \"${title}\",\n \"issued_date\": \"${issuedDate}\",\n \"received_date\": \"${receivedDate || issuedDate}\",\n \"status\": null,\n \"summary\": \"สรุปเนื้อหา 4-5 บรรทัด...\",\n \"tags\": [{\"tag_name\": \"ชื่อหัวข้อ\", \"description\": \"คำอธิบาย key point ของหัวข้อนี้\"}],\n \"key_points\": [\"จุดสำคัญที่ 1\", \"จุดสำคัญที่ 2\", \"จุดสำคัญที่ 3\"],\n \"category\": \"${isRFA ? 'RFA' : 'Correspondence'}\",\n \"confidence\": 0.95\n}`;\n\n return {\n json: {\n ...item.json,\n active_model: model,\n is_fallback: isFallback,\n system_categories: systemCategories,\n pre_mapped: {\n project_id: projectId,\n sender_id: senderId,\n receiver_id: receiverId,\n correspondence_type_id: corrTypeId\n },\n _debug_mapping: {\n excel_project_code: projectCode,\n excel_sender: senderCode,\n excel_receiver: receiverCode,\n excel_corr_type: corrType,\n matched_project: dbProjects.find(p => p.code === projectCode || p.name === projectCode) || null,\n first_org_sample: dbOrgs[0] || null\n },\n ollama_payload: {\n model: model,\n prompt: `${systemPrompt}\\n\\n${userPrompt}`,\n stream: false,\n format: 'json',\n options: {\n temperature: 0.1,\n num_ctx: 8192\n }\n }\n }\n };\n});\n" }, - "id": "9f82950f-7533-4cbd-8e1e-8e441c1cb2a5", + "id": "249eaae6-95ce-4a19-a073-8528b3e8b322", "name": "Build AI Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6032, - 3896 + 15616, + 6800 ], "notes": "สร้าง Prompt โดยใช้ Categories จาก System" }, @@ -242,27 +282,27 @@ "timeout": 120000 } }, - "id": "ae9b6be5-284c-44db-b7f0-b4839a59230e", + "id": "d0939bce-19de-47fe-98e6-84b729737ed8", "name": "Ollama AI Analysis", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 6240, - 3696 + 15808, + 6800 ], "notes": "เรียก Ollama วิเคราะห์เอกสาร" }, { "parameters": { - "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\nfor (let i = 0; i < ollamaItems.length; i++) {\n const ollamaItem = ollamaItems[i];\n const originalItem = originalItems[i];\n if (!originalItem) continue;\n const baseJson = originalItem.json;\n\n try {\n let raw = ollamaItem.json.response || '';\n raw = raw.replace(/\\x60\\x60\\x60json/gi, '').replace(/\\x60\\x60\\x60/g, '').trim();\n if (!raw) throw new Error('Empty response from AI');\n\n const result = JSON.parse(raw);\n\n // Map AI result to database fields\n let tags = Array.isArray(result.tags) ? result.tags : [];\n\n // Enum Validation for Category\n const systemCategories = baseJson.system_categories || [];\n let finalCategory = result.category;\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n\n results.push({\n json: {\n ...baseJson,\n ai_result: {\n suggested_category: finalCategory,\n confidence: result.confidence || 0.8,\n project_id: result.project_id,\n discipline_id: result.discipline_id || 64,\n sender_id: result.sender_id,\n receiver_id: result.receiver_id,\n correspondence_type_id: result.correspondence_type_id || null,\n subject: result.subject || baseJson.title,\n issued_date: result.issued_date || baseJson.issued_date,\n received_date: result.received_date || baseJson.received_date || result.issued_date || baseJson.issued_date,\n remark: result.status,\n body: result.summary,\n tags: tags,\n key_points: result.key_points || []\n },\n parse_error: null\n }\n });\n } catch (err) {\n results.push({\n json: {\n ...baseJson,\n ai_result: null,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response,\n error_type: 'AI_PARSE_ERROR'\n }\n });\n }\n}\nreturn results;" + "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\n// Map AI category names → correspondence_types.type_code in DB\nconst CATEGORY_TO_TYPE_CODE = {\n 'Correspondence': 'LETTER',\n 'RFA': 'RFA',\n 'Transmittal': 'TRANSMITTAL',\n 'Drawing': 'OTHER',\n 'Report': 'OTHER',\n 'Other': 'OTHER',\n};\n\nfor (let i = 0; i < ollamaItems.length; i++) {\n const ollamaItem = ollamaItems[i];\n const originalItem = originalItems[i];\n if (!originalItem) continue;\n const baseJson = originalItem.json;\n\n try {\n let raw = ollamaItem.json.response || '';\n raw = raw.replace(/`{3}json/gi, '').replace(/`{3}/g, '').trim();\n if (!raw) throw new Error('Empty response from AI');\n\n const result = JSON.parse(raw);\n\n // Map AI result to database fields\n let tags = Array.isArray(result.tags) ? result.tags : [];\n\n // Enum Validation for Category\n const systemCategories = baseJson.system_categories || [];\n let finalCategory = result.category;\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n\n // Map to type_code for Backend API\n const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n\n const preMapped = baseJson.pre_mapped || {};\n\n results.push({\n json: {\n ...baseJson,\n ai_result: {\n suggested_category: finalCategory,\n type_code: typeCode,\n confidence: result.confidence || 0.8,\n project_id: preMapped.project_id || null,\n discipline_id: result.discipline_id || 64,\n sender_id: preMapped.sender_id || null,\n receiver_id: preMapped.receiver_id || null,\n correspondence_type_id: preMapped.correspondence_type_id || null,\n subject: result.subject || baseJson.title,\n issued_date: result.issued_date || baseJson.issued_date,\n received_date: result.received_date || baseJson.received_date || result.issued_date || baseJson.issued_date,\n remark: result.status,\n body: result.summary,\n tags: tags,\n key_points: result.key_points || []\n },\n parse_error: null\n }\n });\n } catch (err) {\n results.push({\n json: {\n ...baseJson,\n ai_result: null,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response,\n error_type: 'AI_PARSE_ERROR'\n }\n });\n }\n}\nreturn results;" }, - "id": "281dc950-a3b6-4412-a0b4-76663b8c37ea", + "id": "37a976fe-34f3-43bd-967a-90261365e451", "name": "Parse & Validate AI Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6432, - 3696 + 16000, + 6800 ], "notes": "Parse JSON + Validate Schema + Enum Check" }, @@ -272,13 +312,13 @@ "query": "INSERT INTO migration_fallback_state (batch_id, recent_error_count, is_fallback_active) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', 1, FALSE) ON DUPLICATE KEY UPDATE recent_error_count = recent_error_count + 1, is_fallback_active = CASE WHEN recent_error_count + 1 >= {{$('Set Configuration').first().json.config.FALLBACK_THRESHOLD}} THEN TRUE ELSE is_fallback_active END, updated_at = NOW()", "options": {} }, - "id": "41904cb6-b6f3-4a32-9dd5-c44e8e0cefab", + "id": "35eee4ab-a775-4c44-837e-e5e8ca31e104", "name": "Update Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 6640, - 3888 + 16208, + 6992 ], "credentials": { "mySql": { @@ -290,15 +330,15 @@ }, { "parameters": { - "jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $('Parse & Validate AI Response').all();\n\nconst results = [];\n\nfor (const item of items) {\n const data = item.json;\n \n // Base structure ensuring we keep all existing data\n let resultItem = { json: { ...data } };\n \n // Handle Parse Errors from upstream\n if (data.parse_error || !data.ai_result) {\n resultItem.json.route_index = 3;\n results.push(resultItem);\n continue;\n }\n \n const ai = data.ai_result;\n \n // Revision Drift Protection\n if (data.current_db_revision !== undefined) {\n const expectedRev = data.current_db_revision + 1;\n if (parseInt(data.excel_revision) !== expectedRev) {\n resultItem.json.review_reason = `Revision drift: Excel=${data.excel_revision}, Expected=${expectedRev}`;\n resultItem.json.route_index = 1;\n results.push(resultItem);\n continue;\n }\n }\n \n // Confidence Routing\n if (ai.confidence >= config.CONFIDENCE_HIGH && ai.is_valid === true) {\n resultItem.json.route_index = 0;\n } else if (ai.confidence >= config.CONFIDENCE_LOW) {\n resultItem.json.review_reason = `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n resultItem.json.route_index = 1;\n } else {\n resultItem.json.reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n resultItem.json.route_index = 2;\n }\n results.push(resultItem);\n}\n\nreturn results;" + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $('Parse & Validate AI Response').all();\n\nconst results = [];\n\nfor (const item of items) {\n const data = item.json;\n \n // Base structure ensuring we keep all existing data\n let resultItem = { json: { ...data } };\n \n // Handle Parse Errors from upstream\n if (data.parse_error || !data.ai_result) {\n resultItem.json.route_index = 3;\n results.push(resultItem);\n continue;\n }\n \n const ai = data.ai_result;\n \n // Revision Drift Protection\n if (data.current_db_revision !== undefined) {\n const expectedRev = data.current_db_revision + 1;\n if (parseInt(data.excel_revision) !== expectedRev) {\n resultItem.json.review_reason = `Revision drift: Excel=${data.excel_revision}, Expected=${expectedRev}`;\n resultItem.json.route_index = 1;\n results.push(resultItem);\n continue;\n }\n }\n \n // Confidence Routing\n if (ai.confidence >= config.CONFIDENCE_HIGH) {\n resultItem.json.route_index = 0;\n } else if (ai.confidence >= config.CONFIDENCE_LOW) {\n resultItem.json.review_reason = `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n resultItem.json.route_index = 1;\n } else {\n resultItem.json.reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n resultItem.json.route_index = 2;\n }\n results.push(resultItem);\n}\n\nreturn results;" }, - "id": "897dfc43-9f4f-4a9b-8727-64f3483ac56a", + "id": "90afb155-bf51-48dd-8046-22661351e15f", "name": "Confidence Router", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6640, - 3696 + 16208, + 6800 ], "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)" }, @@ -315,38 +355,38 @@ }, { "name": "Idempotency-Key", - "value": "={{$json.document_number}}:{{$('Set Configuration').first().json.config.BATCH_ID}}" + "value": "={{ $json.document_number + ':' + $('Set Configuration').first().json.config.BATCH_ID }}" } ] }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"document_number\": \"{{$json.document_number}}\",\n \"title\": \"{{$json.ai_result.subject}}\",\n \"category\": \"{{$json.ai_result.suggested_category}}\",\n \"source_file_path\": \"{{$json.file_path}}\",\n \"ai_confidence\": {{$json.ai_result.confidence}},\n \"migrated_by\": \"SYSTEM_IMPORT\",\n \"batch_id\": \"{{$('Set Configuration').first().json.config.BATCH_ID}}\",\n \"project_id\": {{$json.ai_result.project_id || $('Set Configuration').first().json.config.PROJECT_ID}},\n \"discipline_id\": {{$json.ai_result.discipline_id}},\n \"sender_id\": {{$json.ai_result.sender_id || null}},\n \"receiver_id\": {{$json.ai_result.receiver_id || null}},\n \"correspondence_type_id\": {{$json.ai_result.correspondence_type_id || null}},\n \"issued_date\": \"{{$json.ai_result.issued_date}}\",\n \"received_date\": \"{{$json.ai_result.received_date}}\",\n \"body\": {{JSON.stringify($json.ai_result.body || \"\")}},\n \"details\": {\n \"legacy_number\": \"{{$json.legacy_number}}\",\n \"remark\": \"{{$json.ai_result.remark}}\",\n \"key_points\": {{JSON.stringify($json.ai_result.key_points || [])}},\n \"tags\": {{JSON.stringify($json.ai_result.tags || [])}}\n }\n}", + "jsonBody": "={{ $json.import_payload }}", "options": { "timeout": 30000 } }, - "id": "49762c5d-0cb3-4acf-97f7-7e22905148dc", + "id": "d2368dca-715b-4aa7-9b30-bdafad53868c", "name": "Import to Backend", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 6832, - 3488 + 16600, + 6592 ], "notes": "ส่งข้อมูลเข้า LCBP3 Backend พร้อม Idempotency-Key" }, { "parameters": { - "jsCode": "const item = $input.first();\nconst shouldCheckpoint = item.json.original_index % 10 === 0;\n\nreturn [{\n json: {\n ...item.json,\n should_update_checkpoint: shouldCheckpoint,\n checkpoint_index: item.json.original_index,\n import_status: 'success',\n timestamp: new Date().toISOString()\n }\n}];" + "jsCode": "// $input here is the HTTP response from Import to Backend — it has no original_index.\n// We must go back to Build Import Payload which preserved the original item data.\nconst originalData = $('Build Import Payload').first().json;\nconst importResponse = $input.first().json;\n\nconst idx = originalData.original_index ?? 0;\nconst shouldCheckpoint = idx % 10 === 0;\n\nreturn [{\n json: {\n ...originalData,\n import_response: importResponse,\n should_update_checkpoint: shouldCheckpoint,\n checkpoint_index: idx,\n import_status: 'success',\n timestamp: new Date().toISOString()\n }\n}];" }, - "id": "7fd03017-f08c-4e93-9486-36069f91ce57", + "id": "1ae2b021-7ad6-46aa-855c-cceaecef7abf", "name": "Flag Checkpoint", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 7040, - 3488 + 16608, + 6592 ], "notes": "กำหนดว่าจะบันทึก Checkpoint หรือไม่ (ทุก 10 records)" }, @@ -356,13 +396,13 @@ "query": "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.checkpoint_index}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.checkpoint_index}}, updated_at = NOW()", "options": {} }, - "id": "27b26d7b-2b57-479f-81ca-8d9319a45a7d", + "id": "860a6954-fee8-44d6-8cc0-05a00ea19ba0", "name": "Save Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 7232, - 3488 + 17200, + 6592 ], "credentials": { "mySql": { @@ -375,16 +415,16 @@ { "parameters": { "operation": "executeQuery", - "query": "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.suggested_title || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify($json.ai_result.detected_issues)}}', '{{$json.review_reason}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason}}', created_at = NOW()", + "query": "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.subject || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify($json.ai_result.key_points || [])}}', '{{$json.review_reason}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason}}', created_at = NOW()", "options": {} }, - "id": "5d9547a9-36c8-434d-93e2-405be47d4e43", + "id": "d76e9be2-9cc9-4b18-96de-637cbef9e90a", "name": "Insert Review Queue", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 6832, - 3696 + 16400, + 6800 ], "credentials": { "mySql": { @@ -396,15 +436,15 @@ }, { "parameters": { - "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,ai_issues\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(JSON.stringify(item.json.ai_result?.detected_issues || []))\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];" + "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,key_points\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(JSON.stringify(item.json.ai_result?.key_points || []))\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];" }, - "id": "e933dc6a-885c-4607-916f-f28c655ceac4", + "id": "70ff9ff0-b5f8-4d10-baf5-1840c5b0fd24", "name": "Log Reject to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6832, - 3888 + 16400, + 6992 ], "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" }, @@ -412,13 +452,13 @@ "parameters": { "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/error_log.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,raw_ai_response\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type || 'UNKNOWN'),\n esc(item.json.error || item.json.parse_error),\n esc(item.json.raw_ai_response || '')\n ].join(',') + '\\n';\n \n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;" }, - "id": "cda3d253-a14d-4ec5-adaa-3e7b276be1f2", + "id": "cc7b60df-8342-4ffd-ac56-23a101f0719e", "name": "Log Error to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6032, - 4096 + 15600, + 7200 ], "notes": "บันทึก Error ลง CSV (จาก File Validator)" }, @@ -428,13 +468,13 @@ "query": "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, raw_ai_response, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', '{{$json.raw_ai_response || \"\"}}', NOW())", "options": {} }, - "id": "1ee11a28-e339-42ac-9066-0ff6dac30920", + "id": "443631b9-5bf3-4803-9cab-3348bc1f74d9", "name": "Log Error to DB", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 6640, - 4096 + 16208, + 7200 ], "credentials": { "mySql": { @@ -449,26 +489,18 @@ "amount": "={{$('Set Configuration').first().json.config.DELAY_MS}}", "unit": "milliseconds" }, - "id": "0bd637f6-6260-44ab-a27e-d7f4cb372ce4", + "id": "b5c9373b-d617-48fd-8ab5-1c7933dd49fe", "name": "Delay", "type": "n8n-nodes-base.wait", "typeVersion": 1, "position": [ - 7440, - 3696 + 17400, + 6800 ], "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", "notes": "หน่วงเวลาระหว่าง Batches" }, { - "id": "23d11b5e-49b4-4b53-911b-76b6bb77aab8", - "name": "Route by Confidence", - "type": "n8n-nodes-base.switch", - "typeVersion": 3.2, - "position": [ - 6840, - 3696 - ], "parameters": { "rules": { "values": [ @@ -569,21 +601,30 @@ "outputKey": "Error Log" } ] - } - } + }, + "options": {} + }, + "id": "9e7856e3-b487-4410-8062-5c85382a200d", + "name": "Route by Confidence", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [ + 16416, + 6800 + ] }, { "parameters": { "fileSelector": "={{ $json.file_path }}", "options": {} }, - "id": "node-read-pdf-1", + "id": "68d17f1c-81d8-4294-ae8f-88b14833dd84", "name": "Read PDF File", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [ - 5904, - 3064 + 15760, + 6128 ], "onError": "continueErrorOutput" }, @@ -591,6 +632,15 @@ "parameters": { "method": "PUT", "url": "http://tika:9998/tika", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "maxPages", + "value": "2" + } + ] + }, "sendHeaders": true, "headerParameters": { "parameters": [ @@ -611,24 +661,15 @@ "sendBody": true, "contentType": "binaryData", "inputDataFieldName": "data", - "options": {}, - "sendQuery": true, - "queryParameters": { - "parameters": [ - { - "name": "maxPages", - "value": "2" - } - ] - } + "options": {} }, - "id": "node-extract-pdf-1", + "id": "1da911ca-3796-4b60-b323-a541604330d5", "name": "Extract PDF Text", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 6032, - 3064 + 16096, + 6128 ], "onError": "continueErrorOutput" }, @@ -638,27 +679,71 @@ "query": "SELECT 'projects' as type, id, project_code as text1, project_name as text2 FROM projects\nUNION ALL\nSELECT 'disciplines' as type, id, code_name_th as text1, code_name_en as text2 FROM disciplines\nUNION ALL\nSELECT 'organizations' as type, id, organization_name as text1, organization_code as text2 FROM organizations\nUNION ALL\nSELECT 'tags' as type, id, tag_name as text1, description as text2 FROM tags\nUNION ALL\nSELECT 'correspondence_types' as type, id, type_code as text1, type_name as text2 FROM correspondence_types", "options": {} }, - "id": "fetch-db-context-node-id", + "id": "0ef2fc07-af6d-4a2a-aa2b-2b563afb2c6d", "name": "Fetch DB Context", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, "position": [ - 6032, - 3696 + 15616, + 6592 ], + "alwaysOutputData": true, "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", "name": "MySQL account" } }, - "alwaysOutputData": true, "notes": "ดึงข้อมูลจาก Database ส่งให้ AI" + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Build Import Payload", + "typeVersion": 2, + "parameters": { + "jsCode": "const item = $input.first();\nconst config = $('Set Configuration').first().json.config;\nconst ai = item.json.ai_result || {};\n\nreturn [{\n json: {\n ...item.json,\n import_payload: {\n document_number: String(item.json.document_number || ''),\n title: String(ai.subject || item.json.title || 'ไม่มีชื่อเรื่อง'),\n category: ai.type_code || 'LETTER',\n source_file_path: item.json.file_path || '/dev/null',\n ai_confidence: ai.confidence || 0.8,\n migrated_by: 'SYSTEM_IMPORT',\n batch_id: config.BATCH_ID,\n project_id: Number(ai.project_id || config.PROJECT_ID),\n issued_date: ai.issued_date || '',\n received_date: ai.received_date || '',\n body: ai.body || '',\n details: {\n legacy_number: item.json.legacy_number || '',\n remark: ai.remark || '',\n key_points: ai.key_points || [],\n tags: ai.tags || []\n }\n }\n }\n}];" + }, + "type": "n8n-nodes-base.code", + "position": [ + 16400, + 6592 + ], + "notes": "สร้าง payload สำหรับ Import to Backend" + }, + { + "id": "f1e2d3c4-b5a6-7890-fedc-ba0987654321", + "name": "Upsert Tags", + "typeVersion": 2, + "parameters": { + "jsCode": "const item = $input.first().json;\nconst config = $('Set Configuration').first().json.config;\nconst tags = item.import_payload?.details?.tags || item.ai_result?.tags || [];\nconst projectId = item.import_payload?.project_id || item.ai_result?.project_id || config.PROJECT_ID;\n\nconst correspondenceId = item.import_response?.id || item.import_response?.data?.id;\n\nif (!correspondenceId || !Array.isArray(tags) || tags.length === 0) {\n return [{ json: { ...item, tags_upserted: 0, tags_linked: 0, tag_ids_to_link: [], correspondence_id: correspondenceId } }];\n}\n\nconst results = [];\nconst tagIds = [];\n\nfor (const tag of tags) {\n if (!tag || !tag.tag_name) continue;\n try {\n const response = await $helpers.httpRequest({\n method: 'POST',\n url: `${config.BACKEND_URL}/api/master/tags`,\n headers: {\n 'Authorization': config.MIGRATION_TOKEN,\n 'Content-Type': 'application/json'\n },\n body: {\n tag_name: String(tag.tag_name),\n description: String(tag.description || ''),\n project_id: Number(projectId),\n color_code: 'default'\n },\n json: true\n });\n \n if (response && response.id) {\n tagIds.push(response.id);\n results.push({ tag_name: tag.tag_name, status: 'ok', id: response.id });\n }\n } catch (e) {\n try {\n const searchRes = await $helpers.httpRequest({\n method: 'GET',\n url: `${config.BACKEND_URL}/api/master/tags?project_id=${projectId}&search=${encodeURIComponent(tag.tag_name)}`,\n headers: {\n 'Authorization': config.MIGRATION_TOKEN\n },\n json: true\n });\n \n const existing = searchRes.data?.find(t => t.tag_name === tag.tag_name);\n if (existing && existing.id) {\n tagIds.push( existing.id );\n results.push({ tag_name: tag.tag_name, status: 'found_existing', id: existing.id });\n } else {\n results.push({ tag_name: tag.tag_name, status: 'error_conflict_not_found', reason: String(e.message || e) });\n }\n } catch(fetchErr) {\n results.push({ tag_name: tag.tag_name, status: 'error_fetching_existing', reason: String(fetchErr.message || fetchErr) });\n }\n }\n}\n\nreturn [{ \n json: { \n ...item, \n tags_upserted: results.filter(r => r.status === 'ok').length,\n tag_ids_to_link: tagIds,\n correspondence_id: correspondenceId,\n tags_result: results\n } \n}];" + }, + "type": "n8n-nodes-base.code", + "position": [ + 16800, + 6592 + ], + "notes": "Upsert tags หลัง import สำเร็จ" + }, + { + "id": "aaabbbccc-1111-2222-3333-abcdef123456", + "name": "Link Tags to Correspondence", + "typeVersion": 2.4, + "parameters": { + "query": "={{ $json.tag_ids_to_link && $json.tag_ids_to_link.length > 0 && $json.correspondence_id ? 'INSERT IGNORE INTO correspondence_tags (correspondence_id, tag_id) VALUES ' + $json.tag_ids_to_link.map(id => '(' + $json.correspondence_id + ', ' + id + ')').join(', ') + ';' : 'SELECT 1;' }}", + "operation": "executeQuery", + "options": {} + }, + "type": "n8n-nodes-base.mySql", + "position": [ + 17000, + 6592 + ], + "notes": "Insert into correspondence_tags" } ], "pinData": {}, "connections": { - "Manual Trigger": { + "Form Trigger": { "main": [ [ { @@ -871,9 +956,9 @@ "main": [ [ { - "node": "Save Checkpoint", + "index": 0, "type": "main", - "index": 0 + "node": "Upsert Tags" } ] ] @@ -926,9 +1011,9 @@ "main": [ [ { - "node": "Import to Backend", + "index": 0, "type": "main", - "index": 0 + "node": "Build Import Payload" } ], [ @@ -986,6 +1071,39 @@ } ] ] + }, + "Build Import Payload": { + "main": [ + [ + { + "index": 0, + "type": "main", + "node": "Import to Backend" + } + ] + ] + }, + "Upsert Tags": { + "main": [ + [ + { + "index": 0, + "type": "main", + "node": "Link Tags to Correspondence" + } + ] + ] + }, + "Link Tags to Correspondence": { + "main": [ + [ + { + "index": 0, + "type": "main", + "node": "Save Checkpoint" + } + ] + ] } }, "active": false, @@ -993,11 +1111,11 @@ "executionOrder": "v1", "availableInMCP": false }, - "versionId": "c52d7a07-398b-495e-b384-fb4f02ef3fed", + "versionId": "2fa84882-2a0d-4394-9af7-5e9e1f0e28d6", "meta": { "templateCredsSetupCompleted": true, "instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7" }, "id": "u7CLP05AyFb8Um0P", "tags": [] -} +} \ No newline at end of file diff --git a/specs/03-Data-and-Storage/package.json b/specs/03-Data-and-Storage/package.json new file mode 100644 index 0000000..e32e4f6 --- /dev/null +++ b/specs/03-Data-and-Storage/package.json @@ -0,0 +1,16 @@ +{ + "name": "03-data-and-storage", + "version": "1.0.0", + "description": "", + "main": ".mountcheck.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "mysql2": "^3.19.1" + } +} diff --git a/specs/03-Data-and-Storage/test-db.js b/specs/03-Data-and-Storage/test-db.js new file mode 100644 index 0000000..4f31890 --- /dev/null +++ b/specs/03-Data-and-Storage/test-db.js @@ -0,0 +1,28 @@ +const mysql = require('mysql2/promise'); + +async function test() { + const connection = await mysql.createConnection({ + host: '192.168.10.8', + port: 3306, + user: 'migration_bot', + password: 'Center2025', + database: 'lcbp3' + }); + + try { + const [orgs] = await connection.execute('SELECT id, organization_name, organization_code FROM organizations'); + console.log('Organizations:', orgs.slice(0, 5)); + + const [projects] = await connection.execute('SELECT id, project_code, project_name FROM projects'); + console.log('Projects:', projects.slice(0, 5)); + + const [corrTypes] = await connection.execute('SELECT id, type_code, type_name FROM correspondence_types'); + console.log('Correspondence Types:', corrTypes.slice(0, 5)); + } catch (err) { + console.error(err); + } finally { + await connection.end(); + } +} + +test(); diff --git a/specs/03-Data-and-Storage/testOCR.json b/specs/03-Data-and-Storage/testOCR.json deleted file mode 100644 index e69de29..0000000 diff --git a/specs/99-archives/lcbp3-v1.8.0-schema-02-tables.sql b/specs/99-archives/lcbp3-v1.8.0-schema-02-tables.sql new file mode 100644 index 0000000..3868f6b --- /dev/null +++ b/specs/99-archives/lcbp3-v1.8.0-schema-02-tables.sql @@ -0,0 +1,1355 @@ +-- ========================================================== +-- DMS v1.8.0 Schema Part 2/3: CREATE TABLE Statements +-- รัน: mysql < 01-schema-drop.sql แล้วจึงรัน 02-schema-tables.sql +-- ========================================================== +SET NAMES utf8mb4; + +SET time_zone = '+07:00'; + +SET FOREIGN_KEY_CHECKS = 0; + +-- ===================================================== +-- 1. 🏢 Core & Master Data (องค์กร, โครงการ, สัญญา) +-- ===================================================== +-- ตาราง Master เก็บประเภทบทบาทขององค์กร +CREATE TABLE organization_roles ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + role_name VARCHAR(20) NOT NULL UNIQUE COMMENT 'ชื่อบทบาท (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD PARTY)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บประเภทบทบาทขององค์กร'; + +-- ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ +CREATE TABLE organizations ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + organization_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสองค์กร', + organization_name VARCHAR(255) NOT NULL COMMENT 'ชื่อองค์กร', + role_id INT COMMENT 'บทบาทขององค์กร', + is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)', + FOREIGN KEY (role_id) REFERENCES organization_roles (id) ON DELETE + SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ'; + +-- ตาราง Master เก็บข้อมูลโครงการ +CREATE TABLE projects ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสโครงการ', + project_name VARCHAR(255) NOT NULL COMMENT 'ชื่อโครงการ', + -- parent_project_id INT COMMENT 'รหัสโครงการหลัก (ถ้ามี)', + -- contractor_organization_id INT COMMENT 'รหัสองค์กรผู้รับเหมา (ถ้ามี)', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + -- FOREIGN KEY (parent_project_id) REFERENCES projects(id) ON DELETE SET NULL, + -- FOREIGN KEY (contractor_organization_id) REFERENCES organizations(id) ON DELETE SET NULL + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลโครงการ'; + +-- ตาราง Master เก็บข้อมูลสัญญา +CREATE TABLE contracts ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL, + contract_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสสัญญา', + contract_name VARCHAR(255) NOT NULL COMMENT 'ชื่อสัญญา', + description TEXT COMMENT 'คำอธิบายสัญญา', + start_date DATE COMMENT 'วันที่เริ่มสัญญา', + end_date DATE COMMENT 'วันที่สิ้นสุดสัญญา', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)', + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลสัญญา'; + +-- ===================================================== +-- 2. 👥 Users & RBAC (ผู้ใช้, สิทธิ์, บทบาท) +-- ===================================================== +-- ตาราง Master เก็บข้อมูลผู้ใช้งาน (User) +CREATE TABLE users ( + user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + username VARCHAR(50) NOT NULL UNIQUE COMMENT 'ชื่อผู้ใช้งาน', + password_hash VARCHAR(255) NOT NULL COMMENT 'รหัสผ่าน (Hashed)', + first_name VARCHAR(50) COMMENT 'ชื่อจริง', + last_name VARCHAR(50) COMMENT 'นามสกุล', + email VARCHAR(100) NOT NULL UNIQUE COMMENT 'อีเมล', + line_id VARCHAR(100) COMMENT 'LINE ID', + primary_organization_id INT COMMENT 'สังกัดองค์กร', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + failed_attempts INT DEFAULT 0 COMMENT 'จำนวนครั้งที่ล็อกอินล้มเหลว', + locked_until DATETIME COMMENT 'ล็อกอินไม่ได้จนถึงเวลา', + last_login_at TIMESTAMP NULL COMMENT 'วันที่และเวลาที่ล็อกอินล่าสุด', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL DEFAULT NULL COMMENT 'วันที่ลบ', + FOREIGN KEY (primary_organization_id) REFERENCES organizations (id) ON DELETE + SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลผู้ใช้งาน (User)'; + +-- ตารางเก็บ Refresh Tokens สำหรับ Authentication +CREATE TABLE refresh_tokens ( + token_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + user_id INT NOT NULL COMMENT 'เจ้าของ Token', + token_hash VARCHAR(255) NOT NULL COMMENT 'Hash ของ Refresh Token', + expires_at DATETIME NOT NULL COMMENT 'วันหมดอายุ', + is_revoked TINYINT(1) DEFAULT 0 COMMENT 'สถานะยกเลิก (1=Revoked)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + replaced_by_token VARCHAR(255) NULL COMMENT 'Token ใหม่ที่มาแทนที่ (Rotation)', + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Refresh Tokens สำหรับ Authentication'; + +-- ตาราง Master เก็บ "บทบาท" ของผู้ใช้ในระบบ +CREATE TABLE roles ( + role_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + -- role_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสบทบาท (เช่น SUPER_ADMIN, ADMIN, EDITOR, VIEWER)', + role_name VARCHAR(100) NOT NULL COMMENT 'ชื่อบทบาท', + scope ENUM( + 'Global', + 'Organization', + 'Project', + 'Contract' + ) NOT NULL, + -- ขอบเขตของบทบาท (จากข้อ 4.3) + description TEXT COMMENT 'คำอธิบายบทบาท', + is_system BOOLEAN DEFAULT FALSE COMMENT '(1 = บทบาทของระบบ ลบไม่ได้)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ "บทบาท" ของผู้ใช้ในระบบ'; + +-- ตาราง Master เก็บ "สิทธิ์" (Permission) หรือ "การกระทำ" ทั้งหมดในระบบ +CREATE TABLE permissions ( + permission_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + permission_name VARCHAR(100) NOT NULL UNIQUE COMMENT 'รหัสสิทธิ์ (เช่น rfas.create, rfas.view)', + description TEXT COMMENT 'คำอธิบายสิทธิ์', + module VARCHAR(50) COMMENT 'โมดูลที่เกี่ยวข้อง', + scope_level ENUM('GLOBAL', 'ORG', 'PROJECT') COMMENT 'ระดับขอบเขตของสิทธิ์', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ "สิทธิ์" (Permission) หรือ "การกระทำ" ทั้งหมดในระบบ'; + +-- ตารางเชื่อมระหว่าง roles และ permissions (M:N) +CREATE TABLE role_permissions ( + role_id INT COMMENT 'ID ของบทบาท', + permission_id INT COMMENT 'ID ของสิทธิ์', + PRIMARY KEY (role_id, permission_id), + FOREIGN KEY (role_id) REFERENCES roles (role_id) ON DELETE CASCADE, + FOREIGN KEY (permission_id) REFERENCES permissions (permission_id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง roles และ permissions (M :N)'; + +-- search.advanced +-- ตารางเชื่อมผู้ใช้ (users) +CREATE TABLE user_assignments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + role_id INT NOT NULL, + -- คอลัมน์สำหรับกำหนดขอบเขต (จะใช้เพียงอันเดียวต่อแถว) + organization_id INT NULL, + project_id INT NULL, + contract_id INT NULL, + assigned_by_user_id INT, + -- ผู้ที่มอบหมายบทบาทนี้ + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles (role_id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (contract_id) REFERENCES contracts (id) ON DELETE CASCADE, + FOREIGN KEY (assigned_by_user_id) REFERENCES users (user_id), + -- Constraint เพื่อให้แน่ใจว่ามีเพียงขอบเขตเดียวที่ถูกกำหนดในแต่ละแถว + CONSTRAINT chk_scope CHECK ( + ( + organization_id IS NOT NULL + AND project_id IS NULL + AND contract_id IS NULL + ) + OR ( + organization_id IS NULL + AND project_id IS NOT NULL + AND contract_id IS NULL + ) + OR ( + organization_id IS NULL + AND project_id IS NULL + AND contract_id IS NOT NULL + ) + OR ( + organization_id IS NULL + AND project_id IS NULL + AND contract_id IS NULL + ) -- สำหรับ Global scope + ) +); + +CREATE TABLE project_organizations ( + project_id INT NOT NULL, + organization_id INT NOT NULL, + PRIMARY KEY (project_id, organization_id), + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE +); + +CREATE TABLE contract_organizations ( + contract_id INT NOT NULL, + organization_id INT NOT NULL, + role_in_contract VARCHAR(100), + -- เช่น 'Owner', 'Designer', 'Consultant', 'Contractor ' + PRIMARY KEY (contract_id, organization_id), + FOREIGN KEY (contract_id) REFERENCES contracts (id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE +); + +-- ===================================================== +-- 3. ✉️ Correspondences (เอกสารหลัก, Revisions) +-- ===================================================== +-- ตารางเก็บข้อมูลสาขางาน (Disciplines) แยกตามสัญญา +CREATE TABLE disciplines ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + contract_id INT NOT NULL COMMENT 'ผูกกับสัญญา', + discipline_code VARCHAR(10) NOT NULL COMMENT 'รหัสสาขา (เช่น GEN, STR)', + code_name_th VARCHAR(255) COMMENT 'ชื่อไทย', + code_name_en VARCHAR(255) COMMENT 'ชื่ออังกฤษ', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (contract_id) REFERENCES contracts (id) ON DELETE CASCADE, + UNIQUE KEY uk_discipline_contract (contract_id, discipline_code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บข้อมูลสาขางาน (Disciplines) ตาม Req 6B'; + +-- ตาราง Master เก็บประเภทเอกสารโต้ตอบ +CREATE TABLE correspondence_types ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + type_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสประเภท (เช่น RFA, RFI)', + type_name VARCHAR(255) NOT NULL COMMENT 'ชื่อประเภท', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บประเภทเอกสารโต้ตอบ'; + +-- ตารางเก็บประเภทหนังสือย่อย (Sub Types) สำหรับ Mapping เลขรหัส +CREATE TABLE correspondence_sub_types ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + contract_id INT NOT NULL COMMENT 'ผูกกับสัญญา', + correspondence_type_id INT NOT NULL COMMENT 'ผูกกับประเภทเอกสารหลัก (เช่น RFA)', + sub_type_code VARCHAR(20) NOT NULL COMMENT 'รหัสย่อย (เช่น MAT, SHP)', + sub_type_name VARCHAR(255) COMMENT 'ชื่อประเภทหนังสือย่อย', + sub_type_number VARCHAR(10) COMMENT 'เลขรหัสสำหรับ Running Number (เช่น 11, 22)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (contract_id) REFERENCES contracts (id) ON DELETE CASCADE, + FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บประเภทหนังสือย่อย (Sub Types) ตาม Req 6B'; + +-- ตาราง Master เก็บสถานะของเอกสาร +CREATE TABLE correspondence_status ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + status_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสสถานะหนังสือ (เช่น DRAFT, SUBOWN)', + status_name VARCHAR(255) NOT NULL COMMENT 'ชื่อสถานะหนังสือ', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บสถานะของเอกสาร'; + +-- ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision +CREATE TABLE correspondences ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง (นี่คือ "Master ID" ที่ใช้เชื่อมโยง)', + correspondence_type_id INT NOT NULL COMMENT 'ประเภทเอกสาร ใช้แบ่งแยกว่าเป็น RFA หรือ อื่นๆ', + correspondence_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสาร (สร้างจาก DocumentNumberingModule)', + discipline_id INT NULL COMMENT 'สาขางาน (ถ้ามี)', + is_internal_communication TINYINT(1) DEFAULT 0 COMMENT '(1 = ภายใน, 0 = ภายนอก)', + project_id INT NOT NULL COMMENT 'อยู่ในโครงการ', + originator_id INT COMMENT 'องค์กรผู้ส่ง', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + created_by INT COMMENT 'ผู้สร้าง', + deleted_at DATETIME NULL COMMENT 'สำหรับ Soft Delete', + INDEX idx_corr_number (correspondence_number), + INDEX idx_deleted_at (deleted_at), + INDEX idx_created_by (created_by), + FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types (id) ON DELETE RESTRICT, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (originator_id) REFERENCES organizations (id) ON DELETE + SET NULL, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + -- Foreign Key ที่รวมเข้ามาจาก ALTER (ระบุชื่อ Constraint ตามที่ต้องการ) + CONSTRAINT fk_corr_discipline FOREIGN KEY (discipline_id) REFERENCES disciplines (id) ON DELETE + SET NULL, + UNIQUE KEY uq_corr_no_per_project (project_id, correspondence_number) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision'; + +-- ตารางเชื่อมผู้รับ (TO/CC) สำหรับเอกสารแต่ละฉบับ (M:N) +CREATE TABLE correspondence_recipients ( + correspondence_id INT COMMENT 'ID ของเอกสาร', + recipient_organization_id INT COMMENT 'ID องค์กรผู้รับ', + recipient_type ENUM('TO', 'CC ') COMMENT 'ประเภทผู้รับ (TO หรือ CC)', + PRIMARY KEY ( + correspondence_id, + recipient_organization_id, + recipient_type + ), + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (recipient_organization_id) REFERENCES organizations (id) ON DELETE RESTRICT +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมผู้รับ (TO / CC) สำหรับเอกสารแต่ละฉบับ (M :N)'; + +-- ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1:N) +CREATE TABLE correspondence_revisions ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', + correspondence_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)', + is_current BOOLEAN DEFAULT FALSE COMMENT '(1 = Revision ปัจจุบัน)', + -- ข้อมูลเนื้อหาที่เปลี่ยนได้ + correspondence_status_id INT NOT NULL COMMENT 'สถานะของ Revision นี้', + subject VARCHAR(500) NOT NULL COMMENT 'หัวข้อเรื่อง', + description TEXT COMMENT 'คำอธิบายการแก้ไขใน Revision นี้', + body TEXT NULL COMMENT 'เนื้อความ (ถ้ามี)', + remarks TEXT COMMENT 'หมายเหตุ', + document_date DATE COMMENT 'วันที่ในเอกสาร', + issued_date DATETIME COMMENT 'วันที่ออกเอกสาร', + received_date DATETIME COMMENT 'วันที่ลงรับเอกสาร', + due_date DATETIME COMMENT 'วันที่ครบกำหนด', + -- Standard Meta Columns + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้างเอกสาร', + created_by INT COMMENT 'ผู้สร้าง', + updated_by INT COMMENT 'ผู้แก้ไขล่าสุด', + -- ส่วนของ JSON และ Schema Version + details JSON COMMENT 'ข้อมูลเฉพาะ (เช่น LETTET details)', + schema_version INT DEFAULT 1 COMMENT 'เวอร์ชันของ Schema ที่ใช้กับ details', + -- Generated Virtual Columns (ดึงค่าจาก JSON โดยอัตโนมัติ) + v_ref_project_id INT GENERATED ALWAYS AS ( + CAST( + JSON_UNQUOTE(JSON_EXTRACT(details, '$.projectId')) AS UNSIGNED + ) + ) VIRTUAL COMMENT 'Virtual Column: Project ID จาก JSON', + v_doc_subtype VARCHAR(50) GENERATED ALWAYS AS ( + JSON_UNQUOTE(JSON_EXTRACT(details, '$.subType')) + ) VIRTUAL COMMENT 'Virtual Column: Document Subtype จาก JSON', + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (correspondence_status_id) REFERENCES correspondence_status (id) ON DELETE RESTRICT, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE + SET NULL, + UNIQUE KEY uq_master_revision_number (correspondence_id, revision_number), + UNIQUE KEY uq_master_current (correspondence_id, is_current), + INDEX idx_corr_rev_v_project (v_ref_project_id), + INDEX idx_corr_rev_v_subtype (v_doc_subtype) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1 :N)'; + +-- ตาราง Master เก็บ Tags ทั้งหมดที่ใช้ในระบบ +CREATE TABLE tags ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NULL COMMENT 'ID โครงการ (NULL = Global Tag)', + tag_name VARCHAR(100) NOT NULL COMMENT 'ชื่อ Tag', + color_code VARCHAR(30) DEFAULT 'default' COMMENT 'รหัสสี หรือชื่อคลาสสำหรับ UI', + description TEXT COMMENT 'คำอธิบายแท็ก', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + created_by INT COMMENT 'ผู้สร้าง', + deleted_at DATETIME NULL COMMENT 'ลบแบบ Soft Delete', + -- Constraints & Indexes + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + UNIQUE KEY ux_tag_project (project_id, tag_name), + INDEX idx_tags_deleted_at (deleted_at) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ Tags ย่อยตาม Project'; + +-- ตารางเชื่อมระหว่าง correspondences และ tags (M:N) +CREATE TABLE correspondence_tags ( + correspondence_id INT COMMENT 'ID ของเอกสาร', + tag_id INT COMMENT 'ID ของ Tag', + PRIMARY KEY (correspondence_id, tag_id), + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE, + INDEX idx_tag_lookup (tag_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง correspondences และ tags (M:N)'; + +-- ตารางเชื่อมการอ้างอิงระหว่างเอกสาร (M:N) +CREATE TABLE correspondence_references ( + src_correspondence_id INT COMMENT 'ID เอกสารต้นทาง', + tgt_correspondence_id INT COMMENT 'ID เอกสารเป้าหมาย', + PRIMARY KEY ( + src_correspondence_id, + tgt_correspondence_id + ), + FOREIGN KEY (src_correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (tgt_correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมการอ้างอิงระหว่างเอกสาร (M :N)'; + +-- ===================================================== +-- 4. 📐 approval: RFA (เอกสารขออนุมัติ, Workflows) +-- ===================================================== +-- ตาราง Master สำหรับประเภท RFA +CREATE TABLE rfa_types ( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT COMMENT 'ID ของตาราง', + contract_id INT NOT NULL COMMENT 'ผูกกับสัญญา', + type_code VARCHAR(20) NOT NULL COMMENT 'รหัสประเภท RFA (เช่น DWG, DOC, MAT)', + type_name_th VARCHAR(100) NOT NULL COMMENT 'ชื่อประเภท RFA th', + type_name_en VARCHAR(100) NOT NULL COMMENT 'ชื่อประเภท RFA en', + remark TEXT COMMENT 'หมายเหตุ', + -- sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ', + UNIQUE KEY uk_rfa_types_contract_code (contract_id, type_code), + FOREIGN KEY (contract_id) REFERENCES contracts (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับประเภท RFA'; + +-- ตาราง Master สำหรับสถานะ RFA +CREATE TABLE rfa_status_codes ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + status_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสสถานะ RFA (เช่น DFT - Draft, FAP - For Approve)', + status_name VARCHAR(100) NOT NULL COMMENT 'ชื่อสถานะ', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับสถานะ RFA'; + +-- ตาราง Master สำหรับรหัสผลการอนุมัติ RFA +CREATE TABLE rfa_approve_codes ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + approve_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสผลการอนุมัติ ( + เช่น 1A - Approved, + 3R - Revise + and Resubmit + )', + approve_name VARCHAR(100) NOT NULL COMMENT 'ชื่อผลการอนุมัติ', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับรหัสผลการอนุมัติ RFA'; + +CREATE TABLE rfas ( + id INT PRIMARY KEY COMMENT 'ID ของตาราง (RFA Master ID)', + -- ❌ ไม่มี AUTO_INCREMENT + rfa_type_id INT NOT NULL COMMENT 'ประเภท RFA', + -- discipline_id INT NULL COMMENT 'สาขางาน (ถ้ามี)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + created_by INT COMMENT 'ผู้สร้าง', + deleted_at DATETIME NULL COMMENT 'สำหรับ Soft Delete', + FOREIGN KEY (rfa_type_id) REFERENCES rfa_types (id), + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + -- CONSTRAINT fk_rfa_discipline FOREIGN KEY (discipline_id) REFERENCES disciplines (id) ON DELETE SET NULL, + CONSTRAINT fk_rfas_parent FOREIGN KEY (id) REFERENCES correspondences (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของ RFA (มีความสัมพันธ์ 1 :N กับ rfa_revisions)'; + +-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ rfas (1:N) +CREATE TABLE rfa_revisions ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', + rfa_id INT NOT NULL COMMENT 'Master ID ของ RFA', + revision_number INT NOT NULL COMMENT 'หมายเลข Revision (0, 1, 2...)', + revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)', + is_current BOOLEAN DEFAULT FALSE COMMENT '(1 = Revision ปัจจุบัน)', + -- ข้อมูลเฉพาะของ RFA Revision ที่ซับซ้อน + rfa_status_code_id INT NOT NULL COMMENT 'สถานะ RFA', + rfa_approve_code_id INT COMMENT 'ผลการอนุมัติ', + subject VARCHAR(500) NOT NULL COMMENT 'หัวข้อเรื่อง', + description TEXT COMMENT 'คำอธิบายการแก้ไขใน Revision นี้', + body TEXT NULL COMMENT 'เนื้อความ (ถ้ามี)', + remarks TEXT COMMENT 'หมายเหตุ', + document_date DATE COMMENT 'วันที่ในเอกสาร', + issued_date DATE COMMENT 'วันที่ส่งขออนุมัติ', + received_date DATETIME COMMENT 'วันที่ลงรับเอกสาร', + due_date DATETIME COMMENT 'วันที่ครบกำหนด', + approved_date DATE COMMENT 'วันที่อนุมัติ', + -- Standard Meta Columns + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้างเอกสาร', + created_by INT COMMENT 'ผู้สร้าง', + updated_by INT COMMENT 'ผู้แก้ไขล่าสุด', + -- ส่วนของ JSON และ Schema Version + details JSON NULL COMMENT 'RFA Specific Details', + schema_version INT DEFAULT 1 COMMENT 'Version ของ JSON Schema', + -- Generated Virtual Columns (ดึงค่าจาก JSON โดยอัตโนมัติ) + v_ref_drawing_count INT GENERATED ALWAYS AS ( + JSON_UNQUOTE( + JSON_EXTRACT(details, '$.drawingCount') + ) + ) VIRTUAL, + FOREIGN KEY (rfa_id) REFERENCES rfas (id) ON DELETE CASCADE, + FOREIGN KEY (rfa_status_code_id) REFERENCES rfa_status_codes (id), + FOREIGN KEY (rfa_approve_code_id) REFERENCES rfa_approve_codes (id) ON DELETE + SET NULL, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE + SET NULL, + UNIQUE KEY uq_rr_rev_number (rfa_id, revision_number), + UNIQUE KEY uq_rr_current (rfa_id, is_current) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ rfas (1 :N)'; + +-- ตารางเชื่อมระหว่าง rfa_revisions (ที่เป็นประเภท DWG) กับ shop_drawing_revisions (M:N) +CREATE TABLE rfa_items ( + rfa_revision_id INT COMMENT 'ID ของ RFA Revision', + shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision', + PRIMARY KEY ( + rfa_revision_id, + shop_drawing_revision_id + ), + FOREIGN KEY (rfa_revision_id) REFERENCES rfa_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 = 'ตารางเชื่อมระหว่าง rfa_revisions (ที่เป็นประเภท DWG) กับ shop_drawing_revisions (M :N)'; + +-- ===================================================== +-- 5. 📐 Drawings (แบบ, หมวดหมู่) +-- ===================================================== +-- ตาราง Master สำหรับ "เล่ม" ของแบบคู่สัญญา +CREATE TABLE contract_drawing_volumes ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + volume_code VARCHAR(50) NOT NULL COMMENT 'รหัสเล่ม', + volume_name VARCHAR(255) NOT NULL COMMENT 'ชื่อเล่ม', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE KEY ux_volume_project (project_id, volume_code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับ "เล่ม" ของแบบคู่สัญญา'; + +-- ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบคู่สัญญา +CREATE TABLE contract_drawing_cats ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + cat_code VARCHAR(50) NOT NULL COMMENT 'รหัสหมวดหมู่หลัก', + cat_name VARCHAR(255) NOT NULL COMMENT 'ชื่อหมวดหมู่หลัก', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE KEY ux_cat_project (project_id, cat_code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบคู่สัญญา'; + +-- ตาราง Master สำหรับ "หมวดหมู่ย่อย" ของแบบคู่สัญญา +CREATE TABLE contract_drawing_sub_cats ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + sub_cat_code VARCHAR(50) NOT NULL COMMENT 'รหัสหมวดหมู่ย่อย', + sub_cat_name VARCHAR(255) NOT NULL COMMENT 'ชื่อหมวดหมู่ย่อย', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE KEY ux_subcat_project (project_id, sub_cat_code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับ "หมวดหมู่ย่อย" ของแบบคู่สัญญา'; + +-- ตารางเชื่อมระหว่าง หมวดหมู่หลัก-ย่อย (M:N) +CREATE TABLE contract_drawing_subcat_cat_maps ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT COMMENT 'ID ของโครงการ', + sub_cat_id INT COMMENT 'ID ของหมวดหมู่ย่อย', + cat_id INT COMMENT 'ID ของหมวดหมู่หลัก', + UNIQUE KEY ux_map_unique (project_id, sub_cat_id, cat_id), + 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 (cat_id) REFERENCES contract_drawing_cats (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง หมวดหมู่หลัก - ย่อย (M :N)'; + +-- ตาราง Master เก็บข้อมูล "แบบคู่สัญญา" +CREATE TABLE contract_drawings ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + condwg_no VARCHAR(255) NOT NULL COMMENT 'เลขที่แบบสัญญา', + title VARCHAR(255) NOT NULL COMMENT 'ชื่อแบบสัญญา', + map_cat_id INT COMMENT 'หมวดหมู่ย่อย', + volume_id INT COMMENT 'เล่ม', + volume_page INT 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) ON DELETE CASCADE, + 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, + UNIQUE KEY ux_condwg_no_project (project_id, condwg_no) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบคู่สัญญา"'; + +-- ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบก่อสร้าง +CREATE TABLE shop_drawing_main_categories ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + main_category_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสหมวดหมู่หลัก (เช่น ARCH, STR)', + main_category_name VARCHAR(255) NOT NULL COMMENT 'ชื่อหมวดหมู่หลัก', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด ', + FOREIGN KEY (project_id) REFERENCES projects (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบก่อสร้าง'; + +-- ตาราง Master สำหรับ "หมวดหมู่ย่อย" ของแบบก่อสร้าง +CREATE TABLE shop_drawing_sub_categories ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + sub_category_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสหมวดหมู่ย่อย (เช่น STR - COLUMN)', + sub_category_name VARCHAR(255) NOT NULL COMMENT 'ชื่อหมวดหมู่ย่อย', + description TEXT COMMENT 'คำอธิบาย', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + FOREIGN KEY (project_id) REFERENCES projects (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master สำหรับ "หมวดหมู่ย่อย" ของแบบก่อสร้าง'; + +-- ตาราง Master เก็บข้อมูล "แบบก่อสร้าง" +CREATE TABLE shop_drawings ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ Shop 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), + UNIQUE KEY ux_shop_dwg_no_project (project_id, drawing_number) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"'; + +-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N) +CREATE TABLE shop_drawing_revisions ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', + shop_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)', + is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)', + revision_date DATE COMMENT 'วันที่ของ Revision', + title VARCHAR(500) NOT NULL COMMENT 'ชื่อแบบ', + description TEXT COMMENT 'คำอธิบายการแก้ไข', + legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + created_by INT COMMENT 'ผู้สร้าง', + updated_by INT COMMENT 'ผู้แก้ไขล่าสุด', + FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings (id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE + SET NULL, + UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number), + UNIQUE KEY uq_sd_current (shop_drawing_id, is_current) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)'; + +-- ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M:N) +CREATE TABLE shop_drawing_revision_contract_refs ( + shop_drawing_revision_id INT COMMENT 'ID ของ Shop Drawing Revision', + contract_drawing_id INT COMMENT 'ID ของ Contract Drawing', + PRIMARY KEY ( + shop_drawing_revision_id, + contract_drawing_id + ), + FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_drawing_revisions (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)'; + +-- ตาราง 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 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), + UNIQUE KEY ux_asbuilt_no_project (project_id, drawing_number) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบ AS Built"'; + +-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ AS Built (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)', + is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)', + 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 'วันที่สร้าง', + created_by INT COMMENT 'ผู้สร้าง', + updated_by INT COMMENT 'ผู้แก้ไขล่าสุด', + FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings (id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE + SET NULL, + FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE + SET NULL, + UNIQUE KEY ux_asbuilt_rev_drawing_revision (asbuilt_drawing_id, revision_number), + UNIQUE KEY uq_asbuilt_current (asbuilt_drawing_id, is_current) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1:N)'; + +-- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (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)'; + +-- ===================================================== +-- View: Shop Drawing พร้อม Current Revision +-- ===================================================== +CREATE OR REPLACE VIEW vw_shop_drawing_current AS +SELECT sd.id, + sd.project_id, + sd.drawing_number, + sd.main_category_id, + sd.sub_category_id, + sd.created_at, + sd.updated_at, + sd.deleted_at, + sd.updated_by, + sdr.id AS revision_id, + sdr.revision_number, + sdr.revision_label, + sdr.revision_date, + sdr.title AS revision_title, + sdr.description AS revision_description, + sdr.legacy_drawing_number, + sdr.created_by AS revision_created_by, + sdr.updated_by AS revision_updated_by +FROM shop_drawings sd + LEFT JOIN shop_drawing_revisions sdr ON sd.id = sdr.shop_drawing_id + AND sdr.is_current = TRUE; + +-- ===================================================== +-- View: As Built Drawing พร้อม Current Revision +-- ===================================================== +CREATE OR REPLACE VIEW vw_asbuilt_drawing_current AS +SELECT ad.id, + ad.project_id, + ad.drawing_number, + ad.main_category_id, + ad.sub_category_id, + ad.created_at, + ad.updated_at, + ad.deleted_at, + ad.updated_by, + adr.id AS revision_id, + adr.revision_number, + adr.revision_label, + adr.revision_date, + adr.title AS revision_title, + adr.description AS revision_description, + adr.legacy_drawing_number, + adr.created_by AS revision_created_by, + adr.updated_by AS revision_updated_by +FROM asbuilt_drawings ad + LEFT JOIN asbuilt_drawing_revisions adr ON ad.id = adr.asbuilt_drawing_id + AND adr.is_current = TRUE; + +-- ===================================================== +-- 6. 🔄 Circulations (ใบเวียนภายใน) +-- ===================================================== +-- ตาราง Master เก็บสถานะใบเวียน +CREATE TABLE circulation_status_codes ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสสถานะการดำเนินงาน', + description VARCHAR(50) NOT NULL COMMENT 'คำอธิบายสถานะการดำเนินงาน', + sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บสถานะใบเวียน'; + +-- ตาราง "แม่" ของใบเวียนเอกสารภายใน +CREATE TABLE circulations ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตารางใบเวียน', + correspondence_id INT UNIQUE COMMENT 'ID ของเอกสาร (จากตาราง correspondences)', + organization_id INT NOT NULL COMMENT 'ID ขององค์กรณ์ที่เป็นเจ้าของใบเวียนนี้', + circulation_no VARCHAR(100) NOT NULL COMMENT 'เลขที่ใบเวียน', + circulation_subject VARCHAR(500) NOT NULL COMMENT 'เรื่องใบเวียน', + circulation_status_code VARCHAR(20) NOT NULL COMMENT 'รหัสสถานะใบเวียน', + created_by_user_id INT NOT NULL COMMENT 'ID ของผู้สร้างใบเวียน', + submitted_at TIMESTAMP NULL COMMENT 'วันที่ส่งใบเวียน', + closed_at TIMESTAMP NULL COMMENT 'วันที่ปิดใบเวียน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id), + FOREIGN KEY (organization_id) REFERENCES organizations (id), + FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes (code), + FOREIGN KEY (created_by_user_id) REFERENCES users (user_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของใบเวียนเอกสารภายใน'; + +-- ===================================================== +-- 7. 📤 Transmittals (เอกสารนำส่ง) +-- ===================================================== +-- ตารางข้อมูลเฉพาะของเอกสารนำส่ง (เป็นตารางลูก 1:1 ของ correspondences) +CREATE TABLE transmittals ( + correspondence_id INT PRIMARY KEY COMMENT 'ID ของเอกสาร', + purpose ENUM( + 'FOR_APPROVAL', + 'FOR_INFORMATION', + 'FOR_REVIEW', + 'OTHER ' + ) COMMENT 'วัตถุประสงค์', + remarks TEXT COMMENT 'หมายเหตุ', + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางข้อมูลเฉพาะของเอกสารนำส่ง (เป็นตารางลูก 1 :1 ของ correspondences)'; + +-- ตารางเชื่อมระหว่าง transmittals และเอกสารที่นำส่ง (M:N) +CREATE TABLE transmittal_items ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของรายการ', + transmittal_id INT NOT NULL COMMENT 'ID ของ Transmittal', + item_correspondence_id INT NOT NULL COMMENT 'ID ของเอกสารที่แนบไป', + quantity INT DEFAULT 1 COMMENT 'จำนวน', + remarks VARCHAR(255) COMMENT 'หมายเหตุสำหรับรายการนี้', + FOREIGN KEY (transmittal_id) REFERENCES transmittals (correspondence_id) ON DELETE CASCADE, + FOREIGN KEY (item_correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + UNIQUE KEY ux_transmittal_item ( + transmittal_id, + item_correspondence_id + ) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อมระหว่าง transmittals และเอกสารที่นำส่ง (M :N)'; + +-- ===================================================== +-- 8. 📎 File Management (ไฟล์แนบ) +-- ===================================================== +-- ตาราง "กลาง" เก็บไฟล์แนบทั้งหมดของระบบ +-- 2.2 Attachments - Two-Phase Storage & Security +-- รองรับ: Backend Plan T2.2, Req 3.9.1 +-- เหตุผล: จัดการไฟล์ขยะ (Orphan Files) และตรวจสอบความถูกต้องไฟล์ +CREATE TABLE attachments ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของไฟล์แนบ', + original_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ดั้งเดิมตอนอัปโหลด', + stored_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ที่เก็บจริงบน Server (ป้องกันชื่อซ้ำ)', + file_path VARCHAR(500) NOT NULL COMMENT 'Path ที่เก็บไฟล์ (บน QNAP / share / dms - data /)', + mime_type VARCHAR(100) NOT NULL COMMENT 'ประเภทไฟล์ (เช่น application / pdf)', + file_size INT NOT NULL COMMENT 'ขนาดไฟล์ (bytes)', + is_temporary BOOLEAN DEFAULT TRUE COMMENT 'True = ยังไม่ Commit ลง DB จริง', + temp_id VARCHAR(100) NULL COMMENT 'ID ชั่วคราวสำหรับอ้างอิงตอน Upload Phase 1', + uploaded_by_user_id INT NOT NULL COMMENT 'ผู้อัปโหลดไฟล์', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่อัปโหลด', + expires_at DATETIME NULL COMMENT 'เวลาหมดอายุของไฟล์ Temp', + CHECKSUM VARCHAR(64) NULL COMMENT 'SHA-256 Checksum', + reference_date DATE NULL COMMENT 'Date used for folder structure (e.g. Issue Date) to prevent broken paths', + FOREIGN KEY (uploaded_by_user_id) REFERENCES users (user_id) ON DELETE CASCADE, + INDEX idx_attachments_reference_date (reference_date) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "กลาง" เก็บไฟล์แนบทั้งหมดของระบบ'; + +-- ตารางเชื่อม correspondences กับ attachments (M:N) +CREATE TABLE correspondence_attachments ( + correspondence_id INT COMMENT 'ID ของเอกสาร', + attachment_id INT COMMENT 'ID ของไฟล์แนบ', + is_main_document BOOLEAN DEFAULT FALSE COMMENT '(1 = ไฟล์หลัก)', + PRIMARY KEY (correspondence_id, attachment_id), + FOREIGN KEY (correspondence_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (attachment_id) REFERENCES attachments (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อม correspondences กับ attachments (M :N)'; + +-- ตารางเชื่อม circulations กับ attachments (M:N) +CREATE TABLE circulation_attachments ( + circulation_id INT COMMENT 'ID ของใบเวียน', + attachment_id INT COMMENT 'ID ของไฟล์แนบ', + is_main_document BOOLEAN DEFAULT FALSE COMMENT '(1 = ไฟล์หลักของใบเวียน)', + PRIMARY KEY (circulation_id, attachment_id), + FOREIGN KEY (circulation_id) REFERENCES circulations (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)'; + +-- ตารางเชื่อม 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) +CREATE TABLE shop_drawing_revision_attachments ( + shop_drawing_revision_id INT COMMENT 'ID ของ Shop 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 ( + shop_drawing_revision_id, + attachment_id + ), + FOREIGN KEY (shop_drawing_revision_id) REFERENCES shop_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 = 'ตารางเชื่อม shop_drawing_revisions กับ attachments (M :N)'; + +-- ตารางเชื่อม contract_drawings กับ attachments (M:N) +CREATE TABLE contract_drawing_attachments ( + contract_drawing_id INT COMMENT 'ID ของ Contract Drawing', + attachment_id INT COMMENT 'ID ของไฟล์แนบ', + file_type ENUM( + 'PDF', + 'DWG', + 'SOURCE', + 'OTHER ' + ) COMMENT 'ประเภทไฟล์', + is_main_document BOOLEAN DEFAULT FALSE COMMENT '(1 = ไฟล์หลัก)', + PRIMARY KEY ( + contract_drawing_id, + attachment_id + ), + FOREIGN KEY (contract_drawing_id) REFERENCES contract_drawings (id) ON DELETE CASCADE, + FOREIGN KEY (attachment_id) REFERENCES attachments (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเชื่อม contract_drawings กับ attachments (M :N)'; + +-- ===================================================== +-- 9. 🔢 Document Numbering (การสร้างเลขที่เอกสาร) +-- ===================================================== +-- ตาราง Master เก็บ "รูปแบบ" Template ของเลขที่เอกสาร +CREATE TABLE document_number_formats ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', + project_id INT NOT NULL COMMENT 'โครงการ', + correspondence_type_id INT NULL COMMENT 'ประเภทเอกสาร', + discipline_id INT DEFAULT 0 COMMENT 'สาขางาน (0 = ทุกสาขา/ไม่ระบุ)', + format_string VARCHAR(100) NOT NULL COMMENT 'Format pattern (e.g., {ORG}-{TYPE}-{YYYY}-#)', + description TEXT COMMENT 'Format description', + reset_annually BOOLEAN DEFAULT TRUE COMMENT 'เริ่มนับใหม่ทุกปี', + is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT 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 (correspondence_type_id) REFERENCES correspondence_types (id) ON DELETE CASCADE, + UNIQUE KEY unique_format ( + project_id, + correspondence_type_id + ) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ "รูปแบบ" Template ของเลขที่เอกสาร'; + +-- ========================================================== +-- [v1.5.1 UPDATE] ตารางเก็บ "ตัวนับ" (Running Number) ล่าสุด +-- เปลี่ยนแปลงหลัก: +-- - PRIMARY KEY: เปลี่ยนจาก 5 คอลัมน์เป็น 8 คอลัมน์ +-- - เพิ่มคอลัมน์: recipient_organization_id, sub_type_id, rfa_type_id +-- - เพิ่ม INDEXES สำหรับ performance +-- - เพิ่ม CONSTRAINTS สำหรับ data validation +-- เหตุผล: รองรับ 10 token types และ granular counter management +-- รองรับ: Backend Plan T2.3, Req 3.11.5, specs v1.5.1 +-- ========================================================== +CREATE TABLE document_number_counters ( + -- [v1.5.1] Composite Primary Key Columns (8 columns total) + project_id INT NOT NULL COMMENT 'โครงการ', + correspondence_type_id INT NULL COMMENT 'ประเภทเอกสาร (LETTER, RFA, TRANSMITTAL, etc.) NULL = default format for project', + originator_organization_id INT NOT NULL COMMENT 'องค์กรผู้ส่ง', + -- เปลี่ยนจาก NULL เป็น DEFAULT 0 + recipient_organization_id INT NOT NULL DEFAULT 0 COMMENT '[v1.7.0] องค์กรผู้รับ 0 = no recipient (RFA)', + sub_type_id INT DEFAULT 0 COMMENT '[v1.5.1 NEW] ประเภทย่อย สำหรับ TRANSMITTAL (0 = ไม่ระบุ)', + rfa_type_id INT DEFAULT 0 COMMENT '[v1.5.1 NEW] ประเภท RFA เช่น SHD, RPT, MAT (0 = ไม่ใช่ RFA)', + discipline_id INT DEFAULT 0 COMMENT 'สาขางาน เช่น TER, STR, GEO (0 = ไม่ระบุ)', + reset_scope VARCHAR(20) NOT NULL COMMENT 'Scope of reset (PROJECT, ORGANIZATION, etc.)', + -- Counter Data + last_number INT DEFAULT 0 NOT NULL COMMENT 'เลขที่ล่าสุดที่ใช้ไปแล้ว (auto-increment)', + version INT DEFAULT 0 NOT NULL COMMENT 'Optimistic Lock Version (TypeORM @VersionColumn)', + -- Metadata + created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + -- [v1.7.0 UPDATE] Primary Key: 5 columns -> 8 columns + PRIMARY KEY ( + project_id, + originator_organization_id, + recipient_organization_id, + correspondence_type_id, + sub_type_id, + rfa_type_id, + discipline_id, + reset_scope + ), + -- Foreign Keys + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (originator_organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + FOREIGN KEY (correspondence_type_id) REFERENCES correspondence_types (id) ON DELETE CASCADE, + -- Performance Indexes + INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope), + INDEX idx_counter_org (originator_organization_id, reset_scope), + -- Constraints + CONSTRAINT chk_last_number_positive CHECK (last_number >= 0), + CONSTRAINT chk_reset_scope_format CHECK ( + reset_scope IN ('NONE') + OR reset_scope LIKE 'YEAR_%' + OR reset_scope LIKE 'MONTH_%' + OR reset_scope LIKE 'CONTRACT_%' + ) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บ Running Number Counters - รองรับ 8-column composite PK'; + +-- ========================================================== +-- ตารางเก็บ Audit Trail สำหรับการสร้างเลขที่เอกสาร +-- เพิ่มตาราง: document_number_audit +-- เหตุผล: บันทึกประวัติการสร้างเลขที่ รองรับ audit requirement ≥ 7 ปี +-- ========================================================== +CREATE TABLE document_number_audit ( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID ของ audit record', + -- Document Info + document_id INT NULL COMMENT 'ID ของเอกสารที่สร้างเลขที่ (correspondences.id) - NULL if failed/reserved', + document_type VARCHAR(50), + document_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสารที่สร้าง (ผลลัพธ์)', + operation ENUM( + 'RESERVE', + 'CONFIRM', + 'MANUAL_OVERRIDE', + 'VOID_REPLACE', + 'CANCEL' + ) NOT NULL DEFAULT 'CONFIRM' COMMENT 'ประเภทการดำเนินการ', + STATUS ENUM( + 'RESERVED', + 'CONFIRMED', + 'CANCELLED', + 'VOID', + 'MANUAL' + ) NOT NULL DEFAULT 'RESERVED' COMMENT 'สถานะเลขที่เอกสาร', + counter_key JSON NOT NULL COMMENT 'Counter key ที่ใช้ (JSON format) - 8 fields', + reservation_token VARCHAR(36) NULL, + idempotency_key VARCHAR(128) NULL COMMENT 'Idempotency Key from request', + originator_organization_id INT NULL, + recipient_organization_id INT NULL, + template_used VARCHAR(200) NOT NULL COMMENT 'Template ที่ใช้ในการสร้าง', + old_value TEXT NULL COMMENT 'Previous value for audit', + new_value TEXT NULL COMMENT 'New value for audit', + -- User Info + user_id INT NULL COMMENT 'ผู้ขอสร้างเลขที่', + ip_address VARCHAR(45) COMMENT 'IP address ของผู้ขอ (IPv4/IPv6)', + user_agent TEXT COMMENT 'User agent string (browser info)', + is_success BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่/เวลาที่สร้าง', + -- Performance & Error Tracking + retry_count INT DEFAULT 0 COMMENT 'จำนวนครั้งที่ retry ก่อนสำเร็จ', + lock_wait_ms INT COMMENT 'เวลารอ Redis lock (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)', + metadata JSON COMMENT 'Additional context data', + -- Indexes for performance + INDEX idx_document_id (document_id), + INDEX idx_user_id (user_id), + INDEX idx_status (STATUS), + INDEX idx_operation (operation), + INDEX idx_document_number (document_number), + INDEX idx_reservation_token (reservation_token), + INDEX idx_idempotency_key (idempotency_key), + INDEX idx_created_at (created_at), + -- Foreign Keys + FOREIGN KEY (document_id) REFERENCES correspondences (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (user_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '[v1.5.1 NEW] Audit Trail สำหรับการสร้างเลขที่เอกสาร - เก็บ ≥ 7 ปี'; + +-- ========================================================== +-- [v1.5.1 NEW] ตารางเก็บ Error Logs สำหรับ Document Numbering +-- เพิ่มตาราง: document_number_errors +-- เหตุผล: ติดตาม errors, troubleshooting, monitoring +-- รองรับ: Req 3.11.6, Ops monitoring requirements +-- ========================================================== +CREATE TABLE document_number_errors ( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID ของ error record', + -- Error Classification + error_type ENUM( + 'LOCK_TIMEOUT', + -- Redis lock timeout + 'VERSION_CONFLICT', + -- Optimistic lock version mismatch + 'DB_ERROR', + -- Database connection/query error + 'REDIS_ERROR', + -- Redis connection error + 'VALIDATION_ERROR' -- Template/input validation error + ) NOT NULL COMMENT 'ประเภท error (5 types)', + -- Error Details + error_message TEXT COMMENT 'ข้อความ error (stack top)', + stack_trace TEXT COMMENT 'Stack trace แบบเต็ม (สำหรับ debugging)', + context_data JSON COMMENT 'Context ของ request (user, project, counter_key, etc.)', + -- User Info + user_id INT COMMENT 'ผู้ที่เกิด error', + ip_address VARCHAR(45) COMMENT 'IP address', + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่เกิด error', + resolved_at TIMESTAMP NULL COMMENT 'วันที่แก้ไขแล้ว (NULL = ยังไม่แก้)', + -- Indexes for troubleshooting + INDEX idx_error_type (error_type), + INDEX idx_created_at (created_at), + INDEX idx_user_id (user_id), + INDEX idx_unresolved (resolved_at) -- Find unresolved errors +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '[v1.5.1 NEW] Error Log สำหรับ Document Numbering System'; + +-- ===================================================== +CREATE TABLE document_number_reservations ( + id INT AUTO_INCREMENT PRIMARY KEY, + -- Reservation Details + token VARCHAR(36) NOT NULL UNIQUE COMMENT 'UUID v4', + document_number VARCHAR(100) NOT NULL UNIQUE, + document_number_status ENUM('RESERVED', 'CONFIRMED', 'CANCELLED', 'VOID') NOT NULL DEFAULT 'RESERVED', + -- Linkage + document_id INT NULL COMMENT 'FK to documents (NULL until confirmed)', + -- Context (for debugging) + project_id INT NOT NULL, + correspondence_type_id INT NOT NULL, + originator_organization_id INT NOT NULL, + recipient_organization_id INT DEFAULT 0, + user_id INT NOT NULL, + -- Timestamps + reserved_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), + expires_at DATETIME(6) NOT NULL, + confirmed_at DATETIME(6) NULL, + cancelled_at DATETIME(6) NULL, + -- Audit + ip_address VARCHAR(45), + user_agent TEXT, + metadata JSON NULL COMMENT 'Additional context', + -- Indexes + INDEX idx_token (token), + INDEX idx_status (document_number_status), + INDEX idx_status_expires (document_number_status, expires_at), + INDEX idx_document_id (document_id), + INDEX idx_user_id (user_id), + INDEX idx_reserved_at (reserved_at), + -- Foreign Keys + FOREIGN KEY (document_id) REFERENCES correspondence_revisions(id) ON DELETE + SET NULL, + FOREIGN KEY (project_id) REFERENCES projects(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'; + +-- ===================================================== +-- 10. ⚙️ System & Logs (ระบบและ Log) +-- ===================================================== +-- 1.1 JSON Schemas Registry +-- รองรับ: Backend Plan T2.5.1, Req 6.11.1 +-- เหตุผล: เพื่อ Validate โครงสร้าง JSON Details ของเอกสารแต่ละประเภทแบบ Centralized +CREATE TABLE json_schemas ( + id INT AUTO_INCREMENT PRIMARY KEY, + schema_code VARCHAR(100) NOT NULL COMMENT 'รหัส Schema (เช่น RFA_DWG)', + version INT NOT NULL DEFAULT 1 COMMENT 'เวอร์ชันของ Schema', + table_name VARCHAR(100) NOT NULL COMMENT 'ชื่อตารางเป้าหมาย (เช่น rfa_revisions)', + schema_definition JSON NOT NULL COMMENT 'โครงสร้าง Data Schema (AJV Standard)', + ui_schema JSON NULL COMMENT 'โครงสร้าง UI Schema สำหรับ Frontend', + virtual_columns JSON NULL COMMENT 'Config สำหรับสร้าง Virtual Columns', + migration_script JSON NULL COMMENT 'Script สำหรับแปลงข้อมูลจากเวอร์ชันก่อนหน้า', + is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + -- ป้องกัน Schema Code ซ้ำกันใน Version เดียวกัน + UNIQUE KEY uk_schema_version (schema_code, version) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางเก็บ JSON Schema และ Configuration'; + +-- 1.2 User Preferences +-- รองรับ: Req 5.5, 6.8.3 +-- เหตุผล: แยกการตั้งค่า Notification และ UI ออกจากตาราง Users หลัก +CREATE TABLE user_preferences ( + user_id INT PRIMARY KEY, + notify_email BOOLEAN DEFAULT TRUE, + notify_line BOOLEAN DEFAULT TRUE, + digest_mode BOOLEAN DEFAULT FALSE COMMENT 'รับแจ้งเตือนแบบรวม (Digest) แทน Real - time', + ui_theme VARCHAR(20) DEFAULT 'light', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_user_prefs_user FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- ตารางเก็บบันทึกการกระทำของผู้ใช้ +-- 4.1 Audit Logs Enhancements +-- รองรับ: Req 6.1 +-- เหตุผล: รองรับ Distributed Tracing และระบุความรุนแรง +CREATE TABLE audit_logs ( + audit_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'ID ของ Log', + request_id VARCHAR(100) NULL COMMENT 'Trace ID linking to app logs', + user_id INT COMMENT 'ผู้กระทำ', + ACTION VARCHAR(100) NOT NULL COMMENT 'การกระทำ ( + เช่น rfa.create, + correspondence.update, + login.success + )', + severity ENUM( + 'INFO', + 'WARN', + 'ERROR', + 'CRITICAL ' + ) DEFAULT 'INFO', + entity_type VARCHAR(50) COMMENT 'ตาราง / โมดูล (เช่น ''rfa '', ''correspondence '')', + entity_id VARCHAR(50) COMMENT 'Primary ID ของระเบียนที่ได้รับผลกระทำ', + details_json JSON COMMENT 'ข้อมูลบริบท', + ip_address VARCHAR(45) COMMENT 'IP Address', + user_agent VARCHAR(255) COMMENT 'User Agent', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'เวลาที่กระทำ', + -- [แก้ไข] รวม created_at เข้ามาใน Primary Key เพื่อรองรับ Partition + PRIMARY KEY (audit_id, created_at), + -- [แก้ไข] ใช้ Index ธรรมดาแทน Foreign Key เพื่อไม่ให้ติดข้อจำกัดของ Partition Table + INDEX idx_audit_user (user_id), + INDEX idx_audit_action (ACTION), + INDEX idx_audit_entity (entity_type, entity_id), + INDEX idx_audit_created (created_at) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางเก็บบันทึกการกระทำของผู้ใช้' -- [เพิ่ม] คำสั่ง Partition +PARTITION BY RANGE (YEAR(created_at)) ( + PARTITION p_old + VALUES LESS THAN (2024), + PARTITION p2024 + VALUES LESS THAN (2025), + PARTITION p2025 + VALUES LESS THAN (2026), + PARTITION p2026 + VALUES LESS THAN (2027), + PARTITION p2027 + VALUES LESS THAN (2028), + PARTITION p2028 + VALUES LESS THAN (2029), + PARTITION p2029 + VALUES LESS THAN (2030), + PARTITION p2030 + VALUES LESS THAN (2031), + PARTITION p_future + VALUES LESS THAN MAXVALUE +); + +-- ตารางสำหรับจัดการการแจ้งเตือน (Email/Line/System) +CREATE TABLE notifications ( + id INT NOT NULL AUTO_INCREMENT COMMENT 'ID ของการแจ้งเตือน', + user_id INT NOT NULL COMMENT 'ID ผู้ใช้', + title VARCHAR(255) NOT NULL COMMENT 'หัวข้อการแจ้งเตือน', + message TEXT NOT NULL COMMENT 'รายละเอียดการแจ้งเตือน', + notification_type ENUM('EMAIL', 'LINE', 'SYSTEM ') NOT NULL COMMENT 'ประเภท (EMAIL, LINE, SYSTEM)', + is_read BOOLEAN DEFAULT FALSE COMMENT 'สถานะการอ่าน', + entity_type VARCHAR(50) COMMENT 'เช่น ''rfa '', + ''circulation ''', + entity_id INT COMMENT 'ID ของเอนทิตีที่เกี่ยวข้อง', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + -- [แก้ไข] รวม created_at เข้ามาใน Primary Key + PRIMARY KEY (id, created_at), + -- [แก้ไข] ใช้ Index ธรรมดาแทน Foreign Key + INDEX idx_notif_user (user_id), + INDEX idx_notif_type (notification_type), + INDEX idx_notif_read (is_read), + INDEX idx_notif_created (created_at) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางสำหรับจัดการการแจ้งเตือน (Email / Line / System)' -- [เพิ่ม] คำสั่ง Partition +PARTITION BY RANGE (YEAR(created_at)) ( + PARTITION p_old + VALUES LESS THAN (2024), + PARTITION p2024 + VALUES LESS THAN (2025), + PARTITION p2025 + VALUES LESS THAN (2026), + PARTITION p2026 + VALUES LESS THAN (2027), + PARTITION p2027 + VALUES LESS THAN (2028), + PARTITION p2028 + VALUES LESS THAN (2029), + PARTITION p2029 + VALUES LESS THAN (2030), + PARTITION p2030 + VALUES LESS THAN (2031), + PARTITION p_future + VALUES LESS THAN MAXVALUE +); + +-- ตารางสำหรับจัดการดัชนีการค้นหาขั้นสูง (Full-text Search) +CREATE TABLE search_indices ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของดัชนี', + entity_type VARCHAR(50) NOT NULL COMMENT 'ชนิดเอนทิตี (เช่น ''correspondence '', ''rfa '')', + entity_id INT NOT NULL COMMENT 'ID ของเอนทิตี', + content TEXT NOT NULL COMMENT 'เนื้อหาที่จะค้นหา', + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง / อัปเดตัชนี ' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางสำหรับจัดการดัชนีการค้นหาขั้นสูง (Full - text Search)'; + +-- ตารางสำหรับบันทึกประวัติการสำรองข้อมูล +CREATE TABLE backup_logs ( + id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของการสำรอง', + backup_type ENUM('DATABASE', 'FILES', 'FULL') NOT NULL COMMENT 'ประเภท (DATABASE, FILES, FULL)', + backup_path VARCHAR(500) NOT NULL COMMENT 'ตำแหน่งไฟล์สำรอง', + file_size BIGINT COMMENT 'ขนาดไฟล์', + STATUS ENUM( + 'STARTED', + 'COMPLETED', + 'FAILED' + ) NOT NULL COMMENT 'สถานะ', + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'เวลาเริ่มต้น', + completed_at TIMESTAMP NULL COMMENT 'เวลาเสร็จสิ้น', + error_message TEXT COMMENT 'ข้อความผิดพลาด (ถ้ามี)' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางสำหรับบันทึกประวัติการสำรองข้อมูล'; + +-- ============================================================ +-- ส่วนที่ 11: Unified Workflow Engine (Phase 6A/Phase 3) +-- ============================================================ +DROP TABLE IF EXISTS workflow_histories; + +DROP TABLE IF EXISTS workflow_instances; + +DROP TABLE IF EXISTS workflow_definitions; + +-- 1. ตารางเก็บนิยาม Workflow (Definition / DSL) +CREATE TABLE workflow_definitions ( + id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID ของ Workflow Definition', + workflow_code VARCHAR(50) NOT NULL COMMENT 'รหัส Workflow เช่น RFA_FLOW_V1, CORRESPONDENCE_FLOW_V1', + version INT NOT NULL DEFAULT 1 COMMENT 'หมายเลข Version', + description TEXT NULL COMMENT 'คำอธิบาย Workflow', + dsl JSON NOT NULL COMMENT 'นิยาม Workflow ต้นฉบับ (YAML/JSON Format)', + compiled JSON NOT NULL COMMENT 'โครงสร้าง Execution Tree ที่ Compile แล้ว', + is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', + -- ป้องกันการมี Workflow Code และ Version ซ้ำกัน + UNIQUE KEY uq_workflow_version (workflow_code, version) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางเก็บนิยามกฎการเดินเอกสาร (Workflow DSL)'; + +-- สร้าง Index สำหรับการค้นหา Workflow ที่ Active ล่าสุดได้เร็วขึ้น +CREATE INDEX idx_workflow_active ON workflow_definitions (workflow_code, is_active, version); + +-- 2. ตารางเก็บ Workflow Instance (สถานะเอกสารจริง) +CREATE TABLE workflow_instances ( + id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID ของ Instance', + definition_id CHAR(36) NOT NULL COMMENT 'อ้างอิง Definition ที่ใช้', + entity_type VARCHAR(50) NOT NULL COMMENT 'ประเภทเอกสาร (rfa_revision, correspondence_revision, circulation)', + entity_id VARCHAR(50) NOT NULL COMMENT 'ID ของเอกสาร (String/Int)', + current_state VARCHAR(50) NOT NULL COMMENT 'สถานะปัจจุบัน', + STATUS ENUM( + 'ACTIVE', + 'COMPLETED', + 'CANCELLED', + 'TERMINATED' + ) DEFAULT 'ACTIVE' COMMENT 'สถานะภาพรวม', + context JSON NULL COMMENT 'ตัวแปร Context สำหรับตัดสินใจ', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_wf_inst_def FOREIGN KEY (definition_id) REFERENCES workflow_definitions (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางเก็บสถานะการเดินเรื่องของเอกสาร'; + +CREATE INDEX idx_wf_inst_entity ON workflow_instances (entity_type, entity_id); + +CREATE INDEX idx_wf_inst_state ON workflow_instances (current_state); + +-- 3. ตารางเก็บประวัติ (Audit Log / History) +CREATE TABLE workflow_histories ( + id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID', + instance_id CHAR(36) NOT NULL COMMENT 'อ้างอิง Instance', + from_state VARCHAR(50) NOT NULL COMMENT 'สถานะต้นทาง', + to_state VARCHAR(50) NOT NULL COMMENT 'สถานะปลายทาง', + ACTION VARCHAR(50) NOT NULL COMMENT 'Action ที่กระทำ', + action_by_user_id INT NULL COMMENT 'User ID ผู้กระทำ', + COMMENT TEXT NULL COMMENT 'ความเห็น', + metadata JSON NULL COMMENT 'Snapshot ข้อมูล ณ ขณะนั้น', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_wf_hist_inst FOREIGN KEY (instance_id) REFERENCES workflow_instances (id) ON DELETE CASCADE +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางประวัติการเปลี่ยนสถานะ Workflow'; + +CREATE INDEX idx_wf_hist_instance ON workflow_histories (instance_id); + +CREATE INDEX idx_wf_hist_user ON workflow_histories (action_by_user_id);