/* ============================================================ EATME PORTÁL – SHIFT RECONCILIATION SERVICE Porovnává: - plánované směny: SHIFT_SLOTS + SHIFT_SIGNUPS - skutečnou docházku: SHIFTS Nic nepřepisuje. Jde o read-only analytickou vrstvu. ============================================================ */ const SHIFT_RECONCILIATION = Object.freeze({ LATE_TOLERANCE_MINUTES: 10, EARLY_LEAVE_TOLERANCE_MINUTES: 10, MATCH_WINDOW_HOURS: 8 }); /* ============================================================ ZAMĚSTNANEC – PLÁN VS DOCHÁZKA ============================================================ */ function getMyShiftReconciliation( token, period ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } period = normalizeReconciliationPeriod_( period ); const context = buildReconciliationContext_(); const rows = reconcileEmployeePeriod_( user.employee_id, period, context ); return { period: period, stats: reconciliationStats_( rows ), rows: rows }; } /* ============================================================ ADMIN / MANAGER – PLÁN VS DOCHÁZKA ============================================================ */ function getAdminShiftReconciliation( token, period ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); period = normalizeReconciliationPeriod_( period ); const context = buildReconciliationContext_(); const result = []; context.employees .filter( function(employee) { return ( String( employee.active ).toUpperCase() === 'TRUE' ); } ) .forEach( function(employee) { const rows = reconcileEmployeePeriod_( employee.employee_id, period, context ); const employeeName = ( String( employee.first_name || '' ) + ' ' + String( employee.last_name || '' ) ).trim(); rows.forEach( function(row) { row.employee_id = String( employee.employee_id || '' ); row.employee_name = employeeName; result.push( row ); } ); } ); result.sort( function(a,b) { if ( a.date !== b.date ) { return String( a.date ).localeCompare( String( b.date ) ); } if ( a.planned_start !== b.planned_start ) { return String( a.planned_start || a.actual_start || '' ).localeCompare( String( b.planned_start || b.actual_start || '' ) ); } return String( a.employee_name || '' ).localeCompare( String( b.employee_name || '' ), 'cs' ); } ); return { period: period, stats: reconciliationStats_( result ), rows: result }; } /* ============================================================ KONTEXT – KAŽDÝ LIST NAČTEME JEN JEDNOU ============================================================ */ function buildReconciliationContext_() { const slots = displayRows_( 'SHIFT_SLOTS' ); const signups = displayRows_( 'SHIFT_SIGNUPS' ); const shifts = displayRows_( CFG.SHEETS.SHIFTS ); const employees = displayRows_( CFG.SHEETS.EMPLOYEES ); const slotMap = {}; slots.forEach( function(slot) { slotMap[ String( slot.slot_id ) ] = slot; } ); const plannedByEmployee = {}; signups.forEach( function(signup) { if ( String( signup.status ) !== 'APPROVED' ) { return; } const slot = slotMap[ String( signup.slot_id ) ]; if ( !slot ) { return; } const employeeId = String( signup.employee_id || '' ); if ( !plannedByEmployee[ employeeId ] ) { plannedByEmployee[ employeeId ] = []; } plannedByEmployee[ employeeId ].push( { signup: signup, slot: slot } ); } ); const actualByEmployee = {}; shifts.forEach( function(shift) { const employeeId = String( shift.employee_id || '' ); if ( !actualByEmployee[ employeeId ] ) { actualByEmployee[ employeeId ] = []; } actualByEmployee[ employeeId ].push( shift ); } ); return { employees: employees, plannedByEmployee: plannedByEmployee, actualByEmployee: actualByEmployee }; } /* ============================================================ PÁROVÁNÍ JEDNOHO ZAMĚSTNANCE ============================================================ */ function reconcileEmployeePeriod_( employeeId, period, context ) { const planned = ( context.plannedByEmployee[ String( employeeId ) ] || [] ) .filter( function(item) { return ( String( item.slot.date || '' ).substring( 0, 7 ) === period ); } ) .map( function(item) { return makePlannedItem_( item ); } ) .filter( Boolean ) .sort( function(a,b) { return ( a.start.getTime() - b.start.getTime() ); } ); const actual = ( context.actualByEmployee[ String( employeeId ) ] || [] ) .map( function(shift) { return makeActualItem_( shift ); } ) .filter( function(item) { if ( !item || !item.start ) { return false; } return ( Utilities.formatDate( item.start, CFG.TZ, 'yyyy-MM' ) === period ); } ) .sort( function(a,b) { return ( a.start.getTime() - b.start.getTime() ); } ); const usedActual = {}; const rows = []; planned.forEach( function(plan) { let bestIndex = -1; let bestDistance = Infinity; actual.forEach( function(attendance,index) { if ( usedActual[ index ] ) { return; } const distance = Math.abs( attendance.start.getTime() - plan.start.getTime() ); if ( distance > SHIFT_RECONCILIATION.MATCH_WINDOW_HOURS * 3600000 ) { return; } /* * Přednost má stejný pracovní den. */ const planDate = Utilities.formatDate( plan.start, CFG.TZ, 'yyyy-MM-dd' ); const actualDate = Utilities.formatDate( attendance.start, CFG.TZ, 'yyyy-MM-dd' ); const penalty = planDate === actualDate ? 0 : 24 * 3600000; const score = distance + penalty; if ( score < bestDistance ) { bestDistance = score; bestIndex = index; } } ); if ( bestIndex >= 0 ) { usedActual[ bestIndex ] = true; rows.push( reconciliationRow_( plan, actual[ bestIndex ] ) ); } else { rows.push( reconciliationRow_( plan, null ) ); } } ); actual.forEach( function(attendance,index) { if ( usedActual[ index ] ) { return; } rows.push( reconciliationRow_( null, attendance ) ); } ); return rows.sort( function(a,b) { const aKey = String( a.date || '' ) + ' ' + String( a.planned_start || a.actual_start || '' ); const bKey = String( b.date || '' ) + ' ' + String( b.planned_start || b.actual_start || '' ); return aKey.localeCompare( bKey ); } ); } /* ============================================================ PLÁNOVANÁ SMĚNA ============================================================ */ function makePlannedItem_(item) { const slot = item.slot; const date = String( slot.date || '' ).substring( 0, 10 ); if ( !date ) { return null; } const start = reconciliationDateTime_( date, slot.start_time ); let end = reconciliationDateTime_( date, slot.end_time ); if ( !start || !end ) { return null; } if ( end <= start ) { end = new Date( end.getTime() + 86400000 ); } return { slot_id: String( slot.slot_id || '' ), date: date, start: start, end: end, start_time: reconciliationHHMM_( start ), end_time: reconciliationHHMM_( end ) }; } /* ============================================================ SKUTEČNÁ SMĚNA ============================================================ */ function makeActualItem_(shift) { const start = reconciliationParseDate_( shift.clock_in ); if ( !start ) { return null; } const end = reconciliationParseDate_( shift.clock_out ); return { shift_id: String( shift.shift_id || '' ), start: start, end: end, status: String( shift.status || '' ), worked_minutes: reconciliationNumber_( shift.worked_minutes ) }; } /* ============================================================ VÝSLEDNÝ ŘÁDEK ============================================================ */ function reconciliationRow_( plan, attendance ) { const now = new Date(); let status = 'OK'; let label = 'V pořádku'; let lateMinutes = 0; let earlyLeaveMinutes = 0; let differenceMinutes = 0; if ( plan && !attendance ) { if ( now < plan.end ) { status = 'PLANNED'; label = 'Plánováno'; } else { status = 'ABSENT'; label = 'Bez docházky'; } } if ( !plan && attendance ) { status = 'EXTRA'; label = 'Směna navíc'; } if ( plan && attendance ) { lateMinutes = Math.max( 0, Math.round( ( attendance.start.getTime() - plan.start.getTime() ) / 60000 ) ); if ( attendance.end ) { earlyLeaveMinutes = Math.max( 0, Math.round( ( plan.end.getTime() - attendance.end.getTime() ) / 60000 ) ); } const plannedMinutes = Math.round( ( plan.end.getTime() - plan.start.getTime() ) / 60000 ); differenceMinutes = attendance.worked_minutes - plannedMinutes; if ( attendance.status === 'OPEN' ) { status = 'OPEN'; label = 'Právě probíhá'; } else if ( lateMinutes > SHIFT_RECONCILIATION.LATE_TOLERANCE_MINUTES ) { status = 'LATE'; label = 'Pozdní příchod'; } else if ( earlyLeaveMinutes > SHIFT_RECONCILIATION.EARLY_LEAVE_TOLERANCE_MINUTES ) { status = 'EARLY_LEAVE'; label = 'Předčasný odchod'; } } const date = plan ? plan.date : Utilities.formatDate( attendance.start, CFG.TZ, 'yyyy-MM-dd' ); return { date: date, slot_id: plan ? plan.slot_id : '', shift_id: attendance ? attendance.shift_id : '', planned_start: plan ? plan.start_time : '', planned_end: plan ? plan.end_time : '', actual_start: attendance ? reconciliationHHMM_( attendance.start ) : '', actual_end: attendance && attendance.end ? reconciliationHHMM_( attendance.end ) : '', worked_minutes: attendance ? attendance.worked_minutes : 0, late_minutes: lateMinutes, early_leave_minutes: earlyLeaveMinutes, difference_minutes: differenceMinutes, status: status, status_label: label }; } /* ============================================================ STATISTIKY ============================================================ */ function reconciliationStats_(rows) { const stats = { total: rows.length, ok: 0, late: 0, early_leave: 0, absent: 0, extra: 0, open: 0, planned: 0 }; rows.forEach( function(row) { switch( String( row.status ) ) { case 'OK': stats.ok++; break; case 'LATE': stats.late++; break; case 'EARLY_LEAVE': stats.early_leave++; break; case 'ABSENT': stats.absent++; break; case 'EXTRA': stats.extra++; break; case 'OPEN': stats.open++; break; case 'PLANNED': stats.planned++; break; } } ); return stats; } /* ============================================================ HELPERS ============================================================ */ function normalizeReconciliationPeriod_( period ) { period = String( period || '' ).trim(); if ( !/^\d{4}-\d{2}$/.test( period ) ) { return Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM' ); } return period; } function reconciliationDateTime_( date, time ) { const dateParts = String( date || '' ).split( '-' ); const timeParts = String( time || '00:00' ).substring( 0, 5 ).split( ':' ); if ( dateParts.length !== 3 || timeParts.length < 2 ) { return null; } const result = new Date( Number( dateParts[0] ), Number( dateParts[1] ) - 1, Number( dateParts[2] ), Number( timeParts[0] ), Number( timeParts[1] ), 0 ); return isNaN( result.getTime() ) ? null : result; } function reconciliationParseDate_( value ) { if ( !value ) { return null; } if ( value instanceof Date ) { return value; } const text = String( value ).trim(); let match; match = text.match( /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } match = text.match( /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[3] ), Number( match[1] ) - 1, Number( match[2] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } match = text.match( /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ); if ( match ) { return new Date( Number( match[3] ), Number( match[2] ) - 1, Number( match[1] ), Number( match[4] || 0 ), Number( match[5] || 0 ), Number( match[6] || 0 ) ); } const date = new Date( text ); return isNaN( date.getTime() ) ? null : date; } function reconciliationHHMM_( date ) { if ( !date ) { return ''; } return Utilities.formatDate( date, CFG.TZ, 'HH:mm' ); } function reconciliationNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( ',', '.' ) .replace( /\s/g, '' ) ); return isFinite( number ) ? number : 0; } /* ============================================================ DIAGNOSTIKA ============================================================ */ function testShiftReconciliation() { const period = Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM' ); const context = buildReconciliationContext_(); Logger.log( 'Employees: ' + context.employees.length ); Logger.log( 'Period: ' + period ); Logger.log( 'Shift reconciliation OK' ); }