251213:1509 Docunment Number Businee Rule not correct
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-13 15:09:01 +07:00
parent d964546c8d
commit ec35521258
64 changed files with 11956 additions and 223 deletions
@@ -9,6 +9,7 @@ import {
ParseIntPipe,
Query,
Delete,
Put,
} from '@nestjs/common';
import {
ApiTags,
@@ -19,6 +20,7 @@ import {
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
@@ -92,6 +94,23 @@ export class CorrespondenceController {
);
}
@Post('preview-number')
@ApiOperation({ summary: 'Preview next document number' })
@ApiResponse({
status: 200,
description: 'Return preview number and status.',
})
@RequirePermission('correspondence.create')
previewNumber(
@Body() createDto: CreateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.previewDocumentNumber(
createDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Get()
@ApiOperation({ summary: 'Search correspondences' })
@ApiResponse({ status: 200, description: 'Return list of correspondences.' })
@@ -140,6 +159,26 @@ export class CorrespondenceController {
return this.correspondenceService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update correspondence (Draft only)' })
@ApiResponse({
status: 200,
description: 'Correspondence updated successfully.',
})
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
@Audit('correspondence.update', 'correspondence')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.update(
id,
updateDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Get(':id/references')
@ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({
@@ -10,6 +10,8 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { Organization } from '../organization/entities/organization.entity';
// Dependent Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
@@ -32,6 +34,8 @@ import { SearchModule } from '../search/search.module';
CorrespondenceType,
CorrespondenceStatus,
CorrespondenceReference,
CorrespondenceRecipient,
Organization,
]),
DocumentNumberingModule,
JsonSchemaModule,
@@ -118,8 +118,8 @@ describe('CorrespondenceService', () => {
describe('findAll', () => {
it('should return correspondences array', async () => {
const result = await service.findAll({ projectId: 1 });
expect(Array.isArray(result)).toBeTruthy();
expect(result).toBeDefined();
expect(Array.isArray(result.data)).toBeTruthy();
expect(result.meta).toBeDefined();
});
});
});
@@ -17,12 +17,16 @@ import { CorrespondenceRevision } from './entities/correspondence-revision.entit
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { CorrespondenceRecipient } from './entities/correspondence-recipient.entity';
import { User } from '../user/entities/user.entity';
import { Organization } from '../organization/entities/organization.entity';
// DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
import { DeepPartial } from 'typeorm';
// Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
@@ -52,6 +56,8 @@ export class CorrespondenceService {
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(CorrespondenceReference)
private referenceRepo: Repository<CorrespondenceReference>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
private numberingService: DocumentNumberingService,
private jsonSchemaService: JsonSchemaService,
@@ -121,10 +127,17 @@ export class CorrespondenceService {
try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity
// Extract recipient organization from details
const recipientOrganizationId = createDto.details?.to_organization_id as
| number
| undefined;
// [v1.5.1] Extract recipient organization from recipients array (Primary TO)
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
const docNumber = await this.numberingService.generateNextNumber({
projectId: createDto.projectId,
@@ -137,6 +150,8 @@ export class CorrespondenceService {
customTokens: {
TYPE_CODE: type.typeCode,
ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
@@ -157,13 +172,29 @@ export class CorrespondenceService {
revisionLabel: 'A',
isCurrent: true,
statusId: statusDraft.id,
title: createDto.title,
subject: createDto.subject,
body: createDto.body,
remarks: createDto.remarks,
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : undefined,
description: createDto.description,
details: createDto.details,
createdBy: user.user_id,
schemaVersion: 1,
});
await queryRunner.manager.save(revision);
// Save Recipients
if (createDto.recipients && createDto.recipients.length > 0) {
const recipients = createDto.recipients.map((r) =>
queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await queryRunner.manager.save(recipients);
}
await queryRunner.commitTransaction();
// Start Workflow Instance (non-blocking)
@@ -190,7 +221,7 @@ export class CorrespondenceService {
id: savedCorr.id,
type: 'correspondence',
docNumber: docNumber,
title: createDto.title,
title: createDto.subject,
description: createDto.description,
status: 'DRAFT',
projectId: createDto.projectId,
@@ -256,7 +287,7 @@ export class CorrespondenceService {
if (search) {
query.andWhere(
'(corr.correspondenceNumber LIKE :search OR rev.title LIKE :search)',
'(corr.correspondenceNumber LIKE :search OR rev.subject LIKE :search)',
{ search: `%${search}%` }
);
}
@@ -286,6 +317,8 @@ export class CorrespondenceService {
'type',
'project',
'originator',
'recipients',
'recipients.recipientOrganization', // [v1.5.1] Fixed relation name
],
});
@@ -352,4 +385,181 @@ export class CorrespondenceService {
return { outgoing, incoming };
}
async update(id: number, updateDto: UpdateCorrespondenceDto, user: User) {
// 1. Find Current Revision
const revision = await this.revisionRepo.findOne({
where: {
correspondenceId: id,
isCurrent: true,
},
relations: ['correspondence'],
});
if (!revision) {
throw new NotFoundException(
`Current revision for correspondence ${id} not found`
);
}
// 2. Check Permission
if (revision.statusId) {
const status = await this.statusRepo.findOne({
where: { id: revision.statusId },
});
if (status && status.statusCode !== 'DRAFT') {
throw new BadRequestException('Only DRAFT documents can be updated');
}
}
// 3. Update Correspondence Entity if needed
const correspondenceUpdate: DeepPartial<Correspondence> = {};
if (updateDto.disciplineId)
correspondenceUpdate.disciplineId = updateDto.disciplineId;
if (updateDto.projectId)
correspondenceUpdate.projectId = updateDto.projectId;
if (Object.keys(correspondenceUpdate).length > 0) {
await this.correspondenceRepo.update(id, correspondenceUpdate);
}
// 4. Update Revision Entity
const revisionUpdate: DeepPartial<CorrespondenceRevision> = {};
if (updateDto.subject) revisionUpdate.subject = updateDto.subject;
if (updateDto.body) revisionUpdate.body = updateDto.body;
if (updateDto.remarks) revisionUpdate.remarks = updateDto.remarks;
// Format Date correctly if string
if (updateDto.dueDate) revisionUpdate.dueDate = new Date(updateDto.dueDate);
if (updateDto.description)
revisionUpdate.description = updateDto.description;
if (updateDto.details) revisionUpdate.details = updateDto.details;
if (Object.keys(revisionUpdate).length > 0) {
await this.revisionRepo.update(revision.id, revisionUpdate);
}
// 5. Update Recipients if provided
if (updateDto.recipients) {
const recipientRepo = this.dataSource.getRepository(
CorrespondenceRecipient
);
await recipientRepo.delete({ correspondenceId: id });
const newRecipients = updateDto.recipients.map((r) =>
recipientRepo.create({
correspondenceId: id,
recipientOrganizationId: r.organizationId,
recipientType: r.type,
})
);
await recipientRepo.save(newRecipients);
}
// 6. Regenerate Document Number if structural fields changed (Recipient, Discipline, Type, Project)
// AND it is a DRAFT.
const hasRecipientChange = !!updateDto.recipients?.find(
(r) => r.type === 'TO'
);
const hasStructureChange =
updateDto.typeId ||
updateDto.disciplineId ||
updateDto.projectId ||
hasRecipientChange;
if (hasStructureChange) {
// Re-fetch fresh data for context
const freshCorr = await this.correspondenceRepo.findOne({
where: { id },
relations: ['type', 'recipients', 'recipients.recipientOrganization'],
});
if (freshCorr) {
const toRecipient = freshCorr.recipients?.find(
(r) => r.recipientType === 'TO'
);
const recipientOrganizationId = toRecipient?.recipientOrganizationId;
const type = freshCorr.type;
let recipientCode = '';
if (toRecipient?.recipientOrganization) {
recipientCode = toRecipient.recipientOrganization.organizationCode;
} else if (recipientOrganizationId) {
// Fallback fetch if relation not loaded (though we added it)
const recOrg = await this.orgRepo.findOne({
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
const orgCode = 'ORG'; // Placeholder
const newDocNumber = await this.numberingService.generateNextNumber({
projectId: freshCorr.projectId,
originatorId: freshCorr.originatorId!,
typeId: freshCorr.correspondenceTypeId,
disciplineId: freshCorr.disciplineId,
// Use undefined for subTypeId if not present implicitly
year: new Date().getFullYear(),
recipientOrganizationId: recipientOrganizationId ?? 0,
customTokens: {
TYPE_CODE: type?.typeCode || '',
ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
await this.correspondenceRepo.update(id, {
correspondenceNumber: newDocNumber,
});
}
}
return this.findOne(id);
}
async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) {
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
if (!type) throw new NotFoundException('Document Type not found');
let userOrgId = user.primaryOrganizationId;
if (!userOrgId) {
const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) userOrgId = fullUser.primaryOrganizationId;
}
if (createDto.originatorId && createDto.originatorId !== userOrgId) {
// Allow impersonation for preview
userOrgId = createDto.originatorId;
}
// Extract recipient from recipients array
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
return this.numberingService.previewNextNumber({
projectId: createDto.projectId,
originatorId: userOrgId!,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId,
recipientOrganizationId,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
});
}
}
@@ -5,6 +5,8 @@ import {
IsOptional,
IsBoolean,
IsObject,
IsDateString,
IsArray,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@@ -30,12 +32,36 @@ export class CreateCorrespondenceDto {
subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA)
@ApiProperty({
description: 'Correspondence Title',
description: 'Correspondence Subject',
example: 'Monthly Progress Report',
})
@IsString()
@IsNotEmpty()
title!: string;
subject!: string;
@ApiPropertyOptional({
description: 'Body/Content',
example: '<p>...</p>',
})
@IsString()
@IsOptional()
body?: string;
@ApiPropertyOptional({
description: 'Remarks',
example: 'Note...',
})
@IsString()
@IsOptional()
remarks?: string;
@ApiPropertyOptional({
description: 'Due Date',
example: '2025-12-06T00:00:00Z',
})
@IsDateString()
@IsOptional()
dueDate?: string;
@ApiPropertyOptional({
description: 'Correspondence Description',
@@ -66,4 +92,12 @@ export class CreateCorrespondenceDto {
@IsInt()
@IsOptional()
originatorId?: number;
@ApiPropertyOptional({
description: 'Recipients',
example: [{ organizationId: 1, type: 'TO' }],
})
@IsArray()
@IsOptional()
recipients?: { organizationId: number; type: 'TO' | 'CC' }[];
}
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCorrespondenceDto } from './create-correspondence.dto';
export class UpdateCorrespondenceDto extends PartialType(
CreateCorrespondenceDto
) {}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Correspondence } from './correspondence.entity';
import { Organization } from '../../organization/entities/organization.entity';
@Entity('correspondence_recipients')
export class CorrespondenceRecipient {
@PrimaryColumn({ name: 'correspondence_id' })
correspondenceId!: number;
@PrimaryColumn({ name: 'recipient_organization_id' })
recipientOrganizationId!: number;
@PrimaryColumn({ name: 'recipient_type', type: 'enum', enum: ['TO', 'CC'] })
recipientType!: 'TO' | 'CC';
// Relations
@ManyToOne(() => Correspondence, (corr) => corr.recipients, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'correspondence_id' })
correspondence!: Correspondence;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'recipient_organization_id' })
recipientOrganization!: Organization;
}
@@ -35,15 +35,24 @@ export class CorrespondenceRevision {
@Column({ name: 'correspondence_status_id' })
statusId!: number;
@Column({ length: 255 })
title!: string;
@Column({ length: 500 })
subject!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ type: 'text', nullable: true })
body?: string;
@Column({ type: 'text', nullable: true })
remarks?: string;
@Column({ type: 'json', nullable: true })
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type
@Column({ name: 'schema_version', default: 1 })
schemaVersion!: number;
// ✅ [New] Virtual Column: ดึง Project ID จาก JSON details
@Column({
name: 'v_ref_project_id',
@@ -12,7 +12,9 @@ import { Project } from '../../project/entities/project.entity';
import { Organization } from '../../organization/entities/organization.entity';
import { CorrespondenceType } from './correspondence-type.entity';
import { User } from '../../user/entities/user.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity'; // เดี๋ยวสร้าง
import { CorrespondenceRecipient } from './correspondence-recipient.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity';
import { Discipline } from '../../master/entities/discipline.entity';
@Entity('correspondences')
export class Correspondence {
@@ -68,9 +70,9 @@ export class Correspondence {
creator?: User;
// [New V1.5.1]
@ManyToOne('Discipline')
@ManyToOne(() => Discipline)
@JoinColumn({ name: 'discipline_id' })
discipline?: any; // Use 'any' or import Discipline entity if available to avoid circular dependency issues if not careful, but better to import.
discipline?: Discipline;
// One Correspondence has Many Revisions
@OneToMany(
@@ -78,4 +80,11 @@ export class Correspondence {
(revision) => revision.correspondence
)
revisions?: CorrespondenceRevision[];
@OneToMany(
() => CorrespondenceRecipient,
(recipient) => recipient.correspondence,
{ cascade: true }
)
recipients?: CorrespondenceRecipient[];
}