import type { AuthUser } from './auth-user'; import type { Department, Member, RoleItem } from './members'; import { isMemberReference } from './member-system'; import { hasPermission } from './permissions'; export interface OvertimeRecord { id: string; projectId: string; versionId?: string; requirementId?: string; person: string; startTime: string; endTime: string; duration: number; reasonId: string; remark?: string; createdAt: string; } interface OvertimeVisibilityContext { viewer: AuthUser | null | undefined; viewerRole: RoleItem | undefined; members: Member[]; departments: Department[]; } export type OvertimeReason = | 'requirement_change' | 'requirement_add' | 'version_sprint' | 'bug_fix' | 'online_issue' | 'test_fix' | 'tech_difficulty' | 'integration_issue' | 'upstream_delay' | 'overload' | 'rework'; export const OVERTIME_REASON_LABEL: Record = { 'reason-1': '需求变更', 'reason-2': '需求新增', 'reason-3': '版本冲刺', 'reason-4': 'Bug修复', 'reason-5': '线上问题修复', 'reason-6': '测试问题整改', 'reason-7': '技术难点攻关', 'reason-8': '联调问题', 'reason-9': '上游交付延误', 'reason-10': '工作量超负荷', 'reason-11': '返工修改', }; const MS_PER_HOUR = 60 * 60 * 1000; const STANDARD_START_HOUR = 9; const LUNCH_START_HOUR = 12; const LUNCH_END_HOUR = 13; const STANDARD_END_HOUR = 18; function startOfDay(date: Date): Date { const day = new Date(date); day.setHours(0, 0, 0, 0); return day; } function dayBound(day: Date, hour: number): Date { const result = new Date(day); result.setHours(hour, 0, 0, 0); return result; } function isSameLocalDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function overlapHours(start: Date, end: Date, segmentStart: Date, segmentEnd: Date): number { const overlapStart = Math.max(start.getTime(), segmentStart.getTime()); const overlapEnd = Math.min(end.getTime(), segmentEnd.getTime()); return Math.max(0, overlapEnd - overlapStart) / MS_PER_HOUR; } function calcProjectDayHours(start: Date, end: Date): number { if (end.getTime() <= start.getTime()) return 0; const day = startOfDay(start); const gross = (end.getTime() - start.getTime()) / MS_PER_HOUR; const lunch = overlapHours( start, end, dayBound(day, LUNCH_START_HOUR), dayBound(day, LUNCH_END_HOUR), ); return Math.max(0, gross - lunch); } export function calcDuration(start: string, end: string): number { const startDate = new Date(start); const endDate = new Date(end); const s = startDate.getTime(); const e = endDate.getTime(); if (isNaN(s) || isNaN(e) || e <= s) return 0; let total = 0; const cursor = startOfDay(startDate); const lastDay = startOfDay(endDate); while (cursor.getTime() <= lastDay.getTime()) { const isFirst = isSameLocalDay(cursor, startDate); const isLast = isSameLocalDay(cursor, endDate); const dayStart = isFirst ? new Date(startDate) : dayBound(cursor, STANDARD_START_HOUR); const dayEnd = isLast ? new Date(endDate) : dayBound(cursor, STANDARD_END_HOUR); total += calcProjectDayHours(dayStart, dayEnd); cursor.setDate(cursor.getDate() + 1); } return Math.round(total * 10) / 10; } function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set { const ids = new Set([departmentId]); let changed = true; while (changed) { changed = false; for (const department of departments) { if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) { ids.add(department.id); changed = true; } } } return ids; } function resolveRecordMember( record: OvertimeRecord, members: Member[], viewer: AuthUser, ): Pick | null { const member = members.find((item) => isMemberReference(record.person, item)); if (member) return member; if (isMemberReference(record.person, viewer)) { return { id: viewer.id, name: viewer.name, departmentId: viewer.departmentId }; } return null; } export function filterOvertimeRecordsForViewer( records: OvertimeRecord[], context: OvertimeVisibilityContext, ): OvertimeRecord[] { const { viewer, viewerRole, members, departments } = context; if (!viewer || !viewerRole || !hasPermission(viewerRole, 'overtime:view')) return []; if (viewerRole.permissions.includes('*')) return records; const visibleDepartmentIds = collectDepartmentTreeIds(departments, viewer.departmentId); return records.filter((record) => { const member = resolveRecordMember(record, members, viewer); return !!member && visibleDepartmentIds.has(member.departmentId); }); }