690402:2240 fix dashboard
CI / CD Pipeline / build (push) Failing after 4m18s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-02 22:40:11 +07:00
parent c188219e28
commit d4f0d02c62
22 changed files with 396 additions and 232 deletions
@@ -776,127 +776,6 @@ export class CorrespondenceService {
await recipientRepo.save(newRecipients);
}
// 6. Regenerate Document Number if structural fields changed (Recipient, Discipline, Type, Project)
// AND it is a DRAFT.
// Fetch fresh data for context and comparison
const currentCorr = await this.correspondenceRepo.findOne({
where: { id },
relations: ['type', 'recipients', 'recipients.recipientOrganization'],
});
if (currentCorr) {
const currentToRecipient = currentCorr.recipients?.find(
(r) => r.recipientType === 'TO'
);
const currentRecipientId = currentToRecipient?.recipientOrganizationId;
// Check for ACTUAL value changes
const isProjectChanged =
updResolvedProjectId !== undefined &&
updResolvedProjectId !== currentCorr.projectId;
const isOriginatorChanged =
updResolvedOriginatorId !== undefined &&
updResolvedOriginatorId !== currentCorr.originatorId;
const isDisciplineChanged =
updateDto.disciplineId !== undefined &&
updateDto.disciplineId !== currentCorr.disciplineId;
const isTypeChanged =
updateDto.typeId !== undefined &&
updateDto.typeId !== currentCorr.correspondenceTypeId;
let isRecipientChanged = false;
let newRecipientId: number | undefined;
if (updResolvedRecipients) {
const newToRecipient = updResolvedRecipients.find(
(r) => r.type === 'TO'
);
newRecipientId = newToRecipient?.organizationId;
if (newRecipientId !== currentRecipientId) {
isRecipientChanged = true;
}
}
if (
isProjectChanged ||
isDisciplineChanged ||
isTypeChanged ||
isRecipientChanged ||
isOriginatorChanged
) {
const targetRecipientId = isRecipientChanged
? newRecipientId
: currentRecipientId;
// Resolve Recipient Code for the NEW context
let recipientCode = '';
if (targetRecipientId) {
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: targetRecipientId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
// [Fix #6] Fetch real ORG Code from originator organization
const originatorOrgForUpdate = await this.dataSource.manager.findOne(
Organization,
{
where: {
id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
},
}
);
const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK';
// Prepare Contexts
const oldCtx = {
projectId: currentCorr.projectId,
originatorOrganizationId: currentCorr.originatorId ?? 0,
typeId: currentCorr.correspondenceTypeId,
disciplineId: currentCorr.disciplineId,
recipientOrganizationId: currentRecipientId,
year: new Date().getFullYear(),
};
const newCtx = {
projectId: updResolvedProjectId ?? currentCorr.projectId,
originatorOrganizationId:
updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
typeId: updateDto.typeId ?? currentCorr.correspondenceTypeId,
disciplineId: updateDto.disciplineId ?? currentCorr.disciplineId,
recipientOrganizationId: targetRecipientId,
year: new Date().getFullYear(),
userId: user.user_id, // Pass User ID for Audit
customTokens: {
TYPE_CODE: currentCorr.type?.typeCode || '',
ORG_CODE: orgCode,
RECIPIENT_CODE: recipientCode,
REC_CODE: recipientCode,
},
};
// If Type Changed, need NEW Type Code
if (isTypeChanged) {
const newType = await this.typeRepo.findOne({
where: { id: newCtx.typeId },
});
if (newType) newCtx.customTokens.TYPE_CODE = newType.typeCode;
}
const newDocNumber = await this.numberingService.updateNumberForDraft(
currentCorr.correspondenceNumber,
oldCtx,
newCtx
);
await this.correspondenceRepo.update(id, {
correspondenceNumber: newDocNumber,
});
}
}
const updated = await this.findOne(id);
// Re-index updated document in Elasticsearch (fire-and-forget)
@@ -13,7 +13,7 @@ import { User } from '../user/entities/user.entity';
import { DashboardService } from './dashboard.service';
// DTOs
import { GetActivityDto, GetPendingDto } from './dto';
import { GetActivityDto, GetPendingDto, GetStatsDto } from './dto';
@ApiTags('Dashboard')
@ApiBearerAuth()
@@ -27,8 +27,8 @@ export class DashboardController {
*/
@Get('stats')
@ApiOperation({ summary: 'Get dashboard statistics' })
async getStats(@CurrentUser() user: User) {
return this.dashboardService.getStats(user.user_id);
async getStats(@CurrentUser() user: User, @Query() query: GetStatsDto) {
return this.dashboardService.getStats(user.user_id, query);
}
/**
@@ -8,6 +8,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { WorkflowInstance } from '../workflow-engine/entities/workflow-instance.entity';
import { Project } from '../project/entities/project.entity';
import { UserAssignment } from '../user/entities/user-assignment.entity';
// Controller & Service
import { DashboardController } from './dashboard.controller';
@@ -15,7 +17,13 @@ import { DashboardService } from './dashboard.service';
@Module({
imports: [
TypeOrmModule.forFeature([Correspondence, AuditLog, WorkflowInstance]),
TypeOrmModule.forFeature([
Correspondence,
AuditLog,
WorkflowInstance,
Project,
UserAssignment,
]),
],
controllers: [DashboardController],
providers: [DashboardService],
@@ -20,7 +20,11 @@ import {
ActivityItemDto,
GetPendingDto,
PendingTaskItemDto,
GetStatsDto,
} from './dto';
import { Project } from '../project/entities/project.entity';
import { UserAssignment } from '../user/entities/user-assignment.entity';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
@Injectable()
export class DashboardService {
@@ -33,18 +37,82 @@ export class DashboardService {
private auditLogRepo: Repository<AuditLog>,
@InjectRepository(WorkflowInstance)
private workflowInstanceRepo: Repository<WorkflowInstance>,
@InjectRepository(Project)
private projectRepo: Repository<Project>,
@InjectRepository(UserAssignment)
private userAssignmentRepo: Repository<UserAssignment>,
private dataSource: DataSource
) {}
/**
* ตรวจสอบว่า User มีสิทธิเข้าถึงโครงการหรือไม่
*/
private async checkProjectAccess(
userId: number,
projectId: string
): Promise<number> {
// 1. หา Internal ID ของ Project
const project = await this.projectRepo.findOne({
where: { publicId: projectId },
select: ['id'],
});
if (!project) {
throw new NotFoundException(`Project with ID ${projectId} not found`);
}
// 2. ตรวจสอบสิทธิ (UserAssignment)
// สำหรับ Global Admin อาจจะไม่ต้องเช็ค (ในที่นี้เช็คว่ามีการมอบหมายโครงการนี้ให้หรือไม่)
// NOTE: ในอนาคตอาจจะใช้ CASL แทน
const assignment = await this.userAssignmentRepo.findOne({
where: { userId, projectId: project.id },
});
// Check if user is a global admin (assigned to NULL project/org/contract)
const isGlobalAdmin = await this.userAssignmentRepo.findOne({
where: {
userId,
projectId: undefined,
organizationId: undefined,
contractId: undefined,
},
});
if (!assignment && !isGlobalAdmin) {
this.logger.warn(
`User ${userId} attempted to access project ${projectId} without assignment`
);
throw new ForbiddenException(
`You do not have access to project ${projectId}`
);
}
return project.id;
}
/**
* ดึงสถิติ Dashboard
* @param userId - ID ของ User ที่ Login
*/
async getStats(userId: number): Promise<DashboardStatsDto> {
this.logger.debug(`Getting dashboard stats for user ${userId}`);
async getStats(userId: number, dto: GetStatsDto): Promise<DashboardStatsDto> {
const { projectId } = dto;
this.logger.debug(
`Getting dashboard stats for user ${userId}, project: ${projectId || 'Global'}`
);
// นับจำนวนเอกสารทั้งหมด
const totalDocuments = await this.correspondenceRepo.count();
let internalProjectId: number | undefined;
if (projectId) {
internalProjectId = await this.checkProjectAccess(userId, projectId);
}
// นับจำนวนเอกสาร
const totalDocumentsQuery = this.correspondenceRepo.createQueryBuilder('c');
if (internalProjectId) {
totalDocumentsQuery.where('c.projectId = :internalProjectId', {
internalProjectId,
});
}
const totalDocuments = await totalDocumentsQuery.getCount();
// นับจำนวนเอกสารเดือนนี้
const startOfMonth = new Date();
@@ -57,28 +125,43 @@ export class DashboardService {
.getCount();
// นับงานที่รอ Approve (Workflow Active)
const pendingApprovals = await this.workflowInstanceRepo.count({
where: { status: WorkflowStatus.ACTIVE },
});
const pendingApprovalsQuery = this.workflowInstanceRepo
.createQueryBuilder('w')
.where('w.status = :status', { status: WorkflowStatus.ACTIVE });
// นับ RFA ทั้งหมด (correspondence_type_id = RFA type)
// ใช้ Raw Query เพราะต้อง JOIN กับ correspondence_types
const rfaCountResult = await this.dataSource.query<
{ count: string | number }[]
>(`
if (internalProjectId) {
// WorkflowInstance JOIN กับ Correspondence เพื่อเช็ค Project
pendingApprovalsQuery
.innerJoin('correspondences', 'c', 'w.entity_id = c.uuid')
.andWhere('c.project_id = :internalProjectId', { internalProjectId });
}
const pendingApprovals = await pendingApprovalsQuery.getCount();
// นับ RFA ทั้งหมด
let rfaSql = `
SELECT COUNT(*) as count
FROM correspondences c
JOIN correspondence_types ct ON c.correspondence_type_id = ct.id
WHERE ct.type_code = 'RFA'
`);
`;
const params: (string | number)[] = [];
if (internalProjectId) {
rfaSql += ` AND c.project_id = ?`;
params.push(internalProjectId);
}
const rfaCountResult = await this.dataSource.query<
{ count: string | number }[]
>(rfaSql, params);
const totalRfas = Number(rfaCountResult[0]?.count || '0');
// นับ Circulation ทั้งหมด
let circSql = `SELECT COUNT(*) as count FROM circulations ci`;
if (internalProjectId) {
circSql += ` JOIN correspondences c ON ci.correspondence_id = c.id WHERE c.project_id = ?`;
}
const circulationsCountResult = await this.dataSource.query<
{ count: string | number }[]
>(`
SELECT COUNT(*) as count FROM circulations
`);
>(circSql, internalProjectId ? [internalProjectId] : []);
const totalCirculations = Number(circulationsCountResult[0]?.count || '0');
// นับเอกสารที่อนุมัติแล้ว (APPROVED)
@@ -86,21 +169,25 @@ export class DashboardService {
// เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1'
// หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE
// เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี)
// หรือนับจาก RFA ที่มี Approve Code
// สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP)
// และ Correspondence ทั่วไปที่มีสถานะ Completed
// เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved)
// Check status code 'APR' exists
const aprStatusCount = await this.dataSource.query<
{ count: string | number }[]
>(`
// นับเอกสารที่อนุมัติแล้ว
let appSql = `
SELECT COUNT(r.id) as count
FROM correspondence_revisions r
JOIN correspondence_status s ON r.correspondence_status_id = s.id
JOIN correspondences c ON r.correspondence_id = c.id
WHERE r.is_current = 1 AND s.status_code IN ('APR', 'CMP')
`);
`;
if (internalProjectId) {
appSql += ` AND c.project_id = ?`;
}
const aprStatusCount = await this.dataSource.query<
{ count: string | number }[]
>(appSql, internalProjectId ? [internalProjectId] : []);
const approved = Number(aprStatusCount[0]?.count || '0');
return {
@@ -122,11 +209,18 @@ export class DashboardService {
userId: number,
dto: GetActivityDto
): Promise<ActivityItemDto[]> {
const { limit = 10 } = dto;
this.logger.debug(`Getting recent activity for user ${userId}`);
const { limit = 10, projectId } = dto;
this.logger.debug(
`Getting recent activity for user ${userId}, project: ${projectId || 'Global'}`
);
let internalProjectId: number | undefined;
if (projectId) {
internalProjectId = await this.checkProjectAccess(userId, projectId);
}
// ดึง Recent Audit Logs
const logs = await this.auditLogRepo
const query = this.auditLogRepo
.createQueryBuilder('log')
.leftJoin('log.user', 'user')
.select([
@@ -139,7 +233,22 @@ export class DashboardService {
'user.username',
'user.firstName',
'user.lastName',
])
]);
// NOTE: AuditLog อาจจะไม่มี projectId โดยตรง
// ในที่นี้ถ้ามี projectId เราจะพยายามกรองจาก detailsJson หรือ Entity ล่าสุด
// หรือถ้า Entity เป็น Correspondence เราสามารถ JOIN ได้
// เบื้องต้นถ้าไม่ซับซ้อน จะดึง Global มาก่อน หรือถ้าโครงการสำคัญมากให้ปรับ Schema
if (internalProjectId) {
// ตัวอย่างการกรองเบื้องต้นสำหรับ Correspondence
query.andWhere(
`(log.entityType = 'Correspondence' AND CAST(JSON_EXTRACT(log.detailsJson, '$.projectId') AS UNSIGNED) = :internalProjectId)
OR (log.entityType != 'Correspondence')`, // แสดงอย่างอื่นด้วย
{ internalProjectId }
);
}
const logs = await query
.orderBy('log.createdAt', 'DESC')
.limit(limit)
.getMany();
@@ -174,46 +283,73 @@ export class DashboardService {
data: PendingTaskItemDto[];
meta: { total: number; page: number; limit: number };
}> {
const { page = 1, limit = 10 } = dto;
const { page = 1, limit = 10, projectId } = dto;
const offset = (page - 1) * limit;
this.logger.debug(`Getting pending tasks for user ${userId}`);
this.logger.debug(
`Getting pending tasks for user ${userId}, project: ${projectId || 'Global'}`
);
let internalProjectId: number | undefined;
if (projectId) {
internalProjectId = await this.checkProjectAccess(userId, projectId);
}
// ใช้ Raw Query เพราะต้อง Query จาก View และ Filter ด้วย JSON
// v_user_tasks มี assignee_ids_json สำหรับ Filter
// MariaDB 11.8: ใช้ JSON_SEARCH แทน CAST AS JSON
const userIdNum = Number(userId);
const joinClause = internalProjectId
? `JOIN correspondence_revisions cr ON v_user_tasks.entity_id = CAST(cr.id AS CHAR) AND v_user_tasks.entity_type IN ('rfa_revision', 'correspondence_revision')
JOIN correspondences c ON cr.correspondence_id = c.id
LEFT JOIN circulations circ ON v_user_tasks.entity_id = CAST(circ.id AS CHAR) AND v_user_tasks.entity_type = 'circulation'
LEFT JOIN correspondences c2 ON circ.correspondence_id = c2.id`
: '';
const projectFilter = internalProjectId
? `AND (c.project_id = ? OR c2.project_id = ?)`
: '';
const [tasks, countResult] = await Promise.all([
this.dataSource.query<PendingTaskItemDto[]>(
`
SELECT
instance_id as instanceId,
workflow_code as workflowCode,
current_state as currentState,
entity_type as entityType,
entity_id as entityId,
document_number as documentNumber,
subject,
assigned_at as assignedAt
v_user_tasks.instance_id as instanceId,
v_user_tasks.workflow_code as workflowCode,
v_user_tasks.current_state as currentState,
v_user_tasks.entity_type as entityType,
v_user_tasks.entity_id as entityId,
v_user_tasks.document_number as documentNumber,
v_user_tasks.subject,
v_user_tasks.assigned_at as assignedAt
FROM v_user_tasks
${joinClause}
WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL
OR owner_id = ?
ORDER BY assigned_at DESC
(JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?)
${projectFilter}
ORDER BY v_user_tasks.assigned_at DESC
LIMIT ? OFFSET ?
`,
[userIdNum, userIdNum, limit, offset]
internalProjectId
? [
userIdNum,
userIdNum,
internalProjectId,
internalProjectId,
limit,
offset,
]
: [userIdNum, userIdNum, limit, offset]
),
this.dataSource.query<{ total: string | number }[]>(
`
SELECT COUNT(*) as total
SELECT COUNT(v_user_tasks.instance_id) as total
FROM v_user_tasks
${joinClause}
WHERE
JSON_SEARCH(assignee_ids_json, 'one', ?) IS NOT NULL
OR owner_id = ?
(JSON_SEARCH(v_user_tasks.assignee_ids_json, 'one', ?) IS NOT NULL OR v_user_tasks.owner_id = ?)
${projectFilter}
`,
[userIdNum, userIdNum]
internalProjectId
? [userIdNum, userIdNum, internalProjectId, internalProjectId]
: [userIdNum, userIdNum]
),
]);
@@ -3,7 +3,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/**
* DTO สำหรับ Query params ของ GET /dashboard/activity
@@ -16,6 +16,11 @@ export class GetActivityDto {
@Min(1)
@Max(50)
limit?: number = 10;
@ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' })
@IsOptional()
@IsString()
projectId?: string;
}
/**
@@ -3,7 +3,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/**
* DTO สำหรับ Query params ของ GET /dashboard/pending
@@ -23,6 +23,11 @@ export class GetPendingDto {
@Min(1)
@Max(50)
limit?: number = 10;
@ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' })
@IsOptional()
@IsString()
projectId?: string;
}
/**
@@ -0,0 +1,16 @@
// File: src/modules/dashboard/dto/get-stats.dto.ts
// Change Log:
// - Created DTO for Dashboard stats query parameters
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
/**
* DTO สำหรับ Query params ของ GET /dashboard/stats
*/
export class GetStatsDto {
@ApiPropertyOptional({ description: 'ID ของโครงการ (UUID)' })
@IsOptional()
@IsString()
projectId?: string;
}
@@ -4,3 +4,4 @@
export * from './dashboard-stats.dto';
export * from './get-activity.dto';
export * from './get-pending.dto';
export * from './get-stats.dto';