260324:1349 Refactor RFA #01
CI / CD Pipeline / build (push) Failing after 1m52s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
admin
2026-03-24 13:49:30 +07:00
parent a3e3206b06
commit 4cd0952482
29 changed files with 1700 additions and 306 deletions
+5 -2
View File
@@ -17,12 +17,15 @@ jobs:
uses: actions/checkout@v4
- name: 📦 Install pnpm
run: npm install -g pnpm@10.32.1
uses: pnpm/action-setup@v4
with:
version: 10.32.1
- name: 🟢 Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: 📦 Install deps
run: pnpm install --frozen-lockfile
@@ -45,7 +48,7 @@ jobs:
- name: 🧪 Run Tests & Coverage
run: |
cd backend && pnpm test --watchAll=false
cd backend && pnpm test
cd ../frontend && pnpm test run
# ============================================================
+1 -1
View File
@@ -1,3 +1,3 @@
{
"editor.fontSize": 18
"editor.fontSize": 20
}
+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
### CI/CD & Deployment Simplification (2026-03-24)
#### 🚀 **deploy.sh v2.0 — Rewrote deployment script**
- **Changed**: Replaced 9-step blue-green deployment with 3-step direct deploy
- **Step 1**: Build Docker images (backend + frontend) from source
- **Step 2**: `docker compose -f [compose_file] up -d --force-recreate`
- **Step 3**: Health check on `backend` container
- **Removed**: Blue/green directory switching, NGINX switching, `current` file tracking
- **Reason**: QNAP setup uses a single stack — simultaneous blue/green was not viable with shared container names
#### ⚙️ **ci-deploy.yml — CI pipeline improvements**
- **Added**: `pnpm/action-setup@v4` + `cache: 'pnpm'` for faster installs
- **Fixed**: `--watchAll=false` removed from backend test command (not a valid Jest flag)
- **Fixed**: `mkdir -p /share/np-dms/app/logs` before deploy to prevent `tee` error
- **Simplified**: Removed `tee` + `PIPESTATUS``set -e` handles errors
### Document Numbering System Fixes (2026-03-21)
#### 🔢 **Template Management Hardening**
@@ -8,6 +8,7 @@ import {
IsObject,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateRfaDto {
@@ -30,12 +31,14 @@ export class CreateRfaDto {
rfaTypeId!: number;
@ApiProperty({
description: 'ID ของสาขางาน (Discipline) ตาม Req 6B',
description:
'ID ของสาขางาน (Discipline) ตาม Req 6B — Required per spec §3.3.4',
example: 1,
})
@IsInt()
@IsOptional() // Optional ไว้ก่อนเผื่อบางโครงการไม่บังคับ
disciplineId?: number;
@Min(1)
@IsNotEmpty()
disciplineId!: number;
@ApiProperty({
description: 'หัวข้อเอกสาร',
@@ -1,4 +1,14 @@
// File: src/modules/rfa/rfa-workflow.service.ts
//
// NOTE (Phase 4.1 Refactor):
// This service was written as an alternative workflow integration layer using the
// Unified WorkflowEngineService (ADR-001). It is currently NOT called by RfaController.
// Active workflow logic lives in RfaService.submit() / RfaService.processAction().
//
// Reserved for Phase 3: When the Unified Workflow Engine is fully wired to RFA,
// RfaService workflow methods should be migrated here and RfaController updated
// to delegate to RfaWorkflowService.
//
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
+42 -9
View File
@@ -2,9 +2,13 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -20,6 +24,7 @@ import {
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { User } from '../user/entities/user.entity';
import { CreateRfaDto } from './dto/create-rfa.dto';
import { UpdateRfaDto } from './dto/update-rfa.dto';
import { SubmitRfaDto } from './dto/submit-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
import { RfaService } from './rfa.service';
@@ -31,6 +36,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@ApiTags('RFA (Request for Approval)')
@ApiBearerAuth()
@@ -39,7 +45,8 @@ import { ProjectService } from '../project/project.service';
export class RfaController {
constructor(
private readonly rfaService: RfaService,
private readonly projectService: ProjectService
private readonly projectService: ProjectService,
private readonly uuidResolver: UuidResolverService
) {}
@Post()
@@ -99,17 +106,14 @@ export class RfaController {
@ApiOperation({ summary: 'List all RFAs with pagination' })
@ApiResponse({ status: 200, description: 'List of RFAs' })
@RequirePermission('document.view')
async findAll(@Query() query: SearchRfaDto) {
async findAll(@Query() query: SearchRfaDto, @CurrentUser() user: User) {
// ADR-019: resolve projectId UUID→INT if provided
if (query.projectId) {
const pid = query.projectId;
const num = Number(pid);
if (typeof pid === 'string' && isNaN(num)) {
const project = await this.projectService.findOneByUuid(pid);
query.projectId = project.id;
}
query.projectId = await this.uuidResolver.resolveProjectId(
query.projectId
);
}
return this.rfaService.findAll(query);
return this.rfaService.findAll(query, user);
}
@Get(':uuid')
@@ -123,4 +127,33 @@ export class RfaController {
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.rfaService.findOneByUuid(uuid);
}
@Put(':uuid')
@ApiOperation({ summary: 'Update Draft RFA fields (EC-RFA-002: DFT only)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' })
@ApiBody({ type: UpdateRfaDto })
@ApiResponse({ status: 200, description: 'RFA updated successfully' })
@RequirePermission('rfa.create')
@Audit('rfa.update', 'rfa')
async update(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateRfaDto,
@CurrentUser() user: User
) {
return this.rfaService.update(uuid, updateDto, user);
}
@Delete(':uuid')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel Draft RFA (sets status to CC)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' })
@ApiResponse({ status: 200, description: 'RFA cancelled successfully' })
@RequirePermission('rfa.create')
@Audit('rfa.cancel', 'rfa')
async cancel(
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
return this.rfaService.cancel(uuid, user);
}
}
+2
View File
@@ -11,6 +11,7 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty
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';
import { Organization } from '../organization/entities/organization.entity';
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
@@ -60,6 +61,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
RoutingTemplate,
RoutingTemplateStep,
CorrespondenceRecipient,
Organization,
]),
DocumentNumberingModule,
UserModule,
+123 -5
View File
@@ -23,6 +23,7 @@ import { RoutingTemplateStep } from '../correspondence/entities/routing-template
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { Organization } from '../organization/entities/organization.entity';
import { User } from '../user/entities/user.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { RfaItem } from './entities/rfa-item.entity';
@@ -35,6 +36,7 @@ import { Rfa } from './entities/rfa.entity';
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { CreateRfaDto } from './dto/create-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
import { UpdateRfaDto } from './dto/update-rfa.dto';
// ------- Local type helpers (no-any ADR-019) -------
/** CorrespondenceRevision with the rfaRevision relation loaded at runtime */
@@ -94,6 +96,8 @@ export class RfaService {
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(RoutingTemplateStep)
private templateStepRepo: Repository<RoutingTemplateStep>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
private numberingService: DocumentNumberingService,
private userService: UserService,
@@ -232,13 +236,40 @@ export class RfaService {
throw new BadRequestException('User must belong to an organization');
}
// EC-RFA-001: Check for existing active RFA per Shop Drawing Revision
if (shopDrawingRevisionIds.length > 0) {
const conflictingItems = await this.rfaItemRepo
.createQueryBuilder('item')
.innerJoin('item.rfaRevision', 'rfaRev')
.innerJoin('rfaRev.statusCode', 'status')
.where('item.shopDrawingRevisionId IN (:...ids)', {
ids: shopDrawingRevisionIds,
})
.andWhere('status.statusCode NOT IN (:...codes)', {
codes: ['CC', 'OBS'],
})
.getMany();
if (conflictingItems.length > 0) {
throw new BadRequestException(
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA. ' +
'A Shop Drawing Revision can only be referenced by one active RFA at a time.'
);
}
}
// Fetch real Organization Code for document numbering
const userOrg = await this.orgRepo.findOne({
where: { id: userOrgId },
select: ['organizationCode'],
});
const orgCode = userOrg?.organizationCode ?? 'ORG';
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Org Service if needed
// [UPDATED] Generate Document Number with Discipline
const docNumber = await this.numberingService.generateNextNumber({
projectId: internalProjectId,
@@ -427,7 +458,7 @@ export class RfaService {
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
async findAll(query: SearchRfaDto) {
async findAll(query: SearchRfaDto, _user?: User) {
const {
page = 1,
limit = 20,
@@ -481,14 +512,22 @@ export class RfaService {
);
}
// RBAC: DFT documents are visible only to the originator org (spec §3.3.10)
if (_user?.primaryOrganizationId) {
queryBuilder.andWhere(
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
);
}
const [items, total] = await queryBuilder
.orderBy('corr.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
this.logger.log(
`[DEBUG] RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}`
this.logger.debug(
`RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}`
);
// Map `revisions` property back to the expected payload for the frontend
@@ -802,4 +841,83 @@ export class RfaService {
await queryRunner.release();
}
}
/**
* Update a Draft RFA's revision fields (subject, body, remarks, description, dueDate).
* EC-RFA-002: Only allowed when current revision is in DFT status.
*/
async update(uuid: string, dto: UpdateRfaDto, _user: User) {
const rfa = await this.findOneByUuidRaw(uuid);
const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found');
const currentRfaRev = currentCorrRev.rfaRevision;
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
throw new BadRequestException(
'Only DRAFT documents can be edited. Submit a new revision for non-draft documents.'
);
}
const updatedFields: Partial<CorrespondenceRevision> = {};
if (dto.subject !== undefined) updatedFields.subject = dto.subject;
if (dto.body !== undefined) updatedFields.body = dto.body;
if (dto.remarks !== undefined) updatedFields.remarks = dto.remarks;
if (dto.description !== undefined)
updatedFields.description = dto.description;
if (dto.dueDate !== undefined)
updatedFields.dueDate = new Date(dto.dueDate);
Object.assign(currentCorrRev, updatedFields);
await this.corrRevRepo.save(currentCorrRev);
if (dto.details !== undefined) {
currentRfaRev.details = dto.details;
await this.rfaRevisionRepo.save(currentRfaRev);
}
return this.findOneByUuid(uuid);
}
/**
* Cancel (soft-delete) a Draft RFA by setting its status to CC.
* EC-RFA-002: Only allowed when current revision is in DFT status.
*/
async cancel(uuid: string, user: User) {
const rfa = await this.findOneByUuidRaw(uuid);
const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found');
const currentRfaRev = currentCorrRev.rfaRevision;
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
throw new BadRequestException(
'Only DRAFT documents can be cancelled. Contact an Org Admin to cancel submitted documents.'
);
}
const statusCC = await this.rfaStatusRepo.findOne({
where: { statusCode: 'CC' },
});
if (!statusCC)
throw new InternalServerErrorException(
'Status CC (Cancelled) not found in Master Data'
);
currentRfaRev.rfaStatusCodeId = statusCC.id;
await this.rfaRevisionRepo.save(currentRfaRev);
this.logger.log(
`RFA ${rfa.correspondence?.correspondenceNumber} cancelled by user ${user.user_id}`
);
return { message: 'RFA cancelled successfully' };
}
}
@@ -0,0 +1,193 @@
'use client';
import { useParams, useRouter, notFound } from 'next/navigation';
import { useRFA, useUpdateRFA } from '@/hooks/use-rfa';
import { Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UpdateRfaDto } from '@/types/dto/rfa/rfa.dto';
import { useEffect } from 'react';
const editRfaSchema = z.object({
subject: z.string().min(5, 'Subject must be at least 5 characters'),
description: z.string().optional(),
body: z.string().optional(),
remarks: z.string().optional(),
dueDate: z.string().optional(),
});
type EditRfaFormValues = z.infer<typeof editRfaSchema>;
export default function RFAEditPage() {
const { uuid } = useParams();
const router = useRouter();
if (!uuid) notFound();
const { data: rfa, isLoading, isError } = useRFA(String(uuid));
const updateMutation = useUpdateRFA();
const currentRevision =
rfa?.revisions?.find((r) => r.isCurrent) ?? rfa?.revisions?.[0];
const form = useForm<EditRfaFormValues>({
resolver: zodResolver(editRfaSchema),
defaultValues: {
subject: '',
description: '',
body: '',
remarks: '',
dueDate: '',
},
});
useEffect(() => {
if (currentRevision) {
form.reset({
subject: currentRevision.subject ?? '',
description: currentRevision.description ?? '',
body: currentRevision.body ?? '',
remarks: currentRevision.remarks ?? '',
dueDate: currentRevision.dueDate
? currentRevision.dueDate.slice(0, 10)
: '',
});
}
}, [currentRevision, form]);
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (isError || !rfa) {
return <div className="text-center py-20 text-red-500">RFA not found.</div>;
}
if (currentRevision?.statusCode?.statusCode !== 'DFT') {
return (
<div className="text-center py-20 text-amber-600">
Only DRAFT RFAs can be edited.{' '}
<Button variant="link" onClick={() => router.back()}>
Go back
</Button>
</div>
);
}
const onSubmit = (values: EditRfaFormValues) => {
const dto: UpdateRfaDto = {
subject: values.subject,
description: values.description || undefined,
body: values.body || undefined,
remarks: values.remarks || undefined,
dueDate: values.dueDate || undefined,
};
updateMutation.mutate(
{ uuid: String(uuid), data: dto },
{
onSuccess: () => {
router.push(`/rfas/${String(uuid)}`);
},
}
);
};
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Edit RFA</h1>
<p className="text-muted-foreground mt-1">
{rfa.correspondence?.correspondenceNumber || 'Draft RFA'}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Revision Details</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="subject">Subject *</Label>
<Input
id="subject"
{...form.register('subject')}
placeholder="Subject of this RFA"
/>
{form.formState.errors.subject && (
<p className="text-sm text-destructive">
{form.formState.errors.subject.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...form.register('description')}
placeholder="Detailed description..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="body">Body</Label>
<Textarea
id="body"
{...form.register('body')}
placeholder="Main body content..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input
id="remarks"
{...form.register('remarks')}
placeholder="Additional remarks..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label>
<Input
id="dueDate"
type="date"
{...form.register('dueDate')}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+80 -17
View File
@@ -2,38 +2,101 @@
import { RFAList } from '@/components/rfas/list';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import Link from 'next/link';
import { Plus, Loader2 } from 'lucide-react';
import { Plus, Loader2, Search } from 'lucide-react';
import { useRFAs } from '@/hooks/use-rfa';
import { useSearchParams } from 'next/navigation';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { Pagination } from '@/components/common/pagination';
import { Suspense } from 'react';
import { Suspense, useCallback } from 'react';
const RFA_STATUS_OPTIONS = [
{ value: 'ALL', label: 'All Statuses' },
{ value: 'DFT', label: 'Draft' },
{ value: 'FAP', label: 'For Approve' },
{ value: 'FRE', label: 'For Review' },
{ value: 'FCO', label: 'For Comment Only' },
{ value: 'CC', label: 'Cancelled' },
];
function RFAsContent() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const page = Number(searchParams.get('page') || '1');
const statusId = searchParams.get('status') ? Number(searchParams.get('status')!) : undefined;
const statusCode = searchParams.get('statusCode') || undefined;
const search = searchParams.get('search') || undefined;
const projectId = searchParams.get('projectId') || undefined; // ADR-019: Pass UUID string directly
const projectId = searchParams.get('projectId') || undefined;
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId, revisionStatus });
const { data, isLoading, isError } = useRFAs({ page, statusCode, search, projectId, revisionStatus });
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value && value !== 'ALL') {
params.set(key, value);
} else {
params.delete(key);
}
params.set('page', '1');
router.push(`${pathname}?${params.toString()}`);
},
[searchParams, router, pathname]
);
return (
<>
<div className="mb-4 flex gap-2">
{/* Simple Filter Buttons using standard Buttons for now, or use a Select if imported */}
<div className="mb-4 flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search RFA number or subject..."
defaultValue={search ?? ''}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateParam('search', (e.target as HTMLInputElement).value);
}
}}
onBlur={(e) => updateParam('search', e.target.value)}
/>
</div>
<Select
value={statusCode ?? 'ALL'}
onValueChange={(val) => updateParam('statusCode', val)}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{RFA_STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1 bg-muted p-1 rounded-md">
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link
key={status}
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
{(['ALL', 'CURRENT', 'OLD'] as const).map((s) => (
<Button
key={s}
variant={revisionStatus === s ? 'default' : 'ghost'}
size="sm"
className="text-xs px-3"
onClick={() => updateParam('revisionStatus', s)}
>
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
{s === 'CURRENT' ? 'Latest' : s === 'OLD' ? 'Previous' : 'All Revisions'}
</Button>
))}
</div>
</div>
+71 -8
View File
@@ -5,21 +5,23 @@ import { StatusBadge } from '@/components/common/status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
import { ArrowLeft, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { ArrowLeft, CheckCircle, XCircle, Loader2, Send, Edit } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useProcessRFA } from '@/hooks/use-rfa';
import { useProcessRFA, useSubmitRFA } from '@/hooks/use-rfa';
interface RFADetailProps {
data: RFA;
}
export function RFADetail({ data }: RFADetailProps) {
const [actionState, setActionState] = useState<'approve' | 'reject' | null>(null);
const [actionState, setActionState] = useState<'approve' | 'reject' | 'submit' | null>(null);
const [comments, setComments] = useState('');
const [templateId, setTemplateId] = useState<number>(1);
const processMutation = useProcessRFA();
const submitMutation = useSubmitRFA();
const currentRevision = data.revisions.find((revision) => revision.isCurrent) ?? data.revisions[0];
const currentItems = currentRevision?.items ?? [];
const currentStatus = currentRevision?.statusCode?.statusName || currentRevision?.statusCode?.statusCode || 'Unknown';
@@ -54,7 +56,7 @@ export function RFADetail({ data }: RFADetailProps) {
item.shopDrawingRevision?.title || item.asBuiltDrawingRevision?.title || '-';
const handleProcess = () => {
if (!actionState) return;
if (!actionState || actionState === 'submit') return;
const apiAction = actionState === 'approve' ? 'APPROVE' : 'REJECT';
@@ -70,7 +72,17 @@ export function RFADetail({ data }: RFADetailProps) {
onSuccess: () => {
setActionState(null);
setComments('');
// Query invalidation handled in hook
},
}
);
};
const handleSubmit = () => {
submitMutation.mutate(
{ uuid: data.uuid, templateId },
{
onSuccess: () => {
setActionState(null);
},
}
);
@@ -94,7 +106,23 @@ export function RFADetail({ data }: RFADetailProps) {
</div>
</div>
{currentStatus === 'PENDING' && (
<div className="flex gap-2">
{currentRevision?.statusCode?.statusCode === 'DFT' && (
<>
<Link href={`/rfas/${data.uuid}/edit`}>
<Button variant="outline">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button onClick={() => setActionState('submit')}>
<Send className="mr-2 h-4 w-4" />
Submit RFA
</Button>
</>
)}
</div>
{['FAP', 'FRE'].includes(currentRevision?.statusCode?.statusCode ?? '') && (
<div className="flex gap-2">
<Button
variant="outline"
@@ -112,8 +140,38 @@ export function RFADetail({ data }: RFADetailProps) {
)}
</div>
{/* Submit RFA Dialog */}
{actionState === 'submit' && (
<Card className="border-primary">
<CardHeader>
<CardTitle className="text-lg">Submit RFA to Workflow</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="templateId">Routing Template ID</Label>
<input
id="templateId"
type="number"
min={1}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={templateId}
onChange={(e) => setTemplateId(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">Enter the routing template ID for this submission.</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
{submitMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm Submit
</Button>
</div>
</CardContent>
</Card>
)}
{/* Action Input Area */}
{actionState && (
{actionState && actionState !== 'submit' && (
<Card className="border-primary">
<CardHeader>
<CardTitle className="text-lg">
@@ -216,7 +274,12 @@ export function RFADetail({ data }: RFADetailProps) {
<div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
<p className="font-medium mt-1">{data.discipline?.name || data.discipline?.code || '-'}</p>
<p className="font-medium mt-1">
{data.correspondence?.discipline?.codeNameEn ||
data.correspondence?.discipline?.codeNameTh ||
data.correspondence?.discipline?.disciplineCode ||
'-'}
</p>
</div>
</CardContent>
</Card>
+1 -1
View File
@@ -24,7 +24,7 @@ import { correspondenceService } from '@/lib/services/correspondence.service';
const rfaSchema = z.object({
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
contractId: z.string().min(1, 'Contract is required'),
disciplineId: z.number().min(1, 'Discipline is required'),
disciplineId: z.number({ required_error: 'Discipline is required' }).min(1, 'Discipline is required'),
rfaTypeId: z.number().min(1, 'Type is required'),
subject: z.string().min(5, 'Subject must be at least 5 characters'),
description: z.string().optional(),
+4 -3
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Eye, Edit, FileText } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { toast } from 'sonner';
interface RFAListProps {
data: RFA[];
@@ -37,8 +38,8 @@ export function RFAList({ data }: RFAListProps) {
},
},
{
accessorKey: 'contract_name', // AccessorKey can be anything if we provide cell
header: 'Contract',
accessorKey: 'project_name',
header: 'Project',
cell: ({ row }) => {
return <span>{row.original.correspondence?.project?.projectName || '-'}</span>;
},
@@ -85,7 +86,7 @@ export function RFAList({ data }: RFAListProps) {
// But rfa.service.ts in use-rfa.ts uses 'sonner', so 'sonner' is likely available.
// I will try to use toast from 'sonner' if I import it, or just window.alert for safety.
// User said "หน้าต่างแจ้งเตือน" -> Alert window.
alert('ไม่พบไฟล์แนบ (No file attached)');
toast.error('ไม่พบไฟล์แนบ (No file attached)');
}
};
+19
View File
@@ -34,6 +34,25 @@ export function useRFA(uuid: string) {
// --- Mutations ---
export function useSubmitRFA() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ uuid, templateId }: { uuid: string; templateId: number }) =>
rfaService.submit(uuid, templateId),
onSuccess: (_, { uuid }) => {
toast.success('RFA submitted successfully');
queryClient.invalidateQueries({ queryKey: rfaKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: rfaKeys.lists() });
},
onError: (error: unknown) => {
toast.error('Failed to submit RFA', {
description: getApiErrorMessage(error, 'Something went wrong'),
});
},
});
}
export function useCreateRFA() {
const queryClient = useQueryClient();
+11 -2
View File
@@ -37,6 +37,15 @@ export const rfaService = {
return response.data;
},
/**
* Submit a Draft RFA to workflow
*/
submit: async (uuid: string, templateId: number) => {
// POST /rfas/:uuid/submit (ADR-019)
const response = await apiClient.post(`/rfas/${uuid}/submit`, { templateId });
return response.data;
},
/**
* RFA ( Draft)
*/
@@ -50,8 +59,8 @@ export const rfaService = {
* Workflow ( / / )
*/
processWorkflow: async (uuid: string, actionData: WorkflowActionDto) => {
// POST /rfas/:uuid/workflow (ADR-019)
const response = await apiClient.post(`/rfas/${uuid}/workflow`, actionData);
// POST /rfas/:uuid/action (ADR-019)
const response = await apiClient.post(`/rfas/${uuid}/action`, actionData);
return response.data;
},
+3
View File
@@ -56,6 +56,9 @@ export interface SearchRfaDto {
/** กรองตามสถานะ (เช่น Draft, For Approve) */
statusId?: number;
/** กรองตามสถานะ code โดยตรง (เช่น 'DFT', 'FAP', 'FRE') */
statusCode?: string;
/** ค้นหาจาก เลขที่เอกสาร หรือ หัวข้อเรื่อง */
search?: string;
+11
View File
@@ -36,11 +36,17 @@ export interface RFA {
revisions: {
id: number;
revisionNumber: number;
revisionLabel?: string;
subject: string;
description?: string;
body?: string;
remarks?: string;
dueDate?: string;
isCurrent: boolean;
createdAt?: string;
statusCode?: { statusCode: string; statusName: string };
approveCode?: { approveCode: string; approveCodeName: string };
approvedDate?: string;
items?: RFAItem[];
}[];
discipline?: {
@@ -61,6 +67,11 @@ export interface RFA {
projectName: string;
projectCode: string;
};
discipline?: {
disciplineCode: string;
codeNameEn?: string;
codeNameTh?: string;
};
};
// Deprecated/Mapped fields
+3 -2
View File
@@ -27,7 +27,7 @@
"editor.lineHeight": 1.6,
"editor.rulers": [80, 120],
"editor.minimap.enabled": true,
"editor.minimap.sectionHeaderFontSize": 14,
"editor.minimap.sectionHeaderFontSize": 12,
"editor.renderWhitespace": "selection",
// "editor.renderWhitespace": "boundary",
"editor.renderControlCharacters": true,
@@ -47,7 +47,7 @@
// DEFAULT FORMATTER
// ========================================
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
@@ -675,6 +675,7 @@
"workbench.colorTheme": "Default Dark Modern",
"workbench.preferredDarkColorTheme": "Default Dark Modern",
"scm.alwaysShowActions": false,
"workbench.settings.alwaysShowAdvancedSettings": true,
},
// ========================================
// LAUNCH CONFIGURATIONS
@@ -3,22 +3,122 @@
---
title: "Functional Requirements: Project Management"
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## Details (รายละเอียด)
## 3.1.1. วัตถุประสงค์
- 3.1.1. โครงการ (Projects): ระบบต้องสามารถจัดการเอกสารภายในหลายโครงการได้ (ปัจจุบันมี 4 โครงการ และจะเพิ่มขึ้นในอนาคต)
- 3.1.2. สัญญา (Contracts): ระบบต้องสามารถจัดการเอกสารภายในแต่ละสัญญาได้ ในแต่ละโครงการ มีได้หลายสัญญา หรืออย่างน้อย 1 สัญญา
- 3.1.3. องค์กร (Organizations):
- มีหลายองค์กรในโครงการ องค์กรณ์ที่เป็น Owner, Designer และ Consultant สามารถอยู่ในหลายโครงการและหลายสัญญาได้
- Contractor จะถือ 1 สัญญา และอยู่ใน 1 โครงการเท่านั้น
จัดการโครงสร้างหลักของระบบ ได้แก่ **Project → Contract → Organization → Discipline** — ทุกเอกสารในระบบผูกอยู่กับ Project และ/หรือ Contract เสมอ
---
## 3.1.2. โครงสร้างข้อมูล (Database Tables)
| Table | บทบาท |
|---|---|
| `projects` | ข้อมูล Master โครงการ: code, name, is_active |
| `contracts` | สัญญา ผูกกับ Project (N:1) |
| `organizations` | ข้อมูล Master องค์กร: code, name, role |
| `organization_roles` | Master: ประเภทองค์กร (OWNER/DESIGNER/ฯลฯ) |
| `project_organizations` | M:N: Project ↔ Organization |
| `contract_organizations` | M:N: Contract ↔ Organization + role_in_contract |
| `disciplines` | สาขางาน ผูกกับ Contract (N:1) |
### Hierarchy
```
Project (1)
└── Contract (N)
├── Organization (M:N via contract_organizations)
└── Discipline (N)
Project ↔ Organization (M:N via project_organizations)
```
---
## 3.1.3. Projects
| Field | Type | หมายเหตุ |
|---|---|---|
| `project_code` | VARCHAR(50) UNIQUE | รหัสโครงการ |
| `project_name` | VARCHAR(255) | ชื่อโครงการ |
| `is_active` | TINYINT(1) | 1 = Active |
- ปัจจุบันมี **4 โครงการ** — รองรับการเพิ่มในอนาคต
- จัดการโดย Superadmin เท่านั้น
---
## 3.1.4. Contracts
| Field | Type | หมายเหตุ |
|---|---|---|
| `project_id` | INT FK | ผูกกับ Project |
| `contract_code` | VARCHAR(50) UNIQUE | รหัสสัญญา |
| `contract_name` | VARCHAR(255) | ชื่อสัญญา |
| `start_date` / `end_date` | DATE | ระยะเวลาสัญญา |
| `is_active` | BOOLEAN | สถานะ |
- 1 Project มีได้หลาย Contract (≥ 1)
- Document Number ผูกกับ Contract (ไม่ใช่ Project)
---
## 3.1.5. Organizations และ Organization Roles
### Organization Roles (organization_roles)
| role_name | ความหมาย | สามารถอยู่ใน |
|---|---|---|
| `OWNER` | เจ้าของโครงการ | หลาย Project / หลาย Contract |
| `DESIGNER` | ผู้ออกแบบ | หลาย Project / หลาย Contract |
| `CONSULTANT` | ที่ปรึกษา | หลาย Project / หลาย Contract |
| `CONTRACTOR` | ผู้รับเหมา | **1 Contract / 1 Project เท่านั้น** |
| `THIRD PARTY` | บุคคลที่สาม | หลาย Project / หลาย Contract |
### project_organizations (M:N)
- ผูก Organization เข้า Project — ไม่มี role_in_contract ระดับ Project
### contract_organizations (M:N)
- ผูก Organization เข้า Contract พร้อม `role_in_contract` (Owner/Designer/Consultant/Contractor)
- 1 Organization สามารถมีหลาย role ใน Contract เดียวกันได้
---
## 3.1.6. Disciplines (สาขางาน)
Discipline ผูกกับ **Contract** (ไม่ใช่ Project):
| Field | หมายเหตุ |
|---|---|
| `contract_id` | FK → contracts |
| `discipline_code` | VARCHAR(10) เช่น GEN, STR, MEP |
| `code_name_th` / `code_name_en` | ชื่อภาษาไทย/อังกฤษ |
| `is_active` | สถานะ |
- UNIQUE KEY `(contract_id, discipline_code)` — code ซ้ำได้ระหว่าง Contract
- ใช้กรอง RFA Type และ Correspondence ต่อ Contract
---
## 3.1.7. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | Scope |
|---|---|---|
| สร้าง / แก้ไข Project | Superadmin | Global |
| สร้าง / แก้ไข Contract | Superadmin, Org Admin | Project |
| สร้าง / แก้ไข Organization | Superadmin | Global |
| เพิ่ม Organization เข้า Project/Contract | Superadmin | Global |
| จัดการ Disciplines | Superadmin, Org Admin | Contract |
| ดู Project / Contract | ทุกคนที่มีสิทธิ์ใน Project | Project |
@@ -3,37 +3,168 @@
---
title: 'Functional Requirements: Correspondence Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-06-unified-workflow.md
- specs/01-requirements/01-03-modules/01-03-08-circulation-sheet.md
- specs/01-requirements/01-06-edge-cases-and-rules.md (EC-CORR-001 ถึง EC-CORR-003)
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.2.1. วัตถุประสงค์:
## 3.2.1. วัตถุประสงค์
- เอกสารโต้ตอบ (Correspondences) ระหว่างองค์กรณ์-องค์กรณ์ ภายใน โครงการ (Projects) และระหว่าง องค์กรณ์-องค์กรณ์ ภายนอก โครงการ (Projects), รองรับ To (ผู้รับหลัก) และ CC (ผู้รับสำเนา) หลายองค์กรณ์
เอกสารโต้ตอบ (Correspondence) คือ **โครงสร้างข้อมูลหลัก (Parent)** ของเอกสารทุกประเภทในระบบ — ทั้ง RFA, RFI, Transmittal, Letter ฯลฯ เป็นการสื่อสารระหว่างองค์กร-องค์กร ภายนโครงการ (Project) รองรับผู้รับหลัก (TO) และผู้รับสำเนา (CC) ได้หลายองค์กร
## 3.2.2. ประเภทเอกสาร:
---
- ระบบต้องรองรับเอกสารรูปแบบ ไฟล์ PDF, ZIP
- เอกสารโต้ตอบ (Correspondence) สามารถมีได้หลายประเภท (Types) เช่น จดหมาย (Letter), อีเมล์ (Email), Request for Information (RFI), รวมถึง เอกสารขออนุมัติ (RFA) แต่ละ revision และสามารถเพิ่มประเภทใหม่ได้ในภายหลัง
## 3.2.2. โครงสร้างข้อมูล (Database Tables)
## 3.2.3. การสร้างเอกสาร (Correspondence):
| Table | บทบาท |
|---|---|
| `correspondences` | ข้อมูล Master: เลขเอกสาร, type, project, originator — ไม่เปลี่ยนตาม revision |
| `correspondence_revisions` | ประวัติแต่ละ Revision: subject, body, status, due_date |
| `correspondence_recipients` | ผู้รับ TO/CC (M:N กับ organizations) |
| `correspondence_references` | การอ้างอิงข้ามเอกสาร (M:N) |
| `correspondence_tags` | Tags ที่ติดกับเอกสาร (M:N) |
| `correspondence_attachments` | ไฟล์แนบ (M:N กับ attachments) |
| `correspondence_types` | Master: ประเภทเอกสาร (Global) |
| `correspondence_status` | Master: สถานะเอกสาร (Global) |
- ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น
- เมื่อเอกสารเปลี่ยนสถานะเป็น "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล
**ข้อสำคัญ:** `correspondence_number` ต้อง UNIQUE ภายใน Project เดียวกัน (`uq_corr_no_per_project`)
## 3.2.4. การอ้างอิงและจัดกลุ่ม:
---
- เอกสารสามารถอ้างถึง (Reference) เอกสารฉบับก่อนหน้าได้หลายฉบับ
- สามารถกำหนด Tag ได้หลาย Tag เพื่อจัดกลุ่มและใช้ในการค้นหาขั้นสูง
## 3.2.3. ประเภทเอกสาร (correspondence_types)
## 3.2.5. Workflow (Unified Workflow):
ประเภทเป็น Global Master — จัดการโดย Superadmin เท่านั้น:
- ระบบต้องรองรับ Workflow ที่เป็นแบบ Unified Workflow
| type_code | ชื่อ | มี Extension Table |
|---|---|---|
| `RFA` | Request for Approval | ✅ `rfas` + `rfa_revisions` |
| `RFI` | Request for Information | ❌ (ใช้ `details` JSON) |
| `TRANSMITTAL` | Transmittal | ❌ (ใช้ `details` JSON) |
| `EMAIL` | Email | ❌ |
| `INSTRUCTION` | Instruction | ❌ |
| `LETTER` | Letter | ❌ |
| `MEMO` | Memorandum | ❌ |
| `MOM` | Minutes of Meeting | ❌ |
| `NOTICE` | Notice | ❌ |
| `OTHER` | Other | ❌ |
ไฟล์แนบรองรับ **PDF, ZIP**
---
## 3.2.4. Fields ที่ต้องกรอกเมื่อสร้าง Correspondence
| Field | Required | หมายเหตุ |
|---|---|---|
| Project | ✅ | UUID |
| Correspondence Type | ✅ | INT id (Global) |
| Subject | ✅ | ขั้นต่ำ 5 ตัวอักษร — เก็บใน `correspondence_revisions` |
| To Organization | ✅ | UUID — `recipient_type = 'TO'` (≥ 1) |
| CC Organizations | ❌ | UUID[] — `recipient_type = 'CC'` |
| Discipline | ❌ | INT — กรองตาม Contract |
| Body | ❌ | เนื้อหา — เก็บใน `correspondence_revisions` |
| Description | ❌ | คำอธิบาย Revision |
| Remarks | ❌ | หมายเหตุ |
| Document Date | ❌ | วันที่ในเอกสาร |
| Due Date | ❌ | กำหนดส่งคืน |
| Tags | ❌ | UUID[] — จัดกลุ่มเพื่อค้นหาขั้นสูง |
| References | ❌ | UUID[] — อ้างอิงเอกสารฉบับก่อนหน้า |
| Attachments | ❌ | PDF/ZIP — ผ่าน ClamAV scan |
| Is Internal | ❌ | `is_internal_communication = 1` → ใช้ Circulation แทน |
### Document Number Preview
ระบบแสดง Preview เลขเอกสารแบบ Real-time ก่อน Submit เมื่อกรอกครบ: Project + Type + Discipline + To Organization โดยเรียก `POST /api/correspondences/preview-number`
---
## 3.2.5. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | หมายเหตุ |
|---|---|---|
| สร้าง Correspondence (Draft) | Document Control, Org Admin, Superadmin | ภายในองค์กรตัวเอง |
| Submit Correspondence | Document Control, Org Admin, Superadmin | เปลี่ยนสถานะ DRAFT → SUB* |
| แก้ไข/ถอนกลับ/ยกเลิก หลัง Submit | **Org Admin ขึ้นไปเท่านั้น** พร้อมระบุเหตุผล | — |
| ดู Correspondence ที่ Draft | เฉพาะคนในองค์กรเดียวกัน | องค์กรอื่นมองไม่เห็น |
| ดู Correspondence ที่ Submitted แล้ว | ทุกคนที่มีสิทธิ์ใน Project | รวม TO และ CC org |
| จัดการ Correspondence Types (Master) | Superadmin | Global |
---
## 3.2.6. Status Codes (correspondence_status)
สถานะแบ่งตามหมวดและ Actor:
| หมวด | status_code | ชื่อ |
|---|---|---|
| **Draft** | `DRAFT` | Draft — มองเห็นเฉพาะ Originator Org |
| **Submitted** | `SUBOWN` | Submitted to Owner |
| | `SUBDSN` | Submitted to Designer |
| | `SUBCSC` | Submitted to CSC |
| | `SUBCON` | Submitted to Contractor |
| | `SUBOTH` | Submitted to Others |
| **Reply** | `REPOWN` | Reply by Owner |
| | `REPDSN` | Reply by Designer |
| | `REPCSC` | Reply by CSC |
| | `REPCON` | Reply by Contractor |
| | `REPOTH` | Reply by Others |
| **Resubmitted** | `RSBOWN` | Resubmitted by Owner |
| | `RSBDSN` | Resubmitted by Designer |
| | `RSBCSC` | Resubmitted by CSC |
| | `RSBCON` | Resubmitted by Contractor |
| **Closed** | `CLBOWN` | Closed by Owner |
| | `CLBDSN` | Closed by Designer |
| | `CLBCSC` | Closed by CSC |
| | `CLBCON` | Closed by Contractor |
| **Canceled** | `CCBOWN` | Canceled by Owner |
| | `CCBDSN` | Canceled by Designer |
| | `CCBCSC` | Canceled by CSC |
| | `CCBCON` | Canceled by Contractor |
---
## 3.2.7. การอ้างอิงและ Tags
- **References** (`correspondence_references`): M:N — เอกสารหนึ่งอ้างถึงได้หลายฉบับ ทิศทางเดียว (src → tgt)
- **Tags** (`correspondence_tags` + `tags`): M:N — Tag ผูกกับ Project หรือ Global (project_id = NULL)
- ทั้งสองอย่างใช้ค้นหาขั้นสูงใน Elasticsearch Index
---
## 3.2.8. Revision Model
- 1 Correspondence Master → หลาย Revision (1:N)
- `is_current = TRUE` มีได้เพียง 1 แถวต่อ correspondence (UNIQUE constraint)
- `revision_label`: A, B, C, ... (หรือ 1.1, 1.2 แล้วแต่ type)
- `revision_number`: 0-based integer สำหรับ sorting
---
## 3.2.9. Workflow (Unified Workflow)
Correspondence ใช้ Unified Workflow Engine — ดูรายละเอียดที่ `01-03-06-unified-workflow.md`
เมื่อ Correspondence ถูก Submit → ผู้รับ (TO/CC) สามารถสร้าง **Circulation Sheet** เพื่อมอบหมายงานภายในองค์กร (ดู `01-03-08-circulation-sheet.md`)
---
## 3.2.10. Business Rules และ Edge Cases
| รหัส | กฎ | Severity |
|---|---|---|
| **EC-CORR-001** | Cancel Correspondence ที่มี Circulation เปิดอยู่ → ต้องยืนยันก่อน + Force Close Circulation ทั้งหมด + Audit Log | 🔴 Critical |
| **EC-CORR-002** | Reply ต่อ Correspondence ที่ถูก Cancel → ทำได้แต่ UI ต้องแสดง Warning | 🟡 Medium |
| **EC-CORR-003** | Originator และ Recipient เป็นองค์กรเดียวกัน → Block (ใช้ Circulation แทน) | 🟡 Medium |
ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 7: Correspondence Edge Cases"
@@ -1,44 +1,166 @@
# 3.3 RFA Management (การจัดการเอกสาขออนุมัติ)
# 3.3 RFA Management (การจัดการเอกสาขออนุมัติ)
---
title: 'Functional Requirements: RFA Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-02-correspondence.md
- specs/01-requirements/01-03-modules/01-03-06-unified-workflow.md
- specs/01-requirements/01-06-edge-cases-and-rules.md (EC-RFA-001 ถึง EC-RFA-004)
- specs/03-Data-and-Storage/03-01-data-dictionary.md (§4.14.6)
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.3.1. วัตถุประสงค์:
## 3.3.1. วัตถุประสงค์
- เอกสารขออนุมัติ (RFA) ภายใน โครงการ (Projects)
เอกสารขออนุมัติ (RFA — Request For Approval) ใช้สำหรับส่งเอกสารหรือสิ่งของเพื่อขออนุมัติจากผู้ว่าจ้างหรือที่ปรึกษา ภายในโครงการ (Project)**ไม่ได้จำกัดแค่แบบก่อสร้าง** ประเภทของ RFA (RFA Type) เป็นตัวกำหนดว่าต้องแนบ Drawing Revision หรือไฟล์แนบ:
## 3.3.2. ประเภทเอกสาร:
- **ประเภทที่อ้างอิง Drawing** (DDW, SDW, ADW): ผูก Drawing Revision ได้หลายรายการผ่าน `rfa_items`
- **ประเภทอื่น** (DOC, MAT, ฯลฯ): แนบได้เพียง 1 ไฟล์ผ่าน `correspondence_attachments`
- ระบบต้องรองรับเอกสารรูปแบบ ไฟล์ PDF
- เอกสารขออนุมัติ (RFA) สามารถมีได้หลาย revision
- มีประถทของเอกสาร ได้หลายประเภท (RFA Types) และสามารถเพิ่มประเภทใหม่ได้ในภายหลัง
---
## 3.3.3. การสร้างเอกสาร:
## 3.3.2. โครงสร้างข้อมูล (Database Tables)
- ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารขออนุมัติ (RFA) รอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น
- เมื่อเอกสารเปลี่ยนสถานะเป็น "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล
RFA ใช้ pattern **Correspondence + Extension**:
## 3.3.4. การอ้างอิงและจัดกลุ่ม:
| Table | บทบาท |
|---|---|
| `correspondences` | ข้อมูลหลัก: เลขเอกสาร, subject, project, originator, recipients |
| `rfas` | ข้อมูลเฉพาะ RFA: `rfa_type_id` (FK → `rfa_types`) |
| `rfa_revisions` | ประวัติแต่ละ Revision: status, approve code, details JSON |
| `rfa_items` | รายการ Drawing Revision ที่อ้างอิงใน Revision นั้น (เฉพาะ type DDW/SDW/ADW) |
| `rfa_types` | Master: ประเภท RFA (ผูกกับ Contract) |
| `rfa_status_codes` | Master: สถานะ RFA |
| `rfa_approve_codes` | Master: ผลการอนุมัติ |
- RFA สามารถอ้างถึง (Reference) แบบก่อสร้าง (Shop Drawing) ได้หลายฉบับ
- การสร้าง RFA ต้องสร้างเอกสารแม่ใน `correspondences` โดยใช้ `correspondence_types.type_code = 'RFA'`
- ประเภทย่อยของ RFA ต้องเก็บใน `rfas.rfa_type_id`
- ถ้า `rfa_types.type_code` เป็น `DDW` หรือ `SDW` ระบบต้องบังคับให้เลือกอย่างน้อย 1 `shop_drawing_revision`
- ถ้า `rfa_types.type_code` เป็น `ADW` ระบบต้องบังคับให้เลือกอย่างน้อย 1 `asbuilt_drawing_revision`
- 1 แถวใน `rfa_items` ต้องอ้างอิง Drawing Revision ได้เพียง 1 รายการเท่านั้น โดยเป็น `shop_drawing_revision` หรือ `asbuilt_drawing_revision` อย่างใดอย่างหนึ่ง
**ข้อสำคัญ:** `rfas.id` ใช้ FK ชี้ไปที่ `correspondences.id` (ไม่มี AUTO_INCREMENT ของตัวเอง)
## 3.3.5. Workflow (Unified Workflow):
---
- ระบบต้องรองรับ Workflow ที่เป็นแบบ Unified Workflow
## 3.3.3. ประเภทเอกสาร (RFA Types)
- `rfa_types` เป็น Master ที่ผูกกับ **Contract** (ไม่ใช่ Project) — `contract_id` FK
- แต่ละ Contract มี RFA Types ของตัวเอง สามารถเพิ่มใหม่ได้ในภายหลัง
- ไฟล์แนบรองรับรูปแบบ **PDF**
- RFA สามารถมีได้หลาย Revision (Rev.A, Rev.B, ...)
### type_code ที่กำหนด drawing requirement:
| type_code | ชื่อ | Attachment Mechanism | บังคับ |
|---|---|---|---|
| `DDW` | Drawing for Design | `rfa_items` → Shop Drawing Revision | ≥ 1 |
| `SDW` | Shop Drawing | `rfa_items` → Shop Drawing Revision | ≥ 1 |
| `ADW` | As-Built Drawing | `rfa_items` → As-Built Drawing Revision | ≥ 1 |
| อื่นๆ | เช่น DOC, MAT | `correspondence_attachments` (1 ไฟล์) | ไม่บังคับ |
---
## 3.3.4. Fields ที่ต้องกรอกเมื่อสร้าง RFA
| Field | Required | หมายเหตุ |
|---|---|---|
| Project | ✅ | UUID — กรองจาก Project ที่ผู้ใช้มีสิทธิ์ |
| Contract | ✅ | UUID — filter ตาม Project ที่เลือก |
| Discipline | ✅ | INT id — filter ตาม Contract ที่เลือก |
| RFA Type | ✅ | INT id — filter ตาม Contract ที่เลือก |
| To Organization | ✅ | UUID — ผู้รับหลัก (recipients type = 'TO') |
| Subject | ✅ | ขั้นต่ำ 5 ตัวอักษร |
| Body | ❌ | เนื้อหาเพิ่มเติม |
| Description | ❌ | คำอธิบายสั้น |
| Remarks | ❌ | หมายเหตุ |
| Due Date | ❌ | กำหนดส่งคืน |
| Shop Drawing Revisions | บังคับเมื่อ type = DDW/SDW | UUID[] |
| As-Built Drawing Revisions | บังคับเมื่อ type = ADW | UUID[] |
### Document Number Preview
ระบบแสดง Preview เลขเอกสารแบบ Real-time ก่อน Submit เมื่อกรอกครบ: Project + Correspondence Type + Discipline + To Organization โดยเรียก `POST /api/correspondences/preview-number`
---
## 3.3.5. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | หมายเหตุ |
|---|---|---|
| สร้าง RFA (Draft) | Document Control, Org Admin, Superadmin | ภายในองค์กรตัวเอง |
| Submit RFA | Document Control, Org Admin, Superadmin | เปลี่ยนสถานะ Draft → FAP/FRE |
| แก้ไข/ถอนกลับ/ยกเลิก หลัง Submit | **Org Admin ขึ้นไปเท่านั้น** พร้อมระบุเหตุผล | — |
| ดู RFA ที่ Draft | เฉพาะคนในองค์กรเดียวกัน | องค์กรอื่นมองไม่เห็น |
| ดู RFA ที่ Submitted แล้ว | ทุกคนที่มีสิทธิ์ใน Project | — |
| จัดการ RFA Types (Master) | Superadmin | Global |
---
## 3.3.6. การอ้างอิง Drawing Revisions (rfa_items)
- 1 RFA Revision สามารถอ้างอิง Drawing Revision ได้หลายรายการ
- 1 แถวใน `rfa_items` อ้างอิง Drawing Revision ได้ **เพียง 1 รายการเท่านั้น** โดยต้องเป็น `shop_drawing_revision` หรือ `asbuilt_drawing_revision` อย่างใดอย่างหนึ่ง (ไม่ใช่ทั้งคู่)
- `item_type` ENUM: `'SHOP'` | `'AS_BUILT'` — กำหนดว่า FK ไหนที่ต้อง NOT NULL
- 1 Drawing Revision สามารถถูกอ้างอิงโดยหลาย RFA ได้ (แต่ดู EC-RFA-001)
### Unique Constraint:
- `(rfa_revision_id, shop_drawing_revision_id)` — ห้าม Drawing เดิมซ้ำใน RFA เดียวกัน
- `(rfa_revision_id, asbuilt_drawing_revision_id)` — เช่นเดียวกัน
---
## 3.3.7. Status Codes (rfa_status_codes)
| status_code | ชื่อ | ความหมาย |
|---|---|---|
| `DFT` | Draft | ฉบับร่าง — มองเห็นเฉพาะ Originator Org |
| `FAP` | For Approve | ส่งเพื่อขออนุมัติ |
| `FRE` | For Review | ส่งเพื่อตรวจสอบ |
| `FCO` | For Construction | อนุมัติให้ก่อสร้างได้ |
| `ASB` | AS-Built | แบบก่อสร้างจริง |
| `OBS` | Obsolete | ไม่ใช้งานแล้ว |
| `CC` | Canceled | ยกเลิก |
---
## 3.3.8. Approve Codes (rfa_approve_codes)
ผลการอนุมัติบันทึกใน `rfa_revisions.rfa_approve_code_id`:
| approve_code | ชื่อ | ความหมาย |
|---|---|---|
| `1A` | Approved by Authority | อนุมัติโดยหน่วยงานที่มีอำนาจ |
| `1C` | Approved by CSC | อนุมัติโดย CSC |
| `1N` | Approved As Note | อนุมัติพร้อมบันทึก |
| `1R` | Approved with Remarks | อนุมัติพร้อมข้อสังเกต |
| `3C` | Consultant Comments | มีความเห็นจากที่ปรึกษา |
| `3R` | Revise and Resubmit | ขอให้แก้ไขและส่งใหม่ |
| `4X` | Reject | ปฏิเสธ |
| `5N` | No Further Action | ไม่ต้องดำเนินการเพิ่มเติม |
---
## 3.3.9. Workflow (Unified Workflow)
RFA ใช้ Unified Workflow Engine — ดูรายละเอียดที่ `01-03-06-unified-workflow.md`
สถานะการสร้าง Revision ใหม่:
- ห้ามมี 2 Active Revision พร้อมกัน (EC-RFA-002)
- สร้าง Rev.B ได้ก็ต่อเมื่อ Rev.A มีผลสุดท้ายแล้ว (`3R`, `4X`, หรือ Approved)
---
## 3.3.10. Business Rules และ Edge Cases
| รหัส | กฎ | Severity |
|---|---|---|
| **EC-RFA-001** | 1 Shop Drawing Revision มี Active RFA ได้สูงสุด 1 ฉบับ (ยกเว้นถูก REJECTED/CANCELLED แล้ว) | 🔴 Critical |
| **EC-RFA-002** | ห้ามสร้าง Revision ใหม่ถ้า Revision ก่อนหน้ายังไม่มีคำตอบสุดท้าย | 🟠 High |
| **EC-RFA-003** | Discipline ต้องเลือกก่อนเสมอ ไม่มี Auto-detection (AI Classification เป็น Phase 3) | 🟡 Medium |
| **EC-RFA-004** | Transmittal ที่มี RFA Submit ได้ก็ต่อเมื่อทุก RFA อยู่ในสถานะ READY (ไม่ใช่ DRAFT) | 🟠 High |
ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 4: RFA & Drawing Edge Cases"
@@ -3,30 +3,103 @@
---
title: 'Functional Requirements: Contract Drawing Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-05-shop-drawing.md
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.4.1. วัตถุประสงค์:
## 3.4.1. วัตถุประสงค์
- แบบคู่สัญญา (Contract Drawing) ใช้เพื่ออ้างอิงและใช้ในการตรวจสอบ
แบบคู่สัญญา (Contract Drawing) คือแบบที่ได้รับจากสัญญาก่อสร้าง ใช้เป็น **เอกสารอ้างอิงหลัก** สำหรับ Shop Drawing และ As-Built Drawing ภายในโครงการ ไม่มี Revision Model — แต่ละแบบเป็นเอกสาร Master คงที่
## 3.4.2. ประเภทเอกสาร:
> **หมายเหตุการพัฒนา:** ข้อมูล Contract Drawing มาจาก **Seed Data** (นำเข้าจากข้อมูลสัญญาที่มีอยู่แล้ว) ไม่ได้สร้างใหม่ผ่าน UI ระบบอัปโหลดไฟล์ PDF อยู่**ระหว่างการพัฒนา** — ปัจจุบัน record มีอยู่ในระบบแต่ไฟล์แนบยังไม่สมบูรณ์
- ไฟล์ PDF
---
## 3.4.3. การสร้างเอกสาร:
## 3.4.2. โครงสร้างข้อมูล (Database Tables)
- ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้
| Table | บทบาท |
|---|---|
| `contract_drawings` | ข้อมูล Master แบบคู่สัญญา: เลขแบบ, ชื่อ, หมวดหมู่, เล่ม |
| `contract_drawing_attachments` | ไฟล์แนบ (M:N กับ `attachments`) รองรับหลายไฟล์ต่อแบบ |
| `contract_drawing_volumes` | Master: เล่มของแบบ (ผูกกับ Project) |
| `contract_drawing_cats` | Master: หมวดหมู่หลัก (ผูกกับ Project) |
| `contract_drawing_sub_cats` | Master: หมวดหมู่ย่อย (ผูกกับ Project) |
| `contract_drawing_subcat_cat_maps` | M:N ระหว่าง หมวดหมู่หลัก ↔ หมวดหมู่ย่อย (ผูกกับ Project) |
## 3.4.4. การอ้างอิงและจัดกลุ่ม:
**ข้อสำคัญ:** `condwg_no` ต้อง UNIQUE ภายใน Project เดียวกัน (`ux_condwg_no_project`)
- ใช้สำหรับอ้างอิง ใน Shop Drawings, มีการจัดหมวดหมู่ของ Contract Drawing
---
## 3.4.3. Fields ที่ต้องกรอกเมื่อสร้าง Contract Drawing
| Field | Required | หมายเหตุ |
|---|---|---|
| Project | ✅ | UUID — ผูกกับ `projects` |
| Drawing Number (`condwg_no`) | ✅ | VARCHAR(255) — unique per project |
| Title | ✅ | VARCHAR(255) |
| Category (`map_cat_id`) | ❌ | FK → `contract_drawing_subcat_cat_maps` |
| Volume (`volume_id`) | ❌ | FK → `contract_drawing_volumes` |
| Volume Page (`volume_page`) | ❌ | INT |
| Attachments | ❌ | PDF / DWG / SOURCE / OTHER — ผ่าน ClamAV scan (**อยู่ระหว่างพัฒนาระบบนำเข้า PDF**) |
---
## 3.4.4. ไฟล์แนบ (contract_drawing_attachments)
รองรับหลายไฟล์ต่อ 1 Contract Drawing:
| file_type | ความหมาย |
|---|---|
| `PDF` | ไฟล์ PDF แบบดิจิทัล |
| `DWG` | ไฟล์ AutoCAD |
| `SOURCE` | ไฟล์ต้นฉบับอื่นๆ |
| `OTHER` | ประเภทอื่น |
- `is_main_document = TRUE` ระบุไฟล์หลัก (แสดงเป็น Default สำหรับ Preview)
> **สถานะ:** ข้อมูล `contract_drawings` ถูก Seed เข้าระบบแล้ว แต่ `contract_drawing_attachments` ยังว่างอยู่ระหว่างรอระบบ PDF Import
---
## 3.4.5. โครงสร้างหมวดหมู่ (Category Structure)
หมวดหมู่ผูกกับ **Project** (ไม่ใช่ Global) — จัดการโดย Project Manager:
```
contract_drawing_volumes (เล่ม — Volume)
contract_drawing_cats (หมวดหมู่หลัก — Main Category)
contract_drawing_sub_cats (หมวดหมู่ย่อย — Sub Category)
contract_drawing_subcat_cat_maps (M:N: Sub Category ↔ Main Category)
```
- `contract_drawings.map_cat_id` FK → `contract_drawing_subcat_cat_maps`
- 1 Sub Category สามารถอยู่ใน Main Category ได้หลายหมวด (M:N)
---
## 3.4.6. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | Scope |
|---|---|---|
| สร้าง / แก้ไข Contract Drawing | Project Manager, Org Admin, Superadmin | Project |
| ลบ Contract Drawing | Org Admin, Superadmin | Project |
| จัดการ Volume / Category (Master) | Project Manager | Project |
| ดู Contract Drawing | ทุกคนที่มีสิทธิ์ใน Project | Project |
---
## 3.4.7. ความสัมพันธ์กับ Module อื่น
- **Shop Drawing** (`shop_drawings`) อ้างอิง `contract_drawing_id` → ใช้ Contract Drawing เป็นแบบต้นฉบับ
- Contract Drawing **ไม่มี Revision Model** — ต่างจาก Shop Drawing ที่มี `shop_drawing_revisions`
- ไม่มีความสัมพันธ์โดยตรงกับ RFA — RFA อ้างอิง `shop_drawing_revisions` ไม่ใช่ Contract Drawing
@@ -3,30 +3,132 @@
---
title: 'Functional Requirements: Shop Drawing Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-03-rfa.md
- specs/01-requirements/01-03-modules/01-03-04-contract-drawing.md
- specs/01-requirements/01-06-edge-cases-and-rules.md (EC-RFA-001, EC-RFA-003)
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.5.1. วัตถุประสงค์:
## 3.5.1. วัตถุประสงค์
- แบบก่อสร้าง (Shop Drawing) ใช้เในการตรวจสอบ โดยจัดส่งด้วย Request for Approval (RFA)
แบบก่อสร้าง (Shop Drawing) คือแบบที่ Contractor จัดทำขึ้นเพื่อขออนุมัติจากผู้ว่าจ้างหรือที่ปรึกษา โดยส่งผ่าน RFA (Request for Approval) — มี Revision Model (Rev.A, Rev.B, ...) และอ้างอิง Contract Drawing ที่เป็นต้นฉบับได้
## 3.5.2. ประเภทเอกสาร:
---
- ไฟล์ PDF, DWG, ZIP
## 3.5.2. โครงสร้างข้อมูล (Database Tables)
## 3.5.3. การสร้างเอกสาร:
| Table | บทบาท |
|---|---|
| `shop_drawings` | ข้อมูล Master: เลขแบบ, หมวดหมู่หลัก, หมวดหมู่ย่อย, project |
| `shop_drawing_revisions` | ประวัติแต่ละ Revision: title, revision_label, is_current, revision_date |
| `shop_drawing_revision_contract_refs` | M:N: Revision ↔ Contract Drawing ที่อ้างอิง |
| `shop_drawing_revision_attachments` | ไฟล์แนบต่อ Revision (M:N กับ `attachments`) |
| `shop_drawing_main_categories` | Master: หมวดหมู่หลัก (ผูกกับ Project) |
| `shop_drawing_sub_categories` | Master: หมวดหมู่ย่อย (ผูกกับ Project) |
- ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้ โดยผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น
**ข้อสำคัญ:**
- `drawing_number` ต้อง UNIQUE ภายใน Project (`ux_shop_dwg_no_project`)
- `is_current = TRUE` มีได้เพียง 1 แถวต่อ Shop Drawing (`uq_sd_current`)
## 3.5.4. การอ้างอิงและจัดกลุ่ม:
---
- ใช้สำหรับอ้างอิง ใน RFA, มีการจัดหมวดหมู่ของ Shop Drawings โดยทุก แบบก่อสร้าง (Shop Drawing) แต่ละ revision ต้องมี RFA ได้เพียง 1 ฉบับ
## 3.5.3. Fields ที่ต้องกรอกเมื่อสร้าง Shop Drawing
### Master (shop_drawings)
| Field | Required | หมายเหตุ |
|---|---|---|
| Project | ✅ | UUID |
| Drawing Number | ✅ | VARCHAR(100) — unique per project |
| Main Category | ✅ | INT id — FK → `shop_drawing_main_categories` |
| Sub Category | ✅ | INT id — FK → `shop_drawing_sub_categories` |
### Revision (shop_drawing_revisions)
| Field | Required | หมายเหตุ |
|---|---|---|
| Title | ✅ | VARCHAR(500) — ชื่อแบบใน Revision นี้ |
| Revision Label | ❌ | VARCHAR(10) เช่น A, B, 1.1 |
| Revision Date | ❌ | DATE |
| Description | ❌ | คำอธิบายการแก้ไข |
| Legacy Drawing Number | ❌ | VARCHAR(100) — เลขแบบเดิม (สำหรับข้อมูล Migration) |
| Contract Drawing Refs | ❌ | UUID[] — อ้างอิง Contract Drawing ต้นฉบับ |
| Attachments | ❌ | PDF / DWG / SOURCE / OTHER — ผ่าน ClamAV scan |
---
## 3.5.4. ไฟล์แนบ (shop_drawing_revision_attachments)
ไฟล์แนบผูกกับ **Revision** ไม่ใช่ Master — รองรับหลายไฟล์ต่อ 1 Revision:
| file_type | ความหมาย |
|---|---|
| `PDF` | ไฟล์ PDF แบบดิจิทัล |
| `DWG` | ไฟล์ AutoCAD |
| `SOURCE` | ไฟล์ต้นฉบับอื่นๆ |
| `OTHER` | ประเภทอื่น |
- `is_main_document = TRUE` ระบุไฟล์หลักของ Revision นั้น
---
## 3.5.5. โครงสร้างหมวดหมู่ (Category Structure)
หมวดหมู่ผูกกับ **Project** — จัดการโดย Project Manager:
```
shop_drawing_main_categories (หมวดหมู่หลัก เช่น ARCH, STR, MEP)
shop_drawing_sub_categories (หมวดหมู่ย่อย เช่น STR-COLUMN, STR-BEAM)
```
- ต่างจาก Contract Drawing ที่ Main ↔ Sub เป็น M:N — Shop Drawing เป็น **direct FK**: `shop_drawings.main_category_id` และ `shop_drawings.sub_category_id` (1 แบบ = 1 Main + 1 Sub)
---
## 3.5.6. Revision Model
- 1 Shop Drawing Master → หลาย Revision (1:N)
- `revision_number`: 0-based integer สำหรับ sorting
- `revision_label`: A, B, C, ... — แสดงใน UI
- `is_current = TRUE` มีได้เพียง 1 แถว (NULL สำหรับที่ไม่ใช่ปัจจุบัน — ต่างจาก BOOLEAN เพื่อให้ UNIQUE constraint ทำงาน)
---
## 3.5.7. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | Scope |
|---|---|---|
| สร้าง Shop Drawing + Revision | Document Control, Org Admin, Superadmin | Project |
| แก้ไข Shop Drawing | Document Control, Org Admin, Superadmin | Project |
| ลบ Shop Drawing | Org Admin, Superadmin | Project |
| จัดการ Main/Sub Category (Master) | Project Manager | Project |
| ดู Shop Drawing | ทุกคนที่มีสิทธิ์ใน Project | Project |
---
## 3.5.8. ความสัมพันธ์กับ Module อื่น
- **RFA** (`rfa_items`) อ้างอิง `shop_drawing_revision_id` → 1 Revision มี Active RFA ได้สูงสุด 1 ฉบับ (EC-RFA-001)
- **Contract Drawing** (`shop_drawing_revision_contract_refs`) — แต่ละ Revision สามารถอ้างอิง Contract Drawing ต้นฉบับได้หลายฉบับ (M:N)
- **As-Built Drawing** — ไม่มีความสัมพันธ์โดยตรง (คนละ module)
---
## 3.5.9. Business Rules และ Edge Cases
| รหัส | กฎ | Severity |
|---|---|---|
| **EC-RFA-001** | 1 Shop Drawing Revision มี Active RFA ได้สูงสุด 1 ฉบับ (ยกเว้น REJECTED/CANCELLED แล้ว) | 🔴 Critical |
| **EC-RFA-003** | Discipline และ Category ต้องเลือกก่อน Upload — ไม่มี Auto-detection (Phase 3) | 🟡 Medium |
ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 4: RFA & Drawing Edge Cases"
@@ -3,41 +3,147 @@
---
title: 'Functional Requirements: Unified Workflow Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-02-correspondence.md
- specs/01-requirements/01-03-modules/01-03-03-rfa.md
- specs/01-requirements/01-03-modules/01-03-08-circulation-sheet.md
- specs/06-Decision-Records/ADR-001-unified-workflow-engine.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.6.1 Workflow Definition:
## 3.6.1. วัตถุประสงค์
- Admin ต้องสามารถกำหนดและสร้าง/แก้ไข Workflow Rule ได้ (DSL)
- รองรับการกำหนด State, Transition, Required Role, Condition (JS Expression)
Unified Workflow Engine คือ **Engine กลาง** สำหรับจัดการสถานะ (State) และการเปลี่ยนสถานะ (Transition) ของเอกสารทุกประเภทในระบบ ใช้ **JSON-based DSL** เก็บ Workflow Definition ไว้ใน Database ไม่ต้อง Deploy ใหม่เมื่อแก้ Workflow
## 3.6.2 Workflow Execution:
ดูการตัดสินใจเลือก Architecture ได้ที่ `ADR-001-unified-workflow-engine.md`
- ระบบต้องรองรับการสร้าง Instance ของ Workflow ผูกกับเอกสาร (Polymorphic)
- รองรับการเปลี่ยนสถานะ (Action) เช่น Approve, Reject, Comment, Return
- Auto-Action: รองรับการเปลี่ยนสถานะอัตโนมัติเมื่อครบเงื่อนไข (เช่น Review ครบทุกคน)
---
## 3.6.3 Flexibility:
## 3.6.2. โครงสร้างข้อมูล (Database Tables)
- รองรับ Parallel Review (ส่งให้หลายคนตรวจพร้อมกัน)
- รองรับ Conditional Flow (เช่น ถ้ายอดเงิน > X ให้เพิ่มผู้อนุมัติ)
| Table | บทบาท |
|---|---|
| `workflow_definitions` | นิยาม Workflow (DSL + compiled) — versioned per `workflow_code` |
| `workflow_instances` | Instance ผูกกับเอกสาร — เก็บ `current_state` + `context` JSON |
| `workflow_histories` | Audit Trail: ทุก transition บันทึก from/to state, action, user, comment |
## 3.6.4 Workflow การอนุมัติ:
### workflow_instances.entity_type ที่รองรับ
- รองรับกระบวนการอนุมัติที่ซับซ้อนและเป็นลำดับ เช่น ส่งจาก Originator -> Organization 1 -> Organization 2 -> Organization 3 แล้วส่งผลกลับตามลำดับเดิม (โดยถ้า องกรณ์ใดใน Workflow ให้ส่งกลับ ก็สามารถส่งผลกลับตามลำดับเดิมโดยไม่ต้องรอให้ถึง องกรณืในลำดับถัดไป)
| entity_type | Module |
|---|---|
| `rfa_revision` | RFA Revision |
| `correspondence_revision` | Correspondence Revision |
| `circulation` | Circulation Sheet |
## 3.6.5 การจัดการ:
### workflow_instances.status
- สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ได้
- มีระบบแจ้งเตือน ให้ผู้รับผิดชอบของ องกรณ์ ที่อยู่ใน Workflow ทราบ เมื่อมี RFA ใหม่ หรือมีการเปลี่ยนสถานะ
- สามารถข้ามขั้นตอนได้ในกรณีพิเศษ (โดยผู้มีสิทธิ์)
- สามารถส่งกลับขั้นตอนก่อนหน้าได้
| status | ความหมาย |
|---|---|
| `ACTIVE` | กำลังดำเนินการ |
| `COMPLETED` | เสร็จสมบูรณ์ (ถึง terminal state) |
| `CANCELLED` | ยกเลิกโดยผู้ใช้ |
| `TERMINATED` | ถูกยุติโดยระบบ |
---
## 3.6.3. Workflow Definition (DSL)
Workflow Definition เก็บ 2 ฟอร์แมตใน DB:
- `dsl` — JSON ต้นฉบับที่ Admin กำหนด
- `compiled` — Execution Tree ที่ Engine Compile แล้ว (optimize สำหรับ runtime)
### DSL Structure (ตัวอย่าง)
```json
{
"workflow": "CORRESPONDENCE_ROUTING",
"version": 1,
"states": [
{
"name": "DRAFT",
"initial": true,
"on": {
"SUBMIT": {
"to": "SUBMITTED",
"require": { "role": ["Document Control", "Org Admin"] },
"condition": "context.hasRecipient === true",
"events": [{ "type": "notify", "target": "recipients" }]
}
}
},
{ "name": "SUBMITTED", "on": { "RETURN": { "to": "DRAFT" }, "CLOSE": { "to": "CLOSED" } } },
{ "name": "CLOSED", "terminal": true }
]
}
```
### DSL Elements
| Element | ความหมาย |
|---|---|
| `states[].initial` | State เริ่มต้นเมื่อสร้าง Instance |
| `states[].terminal` | State สุดท้าย — Instance จะ COMPLETED |
| `on.<ACTION>.to` | State ปลายทางหลัง transition |
| `on.<ACTION>.require.role` | Role ที่ต้องมีจึงจะ trigger action ได้ |
| `on.<ACTION>.condition` | JS Expression ประเมินจาก `context` JSON |
| `on.<ACTION>.events` | Events ที่ fire หลัง transition (notify, etc.) |
---
## 3.6.4. Workflow Execution
1. **สร้าง Instance** — เมื่อเอกสารถูกสร้าง → `WorkflowEngineService.createInstance(workflowCode, entityType, entityId)`
2. **Transition**`processTransition(instanceId, action, userId, comment)` → validate role + condition → update `current_state` → write `workflow_histories`
3. **Auto-Action** — เมื่อ condition ครบ (เช่น Review ครบทุกคน) Engine trigger transition อัตโนมัติ
4. **Terminal State**`instance.status = COMPLETED` เมื่อถึง terminal state
### Versioning
- แก้ Workflow ได้โดยเพิ่ม `version` ใหม่ — In-progress instances ยังคงใช้ version เดิม
- `UNIQUE KEY (workflow_code, version)` — ป้องกัน version ซ้ำ
---
## 3.6.5. Flexibility
- **Sequential Approval:** Originator → Org1 → Org2 → Org3 → ส่งผลกลับตามลำดับเดิม — องค์กรใด Reject ได้ทันทีโดยไม่รอลำดับถัดไป
- **Parallel Review:** ส่งให้หลายคนตรวจพร้อมกัน — รอผลครบก่อน transition
- **Conditional Flow:** `condition` expression ประเมิน `context` — เช่น เพิ่ม Approver เมื่อยอดเกิน threshold
- **Skip Step:** ผู้มีสิทธิ์ข้ามขั้นตอนได้ (force transition)
- **Return:** ส่งกลับ state ก่อนหน้าได้ พร้อม comment บังคับ
---
## 3.6.6. Notifications & Deadline
- **Event:** `"type": "notify"` ใน DSL → fire ผ่าน Notification Module (BullMQ)
- **Deadline:** กำหนดได้ต่อ Org ใน Workflow Step
- **แจ้งเตือน:** เมื่อมีเอกสารใหม่ หรือสถานะเปลี่ยน → Email / LINE Notify / In-App
---
## 3.6.7. RBAC
| การกระทำ | Role ที่อนุญาต |
|---|---|
| จัดการ Workflow Definition (สร้าง/แก้ไข DSL) | Superadmin |
| Trigger Workflow Action (Submit, Approve, Reject, ฯลฯ) | ขึ้นอยู่กับ `require.role` ใน DSL |
| ดู Workflow History | ทุกคนที่มีสิทธิ์ใน Document นั้น |
---
## 3.6.8. Audit Trail (workflow_histories)
ทุก transition บันทึก:
- `from_state` / `to_state` / `action`
- `action_by_user_id` — ผู้กระทำ
- `comment` — ความเห็น (บังคับสำหรับ Return/Reject)
- `metadata` JSON — Snapshot ข้อมูล ณ ขณะนั้น
@@ -3,30 +3,96 @@
---
title: 'Functional Requirements: Transmittals Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-02-correspondence.md
- specs/01-requirements/01-03-modules/01-03-03-rfa.md
- specs/01-requirements/01-06-edge-cases-and-rules.md (EC-RFA-004)
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.7.1. วัตถุประสงค์:
## 3.7.1. วัตถุประสงค์
- เอกสารนำส่ง ใช้สำหรับ นำส่ง Request for Approval (RFAS) หลายฉบับ ไปยังองค์กรอื่น
เอกสารนำส่ง (Transmittal) ใช้สำหรับรวบรวมเอกสารหลายฉบับ (เช่น RFA) แล้วส่งเป็นชุดไปยังองค์กรอื่นในคราวเดียว เป็น **Correspondence ประเภทหนึ่ง** (`type_code = 'TRANSMITTAL'`) — ใช้ pattern **Correspondence + Extension** เช่นเดียวกับ RFA
## 3.7.2. ประเภทเอกสาร:
---
- ไฟล์ PDF
## 3.7.2. โครงสร้างข้อมูล (Database Tables)
## 3.7.3. การสร้างเอกสาร:
| Table | บทบาท |
|---|---|
| `correspondences` | ข้อมูลหลัก: เลขเอกสาร, subject, project, originator, recipients |
| `transmittals` | ข้อมูลเฉพาะ Transmittal: `purpose`, `remarks` — FK = `correspondences.id` |
| `transmittal_items` | รายการเอกสารที่นำส่งใน Transmittal นั้น (M:N กับ `correspondences`) |
- ผู้ใช้ที่มีสิทธิ์ สามารถสร้างและแก้ไขได้
**ข้อสำคัญ:** `transmittals.correspondence_id` คือ PK และ FK ชี้ไปที่ `correspondences.id` (ไม่มี AUTO_INCREMENT ของตัวเอง)
## 3.7.4. การอ้างอิงและจัดกลุ่ม:
---
- เอกสารนำส่ง เป็นส่วนหนึ่งใน Correspondence
## 3.7.3. Fields ที่ต้องกรอกเมื่อสร้าง Transmittal
| Field | Required | หมายเหตุ |
|---|---|---|
| Project | ✅ | UUID — เหมือน Correspondence ทั่วไป |
| Correspondence Type | ✅ | ต้องเป็น `TRANSMITTAL` |
| Subject | ✅ | ขั้นต่ำ 5 ตัวอักษร |
| To Organization | ✅ | UUID — `recipient_type = 'TO'` |
| Purpose | ❌ | ENUM — วัตถุประสงค์การนำส่ง |
| Remarks | ❌ | หมายเหตุ |
| Items (เอกสารที่นำส่ง) | ❌ | UUID[] — รายการ Correspondence ที่แนบ |
### Purpose ENUM (transmittals.purpose)
| purpose | ความหมาย |
|---|---|
| `FOR_APPROVAL` | นำส่งเพื่อขออนุมัติ |
| `FOR_INFORMATION` | นำส่งเพื่อทราบ |
| `FOR_REVIEW` | นำส่งเพื่อตรวจสอบ |
| `OTHER` | วัตถุประสงค์อื่น |
---
## 3.7.4. transmittal_items
รายการเอกสารที่แนบใน Transmittal:
| Field | หมายเหตุ |
|---|---|
| `transmittal_id` | FK → `transmittals.correspondence_id` |
| `item_correspondence_id` | FK → `correspondences.id` — เอกสารที่นำส่ง (เช่น RFA) |
| `quantity` | จำนวน (default = 1) |
| `remarks` | หมายเหตุสำหรับรายการนี้ |
- UNIQUE KEY `(transmittal_id, item_correspondence_id)` — ป้องกันเอกสารซ้ำใน Transmittal เดียวกัน
- 1 Transmittal รวบรวมเอกสารได้หลายฉบับ
- เอกสารที่นำส่งได้ไม่จำกัดประเภท (RFA, Letter, ฯลฯ)
---
## 3.7.5. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | หมายเหตุ |
|---|---|---|
| สร้าง Transmittal (Draft) | Document Control, Org Admin, Superadmin | ภายในองค์กรตัวเอง |
| Submit Transmittal | Document Control, Org Admin, Superadmin | ต้องผ่าน EC-RFA-004 ก่อน |
| แก้ไข/ยกเลิก หลัง Submit | **Org Admin ขึ้นไปเท่านั้น** พร้อมระบุเหตุผล | — |
| ดู Transmittal ที่ Draft | เฉพาะคนในองค์กรเดียวกัน | — |
| ดู Transmittal ที่ Submitted | ทุกคนที่มีสิทธิ์ใน Project | — |
---
## 3.7.6. Business Rules และ Edge Cases
| รหัส | กฎ | Severity |
|---|---|---|
| **EC-RFA-004** | Transmittal Submit ได้เฉพาะเมื่อ **ทุกเอกสารใน Transmittal** อยู่ในสถานะ READY (ไม่ใช่ DRAFT) — ถ้ามี DRAFT → 422 "RFA [เลข] ยังอยู่ใน Draft กรุณา Submit ก่อน" | 🟠 High |
ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 4: RFA & Drawing Edge Cases"
@@ -3,39 +3,101 @@
---
title: 'Functional Requirements: Circulation Sheet Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-02-correspondence.md
- specs/01-requirements/01-03-modules/01-03-06-unified-workflow.md
- specs/01-requirements/01-06-edge-cases-and-rules.md (EC-CIRC-001 ถึง EC-CIRC-003, EC-CORR-001)
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
---
## 3.8.1. วัตถุประสงค์:
## 3.8.1. วัตถุประสงค์
- การสื่อสาร เอกสาร (Correspondence) ทุกฉบับ จะมีใบเวียนเอกสารเพื่อควบคุมและมอบหมายงานภายในองค์กร (สามารถดูและแก้ไขได้เฉพาะคนในองค์กร)
ใบเวียนเอกสาร (Circulation Sheet) ใช้สำหรับ **มอบหมายและติดตามงานภายในองค์กร** เมื่อได้รับ Correspondence จากภายนอก — **มองเห็นและแก้ไขได้เฉพาะคนในองค์กรเดียวกัน** เท่านั้น
## 3.8.2. ประเภทเอกสาร:
---
- ไฟล์ PDF
## 3.8.2. โครงสร้างข้อมูล (Database Tables)
## 3.8.3. การสร้างเอกสาร:
| Table | บทบาท |
|---|---|
| `circulations` | ข้อมูล Master: เลขใบเวียน, subject, status, organization — ผูก 1:1 กับ `correspondences` |
| `circulation_attachments` | ไฟล์แนบเพิ่มเติม (M:N กับ `attachments`) |
| `circulation_status_codes` | Master: สถานะใบเวียน |
- ผู้ใช้ที่มีสิทธิ์ในองค์กรนั้น สามารถสร้างและแก้ไขได้
**ข้อสำคัญ:**
- `circulations.correspondence_id` UNIQUE — 1 Correspondence มีใบเวียนได้ **1 ฉบับต่อองค์กร**
- ใบเวียนผูกกับ `organization_id` — มองเห็นเฉพาะคนในองค์กรนั้น
## 3.8.4. การอ้างอิงและจัดกลุ่ม:
---
- การระบุผู้รับผิดชอบ:
- ผู้รับผิดชอบหลัก (Main): มีได้หลายคน
- ผู้ร่วมปฏิบัติงาน (Action): มีได้หลายคน
- ผู้ที่ต้องรับทราบ (Information): มีได้หลายคน
## 3.8.3. Fields ที่ต้องกรอกเมื่อสร้าง Circulation
## 3.8.5. การติดตามงาน:
| Field | Required | หมายเหตุ |
|---|---|---|
| Correspondence | ✅ | UUID — เอกสารที่ต้องการเวียน |
| Organization | ✅ | UUID — องค์กรเจ้าของใบเวียน (auto จาก current user org) |
| Subject (`circulation_subject`) | ✅ | VARCHAR(500) |
| Assignees | ✅ | UUID[] — ผู้รับมอบหมาย (จัดการผ่าน Workflow context) |
| Deadline | ❌ | สำหรับผู้รับผิดชอบประเภท Main และ Action |
| Attachments | ❌ | ไฟล์แนบเพิ่มเติม — ผ่าน ClamAV scan |
- สามารถกำหนดวันแล้วเสร็จ (Deadline) สำหรับผู้รับผิดชอบประเภท Main และ Action ได้
- มีระบบแจ้งเตือนเมื่อมี Circulation ใหม่ และแจ้งเตือนล่วงหน้าก่อนถึงวันแล้วเสร็จ
- สามารถปิด Circulation ได้เมื่อดำเนินการตอบกลับไปยังองค์กรผู้ส่ง (Originator) แล้ว หรือ รับทราบแล้ว (For Information)
### ประเภทผู้รับมอบหมาย (Assignee Types)
| ประเภท | ความหมาย | Deadline |
|---|---|---|
| **Main** | ผู้รับผิดชอบหลัก (มีได้หลายคน) | ✅ บังคับ |
| **Action** | ผู้ร่วมปฏิบัติงาน (มีได้หลายคน) | ✅ บังคับ |
| **Information** | ผู้ที่ต้องรับทราบ (มีได้หลายคน) | ❌ |
---
## 3.8.4. Status Codes (circulation_status_codes)
| status_code | ความหมาย |
|---|---|
| `OPEN` | เปิดใบเวียนแล้ว — รอดำเนินการ |
| `IN_REVIEW` | กำลังพิจารณา |
| `COMPLETED` | ดำเนินการเสร็จสมบูรณ์ |
| `CANCELLED` | ยกเลิก / ถอนกลับ |
---
## 3.8.5. การสร้างและสิทธิ์ (RBAC)
| การกระทำ | Role ที่อนุญาต | Scope |
|---|---|---|
| สร้าง Circulation | Document Control, Org Admin, Superadmin | ภายในองค์กรตัวเอง |
| แก้ไข / Re-assign | Document Control, Org Admin | ภายในองค์กรตัวเอง |
| Force Close | Document Control, Org Admin | พร้อมระบุเหตุผล (EC-CIRC-002) |
| ปิด Circulation (ปกติ) | ผู้ที่ถูก Assign (Main/Action) | เมื่อดำเนินการเสร็จ |
| ดู Circulation | เฉพาะคนในองค์กรเดียวกัน | องค์กรอื่นมองไม่เห็น |
---
## 3.8.6. Workflow และ Notifications
- Circulation ใช้ Unified Workflow Engine (`entity_type = 'circulation'`) — ดู `01-03-06-unified-workflow.md`
- **แจ้งเตือน:** เมื่อถูก Assign ใหม่, เมื่อใกล้ถึง Deadline, เมื่อ Overdue → Email / LINE Notify / In-App (BullMQ)
- **Deadline Rule:** หมดเขตเมื่อ `deadline_date 23:59:59` — Overdue Badge ขึ้นเมื่อ `NOW() > deadline_date + 1 day` (EC-CIRC-003)
---
## 3.8.7. Business Rules และ Edge Cases
| รหัส | กฎ | Severity |
|---|---|---|
| **EC-CORR-001** | Cancel Correspondence ที่มี Circulation เปิดอยู่ → Force Close Circulation ทั้งหมด + Audit Log | 🔴 Critical |
| **EC-CIRC-001** | Assignee ถูก Deactivate ก่อน Respond → Document Control สามารถ Re-assign ได้ | 🟠 High |
| **EC-CIRC-002** | Multi-Assignee — บางคนยังไม่ Respond → Document Control Force Close ได้ พร้อมระบุเหตุผล | 🟡 Medium |
| **EC-CIRC-003** | Deadline = Today → หมดเขต 23:59:59, Reminder 08:00, Overdue Badge วันถัดไป | 🟡 Medium |
ดูรายละเอียดครบที่ `01-06-edge-cases-and-rules.md` หมวด "Module 8: Circulation Edge Cases"
@@ -1,21 +1,116 @@
# 3.9 Logs Management (ประวัติการแก้ไข)
# 3.9 Logs Management (ประวัติการแก้ไข / Audit Log)
---
title: 'Functional Requirements: Logs Management'
version: 1.8.0
status: first-draft
title: 'Functional Requirements: Audit Log Management'
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-06-edge-cases-and-rules.md
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
- specs/06-Decision-Records/ADR-010-logging-monitoring.md
---
## 3.9.1. วัตถุประสงค์:
## 3.9.1. วัตถุประสงค์
- เพื่อ บันทึกการกระทำ CRUD ของเอกสารทั้งหมด รวมถึงการ เข้าใช้งาน ของ users
- admin สามารถดูประวัติการแก้ไขของเอกสารทั้งหมด พร้อม จัดทำรายงายตามข้อกำหนดที่ ต้องการได้
Audit Log บันทึก **ทุก action สำคัญ** ในระบบ — ทั้ง CRUD เอกสาร, การ Login/Logout, เหตุการณ์ Security — เพื่อ Traceability, Compliance และการ Debug ในระบบ Production
---
## 3.9.2. โครงสร้างข้อมูล (Database Table)
ใช้ตารางเดียว `audit_logs` ออกแบบสำหรับ **High-Volume Append-Only Write**:
| Column | Type | หมายเหตุ |
|---|---|---|
| `audit_id` | BIGINT AUTO_INCREMENT | Primary Key (ร่วมกับ `created_at` เพื่อ Partition) |
| `request_id` | VARCHAR(100) | Trace ID เชื่อมกับ Application Log (Distributed Tracing) |
| `user_id` | INT | ผู้กระทำ (ไม่มี FK — ป้องกัน Partition constraint) |
| `action` | VARCHAR(100) | รหัส action เช่น `rfa.create`, `login.success` |
| `severity` | ENUM | `INFO` / `WARN` / `ERROR` / `CRITICAL` |
| `entity_type` | VARCHAR(50) | Module/ตาราง เช่น `rfa`, `correspondence` |
| `entity_id` | VARCHAR(50) | Primary ID ของ record ที่ได้รับผลกระทบ |
| `details_json` | JSON | ข้อมูล Context เพิ่มเติม |
| `ip_address` | VARCHAR(45) | IP Address ของผู้กระทำ |
| `user_agent` | VARCHAR(255) | Browser/Client ของผู้กระทำ |
| `created_at` | DATETIME | เวลาที่กระทำ |
**Primary Key:** `(audit_id, created_at)` — รวม `created_at` เพื่อรองรับ **Partition Table**
**ไม่มี FK** — ใช้ INDEX แทน เพื่อให้ Partition ทำงานได้ (MariaDB constraint)
---
## 3.9.3. Action Format
รูปแบบ: `<module>.<action>` — เช่น:
| action | ความหมาย |
|---|---|
| `login.success` / `login.failed` | เข้าสู่ระบบ |
| `rfa.create` / `rfa.update` / `rfa.delete` | CRUD RFA |
| `correspondence.create` / `correspondence.submit` | สร้าง / Submit Correspondence |
| `circulation.create` / `circulation.close` / `circulation.force_close` | Circulation |
| `drawing.upload` / `drawing.delete` | อัปโหลด / ลบ Drawing |
| `file.virus_detected` | ClamAV พบ Virus |
| `file.mime_mismatch` | MIME Type ไม่ตรง |
| `user.deactivate` / `user.reactivate` | เปลี่ยนสถานะ User |
---
## 3.9.4. Severity
| severity | ใช้เมื่อ |
|---|---|
| `INFO` | ทุก CRUD ปกติ, Login สำเร็จ |
| `WARN` | Login ล้มเหลว, Re-assign Circulation |
| `ERROR` | ระบบ Error, Failed Transaction |
| `CRITICAL` | File Virus Detected, Force Close Circulation, Security Event |
---
## 3.9.5. Partitioning (Performance)
`audit_logs` ใช้ **RANGE Partition by YEAR(created_at)**:
```
p_old → ก่อน 2024
p2024 → 2024
p2025 → 2025
...
p2030 → 2030
p_future → หลัง 2030
```
- Query เฉพาะ Partition ที่เกี่ยวข้อง → ลด I/O อย่างมีนัยสำคัญ
- Drop Partition เพื่อ Archive ข้อมูลเก่าได้โดยไม่กระทบ Production
---
## 3.9.6. RBAC
| การกระทำ | Role ที่อนุญาต |
|---|---|
| ดู Audit Log ทั้งหมด | Superadmin, Org Admin (เฉพาะ org ตัวเอง) |
| Export / Report | Superadmin |
| ลบ Audit Log | **ห้ามลบ** — Append-Only เสมอ |
---
## 3.9.7. กรณีที่บันทึก Audit Log บังคับ
| กรณี | severity | ระบุใน Edge Case |
|---|---|---|
| Cancel Correspondence ที่มี Circulation เปิด | `CRITICAL` | EC-CORR-001 |
| Force Close Circulation + reason | `CRITICAL` | EC-CIRC-001, EC-CIRC-002 |
| Re-assign Circulation (Assignee deactivated) | `WARN` | EC-CIRC-001 |
| File Virus Detected (ClamAV) | `CRITICAL` | EC-FILE-001 |
| File MIME Type Mismatch | `WARN` | EC-FILE-002 |
| ทุก Login / Logout | `INFO` / `WARN` | — |
| ทุก CRUD บน Document ทุกประเภท | `INFO` | — |
@@ -1,97 +1,107 @@
# 3.12 JSON Details Management (การจัดการ JSON Details)
# 3.10 JSON Details Management (การจัดการ JSON Details)
---
title: 'Functional Requirements: JSON Details Management'
version: 1.8.0
status: first-draft
version: 1.8.1
status: updated
owner: Nattanin Peancharoen
last_updated: 2026-02-23
last_updated: 2026-03-24
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/01-requirements/01-03-modules/01-03-00-index.md
- specs/01-requirements/01-03-modules/01-03-02-correspondence.md
- specs/01-requirements/01-03-modules/01-03-03-rfa.md
- specs/01-requirements/01-03-modules/01-03-06-unified-workflow.md
- specs/03-Data-and-Storage/03-01-data-dictionary.md
- specs/06-Decision-Records/ADR-009-db-strategy.md
---
## 3.12.1 วัตถุประสงค์
## 3.10.1. วัตถุประสงค์
- จัดเก็บข้อมูลแบบไดนามิกที่เฉพาะเจาะจงกับแต่ละประเภทของเอกสาร
- รองรับการขยายตัวของระบบโดยไม่ต้องเปลี่ยนแปลง database schema
- จัดการ metadata และข้อมูลประกอบสำหรับ correspondence, routing, และ workflows
ระบบใช้ `JSON` column สำหรับข้อมูลที่มีโครงสร้างแตกต่างกันตามประเภทเอกสาร โดยไม่ต้องเพิ่ม column ใหม่ใน Schema รองรับการขยายตัวโดยไม่กระทบ ADR-009 (No-migration policy)
## 3.12.2 โครงสร้าง JSON Schema
---
- ระบบต้องมี predefined JSON schemas สำหรับประเภทเอกสารต่างๆ:
- 3.12.2.1 Correspondence Types
- GENERIC: ข้อมูลพื้นฐานสำหรับเอกสารทั่วไป
- RFI: รายละเอียดคำถามและข้อมูลทางเทคนิค
- RFA: ข้อมูลการขออนุมัติแบบและวัสดุ
- TRANSMITTAL: รายการเอกสารที่ส่งต่อ
- LETTER: ข้อมูลจดหมายทางการ
- EMAIL: ข้อมูลอีเมล
- 3.12.2.2 Rworkflow Types
- workflow_definitions: กฎและเงื่อนไขการส่งต่อ
- workflow_histories: สถานะและประวัติการส่งต่อ
- workflow_instances: การดำเนินการในแต่ละขั้นตอน
- 3.12.2.3 Audit Types
- AUDIT_LOG: ข้อมูลการตรวจสอบ
- SECURITY_SCAN: ผลการตรวจสอบความปลอดภัย
## 3.10.2. JSON Columns ในระบบ (ครบทุกตาราง)
## 3.12.3 Virtual Columns (ปรับปรุง)
| ตาราง | Column | วัตถุประสงค์ |
|---|---|---|
| `correspondence_revisions` | `details` | ข้อมูลเฉพาะตามประเภทเอกสาร (Letter, RFI ฯลฯ) |
| `rfa_revisions` | `details` | ข้อมูลเฉพาะ RFA (เช่น drawingCount) |
| `workflow_definitions` | `dsl` | นิยาม Workflow ต้นฉบับ (JSON DSL) |
| `workflow_definitions` | `compiled` | Execution Tree ที่ Compile แล้ว |
| `workflow_instances` | `context` | ตัวแปร Context สำหรับตัดสินใจ Transition |
| `workflow_histories` | `metadata` | Snapshot ข้อมูล ณ ขณะ Transition |
| `audit_logs` | `details_json` | ข้อมูล Context เพิ่มเติมของ Event |
| `document_numbers` | `counter_key` | Counter key (8 fields) สำหรับ Document Numbering |
| `document_numbers` | `metadata` | Additional context ของการออกเลข |
| `document_number_errors` | `context_data` | Context ของ request ที่เกิด error |
| `json_schemas` | `schema_definition` | JSON Schema (AJV Standard) |
| `json_schemas` | `ui_schema` | UI Schema สำหรับ Frontend render |
| `json_schemas` | `virtual_columns` | Config สำหรับสร้าง Virtual Columns |
| `json_schemas` | `migration_script` | Script แปลงข้อมูลระหว่าง versions |
- สำหรับ Field ใน JSON ที่ต้องใช้ในการค้นหา (Search) หรือจัดเรียง (Sort) บ่อยๆ ต้องสร้าง Generated Column (Virtual Column) ใน Database และทำ Index ไว้ เพื่อประสิทธิภาพสูงสุด
- Schema Consistency: Field ที่ถูกกำหนดเป็น Virtual Column ห้าม เปลี่ยนแปลง Key Name หรือ Data Type ใน JSON Schema Version ถัดไป หากจำเป็นต้องเปลี่ยน ต้องมีแผนการ Re-index หรือ Migration ข้อมูลเดิมที่ชัดเจน
---
## 3.12.4 Validation Rules
## 3.10.3. JSON Schema Registry (json_schemas)
- ต้องมี JSON schema validation สำหรับแต่ละประเภท
- ต้องรองรับ versioning ของ schema
- ต้องมี default values สำหรับ field ที่ไม่บังคับ
- ต้องตรวจสอบ data types และ format ให้ถูกต้อง
ระบบมีตาราง `json_schemas` เป็น **Centralized Registry** สำหรับ validate และ manage โครงสร้าง JSON:
## 3.12.5 Performance Requirements
| Field | หมายเหตุ |
|---|---|
| `schema_code` | รหัส Schema เช่น `RFA_DWG`, `CORRESPONDENCE_LETTER` |
| `version` | Version ของ Schema (UNIQUE ร่วมกับ `schema_code`) |
| `table_name` | ตารางเป้าหมาย เช่น `rfa_revisions` |
| `schema_definition` | โครงสร้าง AJV JSON Schema สำหรับ validation |
| `ui_schema` | โครงสร้าง UI Schema สำหรับ Frontend render dynamic form |
| `virtual_columns` | Config สำหรับสร้าง Generated Columns |
| `migration_script` | Script แปลงข้อมูลจาก version ก่อนหน้า |
- JSON field ต้องมีขนาดไม่เกิน 50KB
- ต้องรองรับ indexing สำหรับ field ที่ใช้ค้นหาบ่อย
- ต้องมี compression สำหรับ JSON ขนาดใหญ่
---
## 3.12.6 Security Requirements
## 3.10.4. Virtual Columns (Generated Columns)
- ต้อง sanitize JSON input เพื่อป้องกัน injection attacks
- ต้อง validate JSON structure ก่อนบันทึก
- ต้อง encrypt sensitive data ใน JSON fields
JSON field ที่ต้องใช้ใน Query / Search สร้างเป็น **Generated Virtual Column** + Index:
## 3.12.7 JSON Schema Migration Strategy (เพิ่มเติม)
| ตาราง | Virtual Column | JSON Path | ใช้สำหรับ |
|---|---|---|---|
| `correspondence_revisions` | `v_ref_project_id` | `$.projectId` | Filter by Project |
| `correspondence_revisions` | `v_doc_subtype` | `$.subType` | Filter by Sub-type |
| `rfa_revisions` | `v_ref_drawing_count` | `$.drawingCount` | Count / Sort |
- สำหรับ Schema Breaking Changes:
- Phase 1 - Add New Column
ALTER TABLE correspondence_revisions
ADD COLUMN ref_project_id_v2 INT GENERATED ALWAYS AS
(JSON_UNQUOTE(JSON_EXTRACT(details, '$.newProjectIdPath'))) VIRTUAL;
**กฎสำคัญ:** Field ที่เป็น Virtual Column **ห้ามเปลี่ยน JSON key หรือ data type** ใน version ถัดไป — ต้องมีแผน Re-index / Migration ก่อน
- Phase 2 - Backfill Old Records
- ใช้ background job แปลง JSON format เก่าเป็นใหม่
- Update `details` JSON ทีละ batch (1000 records)
- Phase 3 - Switch Application Code
- Deploy code ที่ใช้ path ใหม่
- Phase 4 - Remove Old Column
- หลังจาก verify แล้วว่าไม่มี error
- Drop old virtual column
---
- สำหรับ Non-Breaking Changes
- เพิ่ม optional field ใน schema
- Old records ที่ไม่มี field = ใช้ default value
## 3.10.5. Validation Rules
## 3.13. ข้อกำหนดพิเศษ
- Backend validate ด้วย `json_schemas.schema_definition` (AJV Standard) ก่อน save ทุกครั้ง
- แต่ละ record มี `schema_version` — ใช้เลือก schema ที่ถูกต้องสำหรับ validate
- Field ไม่บังคับ → ใช้ `default` value ตามที่กำหนดใน schema
- Sanitize JSON input เพื่อป้องกัน injection ก่อน validate
- ผู้ใช้งานที่มีสิทธิ์ระดับสูง (Global) หรือผู้ได้รับอนุญาตเป็นกรณีพิเศษ
- สามารถเลือก สร้างในนามองค์กร (Create on behalf of) ได้ เพื่อให้สามารถออกเลขที่เอกสาร (Running Number) ขององค์กรอื่นได้โดยไม่ต้องล็อกอินใหม่
- สามารถทำงานแทนผู้ใช้งานอื่นได้ Routing & Workflow ของ Correspondence, RFA, Circulation Sheet
---
## 3.14. การจัดการข้อมูลหลักขั้นสูง (Master Data Management)
## 3.10.6. JSON Schema Migration Strategy
- 3.14.1. Disciplines Management: Admin ต้องสามารถ เพิ่ม/ลบ/แก้ไข สาขางาน (Disciplines) แยกตามสัญญา (Contract) ได้
- 3.14.2. Sub-Type Mapping: Admin ต้องสามารถกำหนด Correspondence Sub-types และ Mapping รหัสตัวเลข (เช่น MAT = 11) ได้
- 3.14.3. Numbering Format Configuration: ระบบต้องรองรับการตั้งค่า Format Template ของแต่ละ Project/Type ได้โดยไม่ต้องแก้โค้ด
### Breaking Changes (เปลี่ยน key / type)
1. **Phase 1 — Add New Virtual Column** ด้วย path ใหม่
2. **Phase 2 — Backfill Old Records** ด้วย background job (batch 1,000 records)
3. **Phase 3 — Deploy Application Code** ที่ใช้ path ใหม่
4. **Phase 4 — Drop Old Column** หลัง verify ไม่มี error
### Non-Breaking Changes (เพิ่ม optional field)
- เพิ่ม field ใน schema definition — old records ที่ไม่มี field ใช้ `default` value อัตโนมัติ
- ไม่ต้อง backfill
---
> **หมายเหตุ:** เนื้อหาเดิม §3.13 (Impersonation) และ §3.14 (Master Data Management) ได้ถูกย้ายไปยัง spec ที่เกี่ยวข้องแล้ว:
> - Impersonation → `01-02-01-rbac-matrix.md`
> - Disciplines Management → `01-03-01-project-management.md` (§3.1.6)
> - Numbering Format → Document Numbering spec
@@ -3,8 +3,8 @@
---
**Project:** LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)
**Version:** 1.8.0
**Last Updated:** 2025-12-02
**Version:** 1.8.4
**Last Updated:** 2026-03-24
**Owner:** Operations Team
**Status:** Active
@@ -12,15 +12,15 @@
## 📋 Overview
This guide provides step-by-step instructions for deploying the LCBP3-DMS system on QNAP Container Station using Docker Compose with Blue-Green deployment strategy.
This guide provides step-by-step instructions for deploying the LCBP3-DMS system on QNAP Container Station using Docker Compose.
### Deployment Strategy
- **Platform:** QNAP TS-473A with Container Station
- **Orchestration:** Docker Compose
- **Deployment Method:** Blue-Green Deployment
- **Zero Downtime:** Yes
- **Rollback Capability:** Instant rollback via NGINX switch
- **Deployment Method:** Direct deploy with `--force-recreate` (replaces previous blue-green approach)
- **Automation:** Gitea Actions → `appleboy/ssh-action``scripts/deploy.sh`
- **Rollback:** Re-trigger previous commit via Gitea Actions
---
@@ -67,17 +67,14 @@ Create the following directory structure on QNAP:
# SSH into QNAP
ssh admin@qnap-ip
# Create base directory
mkdir -p /volume1/lcbp3
# Create base directories
mkdir -p /share/np-dms/app/logs
mkdir -p /share/np-dms/app/uploads
# Create blue-green environments
mkdir -p /volume1/lcbp3/blue
mkdir -p /volume1/lcbp3/green
# Create shared directories
mkdir -p /volume1/lcbp3/shared/uploads
mkdir -p /volume1/lcbp3/shared/logs
mkdir -p /volume1/lcbp3/shared/backups
# Clone source repository (first time only)
mkdir -p /share/np-dms/app/source
cd /share/np-dms/app/source
git clone https://git.np-dms.work/np-dms/lcbp3.git
# Create persistent volumes
mkdir -p /volume1/lcbp3/volumes/mariadb-data
@@ -92,43 +89,23 @@ chmod -R 755 /volume1/lcbp3
chown -R admin:administrators /volume1/lcbp3
```
**Final Structure:**
**Directory Structure:**
```
/volume1/lcbp3/
├── blue/ # Blue environment
── docker-compose.yml
├── .env.production
└── nginx.conf
/share/np-dms/app/
├── source/
── lcbp3/ # Git repository (auto-synced by CI)
├── backend/
├── frontend/
│ ├── scripts/
│ │ ├── deploy.sh # Deployment script v2.0
│ │ └── rollback.sh
│ └── specs/04-Infrastructure-OPS/04-00-docker-compose/
│ └── docker-compose-app.yml # Compose file used for deployment
├── green/ # Green environment
│ ├── docker-compose.yml
│ ├── .env.production
│ └── nginx.conf
├── nginx-proxy/ # Main reverse proxy
│ ├── docker-compose.yml
│ ├── nginx.conf
│ └── ssl/
│ ├── cert.pem
│ └── key.pem
├── shared/ # Shared across blue/green
│ ├── uploads/
│ ├── logs/
│ └── backups/
├── volumes/ # Persistent data
│ ├── mariadb-data/
│ ├── redis-data/
│ └── elastic-data/
├── scripts/ # Deployment scripts
│ ├── deploy.sh
│ ├── rollback.sh
│ └── health-check.sh
└── current # File containing "blue" or "green"
├── .env # Single environment config file
├── logs/ # Deployment logs
└── uploads/ # Persistent file uploads
```
### 2. SSL Certificate Setup
@@ -160,10 +137,10 @@ cp /etc/letsencrypt/live/lcbp3-dms.example.com/privkey.pem \
### 1. Environment Variables (.env.production)
Create `.env.production` in both `blue/` and `green/` directories:
Create `.env` at `/share/np-dms/app/.env`:
```bash
# File: /volume1/lcbp3/blue/.env.production
# File: /share/np-dms/app/.env
# DO NOT commit this file to Git!
# Application