diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08f659f..c9f9d83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,16 +90,16 @@ specs/ ### 📋 หมวดหมู่เอกสาร -| หมวด | วัตถุประสงค์ | ไฟล์สำคัญ | ผู้ดูแล | -|------|---------|---------|--------| -| **00-Overview** | ภาพรวม, Product Vision, KPI, Training | Gap 1/5/6/9 | Project Manager / PO | -| **01-Requirements** | User Stories, UAT, UI, Edge Cases | Gap 2/3/4/10 | Business Analyst + PO | -| **02-Architecture** | สถาปัตยกรรมและการออกแบบ | — | Tech Lead + Architects | -| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA | -| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | -| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads | -| **06-Decision-Records** | Architecture Decision Records (17+1) | ADR-018 | Tech Lead + Senior Devs | -| **99-archives** | Archived / Tasks | — | All Team Members | +| หมวด | วัตถุประสงค์ | ไฟล์สำคัญ | ผู้ดูแล | +| ----------------------------- | -------------------------------------- | ------------ | ----------------------- | +| **00-Overview** | ภาพรวม, Product Vision, KPI, Training | Gap 1/5/6/9 | Project Manager / PO | +| **01-Requirements** | User Stories, UAT, UI, Edge Cases | Gap 2/3/4/10 | Business Analyst + PO | +| **02-Architecture** | สถาปัตยกรรมและการออกแบบ | — | Tech Lead + Architects | +| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA | +| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | +| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads | +| **06-Decision-Records** | Architecture Decision Records (17+1) | ADR-018 | Tech Lead + Senior Devs | +| **99-archives** | Archived / Tasks | — | All Team Members | --- @@ -546,7 +546,58 @@ graph LR **Last Updated**: 2026-02-24 ``` -### 5. ใช้ Consistent Terminology +### 5. UUID Conventions (ADR-019) + +โครงการใช้ **Hybrid Identifier Strategy** — INT PK สำหรับ internal, UUIDv7 สำหรับ public API + +#### Backend Entity Pattern + +```typescript +// ❌ ผิด — ส่ง INT id ออก public API +@Get(':id') +findOne(@Param('id', ParseIntPipe) id: number) { ... } + +// ✅ ถูกต้อง — ใช้ UUID สำหรับ public API +@Get(':uuid') +findOne(@Param('uuid', ParseUuidPipe) uuid: string) { ... } +``` + +#### Backend DTO — FK References + +```typescript +// ❌ ผิด — frontend ไม่มี INT id (ถูก @Exclude() แล้ว) +@IsInt() +projectId!: number; + +// ✅ ถูกต้อง — รับ UUID จาก frontend, resolve เป็น INT ใน controller +@IsUUID() +projectUuid!: string; + +@IsOptional() +@IsInt() +projectId?: number; // resolved internally by controller +``` + +#### Frontend — Select Components + +```typescript +// ❌ ผิด — parseInt บน UUID string จะได้ค่าผิด +onValueChange={(v) => setValue("projectId", parseInt(v))} + +// ✅ ถูกต้อง — ส่ง UUID string ตรงๆ +onValueChange={(v) => setValue("projectUuid", v)} +``` + +#### Serialization Behavior + +- `TransformInterceptor` ใช้ `instanceToPlain()` → `@Exclude()` และ `@Expose()` มีผล +- Entity ทั้ง 14 ตาราง มี `@Exclude()` บน INT `id` → API response **ไม่มี** `id` เป็นตัวเลข +- Project & Contract มี `@Expose({ name: 'id' })` บน `uuid` → API response มี `id` = UUID string +- Entity อื่นๆ มี `uuid` field แยก → API response มี `uuid` แต่ไม่มี `id` + +> ดูรายละเอียดเพิ่มเติมที่ [05-07-hybrid-uuid-implementation-plan.md](./specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md) + +### 6. ใช้ Consistent Terminology อ้างอิงจาก [glossary.md](./specs/00-Overview/00-02-glossary.md) เสมอ diff --git a/README.md b/README.md index 1d08574..37d2c5d 100644 --- a/README.md +++ b/README.md @@ -589,6 +589,15 @@ This project is **Internal Use Only** - ลิขสิทธิ์เป็น - ✅ ADR-019 UUID fixes: Drawing admin pages (5), Contracts, Disciplines, Tags, RFA Types - ✅ Fixed contract edit form (UUID mismatch), disciplines dropdown (hardcoded projectId), tags crash (empty Select value) +### 🔄 ADR-019 Hybrid UUID Migration (Mar 2026) + +- ✅ **Phase 1-4**: Schema, entities, API layer — all 14 tables migrated +- ✅ **Phase 5 (Partial)**: Frontend routes, services, hooks migrated to UUID +- ✅ Drawing search: `projectUuid` sent to backend, resolved in controller +- ✅ Drawing detail page: mock API replaced with real UUID-based services +- 🔄 **Phase 5.4 (Pending)**: FK reference UUID migration — `correspondences/form.tsx`, `user-dialog.tsx`, `numbering/template-tester.tsx`, `rfas/page.tsx` still use `parseInt()` on UUID values (see `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md`) +- 📋 **Phase 6**: Unit + integration tests for UUID-based routes + ### 🔄 Next: Go-Live Preparation - 🔄 **UAT**: ทำ User Acceptance Testing ตาม `01-05-acceptance-criteria.md` diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2c4b7a4..5ce00d3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -101,6 +101,7 @@ import { MigrationModule } from './modules/migration/migration.module'; username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), + charset: 'utf8mb4', autoLoadEntities: true, synchronize: false, // Production Ready: false }), diff --git a/backend/src/common/file-storage/file-storage.service.ts b/backend/src/common/file-storage/file-storage.service.ts index 147247d..058301f 100644 --- a/backend/src/common/file-storage/file-storage.service.ts +++ b/backend/src/common/file-storage/file-storage.service.ts @@ -45,7 +45,9 @@ export class FileStorageService { */ async upload(file: Express.Multer.File, userId: number): Promise { const tempId = uuidv4(); - const fileExt = path.extname(file.originalname); + // Fix: แปลงชื่อไฟล์จาก Latin1 → UTF-8 (Multer/busboy decodes as Latin1 by default) + const originalFilename = this.fixMulterFilename(file.originalname); + const fileExt = path.extname(originalFilename); const storedFilename = `${uuidv4()}${fileExt}`; const tempPath = path.join(this.tempDir, storedFilename); @@ -62,7 +64,7 @@ export class FileStorageService { // 3. สร้าง Record ใน Database const attachment = this.attachmentRepository.create({ - originalFilename: file.originalname, + originalFilename, storedFilename: storedFilename, filePath: tempPath, // เก็บ path ปัจจุบันไปก่อน mimeType: file.mimetype, @@ -197,6 +199,22 @@ export class FileStorageService { return { stream, attachment }; } + /** + * แก้ปัญหา Multer/busboy ถอดรหัสชื่อไฟล์เป็น Latin1 แทน UTF-8 + * ทำให้ภาษาไทยกลายเป็น mojibake (เช่น ผรม → 脿赂聹脿赂拢脿赂隆) + * วิธีแก้: แปลง latin1 bytes กลับเป็น UTF-8 + */ + private fixMulterFilename(originalname: string): string { + try { + const decoded = Buffer.from(originalname, 'latin1').toString('utf8'); + // ตรวจสอบว่า decoded string มี valid UTF-8 characters + // ถ้า originalname เป็น ASCII อยู่แล้ว ผลลัพธ์จะเหมือนเดิม + return decoded; + } catch { + return originalname; + } + } + private calculateChecksum(buffer: Buffer): string { return crypto.createHash('sha256').update(buffer).digest('hex'); } diff --git a/backend/src/config/database.config.ts b/backend/src/config/database.config.ts index 10a6b86..9a7f8df 100644 --- a/backend/src/config/database.config.ts +++ b/backend/src/config/database.config.ts @@ -7,6 +7,7 @@ export const databaseConfig: TypeOrmModuleOptions = { username: process.env.DB_USERNAME || 'root', password: process.env.DB_PASSWORD || 'Center#2025', database: process.env.DB_DATABASE || 'lcbp3_dev', + charset: 'utf8mb4', entities: [__dirname + '/../**/*.entity{.ts,.js}'], migrations: [__dirname + '/../database/migrations/*{.ts,.js}'], synchronize: false, diff --git a/backend/src/modules/drawing/asbuilt-drawing.controller.ts b/backend/src/modules/drawing/asbuilt-drawing.controller.ts index fa2b161..d4a041d 100644 --- a/backend/src/modules/drawing/asbuilt-drawing.controller.ts +++ b/backend/src/modules/drawing/asbuilt-drawing.controller.ts @@ -35,13 +35,17 @@ import { RequirePermission } from '../../common/decorators/require-permission.de import { Audit } from '../../common/decorators/audit.decorator'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { User } from '../user/entities/user.entity'; +import { ProjectService } from '../project/project.service'; @ApiTags('Drawings - AS Built') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RbacGuard) @Controller('drawings/asbuilt') export class AsBuiltDrawingController { - constructor(private readonly asBuiltDrawingService: AsBuiltDrawingService) {} + constructor( + private readonly asBuiltDrawingService: AsBuiltDrawingService, + private readonly projectService: ProjectService + ) {} @Post() @ApiOperation({ summary: 'Create new AS Built Drawing' }) @@ -74,6 +78,10 @@ export class AsBuiltDrawingController { @ApiResponse({ status: 200, description: 'List of AS Built Drawings' }) @RequirePermission('drawing.view') async findAll(@Query() searchDto: SearchAsBuiltDrawingDto) { + const project = await this.projectService.findOneByUuid( + searchDto.projectUuid + ); + searchDto.projectId = project.id; return this.asBuiltDrawingService.findAll(searchDto); } diff --git a/backend/src/modules/drawing/contract-drawing.controller.ts b/backend/src/modules/drawing/contract-drawing.controller.ts index d701e3c..aeecf89 100644 --- a/backend/src/modules/drawing/contract-drawing.controller.ts +++ b/backend/src/modules/drawing/contract-drawing.controller.ts @@ -22,6 +22,7 @@ import { RequirePermission } from '../../common/decorators/require-permission.de import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { User } from '../user/entities/user.entity'; +import { ProjectService } from '../project/project.service'; @ApiTags('Contract Drawings') @ApiBearerAuth() @@ -29,7 +30,8 @@ import { User } from '../user/entities/user.entity'; @Controller('drawings/contract') export class ContractDrawingController { constructor( - private readonly contractDrawingService: ContractDrawingService + private readonly contractDrawingService: ContractDrawingService, + private readonly projectService: ProjectService ) {} // Force rebuild for DTO changes @@ -47,7 +49,11 @@ export class ContractDrawingController { @Get() @ApiOperation({ summary: 'Search Contract Drawings' }) @RequirePermission('document.view') // สิทธิ์ ID 31: ดูเอกสารทั่วไป - findAll(@Query() searchDto: SearchContractDrawingDto) { + async findAll(@Query() searchDto: SearchContractDrawingDto) { + const project = await this.projectService.findOneByUuid( + searchDto.projectUuid + ); + searchDto.projectId = project.id; return this.contractDrawingService.findAll(searchDto); } diff --git a/backend/src/modules/drawing/drawing.module.ts b/backend/src/modules/drawing/drawing.module.ts index 0b19846..b913c94 100644 --- a/backend/src/modules/drawing/drawing.module.ts +++ b/backend/src/modules/drawing/drawing.module.ts @@ -37,6 +37,7 @@ import { DrawingMasterDataController } from './drawing-master-data.controller'; // Modules import { FileStorageModule } from '../../common/file-storage/file-storage.module'; import { UserModule } from '../user/user.module'; +import { ProjectModule } from '../project/project.module'; @Module({ imports: [ @@ -62,6 +63,7 @@ import { UserModule } from '../user/user.module'; ]), FileStorageModule, UserModule, + ProjectModule, ], providers: [ ShopDrawingService, diff --git a/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts b/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts index 61cc18a..5b9c473 100644 --- a/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts +++ b/backend/src/modules/drawing/dto/search-asbuilt-drawing.dto.ts @@ -1,4 +1,4 @@ -import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -6,10 +6,15 @@ import { Type } from 'class-transformer'; * DTO for searching/filtering AS Built Drawings */ export class SearchAsBuiltDrawingDto { - @ApiProperty({ description: 'Project ID' }) + @ApiProperty({ description: 'Project UUID' }) + @IsUUID() + projectUuid!: string; + + @ApiPropertyOptional({ description: 'Project ID (resolved internally)' }) @Type(() => Number) @IsNumber() - projectId!: number; + @IsOptional() + projectId?: number; @ApiPropertyOptional({ description: 'Filter by Main Category ID' }) @Type(() => Number) diff --git a/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts b/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts index cf49240..a2bfa32 100644 --- a/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts +++ b/backend/src/modules/drawing/dto/search-contract-drawing.dto.ts @@ -1,11 +1,14 @@ -import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator'; +import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator'; import { Type } from 'class-transformer'; export class SearchContractDrawingDto { + @IsUUID() + projectUuid!: string; + + @IsOptional() @IsInt() @Type(() => Number) - @IsNotEmpty() - projectId!: number; // จำเป็น: ใส่ ! + projectId?: number; @IsOptional() @IsInt() diff --git a/backend/src/modules/drawing/dto/search-shop-drawing.dto.ts b/backend/src/modules/drawing/dto/search-shop-drawing.dto.ts index 50de93e..167e593 100644 --- a/backend/src/modules/drawing/dto/search-shop-drawing.dto.ts +++ b/backend/src/modules/drawing/dto/search-shop-drawing.dto.ts @@ -1,10 +1,14 @@ -import { IsInt, IsOptional, IsString } from 'class-validator'; +import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator'; import { Type } from 'class-transformer'; export class SearchShopDrawingDto { + @IsUUID() + projectUuid!: string; + + @IsOptional() @IsInt() @Type(() => Number) - projectId!: number; // จำเป็น: ใส่ ! + projectId?: number; @IsOptional() @IsInt() diff --git a/backend/src/modules/drawing/shop-drawing.controller.ts b/backend/src/modules/drawing/shop-drawing.controller.ts index 2422889..6be44bf 100644 --- a/backend/src/modules/drawing/shop-drawing.controller.ts +++ b/backend/src/modules/drawing/shop-drawing.controller.ts @@ -21,13 +21,17 @@ import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { User } from '../user/entities/user.entity'; import { Audit } from '../../common/decorators/audit.decorator'; // Import +import { ProjectService } from '../project/project.service'; @ApiTags('Shop Drawings') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RbacGuard) @Controller('drawings/shop') export class ShopDrawingController { - constructor(private readonly shopDrawingService: ShopDrawingService) {} + constructor( + private readonly shopDrawingService: ShopDrawingService, + private readonly projectService: ProjectService + ) {} @Post() @ApiOperation({ summary: 'Create new Shop Drawing with initial revision' }) @@ -40,7 +44,11 @@ export class ShopDrawingController { @Get() @ApiOperation({ summary: 'Search Shop Drawings' }) @RequirePermission('drawing.view') - findAll(@Query() searchDto: SearchShopDrawingDto) { + async findAll(@Query() searchDto: SearchShopDrawingDto) { + const project = await this.projectService.findOneByUuid( + searchDto.projectUuid + ); + searchDto.projectId = project.id; return this.shopDrawingService.findAll(searchDto); } diff --git a/frontend/app/(dashboard)/drawings/[uuid]/page.tsx b/frontend/app/(dashboard)/drawings/[uuid]/page.tsx index e21afec..6f6c739 100644 --- a/frontend/app/(dashboard)/drawings/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/drawings/[uuid]/page.tsx @@ -1,33 +1,87 @@ +"use client"; + +import { use } from "react"; import { notFound } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Download, FileText, GitCompare } from "lucide-react"; +import { ArrowLeft, Download, FileText, Loader2 } from "lucide-react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { RevisionHistory } from "@/components/drawings/revision-history"; import { format } from "date-fns"; -import { drawingApi } from "@/lib/api/drawings"; +import { useQuery } from "@tanstack/react-query"; +import { contractDrawingService } from "@/lib/services/contract-drawing.service"; +import { shopDrawingService } from "@/lib/services/shop-drawing.service"; +import { asBuiltDrawingService } from "@/lib/services/asbuilt-drawing.service"; -export default async function DrawingDetailPage({ +async function fetchDrawingByUuid(uuid: string) { + // Try each drawing type until one succeeds + try { + const result = await contractDrawingService.getByUuid(uuid); + if (result?.data) return { ...result.data, _type: "CONTRACT" }; + } catch { /* not found in contract drawings */ } + + try { + const result = await shopDrawingService.getByUuid(uuid); + if (result?.data) return { ...result.data, _type: "SHOP" }; + } catch { /* not found in shop drawings */ } + + try { + const result = await asBuiltDrawingService.getByUuid(uuid); + if (result?.data) return { ...result.data, _type: "AS_BUILT" }; + } catch { /* not found in asbuilt drawings */ } + + return null; +} + +export default function DrawingDetailPage({ params, }: { params: Promise<{ uuid: string }>; }) { - const { uuid } = await params; + const { uuid } = use(params); + + const { data: drawing, isLoading } = useQuery({ + queryKey: ["drawing-detail", uuid], + queryFn: () => fetchDrawingByUuid(uuid), + enabled: !!uuid, + }); + if (!uuid) { notFound(); } - // TODO: Replace mock drawingApi with real service call using UUID - // For now, keep using the mock API with a numeric fallback - const drawingId = parseInt(uuid); - const drawing = !isNaN(drawingId) ? await drawingApi.getById(drawingId) : undefined; + if (isLoading) { + return ( +
+ +
+ ); + } if (!drawing) { - notFound(); + return ( +
+
+ + + +

Drawing Not Found

+
+

+ The drawing with UUID {uuid} could not be found. +

+
+ ); } + const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || "N/A"; + const title = drawing.title || drawing.currentRevision?.title || "Untitled"; + const revisions = drawing.revisions || []; + return (
{/* Header */} @@ -39,10 +93,8 @@ export default async function DrawingDetailPage({
-

{drawing.drawingNumber}

-

- {drawing.title} -

+

{drawingNumber}

+

{title}

@@ -51,12 +103,6 @@ export default async function DrawingDetailPage({ Download Current - {(drawing.revisionCount ?? 0) > 1 && ( - - )} @@ -67,31 +113,31 @@ export default async function DrawingDetailPage({
Drawing Details - {drawing.type} + {drawing._type}
-

Discipline

+

Drawing Number

+

{drawingNumber}

+
+
+

Type

+

{drawing._type}

+
+ {drawing.volumePage && ( +
+

Volume Page

+

{drawing.volumePage}

+
+ )} +
+

Created

- {typeof drawing.discipline === 'object' && drawing.discipline - ? `${drawing.discipline.disciplineName} (${drawing.discipline.disciplineCode})` - : drawing.discipline || 'N/A'} + {drawing.createdAt ? format(new Date(drawing.createdAt), "dd MMM yyyy") : "N/A"}

-
-

Sheet Number

-

{drawing.sheetNumber}

-
-
-

Scale

-

{drawing.scale || "N/A"}

-
-
-

Latest Issue Date

-

{drawing.issueDate ? format(new Date(drawing.issueDate), "dd MMM yyyy") : 'N/A'}

-
@@ -111,7 +157,7 @@ export default async function DrawingDetailPage({ {/* Revisions */}
- +
diff --git a/frontend/app/(dashboard)/drawings/page.tsx b/frontend/app/(dashboard)/drawings/page.tsx index 6f5ed1a..436d69c 100644 --- a/frontend/app/(dashboard)/drawings/page.tsx +++ b/frontend/app/(dashboard)/drawings/page.tsx @@ -16,7 +16,7 @@ import Link from "next/link"; import { useProjects } from "@/hooks/use-master-data"; export default function DrawingsPage() { - const [selectedProjectId, setSelectedProjectId] = useState(undefined); + const [selectedProjectUuid, setSelectedProjectUuid] = useState(undefined); const { data: projects = [], isLoading: isLoadingProjects } = useProjects(); return ( @@ -40,8 +40,8 @@ export default function DrawingsPage() {
Project:
- {!selectedProjectId ? ( + {!selectedProjectUuid ? (
Please select a project to view drawings.
) : ( - + )} ); } -function DrawingTabs({ projectId }: { projectId: number }) { +function DrawingTabs({ projectUuid }: { projectUuid: string }) { const [search, setSearch] = useState(""); // We can add more specific filters here (e.g. category) later @@ -98,15 +98,15 @@ function DrawingTabs({ projectId }: { projectId: number }) { - + - + - + ) diff --git a/frontend/components/drawings/list.tsx b/frontend/components/drawings/list.tsx index 1ab789f..72a26d2 100644 --- a/frontend/components/drawings/list.tsx +++ b/frontend/components/drawings/list.tsx @@ -14,11 +14,11 @@ type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | Sea interface DrawingListProps { type: 'CONTRACT' | 'SHOP' | 'AS_BUILT'; - projectId: number; + projectUuid: string; filters?: Partial; } -export function DrawingList({ type, projectId, filters }: DrawingListProps) { +export function DrawingList({ type, projectUuid, filters }: DrawingListProps) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20, @@ -30,7 +30,7 @@ export function DrawingList({ type, projectId, filters }: DrawingListProps) { data: response, isLoading, } = useDrawings(type, { - projectId, + projectUuid, ...filters, page: pagination.pageIndex + 1, // API is 1-based limit: pagination.pageSize, diff --git a/frontend/types/dto/drawing/asbuilt-drawing.dto.ts b/frontend/types/dto/drawing/asbuilt-drawing.dto.ts index 63d21f1..b02ef61 100644 --- a/frontend/types/dto/drawing/asbuilt-drawing.dto.ts +++ b/frontend/types/dto/drawing/asbuilt-drawing.dto.ts @@ -32,9 +32,9 @@ export interface CreateAsBuiltDrawingRevisionDto { // --- Search --- export interface SearchAsBuiltDrawingDto { - projectId: number; + projectUuid: string; search?: string; page?: number; // Default: 1 - pageSize?: number; // Default: 20 + limit?: number; // Default: 20 } diff --git a/frontend/types/dto/drawing/contract-drawing.dto.ts b/frontend/types/dto/drawing/contract-drawing.dto.ts index 9550aae..d9ec3db 100644 --- a/frontend/types/dto/drawing/contract-drawing.dto.ts +++ b/frontend/types/dto/drawing/contract-drawing.dto.ts @@ -29,13 +29,13 @@ export type UpdateContractDrawingDto = Partial; // --- Search --- export interface SearchContractDrawingDto { - /** จำเป็นต้องระบุ Project ID เสมอ */ - projectId: number; + /** จำเป็นต้องระบุ Project UUID เสมอ */ + projectUuid: string; volumeId?: number; mapCatId?: number; search?: string; // ค้นหาจาก Title หรือ Number page?: number; // Default: 1 - pageSize?: number; // Default: 20 + limit?: number; // Default: 20 } diff --git a/frontend/types/dto/drawing/shop-drawing.dto.ts b/frontend/types/dto/drawing/shop-drawing.dto.ts index 2b763f2..b1849c6 100644 --- a/frontend/types/dto/drawing/shop-drawing.dto.ts +++ b/frontend/types/dto/drawing/shop-drawing.dto.ts @@ -30,11 +30,11 @@ export interface CreateShopDrawingRevisionDto { // --- Search --- export interface SearchShopDrawingDto { - projectId: number; + projectUuid: string; mainCategoryId?: number; subCategoryId?: number; search?: string; page?: number; // Default: 1 - pageSize?: number; // Default: 20 + limit?: number; // Default: 20 } diff --git a/n8n-workflow-lcbp3.json b/n8n-workflow-lcbp3.json index 4b6abf0..4a89057 100644 --- a/n8n-workflow-lcbp3.json +++ b/n8n-workflow-lcbp3.json @@ -42,7 +42,10 @@ "name": "Form Trigger", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, - "position": [3952, -26304], + "position": [ + 3952, + -26304 + ], "webhookId": "8c87176d-fa61-4a82-ab2a-1c14615e720c", "notes": "เปิด URL เพื่อเลือก Model ก่อนรัน" }, @@ -54,7 +57,10 @@ "name": "Set Configuration", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [4112, -26304], + "position": [ + 4112, + -26304 + ], "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน" }, { @@ -77,7 +83,10 @@ "name": "Fetch Categories", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [4000, -26128], + "position": [ + 4000, + -26128 + ], "notes": "ดึง Categories จาก Backend" }, { @@ -100,7 +109,10 @@ "name": "Fetch Tags", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [4144, -26128], + "position": [ + 4144, + -26128 + ], "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend" }, { @@ -111,7 +123,10 @@ "name": "File Mount Check", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [4288, -26128], + "position": [ + 4288, + -26128 + ], "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า" }, { @@ -124,7 +139,10 @@ "name": "Read Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [4320, -25936], + "position": [ + 4320, + -25936 + ], "alwaysOutputData": true, "credentials": { "mySql": { @@ -144,7 +162,10 @@ "name": "Read Excel Binary", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, - "position": [4000, -25936], + "position": [ + 4000, + -25936 + ], "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" }, { @@ -155,7 +176,10 @@ "name": "Read Excel", "type": "n8n-nodes-base.spreadsheetFile", "typeVersion": 2, - "position": [4160, -25936], + "position": [ + 4160, + -25936 + ], "notes": "แปลงข้อมูล Excel เป็น JSON Data" }, { @@ -166,7 +190,10 @@ "name": "Process Batch + Encoding", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [4560, -25952], + "position": [ + 4560, + -25952 + ], "alwaysOutputData": true, "notes": "ตัด Batch + Normalize UTF-8" }, @@ -178,7 +205,10 @@ "name": "File Validator", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [4704, -25952], + "position": [ + 4704, + -25952 + ], "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config" }, { @@ -191,7 +221,10 @@ "name": "Check Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [5056, -26336], + "position": [ + 5056, + -26336 + ], "alwaysOutputData": true, "credentials": { "mySql": { @@ -204,13 +237,16 @@ }, { "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\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, description: d.text2 || ''}));\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();\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 subject = String(item.json.subject || '');\n const projectCode = String(item.json.project_code || '');\n const remarks = String(item.json.remarks || '');\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\n const prompt = `Validate and summarize this document. Respond in JSON.\\nDocument Number: ${docNum}\\nOriginal Subject: ${subject}\\nExtracted Text: ${(pdfItem.json.response || pdfItem.json.data || '').substring(0, 4000)}\\n\\nExisting Projects: ${JSON.stringify(dbProjects)}\\nExisting Disciplines: ${JSON.stringify(dbDisciplines)}\\nExisting Orgs: ${JSON.stringify(dbOrgs)}\\nExisting Categories: ${JSON.stringify(systemCategories)}\\nExisting Tags: ${JSON.stringify(dbTags)}\\n\\nAnalyze the content to provide:\\n1. Validate the Subject and Dates against PDF text.\\n2. Write a detailed summary (4-5 sentences) for the body field.\\n3. Suggest 1-5 tags. Prefer Existing Tags when applicable. Each tag MUST have tag_name and description.\\n\\nRespond ONLY with this exact JSON structure:\\n{\\n \\\"is_valid\\\": true,\\n \\\"confidence\\\": 0.9,\\n \\\"category\\\": \\\"Correspondence\\\",\\n \\\"subject\\\": \\\"Verified or corrected subject line\\\",\\n \\\"body\\\": \\\"Detailed 4-5 sentence summary of the document content for archival.\\\",\\n \\\"discipline_id\\\": 64,\\n \\\"tags\\\": [{\\\"tag_name\\\": \\\"TagName\\\", \\\"description\\\": \\\"Why this tag applies\\\"}],\\n \\\"key_points\\\": [\\\"...\\\"],\\n \\\"document_date\\\": \\\"YYYY-MM-DD\\\",\\n \\\"issued_date\\\": \\\"YYYY-MM-DD\\\",\\n \\\"received_date\\\": \\\"YYYY-MM-DD\\\"\\n}`;\n\n return {\n json: {\n ...item.json,\n ollama_payload: {\n model: model,\n prompt: prompt,\n stream: false,\n format: \"json\",\n options: { temperature: 0.2, num_ctx: 8192 }\n },\n system_categories: systemCategories,\n pre_mapped: {\n project_id: (projectCode && dbProjects.find(p => p.code === projectCode)?.id) || dbProjects.find(p => docNum.includes(p.code))?.id || config.PROJECT_ID,\n sender_id: dbOrgs.find(o => senderCode.includes(o.code) || senderCode.includes(o.name))?.id,\n receiver_id: dbOrgs.find(o => receiverCode.includes(o.code) || receiverCode.includes(o.name))?.id\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 subject = String(item.json.subject || '');\n const projectCode = String(item.json.project_code || '');\n const remarks = String(item.json.remarks || '');\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\n // Resolve correspondence_type from Excel against DB\n const resolveType = () => {\n if (!corrType) return { type_id: null, type_code: null };\n const ct = Number(corrType);\n if (!isNaN(ct) && ct > 0) {\n const found = dbCorrTypes.find(t => t.id === ct);\n return found ? { type_id: found.id, type_code: found.code } : { type_id: null, type_code: null };\n }\n const found = dbCorrTypes.find(t =>\n t.code === corrType || t.name === corrType ||\n t.code.toLowerCase() === corrType.toLowerCase() ||\n t.name.toLowerCase() === corrType.toLowerCase()\n );\n return found ? { type_id: found.id, type_code: found.code } : { type_id: null, type_code: null };\n };\n const resolvedType = resolveType();\n\n // JavaScript pre-mapping (same as spec)\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;\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\n const isRFA = docNum.includes('-RFA-') || subject.toLowerCase().includes('rfa');\n\n // ====== System Prompt (from spec — sets AI role) ======\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 // ====== OCR Text (cleaned — from spec) ======\n // Use pdfItem for the OCR extracted data, NOT the metaItem\n const pdfText = String(pdfItem.json.data || pdfItem.json.response || '').substring(0, 3500).replace(/[^a-zA-Z0-9\\u0E01-\\u0E5B\\s\\.\\/\\-:\\[\\]\\(\\)]/g, ' ');\n\n // ====== User Prompt (structured sections — from spec) ======\n const userPrompt = `Analyze this document:\n[EXCEL METADATA]\nDocument Number: ${docNum || 'Not provided'}\nSubject: ${subject || '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 Subject, 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 Subject, 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\": \"${subject}\",\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: resolvedType.type_id,\n type_id: resolvedType.type_id,\n type_code: resolvedType.type_code\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});" }, "id": "08cc5940-194e-486a-bffe-d4ed6a00e252", "name": "Build AI Prompt", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [4736, -26144], + "position": [ + 4736, + -26144 + ], "notes": "สร้าง Prompt โดยใช้ Categories จาก System" }, { @@ -228,18 +264,24 @@ "name": "Ollama AI Analysis", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [4912, -26144], + "position": [ + 4912, + -26144 + ], "notes": "เรียก Ollama วิเคราะห์เอกสาร" }, { "parameters": { - "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\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 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 const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\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 subject: result.subject || baseJson.subject || '',\n body: result.body || result.summary || '',\n issued_date: result.issued_date || baseJson.issued_date,\n received_date: result.received_date || baseJson.received_date,\n summary: result.summary || result.body || '',\n key_points: result.key_points || [],\n tags: (result.tags || []).map(t => (typeof t === 'string' ? { tag_name: t, description: '' } : { tag_name: t.tag_name || t.name || '', description: t.description || '' })).filter(t => t.tag_name),\n is_valid: result.is_valid !== false\n }\n }\n });\n } catch (err) {\n results.push({\n json: {\n ...baseJson,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response\n }\n });\n }\n}\n\nreturn results;" + "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\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 const preMapped = baseJson.pre_mapped || {};\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 const isEmptyResponse = Object.keys(result).length === 0;\n\n const systemCategories = baseJson.system_categories || [];\n let finalCategory = result.category;\n if (!finalCategory || !systemCategories.includes(finalCategory)) {\n finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n\n // Use Excel correspondence_type (pre_mapped) first, fallback to AI category map\n const typeCode = preMapped.type_code || CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n const typeId = preMapped.type_id || preMapped.correspondence_type_id || null;\n\n results.push({\n json: {\n ...baseJson,\n ai_result: {\n suggested_category: finalCategory,\n type_code: typeCode,\n type_id: typeId,\n confidence: isEmptyResponse ? 0.5 : (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 // Spec uses \"summary\" for body content\n subject: result.subject || baseJson.subject || '',\n body: result.summary || result.body || '',\n issued_date: result.issued_date || baseJson.issued_date || '',\n received_date: result.received_date || baseJson.received_date || '',\n summary: result.summary || result.body || '',\n status: result.status || '',\n key_points: result.key_points || [],\n tags: (result.tags || []).map(t => (typeof t === 'string' ? { tag_name: t, description: '' } : { tag_name: t.tag_name || t.name || '', description: t.description || '' })).filter(t => t.tag_name),\n is_valid: result.is_valid !== false,\n ai_empty: isEmptyResponse\n }\n }\n });\n } catch (err) {\n // Parse error — still carry forward Excel data via pre_mapped\n results.push({\n json: {\n ...baseJson,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response,\n ai_result: {\n suggested_category: 'Correspondence',\n type_code: preMapped.type_code || 'LETTER',\n type_id: preMapped.type_id || preMapped.correspondence_type_id || null,\n confidence: 0.3,\n project_id: preMapped.project_id || null,\n discipline_id: 64,\n sender_id: preMapped.sender_id || null,\n receiver_id: preMapped.receiver_id || null,\n subject: baseJson.subject || '',\n body: '',\n issued_date: baseJson.issued_date || '',\n received_date: baseJson.received_date || '',\n summary: '',\n status: '',\n key_points: [],\n tags: [],\n is_valid: false,\n ai_empty: true\n }\n }\n });\n }\n}\n\nreturn results;" }, "id": "9ff12b75-03c6-43f0-8654-855d5da42e56", "name": "Parse & Validate AI Response", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [5104, -26144], + "position": [ + 5104, + -26144 + ], "notes": "Parse JSON + Validate Schema + Enum Check" }, { @@ -252,7 +294,10 @@ "name": "Update Fallback State", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [4928, -25952], + "position": [ + 4928, + -25952 + ], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -269,7 +314,10 @@ "name": "Confidence Router", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [5312, -26304], + "position": [ + 5312, + -26304 + ], "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)" }, { @@ -280,7 +328,10 @@ "name": "Log Reject to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [5680, -25920], + "position": [ + 5680, + -25920 + ], "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" }, { @@ -291,7 +342,10 @@ "name": "Log Error to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [5488, -25856], + "position": [ + 5488, + -25856 + ], "notes": "บันทึก Error ลง CSV (จาก File Validator)" }, { @@ -318,7 +372,10 @@ "name": "Log Error to DB", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [5872, -25856], + "position": [ + 5872, + -25856 + ], "onError": "continueErrorOutput", "notes": "บันทึก Error ผ่าน Backend API (ป้องกัน SQL Injection)" }, @@ -331,7 +388,10 @@ "name": "Delay", "type": "n8n-nodes-base.wait", "typeVersion": 1, - "position": [6096, -25920], + "position": [ + 6096, + -25920 + ], "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", "notes": "หน่วงเวลาระหว่าง Batches" }, @@ -443,7 +503,10 @@ "name": "Route by Confidence", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, - "position": [5328, -26144] + "position": [ + 5328, + -26144 + ] }, { "parameters": { @@ -454,7 +517,10 @@ "name": "Read PDF File", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, - "position": [4560, -26304], + "position": [ + 4560, + -26304 + ], "onError": "continueErrorOutput" }, { @@ -489,18 +555,24 @@ "name": "Upload to Backend", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [5712, -26336], + "position": [ + 5712, + -26336 + ], "notes": "Upload PDF to Backend Temp Storage" }, { "parameters": { - "jsCode": "const item = $input.first();\nconst binaryData = $('Read PDF File').first().binary.data;\n\nreturn {\n json: { ...item.json },\n binary: { data: binaryData }\n};" + "jsCode": "const item = $input.first();\nconst binaryData = $('Read PDF File').first().binary.data;\n\n// Fix: Override fileName to use NFC-normalized name from Excel\n// Prevents encoding corruption (Thai chars appearing as Chinese)\nconst fileName = String(item.json.file_name || '').normalize('NFC');\nconst safeName = fileName.toLowerCase().endsWith('.pdf') ? fileName : fileName + '.pdf';\n\nreturn {\n json: { ...item.json },\n binary: {\n data: {\n ...binaryData,\n fileName: safeName,\n mimeType: 'application/pdf'\n }\n }\n};" }, "id": "fd2c0b17-ba48-488c-89ce-1d95e37e1f75", "name": "Restore Binary", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [5536, -26336], + "position": [ + 5536, + -26336 + ], "notes": "Re-attach PDF binary จาก Read PDF File เพื่อส่ง Upload (หลัง AI ตรวจแล้ว)" }, { @@ -544,7 +616,10 @@ "name": "Extract PDF Text", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [4848, -26320], + "position": [ + 4848, + -26320 + ], "onError": "continueErrorOutput" }, { @@ -557,7 +632,10 @@ "name": "Fetch DB Context", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [4576, -26144], + "position": [ + 4576, + -26144 + ], "alwaysOutputData": true, "credentials": { "mySql": { @@ -569,13 +647,16 @@ }, { "parameters": { - "jsCode": "const items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nreturn items.map(itemWrapper => {\n const item = itemWrapper.json;\n const ai = item.ai_result || {};\n\n return {\n json: {\n ...item,\n enqueue_payload: {\n document_number: String(item.document_number || ''),\n subject: String(ai.subject || item.subject || ''),\n original_subject: String(item.subject || ''),\n category: ai.suggested_category || 'Correspondence',\n body: String(ai.body || ai.summary || ''),\n ai_summary: ai.summary || ai.body || '',\n project_id: Number(ai.project_id || config.PROJECT_ID),\n sender_org_id: ai.sender_id || null,\n receiver_org_id: ai.receiver_id || null,\n issued_date: ai.issued_date || item.issued_date || '',\n received_date: ai.received_date || item.received_date || '',\n remarks: item.remarks ? item.remarks + (item.staging_remarks ? ' [System: ' + item.staging_remarks + ']' : '') : (item.staging_remarks || ''),\n extracted_tags: ai.tags || [],\n details: { tags: ai.tags || [] },\n temp_attachment_id: $('Upload to Backend').first()?.json?.id || item.temp_attachment_id || null,\n is_valid: ai.is_valid !== false,\n confidence: ai.confidence || 0.0,\n ai_issues: ai.key_points || []\n }\n }\n };\n});" + "jsCode": "const uploadResults = $input.all();\nconst config = $('Set Configuration').first().json.config;\n// Original data is in Restore Binary (before Upload replaced json with HTTP response)\nconst originalItems = $('Restore Binary').all();\n\nreturn uploadResults.map((uploadWrapper, i) => {\n const uploadResult = uploadWrapper.json;\n const item = originalItems[i]?.json || {};\n const ai = item.ai_result || {};\n\n return {\n json: {\n ...item,\n enqueue_payload: {\n document_number: String(item.document_number || ''),\n subject: String(ai.subject || item.subject || ''),\n original_subject: String(item.subject || ''),\n category: ai.suggested_category || 'Correspondence',\n type_code: ai.type_code || 'LETTER',\n type_id: ai.type_id || null,\n body: String(ai.body || ai.summary || ''),\n ai_summary: ai.summary || ai.body || '',\n project_id: Number(ai.project_id || config.PROJECT_ID),\n sender_org_id: ai.sender_id || null,\n receiver_org_id: ai.receiver_id || null,\n issued_date: ai.issued_date || item.issued_date || '',\n received_date: ai.received_date || item.received_date || '',\n remarks: item.remarks ? item.remarks + (item.staging_remarks ? ' [System: ' + item.staging_remarks + ']' : '') : (item.staging_remarks || ''),\n extracted_tags: ai.tags || [],\n details: { tags: ai.tags || [] },\n temp_attachment_id: uploadResult.id || uploadResult.tempId || null,\n is_valid: ai.is_valid !== false,\n confidence: ai.confidence || 0.0,\n ai_issues: ai.key_points || [],\n ai_empty: ai.ai_empty || false\n }\n }\n };\n});" }, "id": "51613de7-db28-41bd-bdb3-f7ba12b9186e", "name": "Build Enqueue Payload", "typeVersion": 2, "type": "n8n-nodes-base.code", - "position": [5888, -26336], + "position": [ + 5888, + -26336 + ], "notes": "สร้าง payload สำหรับ Enqueue Migration" }, { @@ -602,7 +683,10 @@ "name": "Enqueue to Review Queue", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [5600, -26144], + "position": [ + 5600, + -26144 + ], "notes": "ส่งข้อมูลเข้า Staging Queue" }, { @@ -615,7 +699,10 @@ "name": "Save Checkpoint", "type": "n8n-nodes-base.mySql", "typeVersion": 2.4, - "position": [5792, -26144], + "position": [ + 5792, + -26144 + ], "credentials": { "mySql": { "id": "CHHfbKhMacNo03V4", @@ -632,7 +719,10 @@ "color": 4 }, "type": "n8n-nodes-base.stickyNote", - "position": [3936, -26352], + "position": [ + 3936, + -26352 + ], "typeVersion": 1, "id": "9eb3cfbd-2fe4-4237-a7ee-9387aa909efb", "name": "Sticky Note" @@ -648,7 +738,10 @@ "name": "Check Backend Health", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [4288, -26304], + "position": [ + 4288, + -26304 + ], "onError": "continueErrorOutput", "notes": "ตรวจสอบ Backend พร้อมใช้งาน" }, @@ -660,7 +753,10 @@ "color": 5 }, "type": "n8n-nodes-base.stickyNote", - "position": [3936, -25968], + "position": [ + 3936, + -25968 + ], "typeVersion": 1, "id": "f2daf117-9cf2-477e-9c69-56a92503c783", "name": "Sticky Note1" @@ -673,7 +769,10 @@ "color": 6 }, "type": "n8n-nodes-base.stickyNote", - "position": [4496, -26352], + "position": [ + 4496, + -26352 + ], "typeVersion": 1, "id": "0b628ba8-de1a-40c6-8722-fc0e2411d666", "name": "Sticky Note2" @@ -686,7 +785,10 @@ "color": 3 }, "type": "n8n-nodes-base.stickyNote", - "position": [5264, -25936], + "position": [ + 5264, + -25936 + ], "typeVersion": 1, "id": "fee4de9d-be2f-4e92-aea6-2a11ee89af8c", "name": "Sticky Note3" @@ -699,7 +801,10 @@ "color": 2 }, "type": "n8n-nodes-base.stickyNote", - "position": [5264, -26352], + "position": [ + 5264, + -26352 + ], "typeVersion": 1, "id": "23619681-d936-4608-92ba-bfb10c062789", "name": "Sticky Note4" @@ -1067,4 +1172,4 @@ }, "id": "8Z6xwWVQ3TUflnSY", "tags": [] -} +} \ No newline at end of file diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml index f7bbdcf..b7677cc 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/docker-compose-lcbp3-db.yml @@ -26,6 +26,9 @@ services: reservations: cpus: "0.5" memory: 1G + command: >- + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci environment: MYSQL_ROOT_PASSWORD: "Center#2025" MYSQL_DATABASE: "lcbp3" diff --git a/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md b/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md index fb665cf..5b3e0a0 100644 --- a/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md +++ b/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md @@ -14,7 +14,7 @@ This document outlines the step-by-step implementation plan to integrate UUIDv7 --- -## Phase 1: Database Foundation (✅ COMPLETED) +## Phase 1: Database Foundation (✅ COMPLETED — 2026-03-16) - [x] Create ADR-019 document - [x] Add `uuid UUID` columns (MariaDB native type) to 14 public-facing tables in schema SQL @@ -50,7 +50,7 @@ This document outlines the step-by-step implementation plan to integrate UUIDv7 --- -## Phase 2: Backend — TypeORM Base Entity & UUID Utilities +## Phase 2: Backend — TypeORM Base Entity & UUID Utilities (✅ COMPLETED) > **Simplified by MariaDB Native UUID Type:** MariaDB 10.7+ stores UUID as `BINARY(16)` internally but auto-converts to/from string format. No manual binary conversion utilities or TypeORM transformers needed. @@ -94,7 +94,7 @@ npm install -D @types/uuid --- -## Phase 3: Backend — Update Existing Entities +## Phase 3: Backend — Update Existing Entities (✅ COMPLETED) For each of the 14 public-facing entities, extend or mix in the UUID column: @@ -135,7 +135,7 @@ export class Correspondence extends UuidBaseEntity { --- -## Phase 4: Backend — API Layer Changes +## Phase 4: Backend — API Layer Changes (✅ COMPLETED) ### 4.1 UUID Pipe (Parameter Validation) @@ -211,25 +211,62 @@ async findByUuidOrId(identifier: string): Promise { --- -## Phase 5: Frontend — UUID Integration +## Phase 5: Frontend — UUID Integration (🔄 PARTIAL — see 5.4) -### 5.1 API Client Updates +### 5.1 API Client Updates (✅ COMPLETED) -- Update all API calls to use UUID in URL paths instead of INT id -- Update TanStack Query cache keys to use UUID -- Update Zustand stores to key by UUID +- [x] Update all API calls to use UUID in URL paths instead of INT id +- [x] Update TanStack Query cache keys to use UUID +- [x] Service functions renamed `getById` → `getByUuid` (12 services) +- [x] Hooks updated with UUID-based cache keys and mutation params -### 5.2 Route Parameters +### 5.2 Route Parameters (✅ COMPLETED) ```typescript // BEFORE: /correspondences/[id] // AFTER: /correspondences/[uuid] ``` -### 5.3 Form Handling +- [x] `/correspondences/[uuid]`, `/circulation/[uuid]`, `/drawings/[uuid]` migrated +- [ ] `/rfas/[id]` and `/transmittals/[id]` — NOT migrated (separate feature scope) -- Hidden `uuid` field in forms for edit operations -- No changes needed for create operations (UUID generated server-side) +### 5.3 Form Handling (✅ PARTIAL) + +- [x] Drawing search: `projectUuid` sent to backend (resolved in controller) +- [x] Drawing detail page: UUID-based service calls replace mock API +- [ ] Correspondence form: still sends `parseInt(projectId)` — see 5.4 +- [ ] User dialog: still sends `parseInt(orgId)` — see 5.4 + +### 5.4 Remaining: FK Reference UUID Migration (❌ PENDING) + +> **Root Cause:** Backend Create/Update DTOs still accept **integer FK IDs** (e.g., `projectId`, `fromOrganizationId`), but the API **no longer returns integer IDs** in responses (stripped by `@Exclude()` + `instanceToPlain()` in `TransformInterceptor`). Frontend forms that use `parseInt()` on Select values break because the values are either UUID strings or `undefined`. + +#### Pattern: Drawing Search (✅ FIXED — reference implementation) + +- Backend DTO accepts `projectUuid: string` instead of `projectId: number` +- Controller resolves: `projectService.findOneByUuid(dto.projectUuid)` → `dto.projectId = project.id` +- Frontend sends UUID string directly (no `parseInt`) + +#### Remaining Issues + +| File | Field | Entity | Issue | +|------|-------|--------|-------| +| `correspondences/form.tsx:212` | `projectId` | Project | `parseInt(p.id)` where `p.id` = UUID string (garbled number) | +| `correspondences/form.tsx:326` | `fromOrganizationId` | Organization | `parseInt(String(org.id))` where `org.id` = undefined (NaN) | +| `correspondences/form.tsx:349` | `toOrganizationId` | Organization | Same as above | +| `admin/users/page.tsx:47` | `primaryOrganizationId` (filter) | Organization | `parseInt(selectedOrgId)` where value = UUID string | +| `admin/user-dialog.tsx:226` | `primaryOrganizationId` | Organization | `parseInt(val)` where `org.id` = undefined → `"0"` fallback | +| `numbering/template-tester.tsx:71-74` | `originatorOrganizationId`, `recipientOrganizationId` | Organization | `parseInt` on org UUID | +| `rfas/page.tsx:17` | `projectId` (URL param) | Project | `parseInt(searchParams.get('projectId'))` — UUID if from URL | + +#### Fix Strategy (same pattern as Drawing Search fix) + +For each affected backend DTO: +1. Add `projectUuid?: string` / `organizationUuid?: string` field +2. Controller resolves UUID → INT id via respective service's `findOneByUuid()` +3. Frontend sends UUID string directly (remove `parseInt`) + +**Estimated Effort:** M (2-3 days) — requires backend DTO changes for Correspondence, User, Numbering modules --- @@ -259,20 +296,21 @@ async findByUuidOrId(identifier: string): Promise { ## Implementation Order (Priority) -| Order | Task | Effort | Depends On | -|-------|------|--------|------------| -| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | Phase 1 | -| 2 | Install `uuid` package | XS | — | -| 3 | Update 14 entity files with uuid column | M | Task 1 | -| 4 | Create ParseUuidPipe | S | — | -| 5 | Update controllers to use UUID params | L | Tasks 3, 4 | -| 6 | Update services with findByUuid methods | L | Task 3 | -| 7 | Update DTOs to expose uuid, hide id | M | Task 3 | -| 8 | Update frontend API calls | L | Tasks 5, 6, 7 | -| 9 | Update frontend routes | M | Task 8 | -| 10 | Write unit + integration tests | M | Tasks 1-7 | -ำ -**Estimated Total Effort:** ~3-5 days for backend, ~2-3 days for frontend +| Order | Task | Effort | Status | +|-------|------|--------|--------| +| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | ✅ Done | +| 2 | Install `uuid` package | XS | ✅ Done | +| 3 | Update 14 entity files with uuid column | M | ✅ Done | +| 4 | Create ParseUuidPipe | S | ✅ Done | +| 5 | Update controllers to use UUID params | L | ✅ Done | +| 6 | Update services with findByUuid methods | L | ✅ Done | +| 7 | Update DTOs to expose uuid, hide id | M | ✅ Done | +| 8 | Update frontend API calls & routes | L | ✅ Done | +| 9 | Drawing search: projectUuid migration | S | ✅ Done (2026-03-18) | +| 10 | FK reference UUID migration (Correspondence, User, Numbering) | M | ❌ Pending (see Phase 5.4) | +| 11 | Write unit + integration tests | M | ❌ Pending | + +**Estimated Remaining Effort:** ~2-3 days for FK migration + ~2 days for tests ---