From 2c6d94bf4485a24156c8267b55339feda6f32b7e Mon Sep 17 00:00:00 2001 From: ahah1313 Date: Tue, 23 Jun 2026 16:43:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EA=B8=B0=EC=97=AC=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/package.json | 1 + .../contributions/contribution.client.spec.ts | 106 +++- .../contributions/contribution.mapper.spec.ts | 34 ++ .../src/contributions/contribution.mapper.ts | 38 +- .../contributions.service.spec.ts | 233 +++++++++ server/src/seed/seed-attendance-demo.ts | 484 ++++++++++++++++++ server/src/teams/teams.service.spec.ts | 97 ++++ server/src/teams/teams.service.ts | 12 + 8 files changed, 1002 insertions(+), 3 deletions(-) create mode 100644 server/src/contributions/contributions.service.spec.ts create mode 100644 server/src/seed/seed-attendance-demo.ts create mode 100644 server/src/teams/teams.service.spec.ts diff --git a/server/package.json b/server/package.json index 43543ec..b2f1615 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts", "migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts", "seed:scenario": "ts-node -r tsconfig-paths/register src/seed/seed-test-scenario.ts", + "seed:attendance-demo": "ts-node -r tsconfig-paths/register src/seed/seed-attendance-demo.ts", "migration:run:prod": "typeorm migration:run -d dist/data-source.js", "migration:revert:prod": "typeorm migration:revert -d dist/data-source.js", "migration:show:prod": "typeorm migration:show -d dist/data-source.js" diff --git a/server/src/contributions/contribution.client.spec.ts b/server/src/contributions/contribution.client.spec.ts index 3addcc4..209ed8a 100644 --- a/server/src/contributions/contribution.client.spec.ts +++ b/server/src/contributions/contribution.client.spec.ts @@ -74,6 +74,7 @@ const RAW_MEETING: TeamPipelineRequest['meetings'][number] = { team_settings: SETTINGS, participant_user_ids: [1, 2], absent_user_ids: [], + excused_late_user_ids: [], utterances: MEETING_REQ.utterances, presence_events: MEETING_REQ.presence_events, anomaly_events: [], @@ -131,6 +132,10 @@ const pipelineResponse = ( final: number; weights_used: Record; }>; + meeting_scores?: Partial<{ + attend_score: number | null; + absent: boolean; + }>[]; } = {}, ) => ({ name: '1', @@ -158,6 +163,9 @@ const pipelineResponse = ( leader_applied: false, ...over.final, }, + // computeMeetingScores()가 attendance_ratio로 가져다 쓰는 단일 회의 상세값. + // 기본값(1.0)은 기존 테스트의 "정시 입장·완전 참석" 가정과 맞춘다. + meeting_scores: over.meeting_scores ?? [{ attend_score: 1.0, absent: false }], }); function mockPipeline(body: unknown): jest.SpyInstance { @@ -204,7 +212,7 @@ describe('ContributionClient — 외부 기여도 API(/pipeline/score) 연동', expect(res!.scores).toHaveLength(2); const u1 = res!.scores.find((s) => s.user_id === 1)!; expect(u1.meeting_score).toBe(0.94); // 회의 1건 누적 = 그 회의 점수 - expect(u1.attendance_ratio).toBe(1.0); // 로컬 파생 (attend/total) + expect(u1.attendance_ratio).toBe(1.0); // 엔진 응답의 attend_score(meeting_scores[0]) expect(u1.confidence_level).toBeNull(); // pipeline 미제공 expect(u1.speech_ratio).toBeCloseTo(0.75); // 300/400 원시 비율 }); @@ -232,6 +240,102 @@ describe('ContributionClient — 외부 기여도 API(/pipeline/score) 연동', expect(res!.scores[0].meeting_score).toBeNull(); }); + it('①: punctuality_score는 하드코딩 5분이 아니라 팀 설정의 late_threshold_minutes를 따른다', async () => { + fetchMock = mockPipeline(pipelineResponse()); + const client = makeClient('http://contrib.test'); + + // SETTINGS.late_threshold_minutes=5(300초). user 1은 4분(240초) 지각 → 기준 이내 → 1.0 + const req: MeetingScoreRequest = { + ...MEETING_REQ, + presence_events: [ + { + user_id: 1, + event_type: 'join', + disconnect_classification: null, + timestamp_offset_ms: 240_000, + }, + MEETING_REQ.presence_events[1], + ], + }; + const res = await client.computeMeetingScores(req); + const u1 = res!.scores.find((s) => s.user_id === 1)!; + expect(u1.punctuality_score).toBe(1.0); + + // late_threshold_minutes를 1분으로 바꾸면 같은 4분 지각이 기준 초과로 바뀐다 + const reqShortThreshold: MeetingScoreRequest = { + ...req, + team_settings: { ...SETTINGS, late_threshold_minutes: 1 }, + }; + const res2 = await client.computeMeetingScores(reqShortThreshold); + const u1b = res2!.scores.find((s) => s.user_id === 1)!; + expect(u1b.punctuality_score).toBe(0.0); + }); + + it('①: attendance_ratio는 엔진의 attend_score를 그대로 쓴다 (지각해도 100%로 뜨지 않음)', async () => { + // 엔진이 지각 페널티를 반영해 attend_score=0.65를 줬다면, 화면 표시값인 + // attendance_ratio도 그 값을 그대로 받아야 한다. 과거엔 actual_attend_sec + // (자발적 자리비움만 차감, 지각 시간은 차감하지 않음)을 직접 나눈 값을 써서 + // 지각해도 100%로 표시되는 버그가 있었다. + fetchMock = mockPipeline( + pipelineResponse({ + meeting_scores: [{ attend_score: 0.65, absent: false }], + }), + ); + const client = makeClient('http://contrib.test'); + + const res = await client.computeMeetingScores(MEETING_REQ); + const u1 = res!.scores.find((s) => s.user_id === 1)!; + expect(u1.attendance_ratio).toBe(0.65); + }); + + it('①: 완전 결석이면 attendance_ratio는 0 (엔진이 attend_score=null을 줘도)', async () => { + fetchMock = mockPipeline( + pipelineResponse({ + meeting_scores: [{ attend_score: null, absent: true }], + }), + ); + const client = makeClient('http://contrib.test'); + + // user 1이 입장 기록이 전혀 없는 회의로 구성 — deriveMemberData가 absent=true로 파생 + const req: MeetingScoreRequest = { + ...MEETING_REQ, + presence_events: [MEETING_REQ.presence_events[1]], // user 1 join 제거 + }; + const res = await client.computeMeetingScores(req); + const u1 = res!.scores.find((s) => s.user_id === 1)!; + expect(u1.attendance_ratio).toBe(0); + }); + + it('①: excused_late_user_ids에 포함된 멤버는 엔진 호출 시 excused_late=true로 전달된다', async () => { + // ①(단일 회의) 계산 경로는 사유 지각 승인 여부를 알 길이 없었다 — 회의 종료 + // 직후 자동 계산 시점엔 보통 사유 신청이 아직 없어 문제가 안 됐지만, 시드나 + // 재계산처럼 승인까지 끝난 뒤에 ①을 다시 계산하면 면제가 반영 안 된 값이 + // 저장되는 회귀가 있었다. excused_late_user_ids로 명시적으로 전달해야 한다. + fetchMock = mockPipeline(pipelineResponse()); + const client = makeClient('http://contrib.test'); + + await client.computeMeetingScores({ + ...MEETING_REQ, + excused_late_user_ids: [1], + }); + + const body = callBody(fetchMock, 0); + expect(body.meetings[0].excused_late).toBe(true); + }); + + it('①: excused_late_user_ids에 없는 멤버는 excused_late=false로 전달된다', async () => { + fetchMock = mockPipeline(pipelineResponse()); + const client = makeClient('http://contrib.test'); + + await client.computeMeetingScores({ + ...MEETING_REQ, + excused_late_user_ids: [999], // user 1은 해당 없음 + }); + + const body = callBody(fetchMock, 0); + expect(body.meetings[0].excused_late).toBe(false); + }); + it('②③④ 멤버별 /pipeline/score 1회 — 원시 회의 행 + 액션 동봉, is_leader 전달', async () => { fetchMock = mockPipeline(pipelineResponse()); const client = makeClient('http://contrib.test'); diff --git a/server/src/contributions/contribution.mapper.spec.ts b/server/src/contributions/contribution.mapper.spec.ts index c52dbef..017dd59 100644 --- a/server/src/contributions/contribution.mapper.spec.ts +++ b/server/src/contributions/contribution.mapper.spec.ts @@ -29,6 +29,8 @@ describe('mapTeamSettings — 우리 설정 → 외부 TeamSettingsSchema', () = weight_attend_in_meeting: 0.4, weight_task_in_final: 0.5, punctuality_grace_ratio: 0.1, + late_threshold_sec: 300, // 5분 → 초 + late_max_sec: null, // late_max_minutes=0 → 상한 없음(null) absence_grace_sec: 30, leader_bonus: 0, action_chars_limit: 500, @@ -49,6 +51,25 @@ describe('mapTeamSettings — 우리 설정 → 외부 TeamSettingsSchema', () = ).toBe(0); }); + it('late_threshold_minutes/late_max_minutes(분) → late_threshold_sec/late_max_sec(초)로 변환한다', () => { + const result = mapTeamSettings({ + ...SETTINGS, + late_threshold_minutes: 5, + late_max_minutes: 10, + }); + expect(result.late_threshold_sec).toBe(300); + expect(result.late_max_sec).toBe(600); + }); + + it('late_max_minutes=0(상한 없음)이면 late_max_sec=null로 변환한다', () => { + const result = mapTeamSettings({ + ...SETTINGS, + late_threshold_minutes: 5, + late_max_minutes: 0, + }); + expect(result.late_max_sec).toBeNull(); + }); + it('lenient/strict 마감 모드는 그대로 전달한다', () => { expect( mapTeamSettings({ ...SETTINGS, deadline_penalty_curve: 'lenient' }) @@ -203,6 +224,19 @@ describe('deriveMemberData — 원시 이벤트 → 외부 MemberMeetingData', ( }); expect(deriveMemberData(req, 1).data.is_official).toBe(false); }); + + it('excusedLate 인자를 안 넘기면 excused_late=false', () => { + const req = baseMeetingReq({ presence_events: [join(1, 300_000)] }); + expect(deriveMemberData(req, 1).data.excused_late).toBe(false); + }); + + it('excusedLate=true 를 넘기면 그대로 excused_late=true 로 전달된다', () => { + const req = baseMeetingReq({ presence_events: [join(1, 300_000)] }); + const { data } = deriveMemberData(req, 1, true); + expect(data.excused_late).toBe(true); + // 사유 지각이어도 late_sec 자체는 그대로 — 면제 여부 판단은 엔진이 한다 + expect(data.late_sec).toBe(300); + }); }); describe('absentUnexcusedIds — 무단결석(0점·누적 포함) 멤버 판정', () => { diff --git a/server/src/contributions/contribution.mapper.ts b/server/src/contributions/contribution.mapper.ts index ebc9209..1562cc8 100644 --- a/server/src/contributions/contribution.mapper.ts +++ b/server/src/contributions/contribution.mapper.ts @@ -16,6 +16,8 @@ export interface ExternalTeamSettings { weight_attend_in_meeting: number; weight_task_in_final: number; punctuality_grace_ratio: number; + late_threshold_sec: number | null; + late_max_sec: number | null; absence_grace_sec: number; leader_bonus: number; action_chars_limit: number; @@ -36,6 +38,7 @@ export interface ExternalMemberMeetingData { audio_loss_pct: number; speech_confidence: number; excused_absence: boolean; + excused_late: boolean; absent: boolean; is_official: boolean; // ③ 테스크 입력 — pipeline 은 회의 행에 동봉된 액션을 모아(collect_actions) 계산한다 @@ -56,6 +59,24 @@ export interface ExternalCumulativeScoreResponse { excluded_count: number; } +// 단일 회의 상세 점수 — attend_score는 출석비율+지각 페널티(사유 지각 면제 포함)가 +// 반영된 값. attendance_ratio(순수 참여시간 비율)와 달리 지각 여부를 실제로 반영하므로 +// 화면의 "출석" 표시는 이 값을 써야 한다. +export interface ExternalMeetingScoreResponse { + name: string; + meeting_id: string; + meeting_total_sec: number; + speech_score: number | null; + attend_score: number | null; + meeting_contribution: number; + reliability: string; + low_attend_flag: boolean; + weights_used: Record; + is_official: boolean; + excused_absence: boolean; + absent: boolean; +} + export interface ExternalTaskScoreResponse { name: string; score: number | null; @@ -74,12 +95,14 @@ export interface ExternalFinalScoreResponse { leader_applied: boolean; } -// /pipeline/score 응답 — meeting 은 보낸 회의들의 누적(②) 결과 +// /pipeline/score 응답 — meeting 은 보낸 회의들의 누적(②) 결과, +// meeting_scores 는 보낸 각 회의의 단일 회의 상세(① 수준 — attend_score 등) export interface ExternalFullPipelineResponse { name: string; meeting: ExternalCumulativeScoreResponse; task: ExternalTaskScoreResponse; final: ExternalFinalScoreResponse; + meeting_scores: ExternalMeetingScoreResponse[]; } // --- 변환 함수 --- @@ -88,11 +111,18 @@ export interface ExternalFullPipelineResponse { // (외부 엔진 기본값 0.75/0.25 미사용 — 로컬 폴백 스코어러와 결과 일관성 유지). export function mapTeamSettings(s: TeamSettingsPayload): ExternalTeamSettings { const curve = s.deadline_penalty_curve ?? 'standard'; + const lateThresholdMin = s.late_threshold_minutes ?? 5; + const lateMaxMin = s.late_max_minutes ?? 0; return { weight_speech_in_meeting: s.weight_speech_in_meeting ?? 0.6, weight_attend_in_meeting: s.weight_attend_in_meeting ?? 0.4, weight_task_in_final: s.final_task_weight ?? 0.5, punctuality_grace_ratio: s.punctuality_grace_ratio ?? 0.1, + // 지각 기준(분) → 초. 엔진의 신(新) 절대시간 기준 로직을 켠다. + late_threshold_sec: lateThresholdMin * 60, + // 지각 최대 인정 시간(분) → 초. 0(상한 없음)이면 null 전달 — 엔진이 점근적 + // 감쇠로 처리한다(0으로 즉시 떨어뜨리지 않고 계속 완만하게 감점). + late_max_sec: lateMaxMin > 0 ? lateMaxMin * 60 : null, absence_grace_sec: s.presence_grace_seconds ?? 30, // 우리는 배율(final×n), 외부는 가산(final×(1+n)) — 1 미만 배율은 표현 불가라 0 클램프 leader_bonus: Math.max(0, (s.leader_bonus_multiplier ?? 1) - 1), @@ -114,9 +144,12 @@ function meetingDurationMs(m: MeetingRawInput['meeting']): number { // 한 참여자의 원시 이벤트를 외부 MemberMeetingData(파생 지표)로 변환. // rawSpeechRatio(own/total)는 UI 발언 비중 바 저장용 — 외부 speech_score 는 1/N 정규화 점수라 별개. +// excusedLate: 이 회의에 대해 본인의 지각 사유가 팀원 과반 동의로 승인된 경우 true. +// 승인되면 엔진이 지각 감점(정시 점수)만 면제하고 출석 비율은 그대로 반영한다. export function deriveMemberData( req: MeetingRawInput, userId: number, + excusedLate = false, ): { data: ExternalMemberMeetingData; rawSpeechRatio: number | null } { const s = req.team_settings; const maxChars = s.max_utterance_chars ?? 500; @@ -210,7 +243,8 @@ export function deriveMemberData( team_size: req.participant_user_ids.length, audio_loss_pct: lossDenom > 0 ? (capLoss / lossDenom) * 100 : 0, speech_confidence: confCount > 0 ? confSum / confCount : 1.0, - excused_absence: false, // 사유 결석은 점수에 반영하지 않음 (출결 표시 전용) + excused_absence: false, // 사유 결석은 absent_user_ids 보호로 처리 (출결 표시 전용 필드) + excused_late: excusedLate, absent, is_official: req.meeting.meeting_type === 'regular', }, diff --git a/server/src/contributions/contributions.service.spec.ts b/server/src/contributions/contributions.service.spec.ts new file mode 100644 index 0000000..8fc5eb0 --- /dev/null +++ b/server/src/contributions/contributions.service.spec.ts @@ -0,0 +1,233 @@ +import { ContributionsService } from './contributions.service'; + +// getTeamContributions()의 attendance_avg 계산이 승인된 사유결석을 평균에서 +// 제외하는지 검증한다. composite_score(②)와 동일한 규칙을 써야 하므로, +// 이 동작이 깨지면 "사유결석 승인됐는데 출석 표시가 그대로 0%로 남는" 회귀가 +// 재발한다. +describe('ContributionsService.getTeamContributions — attendance_avg 사유결석 제외', () => { + const TEAM_ID = 1; + const USER_ID = 10; // 조회 요청자(팀장) + const SY = 30; // 사유결석 대상 멤버 + const MTG1 = 101; + const MTG2 = 102; + + function makeService(overrides: { + scores: Record[]; + absences: Record[]; + presence?: Record[]; + }) { + const meetings = [ + { + id: MTG1, + team_id: TEAM_ID, + is_invalidated: false, + meeting_type: 'regular', + total_minutes: 60, + t0_timestamp: null, + ended_at: null, + scheduled_at: new Date('2026-06-01'), + }, + { + id: MTG2, + team_id: TEAM_ID, + is_invalidated: false, + meeting_type: 'regular', + total_minutes: 50, + t0_timestamp: null, + ended_at: null, + scheduled_at: new Date('2026-06-02'), + }, + ]; + const memberships = [ + { + team_id: TEAM_ID, + user_id: USER_ID, + role: 'leader', + deleted_at: null, + joined_at: new Date('2026-01-01'), + }, + { + team_id: TEAM_ID, + user_id: SY, + role: 'member', + deleted_at: null, + joined_at: new Date('2026-01-01'), + }, + ]; + + const scoreRepo = { find: jest.fn().mockResolvedValue(overrides.scores) }; + const meetingRepo = { find: jest.fn().mockResolvedValue(meetings) }; + const agendaRepo = { find: jest.fn().mockResolvedValue([]) }; + const utteranceRepo = { find: jest.fn().mockResolvedValue([]) }; + const presenceRepo = { + find: jest.fn().mockResolvedValue(overrides.presence ?? []), + }; + const anomalyRepo = { find: jest.fn().mockResolvedValue([]) }; + const actionRepo = { find: jest.fn().mockResolvedValue([]) }; + const membershipRepo = { find: jest.fn().mockResolvedValue(memberships) }; + const absenceRepo = { + find: jest.fn().mockResolvedValue(overrides.absences), + }; + const settingsRepo = { + findOne: jest.fn().mockResolvedValue({ + team_id: TEAM_ID, + contribution_visibility: 'team', + }), + }; + const userRepo = { + find: jest.fn().mockResolvedValue([ + { id: USER_ID, name: '팀장' }, + { id: SY, name: '이서연' }, + ]), + }; + const teamsService = { + requireMembership: jest.fn().mockResolvedValue({ + team_id: TEAM_ID, + user_id: USER_ID, + role: 'leader', + }), + }; + const client = { + computeTeamContributions: jest.fn().mockResolvedValue({ + members: [ + { + user_id: USER_ID, + meeting_aggregate: 1, + task_score: null, + composite_score: 1, + }, + { + user_id: SY, + meeting_aggregate: 0.5, + task_score: null, + composite_score: 0.5, + }, + ], + }), + }; + + const service = new ContributionsService( + scoreRepo as never, + meetingRepo as never, + agendaRepo as never, + utteranceRepo as never, + presenceRepo as never, + anomalyRepo as never, + actionRepo as never, + membershipRepo as never, + absenceRepo as never, + settingsRepo as never, + userRepo as never, + teamsService as never, + client as never, + ); + return service; + } + + it('승인된 사유결석 회의는 attendance_avg 평균에서 제외된다', async () => { + // 회의1: attendance_ratio=0(결석), 승인된 사유결석 있음 → 제외 + // 회의2: attendance_ratio=0.8 → 포함 + // 제외하면 평균은 0.8(회의2만), 제외 안 하면 (0+0.8)/2=0.4 + const service = makeService({ + scores: [ + { + user_id: SY, + meeting_id: MTG1, + attendance_ratio: 0, + speech_ratio: null, + }, + { + user_id: SY, + meeting_id: MTG2, + attendance_ratio: 0.8, + speech_ratio: null, + }, + ], + absences: [{ meeting_id: MTG1, user_id: SY }], + }); + + const result = await service.getTeamContributions(USER_ID, TEAM_ID); + const sy = result.members.find((m) => m.user_id === SY)!; + expect(sy.attendance_avg).toBeCloseTo(0.8); + }); + + it('사유결석이 승인되지 않았으면(미조회) 0점 회의도 평균에 그대로 포함된다', async () => { + const service = makeService({ + scores: [ + { + user_id: SY, + meeting_id: MTG1, + attendance_ratio: 0, + speech_ratio: null, + }, + { + user_id: SY, + meeting_id: MTG2, + attendance_ratio: 0.8, + speech_ratio: null, + }, + ], + absences: [], // 승인된 사유 없음 — 무단결석 그대로 + }); + + const result = await service.getTeamContributions(USER_ID, TEAM_ID); + const sy = result.members.find((m) => m.user_id === SY)!; + expect(sy.attendance_avg).toBeCloseTo(0.4); + }); + + it('승인된 사유가 "지각"(입장 기록 있음)이면 회의를 제외하지 않고 attend_score 그대로 평균에 포함한다', async () => { + // 결석(absence)과 지각(late)은 meeting_absences 테이블에 같은 형태로 저장되므로 + // presence_events의 입장 기록으로 구분해야 한다. 입장 기록이 있으면(=늦게라도 + // 참석) 그 회의는 빼면 안 된다 — 사유 지각의 효과는 "지각 페널티 면제"뿐이고 + // attend_score(0.65)에 이미 그 면제가 반영돼 있으므로, 그 값 그대로 평균에 + // 들어가야 한다. 회의 자체를 빼버리면 데이터가 1건 줄어 평균이 왜곡된다. + const service = makeService({ + scores: [ + { + user_id: SY, + meeting_id: MTG1, + attendance_ratio: 0.65, // 사유 지각 승인 — 페널티 면제된 값 + speech_ratio: null, + }, + { + user_id: SY, + meeting_id: MTG2, + attendance_ratio: 0.85, + speech_ratio: null, + }, + ], + absences: [{ meeting_id: MTG1, user_id: SY }], // status=approved + presence: [{ meeting_id: MTG1, user_id: SY, event_type: 'join' }], // 입장 기록 있음 = 지각 + }); + + const result = await service.getTeamContributions(USER_ID, TEAM_ID); + const sy = result.members.find((m) => m.user_id === SY)!; + // 제외 안 됐으면 (0.65+0.85)/2=0.75. 잘못 제외됐다면 0.85만 남는다. + expect(sy.attendance_avg).toBeCloseTo(0.75); + }); + + it('승인된 사유가 "결석"(입장 기록 없음)이면 여전히 회의를 제외한다 (회귀 방지)', async () => { + const service = makeService({ + scores: [ + { + user_id: SY, + meeting_id: MTG1, + attendance_ratio: 0, + speech_ratio: null, + }, + { + user_id: SY, + meeting_id: MTG2, + attendance_ratio: 0.85, + speech_ratio: null, + }, + ], + absences: [{ meeting_id: MTG1, user_id: SY }], + presence: [], // 입장 기록 없음 = 결석 + }); + + const result = await service.getTeamContributions(USER_ID, TEAM_ID); + const sy = result.members.find((m) => m.user_id === SY)!; + expect(sy.attendance_avg).toBeCloseTo(0.85); + }); +}); diff --git a/server/src/seed/seed-attendance-demo.ts b/server/src/seed/seed-attendance-demo.ts new file mode 100644 index 0000000..75aa88c --- /dev/null +++ b/server/src/seed/seed-attendance-demo.ts @@ -0,0 +1,484 @@ +/** + * 출결 데모 시나리오 시드 — 결석/사유결석/지각이 각각 다른 회의에 분산된 + * 회의 3개(전부 종료)와, 마감 상태가 다양한 태스크 묶음을 생성한다. + * + * 목적: 클라이언트 리포트 화면(REQUIRED_MEETINGS=3 잠금 해제)에서 출석 축 + * 점수가 결석/사유결석/지각별로 어떻게 갈리는지 바로 확인하기 위한 데모 데이터. + * seed-test-scenario.ts(기존 종합 시나리오)와는 별개로 동작하며 서로 다른 + * 초대코드를 쓰므로 함께 실행해도 충돌하지 않는다. + * + * 실행: npm run seed:attendance-demo (가장 최근 카카오 로그인 사용자를 팀장으로) + * npm run seed:attendance-demo -- (특정 user_id를 팀장으로) + * + * 재실행하면 기존 시드 팀(invite_code=ATTDEMO1)을 지우고 다시 만든다. + */ +import { AppDataSource } from '../data-source'; +import type { ConfigService } from '@nestjs/config'; +import { ContributionClient } from '../contributions/contribution.client'; +import type { + MeetingScoreRequest, + TeamSettingsPayload, +} from '../contributions/contribution.types'; + +const INVITE = 'ATTDEMO1'; +const pad = (n: number) => String(n).padStart(2, '0'); + +// 실행 시점 기준 상대 날짜 → 'YYYY-MM-DD HH:mm:ss' (재실행해도 '과거/미래' 관계 유지) +function dt(offsetDays: number, hour = 14, min = 0): string { + const d = new Date(Date.now() + offsetDays * 86400000); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hour)}:${pad(min)}:00`; +} + +// 회의 1건의 결과를 산정 엔진에 보내 ①(contribution_scores)에 저장. +// presence/utterance를 그대로 받아 deriveMemberData가 absent/late_sec 등을 +// 자동 파생하므로, 결석자는 presence row를 안 넣는 것만으로 absent=true가 된다. +async function scoreAndStoreMeeting( + m: { query: (sql: string, params?: unknown[]) => Promise }, + client: ContributionClient, + args: { + meetingId: number; + totalMinutes: number; + scheduledAt: string; + t0: string; + endedAt: string; + participantIds: number[]; + presence: { + user_id: number; + offsetMs: number; + eventType?: 'join' | 'leave' | 'disconnect' | 'reconnect'; + }[]; + utterances: { user_id: number; char_count: number; off: number }[]; + settings: TeamSettingsPayload; + // 이 회의에 대해 사유 지각이 승인된 멤버 — ①(contribution_scores) 계산에도 + // 반영해야 지각 페널티 면제가 attendance_ratio에 실제로 나타난다. 빠뜨리면 + // ①은 일반 지각과 동일하게 감점된 값으로 저장되고, 화면(리포트)에는 그 ①값이 + // 그대로 노출되어 "승인됐는데 왜 점수가 그대로냐"는 불일치가 생긴다. + excusedLateUserIds?: number[]; + }, +): Promise { + const payload: MeetingScoreRequest = { + meeting: { + id: args.meetingId, + total_minutes: args.totalMinutes, + scheduled_at: args.scheduledAt, + t0_timestamp: args.t0, + ended_at: args.endedAt, + meeting_type: 'regular', + }, + team_settings: args.settings, + participant_user_ids: args.participantIds, + excused_late_user_ids: args.excusedLateUserIds ?? [], + utterances: args.utterances.map((u) => ({ + user_id: u.user_id, + char_count: u.char_count, + agenda_id: null, + confidence: 0.95, + })), + agendas: [], + // event_type을 'join'으로 고정하면 leave/disconnect(자리비움) 이벤트를 ①점수 + // 계산 입력으로 전달할 방법이 없어, DB에 저장된 실제 presence_events(②계산이 + // 다시 읽는 원본)와 ①(contribution_scores) 계산 입력이 서로 달라지는 문제가 + // 있었다 — 조퇴해도 ①은 "끝까지 있었음"으로 계산되는 버그의 원인. + presence_events: args.presence.map((p) => ({ + user_id: p.user_id, + event_type: p.eventType ?? 'join', + disconnect_classification: null, + timestamp_offset_ms: p.offsetMs, + })), + anomaly_events: [], + }; + const res = await client.computeMeetingScores(payload); + if (!res) { + throw new Error( + '엔진 산정에 실패했습니다. 엔진 서버가 떠 있는지 확인하세요.', + ); + } + for (const r of res.scores) { + await m.query( + `INSERT INTO contribution_scores (user_id, meeting_id, speech_ratio, speech_consistency, attendance_ratio, punctuality_score, meeting_score, confidence_level, excluded_indicators) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + r.user_id, + args.meetingId, + r.speech_ratio, + r.speech_consistency, + r.attendance_ratio, + r.punctuality_score, + r.meeting_score, + r.confidence_level, + r.excluded_indicators ? JSON.stringify(r.excluded_indicators) : null, + ], + ); + } +} + +async function seed() { + await AppDataSource.initialize(); + const ds = AppDataSource; + try { + // 1) 팀장 결정 — 인자 우선, 없으면 가장 최근 실제 카카오 사용자 + const argId = process.argv[2] ? Number(process.argv[2]) : null; + const leaderRow: { id: number }[] = argId + ? await ds.query('SELECT id FROM users WHERE id = ? AND is_deleted = 0', [ + argId, + ]) + : await ds.query( + "SELECT id FROM users WHERE is_deleted = 0 AND kakao_id REGEXP '^[0-9]+$' ORDER BY id DESC LIMIT 1", + ); + const leader = leaderRow[0]?.id; + if (!leader) { + throw new Error( + '팀장으로 쓸 사용자가 없습니다. 카카오 로그인을 1회 한 뒤 다시 실행하거나, user_id를 인자로 넘기세요.', + ); + } + + // 외부 산정 엔진 클라이언트 — .env 의 CONTRIBUTION_SERVICE_URL 로 /pipeline/score 호출 + const client = new ContributionClient({ + get: (k: string) => process.env[k], + } as unknown as ConfigService); + if (!client.configured) { + throw new Error( + 'CONTRIBUTION_SERVICE_URL 미설정 — server/.env 에 추가하고 엔진(예: http://localhost:8000)을 띄운 뒤 다시 실행하세요.', + ); + } + + await ds.transaction(async (m) => { + // 2) 기존 시드 정리 (재실행 대비) + const old: { id: number }[] = await m.query( + 'SELECT id FROM teams WHERE invite_code = ? LIMIT 1', + [INVITE], + ); + const oldId = old[0]?.id; + if (oldId) { + await m.query( + 'DELETE FROM task_extension_requests WHERE action_item_id IN (SELECT id FROM action_items WHERE team_id = ?)', + [oldId], + ); + await m.query('DELETE FROM action_items WHERE team_id = ?', [oldId]); + await m.query( + 'DELETE FROM absence_consents WHERE absence_id IN (SELECT id FROM meeting_absences WHERE meeting_id IN (SELECT id FROM meetings WHERE team_id = ?))', + [oldId], + ); + await m.query( + 'DELETE FROM meeting_absences WHERE meeting_id IN (SELECT id FROM meetings WHERE team_id = ?)', + [oldId], + ); + await m.query( + 'DELETE FROM contribution_scores WHERE meeting_id IN (SELECT id FROM meetings WHERE team_id = ?)', + [oldId], + ); + await m.query( + 'DELETE FROM utterances WHERE meeting_id IN (SELECT id FROM meetings WHERE team_id = ?)', + [oldId], + ); + await m.query( + 'DELETE FROM presence_events WHERE meeting_id IN (SELECT id FROM meetings WHERE team_id = ?)', + [oldId], + ); + await m.query('DELETE FROM meetings WHERE team_id = ?', [oldId]); + await m.query('DELETE FROM team_memberships WHERE team_id = ?', [ + oldId, + ]); + await m.query('DELETE FROM team_settings WHERE team_id = ?', [oldId]); + await m.query('DELETE FROM teams WHERE id = ?', [oldId]); + } + await m.query("DELETE FROM users WHERE kakao_id LIKE 'attdemo-member-%'"); + + // 3) 더미 팀원 3명 — 각자 역할이 분명하도록 이름에 패턴을 담는다 + const insertId = async ( + sql: string, + params: unknown[], + ): Promise => { + const r = await m.query<{ insertId: number }>(sql, params); + return r.insertId; + }; + const mkUser = (kakao: string, name: string): Promise => + insertId( + 'INSERT INTO users (kakao_id, name, is_deleted) VALUES (?, ?, 0)', + [kakao, name], + ); + const dy = await mkUser('attdemo-member-1', '김도윤'); // 항상 정상 참석 + const sy = await mkUser('attdemo-member-2', '이서연'); // 결석/사유결석 담당 + const jh = await mkUser('attdemo-member-3', '박지훈'); // 지각 담당 + + // 4) 팀 + 설정 + 멤버십 + const team = await insertId( + 'INSERT INTO teams (name, course_name, created_by, invite_code) VALUES (?, ?, ?, ?)', + ['[데모] 출결 케이스 모음', '클라우드 컴퓨팅', leader, INVITE], + ); + // 지각 기준 5분 / 최대 인정 15분 — 신규 절대시간 기준 로직이 또렷이 드러나는 값 + await m.query( + `INSERT INTO team_settings + (team_id, late_threshold_minutes, late_max_minutes, + weight_speech_in_meeting, weight_attend_in_meeting, final_task_weight) + VALUES (?, 5, 15, 0.5, 0.5, 0.5)`, + [team], + ); + await m.query( + `INSERT INTO team_memberships (team_id, user_id, role, joined_at) VALUES + (?, ?, 'leader', NOW()), (?, ?, 'member', NOW()), + (?, ?, 'member', NOW()), (?, ?, 'member', NOW())`, + [team, leader, team, dy, team, sy, team, jh], + ); + + const settings: TeamSettingsPayload = { + punctuality_grace_ratio: 0.1, + presence_grace_seconds: 30, + max_utterance_chars: 500, + deadline_penalty_curve: 'standard', + absent_meeting_handling: 'exclude', + min_meeting_minutes: 5, + final_task_weight: 0.5, + weight_speech_in_meeting: 0.5, + weight_attend_in_meeting: 0.5, + leader_bonus_multiplier: 1.0, + late_threshold_minutes: 5, + late_max_minutes: 15, + }; + + // ── 회의 1 (5일 전, 60분): 이서연 무단 결석 / 박지훈 사유 지각(승인) ── + const mtg1 = await insertId( + `INSERT INTO meetings (team_id, scheduled_at, total_minutes, topic, status, t0_timestamp, ended_at, meeting_type, is_invalidated) + VALUES (?, ?, 60, '1차 정기 회의 - 요구사항 정의', 'ended', ?, ?, 'regular', 0)`, + [team, dt(-5, 14), dt(-5, 14), dt(-5, 15)], + ); + // 팀장·김도윤 정시 입장, 박지훈 12분 지각(720000ms), 이서연 입장 기록 없음(결석) + await m.query( + `INSERT INTO presence_events (user_id, meeting_id, event_type, timestamp_offset_ms) VALUES + (?, ?, 'join', 0), (?, ?, 'join', 0), (?, ?, 'join', 720000)`, + [leader, mtg1, dy, mtg1, jh, mtg1], + ); + const utter1 = [ + { user_id: leader, char_count: 420, off: 60000 }, + { user_id: leader, char_count: 300, off: 1800000 }, + { user_id: dy, char_count: 380, off: 120000 }, + { user_id: dy, char_count: 260, off: 2400000 }, + { user_id: jh, char_count: 180, off: 1500000 }, // 지각해서 적은 발언량 + ]; + for (const u of utter1) { + await m.query( + `INSERT INTO utterances (meeting_id, user_id, text, char_count, confidence, started_at_offset_ms, ended_at_offset_ms) + VALUES (?, ?, '데모 발화 내용입니다.', ?, 0.95, ?, ?)`, + [mtg1, u.user_id, u.char_count, u.off, u.off + 30000], + ); + } + await scoreAndStoreMeeting(m, client, { + meetingId: mtg1, + totalMinutes: 60, + scheduledAt: dt(-5, 14), + t0: dt(-5, 14), + endedAt: dt(-5, 15), + participantIds: [leader, dy, sy, jh], + presence: [ + { user_id: leader, offsetMs: 0 }, + { user_id: dy, offsetMs: 0 }, + { user_id: jh, offsetMs: 720000 }, + ], + utterances: utter1, + settings, + // 박지훈의 사유 지각이 (아래에서) 승인될 예정이므로 ①계산에도 미리 반영한다. + // 그래야 화면(리포트)의 attendance_ratio가 지각 페널티 면제된 값으로 저장된다. + excusedLateUserIds: [jh], + }); + // 박지훈의 지각 사유 → 팀장·김도윤 동의로 승인(사유 지각: 지각 감점만 면제) + const absJh1 = await insertId( + "INSERT INTO meeting_absences (meeting_id, user_id, reason, status) VALUES (?, ?, '직전 수업이 늦게 끝나 지각했습니다.', 'approved')", + [mtg1, jh], + ); + await m.query( + 'INSERT INTO absence_consents (absence_id, voter_id) VALUES (?, ?), (?, ?)', + [absJh1, leader, absJh1, dy], + ); + // 이서연은 결석 사유를 입력하지 않은 무단 결석 상태로 그대로 둔다(=비교 기준점) + + // ── 회의 2 (3일 전, 50분): 이서연 사유 결석(승인) / 박지훈 일반 지각(사유 없음) ── + const mtg2 = await insertId( + `INSERT INTO meetings (team_id, scheduled_at, total_minutes, topic, status, t0_timestamp, ended_at, meeting_type, is_invalidated) + VALUES (?, ?, 50, '2차 정기 회의 - 화면 설계', 'ended', ?, ?, 'regular', 0)`, + [team, dt(-3, 15), dt(-3, 15), dt(-3, 15, 50)], + ); + // 팀장·김도윤 정시, 박지훈 8분 지각(480000ms), 이서연 입장 없음(사유 결석 대상) + await m.query( + `INSERT INTO presence_events (user_id, meeting_id, event_type, timestamp_offset_ms) VALUES + (?, ?, 'join', 0), (?, ?, 'join', 0), (?, ?, 'join', 480000)`, + [leader, mtg2, dy, mtg2, jh, mtg2], + ); + const utter2 = [ + { user_id: leader, char_count: 350, off: 60000 }, + { user_id: dy, char_count: 340, off: 90000 }, + { user_id: dy, char_count: 200, off: 1500000 }, + { user_id: jh, char_count: 220, off: 1000000 }, + ]; + for (const u of utter2) { + await m.query( + `INSERT INTO utterances (meeting_id, user_id, text, char_count, confidence, started_at_offset_ms, ended_at_offset_ms) + VALUES (?, ?, '데모 발화 내용입니다.', ?, 0.95, ?, ?)`, + [mtg2, u.user_id, u.char_count, u.off, u.off + 30000], + ); + } + await scoreAndStoreMeeting(m, client, { + meetingId: mtg2, + totalMinutes: 50, + scheduledAt: dt(-3, 15), + t0: dt(-3, 15), + endedAt: dt(-3, 15, 50), + participantIds: [leader, dy, sy, jh], + presence: [ + { user_id: leader, offsetMs: 0 }, + { user_id: dy, offsetMs: 0 }, + { user_id: jh, offsetMs: 480000 }, + ], + utterances: utter2, + settings, + }); + // 이서연의 결석 사유 → 팀장·박지훈 동의로 승인(사유 결석: 누적에서 해당 회의 제외) + const absSy2 = await insertId( + "INSERT INTO meeting_absences (meeting_id, user_id, reason, status) VALUES (?, ?, '병원 진료 일정과 겹쳤습니다.', 'approved')", + [mtg2, sy], + ); + await m.query( + 'INSERT INTO absence_consents (absence_id, voter_id) VALUES (?, ?), (?, ?)', + [absSy2, leader, absSy2, jh], + ); + // 박지훈은 지각 사유를 입력하지 않은 일반(미승인) 지각으로 둔다 — 회의1의 승인된 + // 사유 지각과 바로 대조되도록, 똑같이 늦었어도 이번엔 정시 점수가 그대로 깎인다. + + // ── 회의 3 (오늘, 40분): 전원 참석. 가벼운 지각·자리비움으로 출석 축 다양성 추가 ── + const mtg3 = await insertId( + `INSERT INTO meetings (team_id, scheduled_at, total_minutes, topic, status, t0_timestamp, ended_at, meeting_type, is_invalidated) + VALUES (?, ?, 40, '3차 정기 회의 - 중간 점검', 'ended', ?, ?, 'regular', 0)`, + [team, dt(0, 10), dt(0, 10), dt(0, 10, 40)], + ); + // 박지훈 2분 지각(120000ms, 지각 기준 5분 이내 → 무감점 케이스), 이서연은 정시 + // 입장했지만 자리비움(leave) 후 미복귀로 실제 참여시간이 줄어든 케이스 + await m.query( + `INSERT INTO presence_events (user_id, meeting_id, event_type, timestamp_offset_ms) VALUES + (?, ?, 'join', 0), (?, ?, 'join', 0), (?, ?, 'join', 0), (?, ?, 'leave', 1500000), (?, ?, 'join', 120000)`, + [leader, mtg3, dy, mtg3, sy, mtg3, sy, mtg3, jh, mtg3], + ); + const utter3 = [ + { user_id: leader, char_count: 300, off: 60000 }, + { user_id: dy, char_count: 280, off: 200000 }, + { user_id: sy, char_count: 260, off: 300000 }, // 퇴장 전 발언 + { user_id: jh, char_count: 190, off: 400000 }, + ]; + for (const u of utter3) { + await m.query( + `INSERT INTO utterances (meeting_id, user_id, text, char_count, confidence, started_at_offset_ms, ended_at_offset_ms) + VALUES (?, ?, '데모 발화 내용입니다.', ?, 0.95, ?, ?)`, + [mtg3, u.user_id, u.char_count, u.off, u.off + 30000], + ); + } + await scoreAndStoreMeeting(m, client, { + meetingId: mtg3, + totalMinutes: 40, + scheduledAt: dt(0, 10), + t0: dt(0, 10), + endedAt: dt(0, 10, 40), + participantIds: [leader, dy, sy, jh], + presence: [ + { user_id: leader, offsetMs: 0 }, + { user_id: dy, offsetMs: 0 }, + { user_id: sy, offsetMs: 0 }, + { user_id: sy, offsetMs: 1500000, eventType: 'leave' }, + { user_id: jh, offsetMs: 120000 }, + ], + utterances: utter3, + settings, + }); + + // 5) 태스크 — 현재 날짜 기준 마감 상태를 다양하게: 기한초과 완료/미완료, + // 오늘 마감, 진행중(미래 마감), 할 일(미래 마감) + const mkTask = ( + assignee: number, + desc: string, + due: string, + status: string, + difficulty: number, + completedAt: string | null = null, + ): Promise => + insertId( + `INSERT INTO action_items (team_id, assignee_id, description, due_date, completed_at, status, difficulty, confirmed) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, + [team, assignee, desc, due, completedAt, status, difficulty], + ); + + // 기한 내 정상 완료 (마감 4일 전, 완료는 마감 하루 전) + await mkTask( + leader, + '요구사항 정의서 작성', + dt(-4, 18), + 'done', + 2, + dt(-5, 18), + ); + // 기한 늦게 완료 (마감 지남 — deadline penalty 케이스) + await mkTask(dy, '와이어프레임 초안', dt(-3, 18), 'done', 2, dt(-1, 12)); + // 기한 지났는데 아직 todo (방치된 태스크) + await mkTask(sy, 'API 명세 문서 정리', dt(-2, 18), 'todo', 2); + // 오늘 마감, 진행 중 + await mkTask(jh, '발표 자료 디자인', dt(0, 23, 59), 'in_progress', 3); + // 진행 중, 마감은 며칠 뒤 + await mkTask(leader, '백엔드 API 연동', dt(4, 18), 'in_progress', 3); + // 할 일, 아직 안 건드림(미래 마감) + await mkTask(dy, '테스트 케이스 작성', dt(7, 18), 'todo', 1); + + console.log( + `✓ 출결 데모 시드 완료 — 팀 id=${team} '[데모] 출결 케이스 모음' (팀장 user_id=${leader}, 초대코드 ${INVITE})`, + ); + console.log( + ' 회의1(5일전): 이서연=무단결석, 박지훈=사유지각(승인,12분)', + ); + console.log( + ' 회의2(3일전): 이서연=사유결석(승인), 박지훈=일반지각(미승인,8분)', + ); + console.log( + ' 회의3(오늘) : 박지훈=경미한지각(2분,기준이내), 이서연=조퇴(자리비움)', + ); + console.log( + ' 태스크 6건 : 완료/지연완료/방치/오늘마감/진행중/할일 각 1건', + ); + + // 검증용: 실제로 DB에 저장된 ①값(contribution_scores)을 다시 읽어 출력. + // 화면에 뜨는 출석 평균(attendance_avg)이 예상과 다를 때, 어느 회의의 + // attendance_ratio가 어떻게 저장됐는지 바로 확인할 수 있게 한다. + const names: Record = { + [leader]: '팀장', + [dy]: '김도윤', + [sy]: '이서연', + [jh]: '박지훈', + }; + const savedScores: { + user_id: number; + meeting_id: number; + attendance_ratio: number | null; + }[] = await m.query( + `SELECT user_id, meeting_id, attendance_ratio FROM contribution_scores + WHERE meeting_id IN (?, ?, ?) ORDER BY meeting_id, user_id`, + [mtg1, mtg2, mtg3], + ); + const meetingLabel: Record = { + [mtg1]: '회의1', + [mtg2]: '회의2', + [mtg3]: '회의3', + }; + console.log( + ' --- 저장된 ①(contribution_scores.attendance_ratio) 검증 ---', + ); + for (const row of savedScores) { + console.log( + ` ${meetingLabel[row.meeting_id] ?? row.meeting_id} / ${names[row.user_id] ?? row.user_id}: attendance_ratio=${row.attendance_ratio}`, + ); + } + }); + } finally { + await ds.destroy(); + } +} + +seed() + .then(() => process.exit(0)) + .catch((e) => { + console.error('시드 실패:', e); + process.exit(1); + }); diff --git a/server/src/teams/teams.service.spec.ts b/server/src/teams/teams.service.spec.ts new file mode 100644 index 0000000..b0b19e8 --- /dev/null +++ b/server/src/teams/teams.service.spec.ts @@ -0,0 +1,97 @@ +import { BadRequestException } from '@nestjs/common'; +import { TeamsService } from './teams.service'; +import { UpdateTeamSettingsDto } from './dto/update-team-settings.dto'; + +// updateSettings()의 검증 로직(가중치 합·지각 최대시간)만 단위로 검증한다. +// 다른 의존성은 이 메서드 경로에서 쓰이지 않으므로 null로 주입한다. +describe('TeamsService.updateSettings — 설정값 검증', () => { + const LEADER_MEMBERSHIP = { team_id: 1, user_id: 1, role: 'leader' }; + const BASE_SETTINGS = { + team_id: 1, + punctuality_grace_ratio: 0.1, + max_utterance_chars: 500, + presence_grace_seconds: 30, + absent_meeting_handling: 'exclude', + deadline_penalty_curve: 'standard', + contribution_visibility: 'team', + min_meeting_minutes: 5, + final_task_weight: 0.5, + weight_speech_in_meeting: 0.6, + weight_attend_in_meeting: 0.4, + leader_bonus_multiplier: 1.0, + late_threshold_minutes: 5, + late_max_minutes: 0, + slack_bot_token: null, + slack_channel_id: null, + }; + + function makeService(settingsOverride: Partial = {}) { + const settings = { ...BASE_SETTINGS, ...settingsOverride }; + const membershipRepo = { + findOne: jest.fn().mockResolvedValue(LEADER_MEMBERSHIP), + }; + const settingsRepo = { + findOne: jest.fn().mockResolvedValue({ ...settings }), + save: jest.fn().mockImplementation((s) => Promise.resolve(s)), + }; + const service = new TeamsService( + null as never, + membershipRepo as never, + settingsRepo as never, + null as never, + null as never, + ); + return { service, settingsRepo }; + } + + it('발언+출석 가중치 합이 1.0이면 정상 저장된다', async () => { + const { service, settingsRepo } = makeService(); + const dto: UpdateTeamSettingsDto = { + weight_speech_in_meeting: 0.7, + weight_attend_in_meeting: 0.3, + }; + await service.updateSettings(1, 1, dto); + expect(settingsRepo.save).toHaveBeenCalled(); + }); + + it('발언+출석 가중치 합이 1.0이 아니면 BadRequestException', async () => { + const { service } = makeService(); + const dto: UpdateTeamSettingsDto = { + weight_speech_in_meeting: 0.6, + weight_attend_in_meeting: 0.6, + }; + await expect(service.updateSettings(1, 1, dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('발언 가중치만 바꿔도(출석은 기존값 유지) 합이 깨지면 거부된다', async () => { + // 기존 발언 0.6/출석 0.4 에서 발언만 0.9로 바꾸면 합이 1.3이 되어야 함 + const { service } = makeService(); + const dto: UpdateTeamSettingsDto = { weight_speech_in_meeting: 0.9 }; + await expect(service.updateSettings(1, 1, dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('지각 최대 인정 시간이 지각 기준보다 작으면 BadRequestException', async () => { + const { service } = makeService(); + const dto: UpdateTeamSettingsDto = { + late_threshold_minutes: 10, + late_max_minutes: 5, + }; + await expect(service.updateSettings(1, 1, dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('지각 최대 인정 시간이 0(상한 없음)이면 기준보다 작아도 허용된다', async () => { + const { service, settingsRepo } = makeService(); + const dto: UpdateTeamSettingsDto = { + late_threshold_minutes: 10, + late_max_minutes: 0, + }; + await service.updateSettings(1, 1, dto); + expect(settingsRepo.save).toHaveBeenCalled(); + }); +}); diff --git a/server/src/teams/teams.service.ts b/server/src/teams/teams.service.ts index eb5a8f7..c252d7f 100644 --- a/server/src/teams/teams.service.ts +++ b/server/src/teams/teams.service.ts @@ -361,6 +361,18 @@ export class TeamsService { '지각 최대 인정 시간은 0(상한 없음) 또는 지각 기준 이상이어야 합니다.', ); } + // 발언:출석 가중치는 엔진에서 "회의 내 비중"으로 합산되므로 합이 1.0이어야 + // 설정 의도(예: "발언 60%, 출석 40%")와 실제 반영 비율이 일치한다. + // (엔진 자체는 합이 1이 아니어도 내부 재정규화로 깨지지 않지만, 그 경우 사용자가 + // 입력한 숫자와 실제 적용 비율이 달라져 설정 화면이 거짓말을 하게 된다.) + const weightSum = + Number(settings.weight_speech_in_meeting) + + Number(settings.weight_attend_in_meeting); + if (Math.abs(weightSum - 1.0) > 1e-6) { + throw new BadRequestException( + '회의 내 발언 가중치와 출석 가중치의 합은 1.0이어야 합니다.', + ); + } await this.settingsRepo.save(settings); return this.formatSettings(settings); From 941de3ab1d01c1a04de7c7e8ee6d84a7b9706b91 Mon Sep 17 00:00:00 2001 From: ahah1313 Date: Tue, 23 Jun 2026 16:52:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20field=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/contributions/contribution.client.ts | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/server/src/contributions/contribution.client.ts b/server/src/contributions/contribution.client.ts index 1f40139..517f697 100644 --- a/server/src/contributions/contribution.client.ts +++ b/server/src/contributions/contribution.client.ts @@ -54,16 +54,20 @@ export class ContributionClient { `[회의 산정] meeting_id=${payload.meeting.id} 참여자=${payload.participant_user_ids.length}명`, ); const cfg = mapTeamSettings(payload.team_settings); - const thresholdSec = payload.team_settings.late_threshold_minutes * 60; - const maxSec = - payload.team_settings.late_max_minutes > 0 - ? payload.team_settings.late_max_minutes * 60 - : null; - const excusedLateSet = new Set(payload.excused_late_user_ids ?? []); + // ① 표시용 punctuality_score 의 "지각" 기준을 실제 점수 산정에 쓰이는 + // late_threshold_sec(팀 설정의 지각 기준)과 일치시킨다. 과거엔 5분(300초)을 + // 하드코딩해서, 팀이 지각 기준을 다르게 설정해도 화면 표시는 항상 5분 기준으로 + // 나와 실제 점수와 어긋나는 문제가 있었다. + const lateThresholdSec = cfg.late_threshold_sec ?? 300; const scores = await Promise.all( payload.participant_user_ids.map(async (uid) => { - const { data, rawSpeechRatio } = deriveMemberData(payload, uid); - const isExcusedLate = excusedLateSet.has(uid); + const excusedLate = + payload.excused_late_user_ids?.includes(uid) ?? false; + const { data, rawSpeechRatio } = deriveMemberData( + payload, + uid, + excusedLate, + ); // 비정규 회의도 ① 점수는 산출해야 하므로 official 로 보낸다 // (officialness 는 누적(②) 포함 여부에만 쓰이고, ②는 별도 호출에서 반영). const ext = await this.pipeline( @@ -71,28 +75,25 @@ export class ContributionClient { false, cfg, ); + // attendance_ratio는 "참여 시간 비율"이 아니라 엔진이 계산한 attend_score + // (출석비율 + 지각 페널티, 사유 지각 면제 포함)를 저장한다. actual_attend_sec은 + // 자발적 자리비움만 차감하고 지각 시간은 차감하지 않아, 그 값을 그대로 쓰면 + // 지각해도 "참석 100%"로 표시되는 문제가 있었다 — 화면(회의 상세·리포트)에 + // 노출되는 이름이 "참석/출석"이므로 실제로 지각이 반영된 값을 보여줘야 한다. + const meetingScore = ext.meeting_scores[0]; + const attendanceRatio = data.absent + ? 0 + : (meetingScore?.attend_score ?? null); return { user_id: uid, speech_ratio: rawSpeechRatio, speech_consistency: null, - attendance_ratio: - data.absent || (maxSec !== null && data.late_sec > maxSec) - ? 0 - : data.meeting_total_sec > 0 - ? Math.max( - 0, - data.actual_attend_sec - - (!isExcusedLate && data.late_sec > thresholdSec - ? data.late_sec - : 0), - ) / data.meeting_total_sec - : null, - punctuality_score: - data.absent || (maxSec !== null && data.late_sec > maxSec) - ? null - : isExcusedLate || data.late_sec <= thresholdSec - ? 1.0 - : 0.0, + attendance_ratio: attendanceRatio, + punctuality_score: data.absent + ? null + : data.late_sec > lateThresholdSec + ? 0.0 + : 1.0, // 포함 0건(최소시간 미만 등) = 측정 불가 → null. // 무단 결석은 엔진이 0점으로 포함시키므로 0 이 저장된다. meeting_score: @@ -138,18 +139,10 @@ export class ContributionClient { mt.absent_user_ids.includes(m.user_id), ) .map((mt) => { - const { data } = deriveMemberData(mt, m.user_id); - // 지각 사유 승인자는 late_sec 를 0 으로 보내 외부 엔진도 패널티 없이 계산 - const isExcusedLate = (mt.excused_late_user_ids ?? []).includes( - m.user_id, - ); - const adjustedData = isExcusedLate - ? { ...data, late_sec: 0 } - : data; + const excusedLate = mt.excused_late_user_ids.includes(m.user_id); + const { data } = deriveMemberData(mt, m.user_id, excusedLate); // 무효 처리된 회의는 비정규로 보내 누적에서 제외시킨다 - return mt.is_invalidated - ? { ...adjustedData, is_official: false } - : adjustedData; + return mt.is_invalidated ? { ...data, is_official: false } : data; }); if (rows.length === 0 && actions.length === 0) { return { @@ -262,6 +255,7 @@ function taskCarrierRow(userId: number): ExternalMemberMeetingData { audio_loss_pct: 0, speech_confidence: 1, excused_absence: true, + excused_late: false, absent: true, is_official: false, }; From 5264dddea05c8921722c541e09448ffa5b973a9e Mon Sep 17 00:00:00 2001 From: ahah1313 Date: Tue, 23 Jun 2026 16:56:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contributions/contribution.client.spec.ts | 1 - .../src/contributions/contribution.types.ts | 10 +- .../contributions/contributions.service.ts | 116 ++++++++++-------- 3 files changed, 74 insertions(+), 53 deletions(-) diff --git a/server/src/contributions/contribution.client.spec.ts b/server/src/contributions/contribution.client.spec.ts index 209ed8a..177ab73 100644 --- a/server/src/contributions/contribution.client.spec.ts +++ b/server/src/contributions/contribution.client.spec.ts @@ -1,4 +1,3 @@ -/// import { ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ContributionClient } from './contribution.client'; diff --git a/server/src/contributions/contribution.types.ts b/server/src/contributions/contribution.types.ts index 38a3254..73f42e0 100644 --- a/server/src/contributions/contribution.types.ts +++ b/server/src/contributions/contribution.types.ts @@ -18,6 +18,10 @@ export interface MeetingScoreRequest { }; team_settings: TeamSettingsPayload; participant_user_ids: number[]; + // 이 회의에 대해 사유 지각이 승인된 멤버 — 지각 감점만 면제하고 출석 비율은 + // 그대로 반영한다. 회의 종료 직후 ① 자동 계산 시점엔 보통 비어있지만(사유 + // 신청이 아직 없으므로), 이후 재계산(예: 시드/배치)에서는 채워 보낼 수 있다. + excused_late_user_ids?: number[]; utterances: { user_id: number; char_count: number; @@ -36,8 +40,6 @@ export interface MeetingScoreRequest { event_type: string; timestamp_offset_ms: number; }[]; - // 지각했지만 사유가 승인된 유저 — late_sec 차감 면제 - excused_late_user_ids?: number[]; } export interface MeetingScoreResult { @@ -94,9 +96,11 @@ export interface TeamPipelineRequest { team_settings: TeamSettingsPayload; members: { user_id: number; role: string }[]; // absent_user_ids: 무단결석(입장 X·사유결석 아님) 멤버 — 누적(②)에 0점으로 포함시킬 대상. + // excused_late_user_ids: 사유 지각(승인됨+실제 입장함) 멤버 — 지각 감점만 면제할 대상. meetings: (MeetingRawInput & { is_invalidated: boolean; absent_user_ids: number[]; + excused_late_user_ids: number[]; })[]; action_items: TeamContributionRequest['action_items']; } @@ -123,6 +127,8 @@ export interface TeamSettingsPayload { weight_speech_in_meeting: number; weight_attend_in_meeting: number; leader_bonus_multiplier: number; + // 지각 기준(분)/지각 최대 인정 시간(분) — late_max_minutes=0 은 "상한 없음". + // 엔진(late_threshold_sec/late_max_sec)에 그대로 전달해 실제 점수 산정에 반영한다. late_threshold_minutes: number; late_max_minutes: number; } diff --git a/server/src/contributions/contributions.service.ts b/server/src/contributions/contributions.service.ts index 13580ec..865b4d7 100644 --- a/server/src/contributions/contributions.service.ts +++ b/server/src/contributions/contributions.service.ts @@ -59,41 +59,23 @@ export class ContributionsService { if (!meeting) return []; const settings = await this.requireSettingsPayload(meeting.team_id); - const [ - utterances, - agendas, - presence, - anomalies, - members, - approvedAbsences, - ] = await Promise.all([ - // 산정에 쓰는 컬럼만 로드 (text TEXT 컬럼 제외 — 응답 크기·메모리 절약) - this.utteranceRepo.find({ - where: { meeting_id: meetingId }, - select: { - user_id: true, - char_count: true, - agenda_id: true, - confidence: true, - }, - }), - this.agendaRepo.find({ where: { meeting_id: meetingId } }), - this.presenceRepo.find({ where: { meeting_id: meetingId } }), - this.anomalyRepo.find({ where: { meeting_id: meetingId } }), - this.membershipRepo.find({ where: { team_id: meeting.team_id } }), - this.absenceRepo.find({ - where: { meeting_id: meetingId, status: 'approved' }, - select: { user_id: true }, - }), - ]); - - // 지각 사유 승인자: join 기록 있음(지각) + 사유 approved → late_sec 차감 면제 - const joinedSet = new Set( - presence.filter((p) => p.event_type === 'join').map((p) => p.user_id), - ); - const excusedLateIds = approvedAbsences - .filter((a) => joinedSet.has(a.user_id)) - .map((a) => a.user_id); + const [utterances, agendas, presence, anomalies, members] = + await Promise.all([ + // 산정에 쓰는 컬럼만 로드 (text TEXT 컬럼 제외 — 응답 크기·메모리 절약) + this.utteranceRepo.find({ + where: { meeting_id: meetingId }, + select: { + user_id: true, + char_count: true, + agenda_id: true, + confidence: true, + }, + }), + this.agendaRepo.find({ where: { meeting_id: meetingId } }), + this.presenceRepo.find({ where: { meeting_id: meetingId } }), + this.anomalyRepo.find({ where: { meeting_id: meetingId } }), + this.membershipRepo.find({ where: { team_id: meeting.team_id } }), + ]); const participantIds = members.map((m) => m.user_id); @@ -126,7 +108,6 @@ export class ContributionsService { event_type: a.event_type, timestamp_offset_ms: a.timestamp_offset_ms, })), - excused_late_user_ids: excusedLateIds, }; // 외부 산정 엔진(cc-team-8/Contribution)에 위임 — CONTRIBUTION_SERVICE_URL 필수 @@ -266,12 +247,49 @@ export class ContributionsService { const resultById = new Map( (response?.members ?? []).map((r) => [r.user_id, r]), ); + // 승인된 사유 중 "결석"만 추려서 composite_score(②)와 동일한 규칙으로 + // "출석" 표시(attendance_avg)에서도 해당 회의를 평균 계산 자체에서 뺀다. + // meeting_absences 테이블엔 결석/지각 구분 필드가 없어, presence_events에 + // 입장(join/reconnect) 기록이 있는지로 구분한다 — 입장 기록이 없으면 결석, + // 있으면 늦게라도 참석한 지각이다. + // ⚠ 사유 지각 승인은 여기서 제외하면 안 된다: 사유 지각은 "지각 페널티만 + // 면제"이고 출석 자체는 했으므로, 그 회의의 attend_score(이미 페널티가 + // 면제된 값)가 평균에 그대로 들어가야 한다. 결석과 지각을 구분 안 하고 + // 둘 다 빼면, 지각해서 참석한 회의까지 사라져 출석 평균이 실제보다 + // 부풀려진다(데이터 1건이 통째로 빠지면서 남은 값들의 영향력이 커짐). + let approvedAbsenceKeys = new Set(); + if (meetings.length > 0) { + const meetingIds = meetings.map((m) => m.id); + const [approvedAbsences, allPresence] = await Promise.all([ + this.absenceRepo.find({ + where: { meeting_id: In(meetingIds), status: 'approved' }, + select: { meeting_id: true, user_id: true }, + }), + this.presenceRepo.find({ + where: { meeting_id: In(meetingIds) }, + select: { meeting_id: true, user_id: true, event_type: true }, + }), + ]); + const joinedKeys = new Set( + allPresence + .filter( + (p) => p.event_type === 'join' || p.event_type === 'reconnect', + ) + .map((p) => `${p.meeting_id}:${p.user_id}`), + ); + approvedAbsenceKeys = new Set( + approvedAbsences + .filter((a) => !joinedKeys.has(`${a.meeting_id}:${a.user_id}`)) + .map((a) => `${a.meeting_id}:${a.user_id}`), + ); + } // 레이더(출석·참여도 축) 표시용 — 누적 집계와 같은 제외 규칙 - // (무효 처리·비정규 회의 제외)으로 저장된 ① 비율을 단순 평균한다. + // (무효 처리·비정규 회의·승인된 사유결석 제외)으로 저장된 ① 비율을 평균한다. const ratiosById = new Map(); for (const s of scores) { const m = meetingById.get(s.meeting_id); if (!m || m.is_invalidated || m.meeting_type !== 'regular') continue; + if (approvedAbsenceKeys.has(`${s.meeting_id}:${s.user_id}`)) continue; const slot = ratiosById.get(s.user_id) ?? { att: [], sp: [] }; if (s.attendance_ratio != null) slot.att.push(Number(s.attendance_ratio)); if (s.speech_ratio != null) slot.sp.push(Number(s.speech_ratio)); @@ -376,20 +394,22 @@ export class ContributionsService { pres.filter((p) => p.event_type === 'join').map((p) => p.user_id), ); // 무단결석(입장 X·승인 사유결석 아님) — 누적(②)에 0점으로 포함시킬 멤버 + const excusedIds = new Set( + (excusedByMeeting.get(m.id) ?? []).map((a) => a.user_id), + ); const absent_user_ids = absentUnexcusedIds({ meetingType: m.meeting_type, isInvalidated: m.is_invalidated, meetingAtMs: m.scheduled_at.getTime(), joinedIds: joined, - excusedIds: new Set( - (excusedByMeeting.get(m.id) ?? []).map((a) => a.user_id), - ), + excusedIds, activeMemberships, }); - // 지각 사유 승인자: join 기록 있음(지각) + 사유 approved → late_sec 차감 면제 - const excused_late_user_ids = (excusedByMeeting.get(m.id) ?? []) - .filter((a) => joined.has(a.user_id)) - .map((a) => a.user_id); + // 사유 지각(승인됨 + 실제 입장함=joined) — 입장 자체를 안 한 사람은 + // absentUnexcusedIds() 가 따로 보호하므로 여기서는 "늦게라도 들어온" 케이스만 해당. + const excused_late_user_ids = [...excusedIds].filter((uid) => + joined.has(uid), + ); return { meeting: { id: m.id, @@ -462,12 +482,8 @@ export class ContributionsService { weight_speech_in_meeting: s?.weight_speech_in_meeting ?? 0.6, weight_attend_in_meeting: s?.weight_attend_in_meeting ?? 0.4, leader_bonus_multiplier: s?.leader_bonus_multiplier ?? 1.0, - late_threshold_minutes: - s?.late_threshold_minutes != null - ? Number(s.late_threshold_minutes) - : 5, - late_max_minutes: - s?.late_max_minutes != null ? Number(s.late_max_minutes) : 0, + late_threshold_minutes: s?.late_threshold_minutes ?? 5, + late_max_minutes: s?.late_max_minutes ?? 0, }; }