feat: 중간 점검(체크포인트) 백엔드 — 진행상태/점검일/보고
체크리스트에 작업자 진행상태(work_status: 미착수/진행중/완료) 필드 추가(지시자 검토와 분리). 업무당 중간 점검일(task_checkpoints) 여러 개 + 작업자 보고 스냅샷(checkpoint_reports) 도입. - 생성/수정 DTO에 checkpoints(날짜 목록) 추가, 날짜 기준 동기화(보고 보존) - PATCH .../work-status(담당자 진행상태 갱신), POST .../checkpoints/:id/report(점검일~마감 구간, 스냅샷+지시자 알림 task.checkpoint_reported) - 상세 응답에 checkpoints/checkpointReports/항목 workStatus 포함 - 운영용 마이그레이션 추가(dev 는 synchronize 자동 반영) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ export type NotificationType =
|
||||
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
|
||||
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
|
||||
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
|
||||
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 중간 점검 보고 — 보고 시점의 현재 진행 상태(서버가 스냅샷)와 함께 선택 메모를 전송
|
||||
export class CheckpointReportDto {
|
||||
@ApiProperty({ description: '보고 메모(선택)', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000, { message: '메모가 너무 깁니다.' })
|
||||
note?: string;
|
||||
}
|
||||
@@ -93,4 +93,20 @@ export class CreateTaskDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChecklistInputDto)
|
||||
checklist?: ChecklistInputDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'중간 점검일 목록(ISO8601, 선택) — 작업자가 진행 상태를 보고하는 기준일',
|
||||
type: [String],
|
||||
required: false,
|
||||
example: ['2026-06-15T00:00:00Z'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
|
||||
@IsISO8601(
|
||||
{ strict: false },
|
||||
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
|
||||
)
|
||||
checkpoints?: string[];
|
||||
}
|
||||
|
||||
@@ -101,4 +101,19 @@ export class UpdateTaskDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateChecklistItemInput)
|
||||
checklist?: UpdateChecklistItemInput[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'중간 점검일 목록(ISO8601). 제공 시 전체 교체, 빈 배열이면 모두 삭제',
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
|
||||
@IsISO8601(
|
||||
{ strict: false },
|
||||
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
|
||||
)
|
||||
checkpoints?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
CHECKLIST_WORK_STATUSES,
|
||||
type ChecklistWorkStatus,
|
||||
} from '../entities/checklist-item.entity';
|
||||
|
||||
// 항목별 진행 상태 입력(작업자 자가보고)
|
||||
export class WorkStatusItemInput {
|
||||
@ApiProperty({ description: '체크리스트 항목 ID' })
|
||||
@IsUUID('4')
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '진행 상태', enum: CHECKLIST_WORK_STATUSES })
|
||||
@IsIn(CHECKLIST_WORK_STATUSES)
|
||||
workStatus!: ChecklistWorkStatus;
|
||||
}
|
||||
|
||||
// 진행 상태 일괄 갱신 — 담당자만 호출
|
||||
export class UpdateWorkStatusDto {
|
||||
@ApiProperty({ description: '갱신할 항목 목록', type: [WorkStatusItemInput] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: '갱신할 항목이 없습니다.' })
|
||||
@ArrayMaxSize(100, { message: '항목이 너무 많습니다.' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkStatusItemInput)
|
||||
items!: WorkStatusItemInput[];
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { Task } from './task.entity';
|
||||
export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const;
|
||||
export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number];
|
||||
|
||||
// 작업자 진행 상태(자가보고) — 지시자의 검토(done/reviewNote)와는 별개.
|
||||
// 작업자가 항목별로 직접 설정하며, 중간 점검 보고 시 스냅샷으로 기록된다.
|
||||
export const CHECKLIST_WORK_STATUSES = ['todo', 'doing', 'done'] as const;
|
||||
export type ChecklistWorkStatus = (typeof CHECKLIST_WORK_STATUSES)[number];
|
||||
|
||||
// 업무 체크리스트 항목
|
||||
@Entity('checklist_items')
|
||||
export class ChecklistItem {
|
||||
@@ -31,10 +36,14 @@ export class ChecklistItem {
|
||||
@Column({ type: 'varchar', default: '필수' })
|
||||
category!: ChecklistCategory;
|
||||
|
||||
// 완료 여부
|
||||
// 완료 여부(지시자 검토 = 승인). 보완거절은 done:false + reviewNote 로 표현
|
||||
@Column({ default: false })
|
||||
done!: boolean;
|
||||
|
||||
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료. 검토(done)와 독립
|
||||
@Column({ name: 'work_status', type: 'varchar', default: 'todo' })
|
||||
workStatus!: ChecklistWorkStatus;
|
||||
|
||||
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
|
||||
@Column({ name: 'review_note', type: 'text', nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Task } from './task.entity';
|
||||
import { TaskCheckpoint } from './task-checkpoint.entity';
|
||||
import type { ChecklistWorkStatus } from './checklist-item.entity';
|
||||
|
||||
// 보고 스냅샷의 항목 단위(보고 시점의 항목별 진행 상태를 동결 보관)
|
||||
export interface ReportItemSnapshot {
|
||||
itemId: string;
|
||||
text: string;
|
||||
workStatus: ChecklistWorkStatus;
|
||||
}
|
||||
|
||||
// 중간 점검 보고 — 작업자가 점검일에 현재 진행 상태를 지시자에게 보낸 기록(스냅샷).
|
||||
@Entity('checkpoint_reports')
|
||||
export class CheckpointReport {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 업무 (업무 삭제 시 함께 삭제)
|
||||
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task!: Relation<Task>;
|
||||
|
||||
// 대상 체크포인트 (점검일 삭제 시 보고도 함께 삭제)
|
||||
@ManyToOne(() => TaskCheckpoint, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'checkpoint_id' })
|
||||
checkpoint!: Relation<TaskCheckpoint>;
|
||||
|
||||
// 보고한 작업자 (사용자 삭제 시에도 보고는 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'reporter_id' })
|
||||
reporter!: User | null;
|
||||
|
||||
// 보고 메모(선택)
|
||||
@Column({ type: 'text', nullable: true })
|
||||
note!: string | null;
|
||||
|
||||
// 보고 시점의 항목별 진행 상태 스냅샷
|
||||
@Column({ name: 'items_snapshot', type: 'jsonb', default: () => "'[]'" })
|
||||
itemsSnapshot!: ReportItemSnapshot[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { Task } from './task.entity';
|
||||
|
||||
// 중간 점검일 — 지시자가 업무 배정 시 지정. 한 업무에 여러 개 가능.
|
||||
// 작업자는 점검일~마감 구간에 진행 상태를 보고할 수 있다.
|
||||
@Entity('task_checkpoints')
|
||||
export class TaskCheckpoint {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 업무 (업무 삭제 시 함께 삭제)
|
||||
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task!: Relation<Task>;
|
||||
|
||||
// 점검 예정일
|
||||
@Column({ name: 'check_at', type: 'timestamptz' })
|
||||
checkAt!: Date;
|
||||
|
||||
// 표시 순서(입력 순)
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from './task.service';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { UpdateWorkStatusDto } from './dto/update-work-status.dto';
|
||||
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
|
||||
import { ChangeStatusDto } from './dto/change-status.dto';
|
||||
import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto';
|
||||
import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
|
||||
@@ -160,6 +162,44 @@ export class TaskController {
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================== 중간 점검(체크포인트) ===================== */
|
||||
|
||||
@Patch(':taskId/work-status')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '체크리스트 진행 상태 갱신 (담당자)' })
|
||||
@ApiResponse({ status: 200, description: '갱신 성공' })
|
||||
@ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' })
|
||||
updateWorkStatus(
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: UpdateWorkStatusDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.updateWorkStatus(projectId, user.id, taskId, dto);
|
||||
}
|
||||
|
||||
@Post(':taskId/checkpoints/:checkpointId/report')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '중간 점검 보고 (담당자) — 점검일~마감 구간' })
|
||||
@ApiResponse({ status: 201, description: '보고 접수' })
|
||||
@ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' })
|
||||
@ApiResponse({ status: 404, description: '점검일을 찾을 수 없음 (RES_001)' })
|
||||
submitCheckpointReport(
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('checkpointId', ParseUUIDPipe) checkpointId: string,
|
||||
@Body() dto: CheckpointReportDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.submitCheckpointReport(
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
checkpointId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
@Post(':taskId/comments')
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Project } from '../project/entities/project.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { Task } from './entities/task.entity';
|
||||
import { ChecklistItem } from './entities/checklist-item.entity';
|
||||
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
|
||||
import { CheckpointReport } from './entities/checkpoint-report.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
@@ -18,6 +20,8 @@ import { MyTaskController } from './my-task.controller';
|
||||
TypeOrmModule.forFeature([
|
||||
Task,
|
||||
ChecklistItem,
|
||||
TaskCheckpoint,
|
||||
CheckpointReport,
|
||||
Comment,
|
||||
Attachment,
|
||||
Project,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Task } from './entities/task.entity';
|
||||
import { ChecklistItem } from './entities/checklist-item.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
|
||||
import { CheckpointReport } from './entities/checkpoint-report.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
@@ -26,6 +28,19 @@ function makeService() {
|
||||
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
|
||||
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
// 중간 점검(체크포인트)·보고 — buildDetail 에서 빈 목록으로 모킹
|
||||
const checkpointRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
const reportRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
// 활동 서비스 — 적재는 no-op, 업무 활동 조회는 빈 목록
|
||||
const activity = {
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -40,6 +55,8 @@ function makeService() {
|
||||
memberRepo as unknown as Repository<ProjectMember>,
|
||||
commentRepo as unknown as Repository<Comment>,
|
||||
attachmentRepo as unknown as Repository<Attachment>,
|
||||
checkpointRepo as unknown as Repository<TaskCheckpoint>,
|
||||
reportRepo as unknown as Repository<CheckpointReport>,
|
||||
activity as unknown as ActivityService,
|
||||
notification as unknown as NotificationService,
|
||||
);
|
||||
|
||||
@@ -14,11 +14,19 @@ import { Task, type TaskStatus } from './entities/task.entity';
|
||||
import {
|
||||
ChecklistItem,
|
||||
type ChecklistCategory,
|
||||
type ChecklistWorkStatus,
|
||||
} from './entities/checklist-item.entity';
|
||||
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
|
||||
import {
|
||||
CheckpointReport,
|
||||
type ReportItemSnapshot,
|
||||
} from './entities/checkpoint-report.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
import { Attachment, type AttachmentKind } from './entities/attachment.entity';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { UpdateWorkStatusDto } from './dto/update-work-status.dto';
|
||||
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
|
||||
import {
|
||||
ActivityService,
|
||||
type ActivityResponse,
|
||||
@@ -58,6 +66,25 @@ export interface ChecklistItemResponse {
|
||||
category: ChecklistCategory;
|
||||
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
|
||||
reviewNote: string | null;
|
||||
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료
|
||||
workStatus: ChecklistWorkStatus;
|
||||
}
|
||||
|
||||
// 중간 점검일 응답
|
||||
export interface CheckpointResponse {
|
||||
id: string;
|
||||
checkAt: string;
|
||||
}
|
||||
|
||||
// 중간 점검 보고 응답(스냅샷 포함)
|
||||
export interface CheckpointReportResponse {
|
||||
id: string;
|
||||
checkpointId: string;
|
||||
checkAt: string | null;
|
||||
reporter: PublicUser | null;
|
||||
note: string | null;
|
||||
items: ReportItemSnapshot[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
|
||||
@@ -140,6 +167,9 @@ export interface TaskDetailResponse {
|
||||
files: AttachmentResponse[];
|
||||
approval: ApprovalInfo | null;
|
||||
changesNote: string | null;
|
||||
// 중간 점검일 + 작업자 보고 이력
|
||||
checkpoints: CheckpointResponse[];
|
||||
checkpointReports: CheckpointReportResponse[];
|
||||
}
|
||||
|
||||
// 상태 머신 전이 규칙(허용 목적지)
|
||||
@@ -176,6 +206,10 @@ export class TaskService {
|
||||
private readonly commentRepo: Repository<Comment>,
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
@InjectRepository(TaskCheckpoint)
|
||||
private readonly checkpointRepo: Repository<TaskCheckpoint>,
|
||||
@InjectRepository(CheckpointReport)
|
||||
private readonly reportRepo: Repository<CheckpointReport>,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly notification: NotificationService,
|
||||
) {}
|
||||
@@ -357,6 +391,9 @@ export class TaskService {
|
||||
});
|
||||
const saved = await this.taskRepo.save(task);
|
||||
|
||||
// 중간 점검일 생성(있으면)
|
||||
await this.syncCheckpoints(saved, dto.checkpoints);
|
||||
|
||||
// 활동 적재 — 업무 생성
|
||||
await this.activity.record({
|
||||
projectId: project.id,
|
||||
@@ -417,6 +454,11 @@ export class TaskService {
|
||||
checklistChanged = await this.syncChecklist(task, dto.checklist);
|
||||
}
|
||||
|
||||
// 중간 점검일 동기화(제공된 경우에만) — 날짜 기준 추가/삭제(미변경 항목·보고 유지)
|
||||
if (dto.checkpoints !== undefined) {
|
||||
await this.syncCheckpoints(task, dto.checkpoints);
|
||||
}
|
||||
|
||||
// 변경 항목별 활동 적재(담당자/마감/일반 수정)
|
||||
await this.recordUpdateActivities(project, actingUserId, task, {
|
||||
dto,
|
||||
@@ -1293,15 +1335,166 @@ export class TaskService {
|
||||
}
|
||||
|
||||
// 업무 상세 + 부속(댓글/첨부/승인) 조립
|
||||
// --- 중간 점검(체크포인트) ---
|
||||
|
||||
// 작업자 진행 상태 갱신(자가보고) — 담당자만. 항목별 work_status 갱신
|
||||
async updateWorkStatus(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
seq: number,
|
||||
dto: UpdateWorkStatusDto,
|
||||
): Promise<TaskDetailResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
const task = await this.getTaskOrThrow(project.id, seq);
|
||||
this.assertAssignee(task, actingUserId);
|
||||
|
||||
const byId = new Map((task.checklist ?? []).map((c) => [c.id, c]));
|
||||
const changed: ChecklistItem[] = [];
|
||||
for (const it of dto.items) {
|
||||
const item = byId.get(it.id);
|
||||
if (item && item.workStatus !== it.workStatus) {
|
||||
item.workStatus = it.workStatus;
|
||||
changed.push(item);
|
||||
}
|
||||
}
|
||||
if (changed.length) {
|
||||
await this.checklistRepo.save(changed);
|
||||
}
|
||||
return this.buildDetail(
|
||||
await this.getTaskOrThrow(project.id, seq),
|
||||
project,
|
||||
);
|
||||
}
|
||||
|
||||
// 중간 점검 보고 — 담당자만. 점검일~마감 구간에서 현재 진행 상태를 스냅샷 기록 + 지시자 알림
|
||||
async submitCheckpointReport(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
seq: number,
|
||||
checkpointId: string,
|
||||
dto: CheckpointReportDto,
|
||||
): Promise<TaskDetailResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
const task = await this.getTaskOrThrow(project.id, seq);
|
||||
this.assertAssignee(task, actingUserId);
|
||||
|
||||
const checkpoint = await this.checkpointRepo.findOne({
|
||||
where: { id: checkpointId, task: { id: task.id } },
|
||||
});
|
||||
if (!checkpoint) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// 보고 가능 시점: 점검일 당일부터 마감 전까지
|
||||
const now = Date.now();
|
||||
if (checkpoint.checkAt.getTime() > now) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'아직 중간 점검일이 되지 않았습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (task.dueDate && task.dueDate.getTime() <= now) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'마감이 지나 중간 점검 보고를 보낼 수 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 항목별 진행 상태 스냅샷(동결)
|
||||
const snapshot: ReportItemSnapshot[] = (task.checklist ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((c) => ({ itemId: c.id, text: c.text, workStatus: c.workStatus }));
|
||||
|
||||
const reporter =
|
||||
(task.assignees ?? []).find((a) => a.id === actingUserId) ?? null;
|
||||
const note = dto.note?.trim() ? dto.note.trim() : null;
|
||||
|
||||
await this.reportRepo.save(
|
||||
this.reportRepo.create({
|
||||
task: { id: task.id } as Task,
|
||||
checkpoint: { id: checkpoint.id } as TaskCheckpoint,
|
||||
reporter,
|
||||
note,
|
||||
itemsSnapshot: snapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
// 지시자에게 알림(행위자=작업자는 자동 제외)
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
actorId: actingUserId,
|
||||
type: 'task.checkpoint_reported',
|
||||
payload: {
|
||||
...this.taskNotifyPayload(project, task, reporter?.name ?? '담당자'),
|
||||
checkpointAt: checkpoint.checkAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.buildDetail(
|
||||
await this.getTaskOrThrow(project.id, seq),
|
||||
project,
|
||||
);
|
||||
}
|
||||
|
||||
// 중간 점검일 동기화 — 날짜(시각) 기준 추가/삭제. 미변경 날짜는 그대로 두어 보고를 보존.
|
||||
private async syncCheckpoints(task: Task, dates?: string[]): Promise<void> {
|
||||
if (dates === undefined) return;
|
||||
const existing = await this.checkpointRepo.find({
|
||||
where: { task: { id: task.id } },
|
||||
});
|
||||
const existingTimes = existing.map((c) => c.checkAt.getTime());
|
||||
const wantedTimes = [...new Set(dates.map((d) => new Date(d).getTime()))];
|
||||
|
||||
const toRemove = existing.filter(
|
||||
(c) => !wantedTimes.includes(c.checkAt.getTime()),
|
||||
);
|
||||
if (toRemove.length) {
|
||||
await this.checkpointRepo.remove(toRemove);
|
||||
}
|
||||
const toAdd = wantedTimes
|
||||
.filter((t) => !existingTimes.includes(t))
|
||||
.map((t, i) =>
|
||||
this.checkpointRepo.create({
|
||||
task: { id: task.id } as Task,
|
||||
checkAt: new Date(t),
|
||||
sortOrder: i,
|
||||
}),
|
||||
);
|
||||
if (toAdd.length) {
|
||||
await this.checkpointRepo.save(toAdd);
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자(작업자) 확인 — 아니면 403
|
||||
private assertAssignee(task: Task, userId: string): void {
|
||||
const isAssignee = (task.assignees ?? []).some((a) => a.id === userId);
|
||||
if (!isAssignee) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
private async buildDetail(
|
||||
task: Task,
|
||||
project: Project,
|
||||
): Promise<TaskDetailResponse> {
|
||||
const [comments, files, activities] = await Promise.all([
|
||||
this.loadComments(task.id, project, task.seq),
|
||||
this.loadFiles(task.id, project, task.seq),
|
||||
this.activity.listForTask(project.id, task.seq),
|
||||
]);
|
||||
const [comments, files, activities, checkpointRows, reportRows] =
|
||||
await Promise.all([
|
||||
this.loadComments(task.id, project, task.seq),
|
||||
this.loadFiles(task.id, project, task.seq),
|
||||
this.activity.listForTask(project.id, task.seq),
|
||||
this.checkpointRepo.find({
|
||||
where: { task: { id: task.id } },
|
||||
order: { checkAt: 'ASC' },
|
||||
}),
|
||||
this.reportRepo.find({
|
||||
where: { task: { id: task.id } },
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['reporter', 'checkpoint'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const checklist = (task.checklist ?? [])
|
||||
.slice()
|
||||
@@ -1312,8 +1505,27 @@ export class TaskService {
|
||||
done: c.done,
|
||||
category: c.category,
|
||||
reviewNote: c.reviewNote ?? null,
|
||||
workStatus: c.workStatus,
|
||||
}));
|
||||
|
||||
const checkpoints: CheckpointResponse[] = checkpointRows.map((c) => ({
|
||||
id: c.id,
|
||||
checkAt: c.checkAt.toISOString(),
|
||||
}));
|
||||
const checkpointReports: CheckpointReportResponse[] = reportRows.map(
|
||||
(r) => ({
|
||||
id: r.id,
|
||||
checkpointId: r.checkpoint?.id ?? '',
|
||||
checkAt: r.checkpoint?.checkAt
|
||||
? r.checkpoint.checkAt.toISOString()
|
||||
: null,
|
||||
reporter: r.reporter ? UserService.toPublic(r.reporter) : null,
|
||||
note: r.note,
|
||||
items: r.itemsSnapshot ?? [],
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
|
||||
const approval: ApprovalInfo | null =
|
||||
task.status === 'review' && task.approvalRequestedAt
|
||||
@@ -1343,6 +1555,8 @@ export class TaskService {
|
||||
files,
|
||||
approval,
|
||||
changesNote: task.status === 'changes' ? task.changesNote : null,
|
||||
checkpoints,
|
||||
checkpointReports,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user